diff --git a/Box3JS-NeoForge-1.21.1/README.md b/Box3JS-NeoForge-1.21.1/README.md index eb2aefd..ead322d 100644 --- a/Box3JS-NeoForge-1.21.1/README.md +++ b/Box3JS-NeoForge-1.21.1/README.md @@ -31,10 +31,14 @@ config/box3/script/mygame/ ├── build.mjs ← 构建脚本(esbuild → Babel → Rhino) ├── eslint.config.mjs ├── types/ -│ └── globals.d.ts ← 完整 API 类型声明(IDE 自动补全) +│ ├── shared.d.ts ← 服务端&客户端共享类型 +│ ├── server.d.ts ← 服务端专属类型 +│ └── client.d.ts ← 客户端专属类型 └── src/ - └── app.ts ← 入口文件,代码写在这里 -``` + ├── server/ + │ └── app.ts ← 服务端入口(游戏逻辑) + └── client/ + └── app.ts ← 客户端入口(UI/按键/网络) 构建并启动: @@ -52,48 +56,53 @@ npm install && npm run build ## 为什么选择 Box3JS? -| 特性 | 说明 | -|------|------| -| **零门槛** | 会 JS/TS 就能写,无需 Gradle、无需 IDE、无需重启 | -| **热重载** | 改代码 → build → reload,秒级生效。开启 `watch` 自动重载 | -| **沙盒保护** | 开启沙盒自动追踪所有脚本修改,关闭时完整回滚,服务器不留痕迹 | -| **TypeScript** | 完整 `.d.ts` 类型声明,esbuild + Babel 编译管线,享受智能提示 | -| **17 种事件** | onTick、onPlayerJoin、onChat、onEntityDeath、onBlockActivate、onButtonPressed... | -| **视觉效果** | 13+ 粒子、烟花、闪电、爆炸、音效 | -| **游戏系统** | 计分板、BossBar、队伍、世界边界、跨脚本通信 | -| **自定义物品** | JSON 配置注册自定义物品(食物、稀有度、附魔光效),动态管理配方 | -| **数据持久化** | JSON 存储 + SQLite 数据库(排行榜、经济、玩家数据) | +| 特性 | 说明 | +| -------------- | -------------------------------------------------------------------------------- | +| **零门槛** | 会 JS/TS 就能写,无需 Gradle、无需 IDE、无需重启 | +| **热重载** | 改代码 → build → reload,秒级生效。开启 `watch` 自动重载 | +| **沙盒保护** | 开启沙盒自动追踪所有脚本修改,关闭时完整回滚,服务器不留痕迹 | +| **TypeScript** | 完整 `.d.ts` 类型声明,esbuild + Babel 编译管线,享受智能提示 | +| **20+ 种事件** | onTick、onPlayerJoin、onChat、onEntityDeath、onBlockActivate、onButtonPressed... | +| **视觉效果** | 13+ 粒子、烟花、闪电、爆炸、音效 | +| **客户端 API** | 键盘输入、屏幕 UI、聊天拦截、客户端存储、SQLite、HTTP、双向事件通道 | +| **游戏系统** | 计分板、BossBar、队伍、世界边界、跨脚本通信 | +| **自定义物品** | JSON 配置注册自定义物品(食物、稀有度、附魔光效),动态管理配方 | +| **数据持久化** | JSON 存储 + SQLite 数据库(排行榜、经济、玩家数据) | +| **独立打包** | `/box3script compile` 将脚本编译为独立 JAR 模组,便于分发部署 | ## 命令 -| 命令 | 说明 | -|------|------| -| `/box3script` | 查看项目状态总览 | -| `/box3script create ` | 创建新 TypeScript 项目 | -| `/box3script start [project\|all]` | 启用并加载项目 | -| `/box3script stop [project\|all]` | 禁用并卸载项目 | -| `/box3script reload [project]` | 重载脚本(开发用) | -| `/box3script watch` | 切换文件监控(自动热重载) | -| `/box3script sandbox ` | 切换沙盒模式(开=追踪 / 关=回滚) | -| `/box3script compile ` | 编译为独立 JAR 模组(无需 Box3JS) | +| 命令 | 说明 | +| ---------------------------------- | --------------------------------- | +| `/box3script` | 查看项目状态总览 | +| `/box3script create ` | 创建新 TypeScript 项目 | +| `/box3script start [project\|all]` | 启用并加载项目 | +| `/box3script stop [project\|all]` | 禁用并卸载项目 | +| `/box3script reload [project]` | 重载脚本(开发用) | +| `/box3script watch` | 切换文件监控(自动热重载) | +| `/box3script sandbox ` | 切换沙盒模式(开=追踪 / 关=回滚) | +| `/box3script compile ` | 编译为独立 JAR 模组 | 所有 `` 参数支持 **Tab 自动补全**。[完整命令文档 →](docs/api/commands.md) ## API 速览 -| 全局对象 | 用途 | -|----------|------| -| `world` | 世界状态、事件回调、粒子、烟花、闪电、音效、计分板、BossBar、队伍、边界、自定义物品 | -| `entity` | 实体属性、AI 寻路、装备、药水效果、标签、导航 | -| `player` | 背包、飞行、游戏模式、传送、消息、经验、音效 | -| `voxels` | 方块读写、区域填充、刷怪笼 | -| `storage` | JSON 数据持久化 | -| `db` | SQLite 数据库 — SQL 查询、排行榜、玩家数据 | -| `console` | 控制台日志输出(`log`/`warn`/`error`/`debug`) | -| `GameVector3` | 三维向量(坐标运算) | -| `GameBounds3` | 包围盒 | -| `GameRGBColor` / `GameRGBAColor` | RGB/RGBA 颜色 | -| `GameQuaternion` | 四元数(旋转运算) | +| 全局对象 | 用途 | +| -------------------------------- | ----------------------------------------------------------------------------------- | +| `world` | 世界状态、事件回调、粒子、烟花、闪电、音效、计分板、BossBar、队伍、边界、自定义物品 | +| `entity` | 实体属性、AI 寻路、装备、药水效果、标签、导航 | +| `player` | 背包、飞行、游戏模式、传送、消息、经验、音效 | +| `voxels` | 方块读写、区域填充、刷怪笼 | +| `http` | HTTP 网络请求(同步 + 异步,GET/POST/JSON) | +| `remoteChannel` | 服务端 ↔ 客户端双向事件通讯 | +| `client` · `input` · `ui` · `chat` | 客户端脚本:生命周期、键盘、屏幕文字、聊天消息 | +| `storage` | JSON 数据持久化(服务端 & 客户端) | +| `db` | SQLite 数据库(服务端 & 客户端) | +| `console` | 控制台日志输出(`log`/`warn`/`error`/`debug`/`assert`/`clear`) | +| `GameVector3` | 三维向量(坐标运算) | +| `GameBounds3` | 包围盒 | +| `GameRGBColor` / `GameRGBAColor` | RGB/RGBA 颜色 | +| `GameQuaternion` | 四元数(旋转运算) | [API 总览 →](docs/api/README.md) · [按任务速查 →](docs/api/README.md#功能速查---我想) · [English](docs/api/README_en.md) @@ -101,13 +110,13 @@ npm install && npm run build 从零到完整小游戏,每个示例均经过 TypeScript 编译 + ESLint 验证: -| # | 教程 | 时长 | 学什么 | -|---|------|------|--------| -| 1 | [从零开始](docs/tutorial/01-basics.md) | 10 min | 创建项目、第一个脚本、聊天命令、定时任务 | -| 2 | [玩家操控与物品](docs/tutorial/02-player-items.md) | 15 min | 传送、飞行、物品、附魔、药水、自定义物品 | -| 3 | [事件系统与实体](docs/tutorial/03-events-entities.md) | 15 min | 事件回调、实体生成、AI、战斗、巡逻 | -| 4 | [高级游戏系统](docs/tutorial/04-advanced-systems.md) | 15 min | 计分板、BossBar、队伍、边界、跨脚本通信 | -| 5 | [实战小游戏](docs/tutorial/05-examples.md) | 20 min | PvP 竞技场、粒子烟花、波次刷怪、特效大全 | +| # | 教程 | 时长 | 学什么 | +| --- | ----------------------------------------------------- | ------ | ---------------------------------------- | +| 1 | [从零开始](docs/tutorial/01-basics.md) | 10 min | 创建项目、第一个脚本、聊天命令、定时任务 | +| 2 | [玩家操控与物品](docs/tutorial/02-player-items.md) | 15 min | 传送、飞行、物品、附魔、药水、自定义物品 | +| 3 | [事件系统与实体](docs/tutorial/03-events-entities.md) | 15 min | 事件回调、实体生成、AI、战斗、巡逻 | +| 4 | [高级游戏系统](docs/tutorial/04-advanced-systems.md) | 15 min | 计分板、BossBar、队伍、边界、跨脚本通信 | +| 5 | [实战小游戏](docs/tutorial/05-examples.md) | 20 min | PvP 竞技场、粒子烟花、波次刷怪、特效大全 | [教程总览 →](docs/tutorial/README.md) @@ -123,6 +132,8 @@ docs/ │ ├── voxels.md 方块 API(读写、填充、刷怪笼) │ ├── storage.md 存储 API(JSON 持久化) │ ├── database.md 数据库 API(SQLite) +│ ├── http.md HTTP 请求 API +│ ├── client.md 客户端 API(UI、输入、聊天、通讯) │ ├── math.md 数学 API(Vector3、Color、Quaternion) │ └── commands.md /box3script 命令参考 ├── tutorial/ ← 入门教程 @@ -137,15 +148,15 @@ docs/ ## 示例项目 -`run/config/box3/script/colorzone/` 包含完整的领地争夺战游戏和 7 个功能示例,涵盖 Hello World 到波次刷怪的全部教学场景。 +`run/config/box3/script/colorzone/` 包含完整的双向通讯游戏和 7 个功能示例,涵盖服务端逻辑、客户端 UI、键盘输入、HTTP 请求、数据库等全部教学场景。 ## 依赖说明 -| 功能 | 依赖 | -|------|------| -| 脚本引擎核心 | 内嵌 Rhino 1.9.1,无需额外安装 | +| 功能 | 依赖 | +| ------------------ | ------------------------------------------------------------------------------------- | +| 脚本引擎核心 | 内嵌 Rhino 1.9.1,无需额外安装 | | `db` API(SQLite) | 需安装 [`minecraft-sqlite-jdbc`](https://modrinth.com/mod/minecraft-sqlite-jdbc) 模组 | -| 其他 API | 无额外依赖 | +| 其他 API | 无额外依赖 | > 未安装 `minecraft-sqlite-jdbc` 时,`db` 以外的所有 API 正常工作。只有调用 `db.sql()` 才会提示需要安装。 diff --git a/Box3JS-NeoForge-1.21.1/README_en.md b/Box3JS-NeoForge-1.21.1/README_en.md index d318100..f549260 100644 --- a/Box3JS-NeoForge-1.21.1/README_en.md +++ b/Box3JS-NeoForge-1.21.1/README_en.md @@ -31,10 +31,14 @@ config/box3/script/mygame/ ├── build.mjs ← build script (esbuild → Babel → Rhino) ├── eslint.config.mjs ├── types/ -│ └── globals.d.ts ← full API type declarations (IDE autocomplete) +│ ├── shared.d.ts ← types shared by server & client +│ ├── server.d.ts ← server-only types +│ └── client.d.ts ← client-only types └── src/ - └── app.ts ← entry point — write your code here -``` + ├── server/ + │ └── app.ts ← server entry (game logic) + └── client/ + └── app.ts ← client entry (UI/input/network) Build and start: @@ -52,48 +56,53 @@ Edit `src/app.ts`, re-run `npm run build`, then `/box3script reload mygame` — ## Why Box3JS? -| Feature | Description | -|---------|-------------| -| **Zero barrier** | Know JS/TS? You can build. No Gradle, no IDE, no restarts | -| **Hot reload** | Edit → build → reload in seconds. Enable `watch` for auto-reload | -| **Sandbox** | Toggle sandbox to track all script changes; disable to fully roll back | -| **TypeScript** | Full `.d.ts` type declarations, esbuild + Babel pipeline, IDE IntelliSense | -| **17 events** | onTick, onPlayerJoin, onChat, onEntityDeath, onBlockActivate, onButtonPressed... | -| **Visual effects** | 13+ particles, fireworks, lightning, explosions, sounds | -| **Game systems** | Scoreboards, BossBar, teams, world border, cross-script messaging | -| **Custom items** | JSON-configured items (food, rarity, glint), dynamic recipe management | -| **Data persistence** | JSON storage + SQLite database (leaderboards, economy, player data) | +| Feature | Description | +| -------------------- | -------------------------------------------------------------------------------- | +| **Zero barrier** | Know JS/TS? You can build. No Gradle, no IDE, no restarts | +| **Hot reload** | Edit → build → reload in seconds. Enable `watch` for auto-reload | +| **Sandbox** | Toggle sandbox to track all script changes; disable to fully roll back | +| **TypeScript** | Full `.d.ts` type declarations, esbuild + Babel pipeline, IDE IntelliSense | +| **20+ events** | onTick, onPlayerJoin, onChat, onEntityDeath, onBlockActivate, onButtonPressed... | +| **Visual effects** | 13+ particles, fireworks, lightning, explosions, sounds | +| **Client API** | Keyboard input, screen UI, chat interception, client storage, SQLite, HTTP, bidirectional events | +| **Game systems** | Scoreboards, BossBar, teams, world border, cross-script messaging | +| **Custom items** | JSON-configured items (food, rarity, glint), dynamic recipe management | +| **Data persistence** | JSON storage + SQLite database (leaderboards, economy, player data) | +| **Standalone JAR** | `/box3script compile` packages scripts into a standalone JAR mod for distribution | ## Commands -| Command | Description | -|---------|-------------| -| `/box3script` | Show project status overview | -| `/box3script create ` | Create a new TypeScript project | -| `/box3script start [project\|all]` | Enable and load projects | -| `/box3script stop [project\|all]` | Disable and unload projects | -| `/box3script reload [project]` | Reload scripts (for development) | -| `/box3script watch` | Toggle file watching (auto hot-reload) | -| `/box3script sandbox ` | Toggle sandbox (on=track / off=rollback) | -| `/box3script compile ` | Compile to standalone JAR (no Box3JS needed) | +| Command | Description | +| ---------------------------------- | ---------------------------------------- | +| `/box3script` | Show project status overview | +| `/box3script create ` | Create a new TypeScript project | +| `/box3script start [project\|all]` | Enable and load projects | +| `/box3script stop [project\|all]` | Disable and unload projects | +| `/box3script reload [project]` | Reload scripts (for development) | +| `/box3script watch` | Toggle file watching (auto hot-reload) | +| `/box3script sandbox ` | Toggle sandbox (on=track / off=rollback) | +| `/box3script compile ` | Compile to standalone JAR | All `` arguments support **Tab completion**. [Full command reference →](docs/api/commands_en.md) ## API Overview -| Global | Purpose | -|--------|---------| -| `world` | World state, events, particles, fireworks, lightning, sounds, scoreboards, BossBar, teams, border, custom items | -| `entity` | Entity properties, AI pathfinding, equipment, potion effects, tags, navigation | -| `player` | Inventory, flight, game mode, teleport, messaging, XP, sounds | -| `voxels` | Block read/write, region fill, spawner control | -| `storage` | JSON data persistence | -| `db` | SQLite database — SQL queries, leaderboards, player data | -| `console` | Server console logging (`log`/`warn`/`error`/`debug`) | -| `GameVector3` | 3D vector (coordinate math) | -| `GameBounds3` | Bounding box | -| `GameRGBColor` / `GameRGBAColor` | RGB / RGBA color | -| `GameQuaternion` | Quaternion (rotation math) | +| Global | Purpose | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `world` | World state, events, particles, fireworks, lightning, sounds, scoreboards, BossBar, teams, border, custom items | +| `entity` | Entity properties, AI pathfinding, equipment, potion effects, tags, navigation | +| `player` | Inventory, flight, game mode, teleport, messaging, XP, sounds | +| `voxels` | Block read/write, region fill, spawner control | +| `http` | HTTP requests (sync + async, GET/POST/JSON) | +| `remoteChannel` | Server ↔ client bidirectional event channel | +| `client` · `input` · `ui` · `chat` | Client scripts: lifecycle, keyboard, screen text, chat messages | +| `storage` | JSON data persistence (server & client) | +| `db` | SQLite database (server & client) | +| `console` | Console logging (`log`/`warn`/`error`/`debug`/`assert`/`clear`) | +| `GameVector3` | 3D vector (coordinate math) | +| `GameBounds3` | Bounding box | +| `GameRGBColor` / `GameRGBAColor` | RGB / RGBA color | +| `GameQuaternion` | Quaternion (rotation math) | [API Overview →](docs/api/README_en.md) · [Find by Task →](docs/api/README_en.md#find-by-task--i-want-to) @@ -101,13 +110,13 @@ All `` arguments support **Tab completion**. [Full command reference From zero to full mini-games. Every example is TypeScript-compiled and ESLint-verified: -| # | Tutorial | Time | What you'll learn | -|---|----------|------|-------------------| -| 1 | [Getting Started](docs/tutorial/01-basics.md) | 10 min | Project setup, first script, chat commands, timers | -| 2 | [Players & Items](docs/tutorial/02-player-items.md) | 15 min | Teleport, flight, items, enchantments, potions, custom items | -| 3 | [Events & Entities](docs/tutorial/03-events-entities.md) | 15 min | Event callbacks, entity spawning, AI, combat, patrols | -| 4 | [Advanced Systems](docs/tutorial/04-advanced-systems.md) | 15 min | Scoreboards, BossBar, teams, world border, cross-script messaging | -| 5 | [Mini-Games](docs/tutorial/05-examples.md) | 20 min | PvP arena, particles & fireworks, wave mobs, visual effects | +| # | Tutorial | Time | What you'll learn | +| --- | -------------------------------------------------------- | ------ | ----------------------------------------------------------------- | +| 1 | [Getting Started](docs/tutorial/01-basics.md) | 10 min | Project setup, first script, chat commands, timers | +| 2 | [Players & Items](docs/tutorial/02-player-items.md) | 15 min | Teleport, flight, items, enchantments, potions, custom items | +| 3 | [Events & Entities](docs/tutorial/03-events-entities.md) | 15 min | Event callbacks, entity spawning, AI, combat, patrols | +| 4 | [Advanced Systems](docs/tutorial/04-advanced-systems.md) | 15 min | Scoreboards, BossBar, teams, world border, cross-script messaging | +| 5 | [Mini-Games](docs/tutorial/05-examples.md) | 20 min | PvP arena, particles & fireworks, wave mobs, visual effects | [Tutorial overview →](docs/tutorial/README.md) @@ -123,6 +132,8 @@ docs/ │ ├── voxels.md Voxels API (read/write, fill, spawner) │ ├── storage.md Storage API (JSON persistence) │ ├── database.md Database API (SQLite) +│ ├── http.md HTTP request API +│ ├── client.md Client API (UI, input, chat, events) │ ├── math.md Math API (Vector3, Color, Quaternion) │ └── commands.md /box3script command reference ├── tutorial/ ← Tutorials @@ -137,15 +148,15 @@ docs/ ## Example Project -`run/config/box3/script/colorzone/` contains a complete Territory Rush game and 7 verified feature examples covering every tutorial scenario. +`run/config/box3/script/colorzone/` contains a complete bidirectional communication game and 7 verified feature examples covering every tutorial scenario — from server logic to client UI. ## Dependencies -| Feature | Requirement | -|---------|-------------| -| Script engine core | Rhino 1.9.1 bundled — no extra install needed | -| `db` API (SQLite) | Requires [`minecraft-sqlite-jdbc`](https://modrinth.com/mod/minecraft-sqlite-jdbc) mod | -| All other APIs | No additional dependencies | +| Feature | Requirement | +| ------------------ | -------------------------------------------------------------------------------------- | +| Script engine core | Rhino 1.9.1 bundled — no extra install needed | +| `db` API (SQLite) | Requires [`minecraft-sqlite-jdbc`](https://modrinth.com/mod/minecraft-sqlite-jdbc) mod | +| All other APIs | No additional dependencies | > Without `minecraft-sqlite-jdbc`, all APIs except `db` work normally. Only calling `db.sql()` triggers an error asking you to install it. diff --git a/Box3JS-NeoForge-1.21.1/docs/BOX3_API_COMPARISON.md b/Box3JS-NeoForge-1.21.1/docs/BOX3_API_COMPARISON.md index dadef76..a3661cc 100644 --- a/Box3JS-NeoForge-1.21.1/docs/BOX3_API_COMPARISON.md +++ b/Box3JS-NeoForge-1.21.1/docs/BOX3_API_COMPARISON.md @@ -1031,9 +1031,34 @@ MC 无内置语音通信,无法实现。 ### 7.6 GameHttpAPI (http) -**状态**: ❌ 未实现 +**状态**: ✅ 已实现(同步调用) -Box3 的 `http.fetch(url, options?)` 用于服务端发起 HTTP 请求。Box3JS 暂未实现。 +Box3 的 `http.fetch(url, options?)` 用于服务端发起 HTTP 请求。 + +| Box3 API | Box3JS 实现 | 状态 | 差异说明 | +|----------|-------------|------|---------| +| `http.fetch(url, options?)` → `Promise` | `http.fetch(url, options?)` → `Response` | ⚠️ | Box3 异步返回 Promise;Box3JS 同步阻塞调用 | +| `options.method` | ✅ | ✅ | GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS | +| `options.headers` | ✅ | ✅ | 键值对 | +| `options.body` (string) | ✅ | ✅ | 文本请求体 | +| `options.body` (ArrayBuffer) | ✅ | ✅ | 二进制请求体 | +| `options.timeout` | ✅ | ✅ | 超时毫秒 | +| — | `options.responseType` | ⬆ | 自动解析:`"json"` / `"text"` / `"arrayBuffer"`,结果见 `resp.data` | +| — | `options.maxBodySize` | ⬆ | 响应体最大字节数,超出截断并标记 `resp.truncated` | +| `Response.ok` | ✅ | ✅ | 状态码 200-299 | +| `Response.status` | ✅ | ✅ | HTTP 状态码 | +| `Response.statusText` | ✅ | ✅ | 状态描述 | +| `Response.headers` | ✅ | ✅ | 响应头键值对 | +| `Response.json()` → `Promise` | `Response.json()` → `any` | ⚠️ | Box3 异步;Box3JS 同步返回,解析失败返回 `null` | +| `Response.text()` → `Promise` | `Response.text()` → `string` | ⚠️ | Box3 异步;Box3JS 同步返回 | +| `Response.arrayBuffer()` → `Promise` | `Response.arrayBuffer()` → `ArrayBuffer` | ⚠️ | Box3 异步;Box3JS 同步返回 | +| — | `Response.getHeader(name)` | ⬆ | 获取单个响应头值 | +| — | `Response.errorMessage` | ⬆ | 请求失败时的错误信息 | +| — | `Response.truncated` | ⬆ | 响应体是否因 maxBodySize 被截断 | +| — | `Response.data` | ⬆ | responseType 自动解析的结果 | +| `Response.close()` | ✅ | ✅ | 关闭连接(Box3JS 为空操作) | + +> **⚠️ 重要差异:** Box3JS 的 `http.fetch()` 是**同步阻塞**调用(Rhino 引擎限制),会阻塞服务器 tick。Box3 原版是异步 Promise。请避免在高频回调(`world.onTick()` 等)中使用。 ### 7.7 GameAnalytics (analytics) @@ -1152,6 +1177,13 @@ Box3 的事件注册方法返回 `GameEventHandlerToken`,可调用 `.cancel()` - `GameQueryResult` — 查询结果(rows, firstRow, columnNames, rowCount, affectedRows, isQuery) - 每个项目独立数据库文件 `config/box3/data/.db` +### 9.7 HTTP 请求 +- `http.fetch(url, options?)` — 同步 HTTP 请求,支持 GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS +- `options.responseType` — 自动解析响应体(`"json"` / `"text"` / `"arrayBuffer"`),结果见 `resp.data` +- `options.maxBodySize` — 响应体大小限制,超出截断并标记 `resp.truncated` +- `Response.getHeader(name)` — 获取单个响应头值 +- `Response.errorMessage` — 请求失败时的错误信息 + --- ## 10. 总结 diff --git a/Box3JS-NeoForge-1.21.1/docs/api/README.md b/Box3JS-NeoForge-1.21.1/docs/api/README.md index 5e50f64..e27b7d0 100644 --- a/Box3JS-NeoForge-1.21.1/docs/api/README.md +++ b/Box3JS-NeoForge-1.21.1/docs/api/README.md @@ -98,6 +98,22 @@ console.log("脚本已加载"); | 查询附近实体 | `world.entitiesInRadius(pos, radius)` | | 查询所有实体 | `world.querySelectorAll("*")` | +### 客户端本地功能(需 Box3JS 客户端 Mod) + +| 我想... | 用这个 | +|---------|--------| +| 客户端每帧执行 | `client.onTick(() => { ... })` | +| 检测按键按下 | `input.isKeyDown("space")` | +| 监听按键事件 | `input.onKeyPress("f", () => { ... })` | +| 客户端播放音效 | `client.playSound("pling", 1.0, 1.0)` | +| 快捷栏上方显示文字 | `ui.showOverlay("文字")` | +| 显示屏幕大标题 | `ui.showTitle("标题", "副标题")` | +| 发送聊天消息 | `chat.sendMessage("消息")` | +| 接收聊天消息 | `chat.onMessage((msg, sender, isSystem) => { ... })` | +| 发送服务端事件 | `remoteChannel.sendServerEvent({ ... })` | +| 接收服务端事件 | `remoteChannel.onClientEvent((event) => { ... })` | +| 客户端本地存储 | `storage.getDataStorage("key")` | + ### 视觉效果 | 我想... | 用这个 | @@ -143,6 +159,16 @@ console.log("脚本已加载"); | SQL 查询 | `db.sql("SELECT ...")` | | SQL 写入 | `db.sql("INSERT INTO ...")` | +### 网络请求 + +| 我想... | 用这个 | +|---------|--------| +| GET 请求 | `http.fetch("https://...")` | +| POST JSON | `http.fetch(url, { method: "POST", headers, body })` | +| 解析 JSON | `resp.json()` 或 `{ responseType: "json" }` | +| 读取文本 | `resp.text()` | +| 设置超时 | `http.fetch(url, { timeout: 5000 })` | + ### 游戏系统 | 我想... | 用这个 | @@ -187,6 +213,12 @@ console.log("脚本已加载"); | `voxels` | ✅ Box3 | 方块操作,见 [voxels.md](voxels.md) | | `storage` | ✅ Box3 | 数据持久化,见 [storage.md](storage.md) | | `db` | ✅ Box3 | SQLite 数据库,见 [database.md](database.md) | +| `http` | 🆕 MC 扩展 | HTTP 请求,见 [http.md](http.md) | +| `client` | 🆕 MC 扩展 | 客户端生命周期与音效,见 [client.md](client.md) | +| `input` | 🆕 MC 扩展 | 客户端键盘输入,见 [client.md](client.md) | +| `ui` | 🆕 MC 扩展 | 客户端屏幕 UI,见 [client.md](client.md) | +| `chat` | 🆕 MC 扩展 | 客户端聊天收发,见 [client.md](client.md) | +| `remoteChannel` | 🆕 MC 扩展 | 服务端↔客户端事件通信,见 [client.md](client.md) | | `console` | ✅ Box3 | 控制台日志输出(`log`/`warn`/`error`/`debug`) | | `GameVector3` | ✅ Box3 | 三维向量,见 [math.md](math.md) | | `GameBounds3` | ✅ Box3 | 包围盒,见 [math.md](math.md) | @@ -211,6 +243,8 @@ console.log("脚本已加载"); | [voxels.md](voxels.md) | 方块读写、区域填充、刷怪笼 | | [storage.md](storage.md) | 数据持久化存储 | | [database.md](database.md) | SQLite 数据库 | +| [http.md](http.md) | HTTP 网络请求 | +| [client.md](client.md) | 客户端脚本:生命周期、键盘输入、屏幕 UI、聊天、remoteChannel、客户端本地存储 | | [math.md](math.md) | GameVector3、GameBounds3、GameRGBColor、GameRGBAColor、GameQuaternion | | [commands.md](commands.md) | `/box3script` 命令参考 | @@ -221,16 +255,24 @@ console.log("脚本已加载"); ``` config/box3/script/mygame/ ├── package.json ← esbuild + Babel + @babel/preset-typescript -├── tsconfig.json +├── tsconfig.base.json ← 公共 TS 编译选项 +├── tsconfig.server.json ← 服务端 TS 配置 +├── tsconfig.client.json ← 客户端 TS 配置 ├── build.mjs ← Babel TS→JS → esbuild bundle → dist/ ├── types/ -│ └── globals.d.ts ← 完整 API 类型声明(IDE 自动补全) +│ ├── shared.d.ts ← 服务端&客户端共享类型 +│ ├── server.d.ts ← 服务端专属类型 +│ └── client.d.ts ← 客户端专属类型 ├── src/ -│ ├── app.ts ← 入口,require() 其他模块 -│ ├── state.ts ← 共享游戏状态 -│ └── ... +│ ├── server/ +│ │ ├── app.ts ← 服务端入口 +│ │ └── ... +│ └── client/ +│ ├── app.ts ← 客户端入口 +│ └── ... └── dist/ - ├── app.js ← 编译产物(模组实际加载此文件) + ├── server.js ← 服务端编译产物 + ├── client.js ← 客户端编译产物 └── -.jar ← 独立 JAR(/box3script compile) ``` @@ -238,7 +280,7 @@ config/box3/script/mygame/ ## 发布部署 -开发调试完成后,将脚本编译为**独立 JAR 模组**,无需 Box3JS 即可运行在任意 NeoForge 服务器: +开发调试完成后,将脚本编译为**独立 JAR 模组**,需与 Box3JS 一同部署在 NeoForge 服务器: ``` /box3script compile <项目名> diff --git a/Box3JS-NeoForge-1.21.1/docs/api/README_en.md b/Box3JS-NeoForge-1.21.1/docs/api/README_en.md index 5376ee7..d83d252 100644 --- a/Box3JS-NeoForge-1.21.1/docs/api/README_en.md +++ b/Box3JS-NeoForge-1.21.1/docs/api/README_en.md @@ -98,6 +98,22 @@ Find APIs by what you want to do, not by which global object they live on. | Query nearby entities | `world.entitiesInRadius(pos, radius)` | | Query all entities | `world.querySelectorAll("*")` | +### Client-side Features (requires Box3JS client mod) + +| I want to... | Use this | +|--------------|----------| +| Run every client tick | `client.onTick(() => { ... })` | +| Check key held down | `input.isKeyDown("space")` | +| Listen for key press | `input.onKeyPress("f", () => { ... })` | +| Play sound on client | `client.playSound("pling", 1.0, 1.0)` | +| Show action bar text | `ui.showOverlay("text")` | +| Show screen title | `ui.showTitle("Title", "Subtitle")` | +| Send chat message | `chat.sendMessage("message")` | +| Receive chat messages | `chat.onMessage((msg, sender, isSystem) => { ... })` | +| Send event to server | `remoteChannel.sendServerEvent({ ... })` | +| Receive event from server | `remoteChannel.onClientEvent((event) => { ... })` | +| Client-side local storage | `storage.getDataStorage("key")` | + ### Visual Effects | I want to... | Use this | @@ -143,6 +159,16 @@ Find APIs by what you want to do, not by which global object they live on. | SQL query | `db.sql("SELECT ...")` | | SQL write | `db.sql("INSERT INTO ...")` | +### HTTP Requests + +| I want to... | Use this | +|-------------|----------| +| GET request | `http.fetch("https://...")` | +| POST JSON | `http.fetch(url, { method: "POST", headers, body })` | +| Parse JSON | `resp.json()` or `{ responseType: "json" }` | +| Read text | `resp.text()` | +| Set timeout | `http.fetch(url, { timeout: 5000 })` | + ### Game Systems | I want to... | Use this | @@ -187,6 +213,12 @@ Find APIs by what you want to do, not by which global object they live on. | `voxels` | ✅ Box3 | Block operations, see [voxels_en.md](voxels_en.md) | | `storage` | ✅ Box3 | Data persistence, see [storage_en.md](storage_en.md) | | `db` | ✅ Box3 | SQLite database, see [database_en.md](database_en.md) | +| `http` | 🆕 MC Extension | HTTP requests, see [http_en.md](http_en.md) | +| `client` | 🆕 MC Extension | Client lifecycle & sound, see [client_en.md](client_en.md) | +| `input` | 🆕 MC Extension | Client keyboard input, see [client_en.md](client_en.md) | +| `ui` | 🆕 MC Extension | Client screen UI, see [client_en.md](client_en.md) | +| `chat` | 🆕 MC Extension | Client chat send/receive, see [client_en.md](client_en.md) | +| `remoteChannel` | 🆕 MC Extension | Server↔client event channel, see [client_en.md](client_en.md) | | `console` | ✅ Box3 | Console logging (`log`/`warn`/`error`/`debug`) | | `GameVector3` | ✅ Box3 | 3D vector, see [math_en.md](math_en.md) | | `GameBounds3` | ✅ Box3 | Bounding box, see [math_en.md](math_en.md) | @@ -211,6 +243,8 @@ Find APIs by what you want to do, not by which global object they live on. | [voxels_en.md](voxels_en.md) | Block read/write, region fill, spawner control | | [storage_en.md](storage_en.md) | Persistent data storage | | [database_en.md](database_en.md) | SQLite database API | +| [http_en.md](http_en.md) | HTTP request API | +| [client_en.md](client_en.md) | Client scripts: lifecycle, keyboard, screen UI, chat, remoteChannel, client-side storage | | [math_en.md](math_en.md) | GameVector3, GameBounds3, GameRGBColor, GameRGBAColor, GameQuaternion | | [commands_en.md](commands_en.md) | `/box3script` command reference | @@ -221,16 +255,24 @@ Projects created with `/box3script create` come with a complete TS build environ ``` config/box3/script/mygame/ ├── package.json ← esbuild + Babel + @babel/preset-typescript -├── tsconfig.json +├── tsconfig.base.json ← Shared TS compiler options +├── tsconfig.server.json ← Server-side TS config +├── tsconfig.client.json ← Client-side TS config ├── build.mjs ← Babel TS→JS → esbuild bundle → dist/ ├── types/ -│ └── globals.d.ts ← Full API type declarations (IDE autocomplete) +│ ├── shared.d.ts ← Shared types (server & client) +│ ├── server.d.ts ← Server-only types +│ └── client.d.ts ← Client-only types ├── src/ -│ ├── app.ts ← Entry point, require() other modules -│ ├── state.ts ← Shared game state -│ └── ... +│ ├── server/ +│ │ ├── app.ts ← Server entry point +│ │ └── ... +│ └── client/ +│ ├── app.ts ← Client entry point +│ └── ... └── dist/ - ├── app.js ← Compiled output (what the mod actually loads) + ├── server.js ← Server compiled output + ├── client.js ← Client compiled output └── -.jar ← Standalone JAR (/box3script compile) ``` @@ -238,7 +280,7 @@ Run `npm run build` to build. Use `/box3script watch` to enable file watching fo ## Deployment -When ready to distribute, compile your script into a **standalone JAR mod** that runs on any NeoForge server without Box3JS: +When ready to distribute, compile your script into a **standalone JAR mod** that runs on any NeoForge server alongside Box3JS: ``` /box3script compile diff --git a/Box3JS-NeoForge-1.21.1/docs/api/client.md b/Box3JS-NeoForge-1.21.1/docs/api/client.md new file mode 100644 index 0000000..7131a2e --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/docs/api/client.md @@ -0,0 +1,256 @@ +# client — 客户端 API + +客户端脚本运行在玩家本地 Minecraft 客户端上,通过以下四个全局对象访问: + +| 对象 | 类型 | 用途 | +|------|------|------| +| `client` | `GameClient` | 生命周期回调、音效、命令发送 | +| `input` | `GameInput` | 键盘输入检测 | +| `ui` | `GameUI` | 屏幕文字显示(ActionBar、标题) | +| `chat` | `GameChat` | 收发聊天消息 | +| `storage` | `GameStorage` | 客户端本地持久化存储 | +| `db` | `GameDatabase` | 客户端本地 SQLite 数据库 | +| `http` | `GameHttpAPI` | HTTP 请求(同步/异步) | +| `remoteChannel` | `RemoteChannel` | 客户端 ↔ 服务端事件通信 | + +> **前置条件:** 客户端必须安装 Box3JS mod,服务端必须启用该项目的客户端脚本并通过网络自动下发。 +> 客户端脚本放在 `src/client/` 目录下,服务端脚本放在 `src/server/` 目录下。 + +## client — 生命周期 & 服务端交互 + +### client.onTick(callback) + +🆕 MC 扩展 | 注册客户端每 tick 回调(每秒 20 次)。无参数,无返回值。 + +```js +client.onTick(() => { + // 每帧更新逻辑 +}); +``` + +> **注意:** 服务端也有 `world.onTick()`,但参数为 `TickInfo` 对象。客户端 `client.onTick()` 无参数。 + +### client.playSound(path, volume, pitch) + +🆕 MC 扩展 | 向当前客户端播放声音。 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `path` | string | (必需) | 声音 ID,如 `"minecraft:block.note_block.pling"` | +| `volume` | number | `1.0` | 音量 (0–1) | +| `pitch` | number | `1.0` | 音高 (0.5–2) | + +```js +client.playSound("minecraft:block.note_block.pling", 1.0, 1.0); +client.playSound("minecraft:entity.experience_orb.pickup", 0.5, 1.5); +``` + +### client.sendCommand(cmd) + +🆕 MC 扩展 | 向服务端发送命令(等同于在聊天框输入 `/` 前缀的命令)。 + +```js +client.sendCommand("spawn"); +client.sendCommand("home"); +``` + +## input — 键盘输入 + +### input.isKeyDown(key) + +🆕 MC 扩展 | 检查指定按键当前是否被按下。 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `key` | string | 按键名称(小写),见下方按键列表 | + +```js +if (input.isKeyDown("space")) { + // 空格键正在被按住 +} +``` + +### input.onKeyPress(key, callback) + +🆕 MC 扩展 | 注册按键按下回调(按下瞬间触发一次)。返回 `GameEventHandlerToken`,调用 `.cancel()` 取消。 + +```js +var token = input.onKeyPress("f", () => { + client.sendCommand("fly"); +}); + +// 取消监听 +token.cancel(); +``` + +### 支持的按键名称 + +| 类别 | 按键 | +|------|------| +| 字母 | `a`–`z` | +| 数字 | `0`–`9` | +| 功能键 | `f1`–`f12` | +| 方向键 | `up`, `down`, `left`, `right` | +| 特殊键 | `space`, `enter`, `escape`, `tab`, `backspace`, `delete` | +| 修饰键 | `left_shift`, `right_shift`, `left_ctrl`, `right_ctrl`, `left_alt`, `right_alt` | + +## ui — 屏幕 UI + +### ui.showOverlay(text) + +🆕 MC 扩展 | 在动作栏(快捷栏上方)显示文字。支持颜色代码(`§a`、`§b` 等)。 + +```js +ui.showOverlay("§a欢迎来到服务器!"); +``` + +### ui.showTitle(title, subtitle, fadeIn?, stay?, fadeOut?) + +🆕 MC 扩展 | 显示屏幕中央大标题。 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `title` | string | (必需) | 主标题 | +| `subtitle` | string | (必需) | 副标题 | +| `fadeIn` | number | `10` | 淡入 tick 数 | +| `stay` | number | `70` | 停留 tick 数 | +| `fadeOut` | number | `20` | 淡出 tick 数 | + +```js +ui.showTitle("Boss 来袭!", "准备战斗", 10, 70, 20); +ui.showTitle("§c游戏结束", "§7再接再厉"); +``` + +### ui.showActionBar(text) + +🆕 MC 扩展 | 在动作栏显示文字(与 `showOverlay` 相同)。 + +```js +ui.showActionBar("§e按 F 键使用技能"); +``` + +## chat — 聊天消息 + +### chat.sendMessage(text) + +🆕 MC 扩展 | 向服务端发送聊天消息。 + +```js +chat.sendMessage("大家好!"); +``` + +### chat.onMessage(handler) + +🆕 MC 扩展 | 注册接收聊天消息的处理器。返回 `GameEventHandlerToken`,调用 `.cancel()` 取消。 + +回调参数:`(message: string, sender: string, isSystem: boolean) => boolean | void` + +返回 `false` 可阻止消息显示在聊天栏。 + +```js +var token = chat.onMessage((message, sender, isSystem) => { + console.log(`[chat] ${sender}: ${message}`); + + if (message.includes("filtered_word")) { + return false; // 阻止该消息显示 + } +}); + +// 取消监听 +token.cancel(); +``` + +## remoteChannel — 客户端 ↔ 服务端通信 + +客户端通过 `remoteChannel` 与服务端进行双向事件通信。事件数据通过 JSON 序列化传输。 + +### remoteChannel.sendServerEvent(event) + +🆕 MC 扩展 | 向服务端发送事件。`event` 为任意 JSON 可序列化的值。 + +```js +remoteChannel.sendServerEvent({ + type: "clientReady", + timestamp: Date.now(), +}); +``` + +### remoteChannel.onClientEvent(handler) + +🆕 MC 扩展 | 注册来自服务端的远程事件处理器。返回 `GameEventHandlerToken`。 + +回调参数:`(event: { tick: number, args: T }) => void` + +```js +remoteChannel.onClientEvent((event) => { + const { tick, args } = event; + + switch (args.type) { + case "ping": + console.log(`[client] Ping: ${args.message}`); + remoteChannel.sendServerEvent({ type: "pong" }); + break; + case "notify": + ui.showOverlay(`§b${args.message}`); + break; + } +}); +``` + +> 服务端对应 API 为 `remoteChannel.sendClientEvent()` / `broadcastClientEvent()` / `onServerEvent()`。 +> 详见 `server.d.ts` 中的类型声明。 + +## storage — 客户端存储 + +客户端也有独立的 `storage`,数据保存在客户端本地 `.minecraft/config/box3/data/<项目名>/` 目录下。API 与服务端 `storage` 完全一致: + +```js +var store = storage.getDataStorage("settings"); +store.set("volume", 0.8); +var volume = store.get("volume"); // 0.8 +``` + +详细 API 参考 [storage.md](storage.md)。 + +## 客户端完整示例 + +```js +// src/client/app.ts + +// 每帧更新 +client.onTick(() => { + if (input.isKeyDown("space")) { + // 空格键被按住 + } +}); + +// 按键触发命令 +input.onKeyPress("g", () => { + client.sendCommand("gamemode creative"); +}); + +// 显示欢迎标题 +ui.showTitle("§a欢迎回来", "§7祝你游戏愉快", 10, 70, 20); + +// 接收聊天 +chat.onMessage((message, sender, isSystem) => { + if (message === "!info") { + ui.showOverlay("§e当前服务器: §f" + sender); + return false; + } +}); + +// 与服务端通信 +remoteChannel.sendServerEvent({ type: "clientLoaded" }); + +remoteChannel.onClientEvent((event) => { + if (event.args.type === "alert") { + client.playSound("minecraft:block.note_block.pling", 1.0, 1.0); + ui.showOverlay("§c" + event.args.message); + } +}); + +console.log("[client] loaded!"); +``` + +全部 🆕 MC 扩展(客户端 API 为 Box3JS 专属,非 Box3 平台原有)。 diff --git a/Box3JS-NeoForge-1.21.1/docs/api/client_en.md b/Box3JS-NeoForge-1.21.1/docs/api/client_en.md new file mode 100644 index 0000000..e127fa2 --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/docs/api/client_en.md @@ -0,0 +1,256 @@ +# client — Client-side API + +Client scripts run locally on the player's Minecraft client and are accessed through four globals: + +| Object | Type | Purpose | +|--------|------|---------| +| `client` | `GameClient` | Lifecycle callbacks, sound playback, command sending | +| `input` | `GameInput` | Keyboard input detection | +| `ui` | `GameUI` | On-screen text (ActionBar, titles) | +| `chat` | `GameChat` | Send and receive chat messages | +| `storage` | `GameStorage` | Client-side persistent key-value storage | +| `db` | `GameDatabase` | Client-side SQLite database | +| `http` | `GameHttpAPI` | HTTP requests (sync/async) | +| `remoteChannel` | `RemoteChannel` | Client ↔ Server event communication | + +> **Prerequisite:** The client must have the Box3JS mod installed. The server must enable the project's client script, which is automatically sent to connecting players. +> Client scripts go in `src/client/`, server scripts in `src/server/`. + +## client — Lifecycle & Server Interaction + +### client.onTick(callback) + +🆕 MC Extension | Registers a callback invoked every client tick (20 times/sec). No parameters, no return value. + +```js +client.onTick(() => { + // Per-frame logic +}); +``` + +> **Note:** Server-side `world.onTick()` receives a `TickInfo` object. Client-side `client.onTick()` receives nothing. + +### client.playSound(path, volume, pitch) + +🆕 MC Extension | Plays a sound on the client. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `path` | string | (required) | Sound ID, e.g. `"minecraft:block.note_block.pling"` | +| `volume` | number | `1.0` | Volume (0–1) | +| `pitch` | number | `1.0` | Pitch (0.5–2) | + +```js +client.playSound("minecraft:block.note_block.pling", 1.0, 1.0); +client.playSound("minecraft:entity.experience_orb.pickup", 0.5, 1.5); +``` + +### client.sendCommand(cmd) + +🆕 MC Extension | Sends a command to the server (equivalent to typing a `/` command in chat). + +```js +client.sendCommand("spawn"); +client.sendCommand("home"); +``` + +## input — Keyboard Input + +### input.isKeyDown(key) + +🆕 MC Extension | Checks whether a key is currently held down. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `key` | string | Key name (lowercase), see key list below | + +```js +if (input.isKeyDown("space")) { + // Space key is held +} +``` + +### input.onKeyPress(key, callback) + +🆕 MC Extension | Registers a callback fired once when the key is pressed. Returns `GameEventHandlerToken`; call `.cancel()` to unregister. + +```js +var token = input.onKeyPress("f", () => { + client.sendCommand("fly"); +}); + +// Unregister +token.cancel(); +``` + +### Supported Key Names + +| Category | Keys | +|----------|------| +| Letters | `a`–`z` | +| Digits | `0`–`9` | +| Function keys | `f1`–`f12` | +| Arrow keys | `up`, `down`, `left`, `right` | +| Special keys | `space`, `enter`, `escape`, `tab`, `backspace`, `delete` | +| Modifiers | `left_shift`, `right_shift`, `left_ctrl`, `right_ctrl`, `left_alt`, `right_alt` | + +## ui — Screen UI + +### ui.showOverlay(text) + +🆕 MC Extension | Displays text in the action bar (above the hotbar). Supports color codes (`§a`, `§b`, etc.). + +```js +ui.showOverlay("§aWelcome to the server!"); +``` + +### ui.showTitle(title, subtitle, fadeIn?, stay?, fadeOut?) + +🆕 MC Extension | Displays a large centered screen title. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `title` | string | (required) | Main title | +| `subtitle` | string | (required) | Subtitle | +| `fadeIn` | number | `10` | Fade-in ticks | +| `stay` | number | `70` | Stay ticks | +| `fadeOut` | number | `20` | Fade-out ticks | + +```js +ui.showTitle("Boss Incoming!", "Get ready", 10, 70, 20); +ui.showTitle("§cGame Over", "§7Try again"); +``` + +### ui.showActionBar(text) + +🆕 MC Extension | Displays text in the action bar (same as `showOverlay`). + +```js +ui.showActionBar("§ePress F to use ability"); +``` + +## chat — Chat Messages + +### chat.sendMessage(text) + +🆕 MC Extension | Sends a chat message to the server. + +```js +chat.sendMessage("Hello everyone!"); +``` + +### chat.onMessage(handler) + +🆕 MC Extension | Registers a handler for incoming chat messages. Returns `GameEventHandlerToken`; call `.cancel()` to unregister. + +Callback: `(message: string, sender: string, isSystem: boolean) => boolean | void` + +Return `false` to suppress the message from appearing in chat. + +```js +var token = chat.onMessage((message, sender, isSystem) => { + console.log(`[chat] ${sender}: ${message}`); + + if (message.includes("filtered_word")) { + return false; // Suppress this message + } +}); + +// Unregister +token.cancel(); +``` + +## remoteChannel — Client ↔ Server Communication + +The client uses `remoteChannel` for bidirectional event communication with the server. Event data is JSON-serialized. + +### remoteChannel.sendServerEvent(event) + +🆕 MC Extension | Sends an event to the server. `event` is any JSON-serializable value. + +```js +remoteChannel.sendServerEvent({ + type: "clientReady", + timestamp: Date.now(), +}); +``` + +### remoteChannel.onClientEvent(handler) + +🆕 MC Extension | Registers a handler for remote events sent from the server. Returns `GameEventHandlerToken`. + +Callback: `(event: { tick: number, args: T }) => void` + +```js +remoteChannel.onClientEvent((event) => { + const { tick, args } = event; + + switch (args.type) { + case "ping": + console.log(`[client] Ping: ${args.message}`); + remoteChannel.sendServerEvent({ type: "pong" }); + break; + case "notify": + ui.showOverlay(`§b${args.message}`); + break; + } +}); +``` + +> Server-side equivalents: `remoteChannel.sendClientEvent()` / `broadcastClientEvent()` / `onServerEvent()`. +> See type declarations in `server.d.ts`. + +## storage — Client-side Storage + +The client has its own `storage`, saving data locally under `.minecraft/config/box3/data//`. The API is identical to the server-side `storage`: + +```js +var store = storage.getDataStorage("settings"); +store.set("volume", 0.8); +var volume = store.get("volume"); // 0.8 +``` + +Full API reference: [storage_en.md](storage_en.md). + +## Complete Client Example + +```js +// src/client/app.ts + +// Per-frame updates +client.onTick(() => { + if (input.isKeyDown("space")) { + // Space key is held + } +}); + +// Key-triggered command +input.onKeyPress("g", () => { + client.sendCommand("gamemode creative"); +}); + +// Welcome title +ui.showTitle("§aWelcome back", "§7Have fun", 10, 70, 20); + +// Chat handler +chat.onMessage((message, sender, isSystem) => { + if (message === "!info") { + ui.showOverlay("§eServer: §f" + sender); + return false; + } +}); + +// Server communication +remoteChannel.sendServerEvent({ type: "clientLoaded" }); + +remoteChannel.onClientEvent((event) => { + if (event.args.type === "alert") { + client.playSound("minecraft:block.note_block.pling", 1.0, 1.0); + ui.showOverlay("§c" + event.args.message); + } +}); + +console.log("[client] loaded!"); +``` + +All 🆕 MC Extension (client APIs are Box3JS-specific, not from the Box3 platform). diff --git a/Box3JS-NeoForge-1.21.1/docs/api/commands.md b/Box3JS-NeoForge-1.21.1/docs/api/commands.md index c99a410..20b200e 100644 --- a/Box3JS-NeoForge-1.21.1/docs/api/commands.md +++ b/Box3JS-NeoForge-1.21.1/docs/api/commands.md @@ -144,7 +144,7 @@ npm install && npm run build **前提条件:** -- 已完成 `npm run build`(`dist/app.js` 存在) +- 已完成 `npm run build`(`dist/server.js` 存在) - 服务器运行在 **JDK**(不是 JRE),因为需要调用 `javac` 编译生成的 `@Mod` 入口类 **输出 JAR 内容:** @@ -154,7 +154,7 @@ mygame-1.0.0.jar ├── META-INF/neoforge.mods.toml ← 模组元数据(依赖 box3js) ├── logo.png ← 模组图标(如有指定) ├── box3script/mygame/MygameMod.class ← @Mod 入口(含硬编码元数据) -└── box3script/mygame/app.js ← 打包的脚本源码 +└── box3script/mygame/server.js ← 打包的脚本源码 ``` **部署:** 将脚本 JAR 与 Box3JS 模组一起放入 `mods/`: @@ -203,7 +203,7 @@ config/box3/ │ ├── types/globals.d.ts │ ├── src/app.ts │ └── dist/ -│ ├── app.js ← 编译产物 +│ ├── server.js ← 编译产物 │ └── -.jar ← 独立 JAR(compile 命令生成) ├── data/ ← SQLite 数据库 (db API) └── storage/ ← storage API 持久化 diff --git a/Box3JS-NeoForge-1.21.1/docs/api/commands_en.md b/Box3JS-NeoForge-1.21.1/docs/api/commands_en.md index 84e0c58..475890d 100644 --- a/Box3JS-NeoForge-1.21.1/docs/api/commands_en.md +++ b/Box3JS-NeoForge-1.21.1/docs/api/commands_en.md @@ -142,7 +142,7 @@ Output filename format: `dist/-.jar`. Compilation runs on a backg **Prerequisites:** -- Project must be built (`npm run build`) — `dist/app.js` must exist +- Project must be built (`npm run build`) — `dist/server.js` must exist - Server must run on **JDK** (not JRE), as `javac` is needed to compile the generated `@Mod` entry class **Output JAR contents:** @@ -152,7 +152,7 @@ mygame-1.0.0.jar ├── META-INF/neoforge.mods.toml ← mod metadata (depends on box3js) ├── logo.png ← mod icon (if specified) ├── box3script/mygame/MygameMod.class ← @Mod entry point (hardcoded metadata) -└── box3script/mygame/app.js ← bundled script source +└── box3script/mygame/server.js ← bundled script source ``` **Deployment:** Place the script JAR alongside the Box3JS mod in `mods/`: @@ -201,7 +201,7 @@ config/box3/ │ ├── types/globals.d.ts │ ├── src/app.ts │ └── dist/ -│ ├── app.js ← compiled output +│ ├── server.js ← compiled output │ └── -.jar ← standalone JAR (compile command) ├── data/ ← SQLite database (db API) └── storage/ ← storage API persistence diff --git a/Box3JS-NeoForge-1.21.1/docs/api/http.md b/Box3JS-NeoForge-1.21.1/docs/api/http.md new file mode 100644 index 0000000..2518ba9 --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/docs/api/http.md @@ -0,0 +1,134 @@ +# HTTP API + +Box3JS 通过全局 `http` 对象提供 HTTP 请求能力,支持全部 HTTP 方法、超时、自定义请求头、自动解析、二进制上传,以及同步/异步两种调用方式。 + +> **同步请求**会阻塞服务器 tick,避免在高频回调中执行长时间请求。**异步请求**(`async: true`)不阻塞 tick,通过回调接收结果。 + +## `http.fetch(url, options?)` + +发送 HTTP 请求,返回 `GameHttpFetchResponse`。 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `url` | `string` | 请求地址 | +| `options` | `GameHttpFetchRequestOptions` | 可选配置 | + +## GameHttpFetchRequestOptions + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `method` | `string` | `"GET"` | HTTP 方法:GET / POST / PUT / DELETE / PATCH / HEAD / OPTIONS | +| `headers` | `object` | `{}` | 请求头,键值对形式 | +| `body` | `string \| ArrayBuffer` | — | 请求体(文本或二进制) | +| `timeout` | `number` | `10000` | 超时时间(毫秒) | +| `responseType` | `string` | — | 自动解析:`"json"` / `"text"` / `"arrayBuffer"` | +| `maxBodySize` | `number` | `0` | 响应体最大字节数,`0` = 不限制。超出部分截断,`resp.truncated = true` | +| `async` | `boolean` | `false` | 设为 `true` 启用异步请求(不阻塞 tick),需同时提供 `onResponse` / `onError` | +| `onResponse` | `function` | — | 异步请求成功回调,参数为 `GameHttpFetchResponse` | +| `onError` | `function` | — | 异步请求失败回调,参数为错误信息字符串 | + +> 设置 `responseType` 后,解析结果可直接通过 `resp.data` 获取,无需手动调 `resp.json()` 等。 +> +> 异步模式下 `fetch()` 返回 `null`,结果通过回调接收。 + +## GameHttpFetchResponse + +### 属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `status` | `number` | HTTP 状态码 | +| `statusText` | `string` | 状态码描述文本 | +| `ok` | `boolean` | 是否成功(状态码 200-299) | +| `errorMessage` | `string` | 错误信息(仅在请求失败时有值) | +| `headers` | `object` | 响应头键值对 | +| `data` | `any` | 自动解析结果(需设置 `responseType`) | +| `truncated` | `boolean` | 响应体是否因超过 `maxBodySize` 被截断 | + +### 方法 + +#### `getHeader(name)` + +获取指定响应头的值,不存在返回 `null`。 + +```js +const ct = resp.getHeader("Content-Type"); +``` + +#### `json()` + +将响应体解析为 JSON 对象。解析失败返回 `null`。 + +#### `text()` + +返回响应体的文本内容。 + +#### `arrayBuffer()` + +返回响应体的字节数组。 + +#### `close()` + +关闭连接(同步实现中为空操作,提供 API 兼容性)。 + +## 示例 + +```js +// GET 请求 +const resp = http.fetch("https://api.example.com/data"); + +// GET + 自动解析 JSON +const data = http.fetch("https://api.example.com/data", { responseType: "json" }).data; + +// POST JSON +const resp2 = http.fetch("https://api.example.com/submit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "test", score: 100 }) +}); +const result = resp2.json(); + +// PUT 更新 +http.fetch("https://api.example.com/item/1", { + method: "PUT", + body: JSON.stringify({ value: 99 }) +}); + +// DELETE 删除 +http.fetch("https://api.example.com/item/1", { method: "DELETE" }); + +// PATCH 部分更新 +http.fetch("https://api.example.com/item/1", { + method: "PATCH", + body: JSON.stringify({ status: "active" }) +}); + +// 自定义超时 +http.fetch("https://slow-server.com/api", { timeout: 5000 }); + +// 读取响应头 +console.log(resp.getHeader("Content-Type")); + +// 二进制上传(ArrayBuffer) +const bytes = new ArrayBuffer(4); +http.fetch("https://api.example.com/upload", { method: "PUT", body: bytes }); + +// 错误处理 +const resp4 = http.fetch("https://invalid.example.com"); +if (!resp4.ok) { + console.log("请求失败:", resp4.errorMessage); +} + +// 异步请求(不阻塞 tick) +http.fetch("https://api.example.com/data", { + async: true, + responseType: "json", + onResponse: function(resp) { + console.log("异步响应:", resp.status, resp.data); + }, + onError: function(err) { + console.log("异步失败:", err); + } +}); +console.log("请求已发出,代码继续执行"); +``` diff --git a/Box3JS-NeoForge-1.21.1/docs/api/http_en.md b/Box3JS-NeoForge-1.21.1/docs/api/http_en.md new file mode 100644 index 0000000..6c230ef --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/docs/api/http_en.md @@ -0,0 +1,134 @@ +# HTTP API + +Box3JS provides HTTP request capabilities via the global `http` object, supporting all HTTP methods, timeout, custom headers, auto-parsing, binary uploads, and both synchronous and asynchronous calling modes. + +> **Synchronous requests** block the server tick — avoid long-running requests in high-frequency callbacks. **Async requests** (`async: true`) are non-blocking and deliver results via callbacks. + +## `http.fetch(url, options?)` + +Sends an HTTP request and returns `GameHttpFetchResponse`. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `url` | `string` | The request URL | +| `options` | `GameHttpFetchRequestOptions` | Optional configuration | + +## GameHttpFetchRequestOptions + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `method` | `string` | `"GET"` | HTTP method: GET / POST / PUT / DELETE / PATCH / HEAD / OPTIONS | +| `headers` | `object` | `{}` | Request headers as key-value pairs | +| `body` | `string \| ArrayBuffer` | — | Request body (text or binary) | +| `timeout` | `number` | `10000` | Timeout in milliseconds | +| `responseType` | `string` | — | Auto-parse: `"json"` / `"text"` / `"arrayBuffer"` | +| `maxBodySize` | `number` | `0` | Max response body bytes, `0` = no limit. Exceeding part is truncated, `resp.truncated = true` | +| `async` | `boolean` | `false` | Set to `true` for non-blocking async request. Must provide `onResponse` / `onError` callbacks | +| `onResponse` | `function` | — | Callback on async success, receives `GameHttpFetchResponse` | +| `onError` | `function` | — | Callback on async failure, receives error message string | + +> When `responseType` is set, the parsed result is available via `resp.data` — no need to call `resp.json()` manually. +> +> In async mode `fetch()` returns `null`. Results are delivered via callbacks. + +## GameHttpFetchResponse + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `status` | `number` | HTTP status code | +| `statusText` | `string` | Status text description | +| `ok` | `boolean` | Whether the request was successful (status 200-299) | +| `errorMessage` | `string` | Error message (only set on failure) | +| `headers` | `object` | Response headers as key-value pairs | +| `data` | `any` | Auto-parsed result (requires `responseType`) | +| `truncated` | `boolean` | Whether the response body was truncated due to `maxBodySize` | + +### Methods + +#### `getHeader(name)` + +Returns a single response header value, or `null` if absent. + +```js +const ct = resp.getHeader("Content-Type"); +``` + +#### `json()` + +Parses the response body as JSON. Returns `null` on parse failure. + +#### `text()` + +Returns the response body as text. + +#### `arrayBuffer()` + +Returns the response body as a byte array. + +#### `close()` + +Closes the connection (no-op in synchronous implementation, provided for API compatibility). + +## Examples + +```js +// GET request +const resp = http.fetch("https://api.example.com/data"); + +// GET with auto-parse JSON +const data = http.fetch("https://api.example.com/data", { responseType: "json" }).data; + +// POST JSON +const resp2 = http.fetch("https://api.example.com/submit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "test", score: 100 }) +}); +const result = resp2.json(); + +// PUT update +http.fetch("https://api.example.com/item/1", { + method: "PUT", + body: JSON.stringify({ value: 99 }) +}); + +// DELETE +http.fetch("https://api.example.com/item/1", { method: "DELETE" }); + +// PATCH partial update +http.fetch("https://api.example.com/item/1", { + method: "PATCH", + body: JSON.stringify({ status: "active" }) +}); + +// Custom timeout +http.fetch("https://slow-server.com/api", { timeout: 5000 }); + +// Read response headers +console.log(resp.getHeader("Content-Type")); + +// Binary upload (ArrayBuffer) +const bytes = new ArrayBuffer(4); +http.fetch("https://api.example.com/upload", { method: "PUT", body: bytes }); + +// Error handling +const resp4 = http.fetch("https://invalid.example.com"); +if (!resp4.ok) { + console.log("Request failed:", resp4.errorMessage); +} + +// Async request (non-blocking) +http.fetch("https://api.example.com/data", { + async: true, + responseType: "json", + onResponse: function(resp) { + console.log("Async response:", resp.status, resp.data); + }, + onError: function(err) { + console.log("Async failed:", err); + } +}); +console.log("Request sent, code continues immediately"); +``` diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/Box3JS.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/Box3JS.java index e18942f..9b6885f 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/Box3JS.java +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/Box3JS.java @@ -1,5 +1,6 @@ package com.box3lab.box3js; +import com.box3lab.box3js.client.Box3JSClientEngine; import com.box3lab.box3js.registries.Box3JSCustomItems; import com.box3lab.box3js.registries.Box3JSRecipeManager; import com.box3lab.box3js.script.Box3ScriptCommand; @@ -10,8 +11,12 @@ import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; import java.nio.file.Path; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import net.neoforged.neoforge.event.ServerChatEvent; import net.neoforged.neoforge.event.entity.living.LivingDamageEvent; import net.neoforged.neoforge.event.entity.living.LivingDeathEvent; @@ -28,10 +33,47 @@ public class Box3JS { public static final String MODID = "box3js"; public static final Logger LOGGER = LogUtils.getLogger(); + /** Tracks which connected players have Box3JS installed on their client. */ + public static final Set clientsWithBox3JS = ConcurrentHashMap.newKeySet(); + public Box3JS(IEventBus modEventBus, ModContainer modContainer) { // Custom items via data components + resource pack (no DeferredRegister, no registry sync) Box3JSCustomItems.init(Path.of(".").toAbsolutePath().normalize()); + // Register custom payloads + modEventBus.addListener(RegisterPayloadHandlersEvent.class, event -> { + var registrar = event.registrar("1"); + + // Server → Client: send client scripts on join (optional — client without mod can still connect) + registrar.optional().playToClient( + Box3JSNetwork.ClientScriptPayload.TYPE, + Box3JSNetwork.ClientScriptPayload.STREAM_CODEC, + (payload, context) -> Box3JSClientEngine.get() + .loadScript(payload.projectName(), payload.scriptSource()) + ); + + // Server → Client: remote event from server (optional) + registrar.optional().playToClient( + Box3JSNetwork.ServerEventPayload.TYPE, + Box3JSNetwork.ServerEventPayload.STREAM_CODEC, + (payload, context) -> Box3JSClientEngine.get() + .fireClientEvent(payload.projectName(), payload.tick(), payload.eventJson()) + ); + + // Client → Server: remote event from client (optional) + registrar.optional().playToServer( + Box3JSNetwork.ClientEventPayload.TYPE, + Box3JSNetwork.ClientEventPayload.STREAM_CODEC, + (payload, context) -> { + if (context.player() instanceof ServerPlayer sp) { + clientsWithBox3JS.add(sp.getUUID()); + Box3ScriptEngine.get().handleClientEvent( + sp, payload.projectName(), payload.eventJson()); + } + } + ); + }); + // Script commands NeoForge.EVENT_BUS.addListener(Box3ScriptCommand::register); @@ -42,9 +84,13 @@ public Box3JS(IEventBus modEventBus, ModContainer modContainer) { // Player join / leave NeoForge.EVENT_BUS.addListener((PlayerEvent.PlayerLoggedInEvent event) -> { + Box3JSNetwork.sendClientScripts((ServerPlayer) event.getEntity()); Box3ScriptEngine.get().firePlayerJoin((ServerPlayer) event.getEntity()); }); NeoForge.EVENT_BUS.addListener((PlayerEvent.PlayerLoggedOutEvent event) -> { + if (event.getEntity() instanceof ServerPlayer sp) { + clientsWithBox3JS.remove(sp.getUUID()); + } Box3ScriptEngine.get().firePlayerLeave((ServerPlayer) event.getEntity()); }); diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/Box3JSNetwork.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/Box3JSNetwork.java new file mode 100644 index 0000000..cbe5573 --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/Box3JSNetwork.java @@ -0,0 +1,126 @@ +package com.box3lab.box3js; + +import com.box3lab.box3js.script.Box3ScriptConfig; +import net.minecraft.network.ConnectionProtocol; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.neoforge.network.PacketDistributor; +import net.neoforged.neoforge.network.registration.NetworkRegistry; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public final class Box3JSNetwork { + + private Box3JSNetwork() {} + + // ── Payloads ── + + public record ClientScriptPayload(String projectName, String scriptSource) + implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(ResourceLocation.fromNamespaceAndPath(Box3JS.MODID, "client_script")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.STRING_UTF8, ClientScriptPayload::projectName, + ByteBufCodecs.STRING_UTF8, ClientScriptPayload::scriptSource, + ClientScriptPayload::new + ); + + @Override + public Type type() { + return TYPE; + } + } + + /** Server → client: a remote event targeting the client's project handlers. */ + public record ServerEventPayload(String projectName, long tick, String eventJson) + implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(ResourceLocation.fromNamespaceAndPath(Box3JS.MODID, "server_event")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.STRING_UTF8, ServerEventPayload::projectName, + ByteBufCodecs.VAR_LONG, ServerEventPayload::tick, + ByteBufCodecs.STRING_UTF8, ServerEventPayload::eventJson, + ServerEventPayload::new + ); + + @Override + public Type type() { + return TYPE; + } + } + + /** Client → server: a remote event targeting the server project's handlers. */ + public record ClientEventPayload(String projectName, String eventJson) + implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(ResourceLocation.fromNamespaceAndPath(Box3JS.MODID, "client_event")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.STRING_UTF8, ClientEventPayload::projectName, + ByteBufCodecs.STRING_UTF8, ClientEventPayload::eventJson, + ClientEventPayload::new + ); + + @Override + public Type type() { + return TYPE; + } + } + + // ── Server-side: send client scripts to a joining player ── + + private static final ResourceLocation CLIENT_SCRIPT_ID = + ResourceLocation.fromNamespaceAndPath(Box3JS.MODID, "client_script"); + + public static void sendClientScripts(ServerPlayer player) { + // Only send to clients that have the optional channel negotiated + if (!NetworkRegistry.hasChannel( + player.connection.getConnection(), + ConnectionProtocol.PLAY, + CLIENT_SCRIPT_ID)) { + return; + } + + var server = player.getServer(); + if (server == null) return; + + Path scriptDir = Box3ScriptConfig.get().getScriptDir(server); + if (!Files.exists(scriptDir)) return; + + try (var dirs = Files.list(scriptDir)) { + dirs.filter(Files::isDirectory).forEach(projectDir -> { + String name = projectDir.getFileName().toString(); + if (!Box3ScriptConfig.get().isEnabled(name)) return; + + Path clientJs = projectDir.resolve("dist/client.js"); + if (!Files.exists(clientJs)) { + clientJs = projectDir.resolve("client.js"); + } + if (!Files.exists(clientJs)) return; + + try { + String source = Files.readString(clientJs, StandardCharsets.UTF_8); + PacketDistributor.sendToPlayer(player, new ClientScriptPayload(name, source)); + Box3JS.LOGGER.debug("Sent client script '{}' to {}", name, player.getName().getString()); + } catch (IOException e) { + Box3JS.LOGGER.error("Failed to send client script '{}': {}", name, e.getMessage()); + } + }); + } catch (IOException ignored) {} + } +} diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientDatabase.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientDatabase.java new file mode 100644 index 0000000..8464102 --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientDatabase.java @@ -0,0 +1,218 @@ +package com.box3lab.box3js.client; + +import com.box3lab.box3js.script.Box3JSQueryResult; +import com.mojang.logging.LogUtils; +import org.mozilla.javascript.NativeArray; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.*; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Client-side SQLite database exposed to JS as the {@code db} global. + * + *

Database files are stored at {@code /box3/client-db/.db}. + */ +public class Box3JSClientDatabase { + + private static final Logger LOGGER = LogUtils.getLogger(); + + private static final String SQLITE_DRIVER_CLASS = "org.sqlite.JDBC"; + private static final String SQLITE_MISSING_HINT = "db API requires SQLite JDBC driver. Install the minecraft-sqlite-jdbc mod."; + private static final boolean SQLITE_AVAILABLE; + + private final Path dataDir; + private String projectName; + private Connection connection; + + static { + boolean ok; + try { + Class.forName(SQLITE_DRIVER_CLASS); + ok = true; + } catch (ClassNotFoundException e) { + ok = false; + LOGGER.warn("{}", SQLITE_MISSING_HINT); + } + SQLITE_AVAILABLE = ok; + } + + public Box3JSClientDatabase(java.io.File gameDir) { + this.dataDir = gameDir.toPath().resolve("box3").resolve("client-db"); + try { Files.createDirectories(dataDir); } catch (IOException ignored) {} + } + + public void setProjectName(String name) { + if (!name.equals(this.projectName)) { + close(); + this.projectName = name; + this.connection = null; + } + } + + public Box3JSQueryResult sql(Object... args) { + ensureSqliteAvailable(); + + if (args.length == 0) { + throw new IllegalArgumentException("db.sql() requires at least a SQL string argument"); + } + + String sql; + Object[] params; + + if (args[0] instanceof String s) { + sql = s; + params = new Object[args.length - 1]; + System.arraycopy(args, 1, params, 0, params.length); + } else if (args[0] instanceof NativeArray parts) { + StringBuilder sb = new StringBuilder(); + long len = parts.getLength(); + int paramCount = args.length - 1; + for (int i = 0; i < len; i++) { + sb.append(parts.get(i).toString()); + if (i < paramCount) { + sb.append("?"); + } + } + sql = sb.toString(); + params = new Object[paramCount]; + System.arraycopy(args, 1, params, 0, params.length); + } else { + throw new IllegalArgumentException( + "db.sql(): first argument must be a SQL string or string array"); + } + + Connection conn = getConnection(); + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (int i = 0; i < params.length; i++) { + bindParam(stmt, i + 1, params[i]); + } + + boolean isQuery = stmt.execute(); + if (isQuery) { + try (ResultSet rs = stmt.getResultSet()) { + return readResultSet(rs); + } + } else { + int count = stmt.getUpdateCount(); + return new Box3JSQueryResult(count); + } + } catch (SQLException e) { + LOGGER.error("SQL error: {}", e.getMessage()); + throw new RuntimeException("SQL error: " + e.getMessage(), e); + } + } + + public void close() { + if (connection != null) { + try { + if (!connection.isClosed()) { + connection.close(); + } + } catch (SQLException e) { + LOGGER.warn("Error closing client database: {}", e.getMessage()); + } + connection = null; + } + } + + private Connection getConnection() { + ensureSqliteAvailable(); + if (projectName == null) { + throw new IllegalStateException("db: no active project context"); + } + + if (connection == null) { + try { + Path dbFile = dataDir.resolve(projectName + ".db"); + Files.createDirectories(dbFile.getParent()); + String url = "jdbc:sqlite:" + dbFile.toAbsolutePath().toString().replace('\\', '/'); + connection = DriverManager.getConnection(url); + try (Statement stmt = connection.createStatement()) { + stmt.execute("PRAGMA journal_mode=WAL"); + } + LOGGER.info("Opened client database for project {}: {}", projectName, dbFile); + } catch (IOException | SQLException e) { + LOGGER.error("Failed to open client database: {}", e.getMessage()); + throw new RuntimeException("Failed to open database: " + e.getMessage(), e); + } + } + + try { + if (connection.isClosed()) { + connection = null; + return getConnection(); + } + } catch (SQLException e) { + connection = null; + return getConnection(); + } + + return connection; + } + + private void bindParam(PreparedStatement stmt, int index, Object value) throws SQLException { + if (value == null || value == org.mozilla.javascript.Undefined.instance) { + stmt.setNull(index, java.sql.Types.NULL); + } else if (value instanceof Number n) { + double d = n.doubleValue(); + if (d == Math.floor(d) && d <= Long.MAX_VALUE && d >= Long.MIN_VALUE) { + stmt.setLong(index, (long) d); + } else { + stmt.setDouble(index, d); + } + } else if (value instanceof Boolean b) { + stmt.setBoolean(index, b); + } else if (value instanceof String s) { + stmt.setString(index, s); + } else if (value instanceof NativeArray arr) { + byte[] bytes = new byte[(int) arr.getLength()]; + for (int i = 0; i < bytes.length; i++) { + Object elem = arr.get(i); + bytes[i] = (byte) (elem instanceof Number n ? n.intValue() : 0); + } + stmt.setBytes(index, bytes); + } else { + stmt.setString(index, value.toString()); + } + } + + /** @see Box3JSDatabase#isAvailable() */ + public static boolean isAvailable() { + return SQLITE_AVAILABLE; + } + + private static void ensureSqliteAvailable() { + if (!SQLITE_AVAILABLE) { + throw new IllegalStateException(SQLITE_MISSING_HINT); + } + } + + private Box3JSQueryResult readResultSet(ResultSet rs) throws SQLException { + ResultSetMetaData meta = rs.getMetaData(); + int colCount = meta.getColumnCount(); + String[] columnNames = new String[colCount]; + for (int i = 0; i < colCount; i++) { + columnNames[i] = meta.getColumnName(i + 1); + } + + List> rows = new ArrayList<>(); + while (rs.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < colCount; i++) { + Object value = rs.getObject(i + 1); + row.put(columnNames[i], value); + } + rows.add(row); + } + + return new Box3JSQueryResult(rows, columnNames); + } +} diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientEngine.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientEngine.java new file mode 100644 index 0000000..787e2a0 --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientEngine.java @@ -0,0 +1,778 @@ +package com.box3lab.box3js.client; + +import com.box3lab.box3js.Box3JSNetwork; +import com.box3lab.box3js.script.Box3JSQueryResult; +import com.box3lab.box3js.script.GameBounds3; +import com.box3lab.box3js.script.GameEventHandlerToken; +import com.box3lab.box3js.script.GameQuaternion; +import com.box3lab.box3js.script.GameRGBAColor; +import com.box3lab.box3js.script.GameRGBColor; +import com.box3lab.box3js.script.GameVector3; +import com.mojang.blaze3d.platform.InputConstants; +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.game.ClientboundSetSubtitleTextPacket; +import net.minecraft.network.protocol.game.ClientboundSetTitleTextPacket; +import net.minecraft.network.protocol.game.ClientboundSetTitlesAnimationPacket; +import net.minecraft.sounds.SoundSource; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.client.event.ClientChatReceivedEvent; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.InputEvent; +import net.neoforged.neoforge.network.PacketDistributor; +import org.mozilla.javascript.*; +import org.slf4j.Logger; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Singleton client-side Rhino engine. + * + *

Evaluates scripts received from the server via {@code ClientScriptPayload} + * and exposes a {@code client} global for per-frame callbacks and UI overlays. + * + *

This class is only ever loaded on the physical client, because the sole + * code path that references it is the payload-handler lambda registered in + * {@code Box3JS.java}. The JVM resolves lambda-method symbolic references + * lazily, so the server classloader never touches this package. + */ +public class Box3JSClientEngine { + + private static final Logger LOGGER = LogUtils.getLogger(); + private static final Box3JSClientEngine INSTANCE = new Box3JSClientEngine(); + + // ── Rhino scope ── + + private ScriptableObject scope; + private boolean initialized; + + // ── Callbacks ── + + private final List tickCallbacks = new CopyOnWriteArrayList<>(); + private final Map> clientEventHandlers = new ConcurrentHashMap<>(); + private final Map> keyPressHandlers = new ConcurrentHashMap<>(); + private final List chatMessageHandlers = new CopyOnWriteArrayList<>(); + private volatile boolean tickRegistered; + private volatile boolean keyRegistered; + private volatile boolean chatRegistered; + private String currentProject = ""; + private Box3JSClientStorage storage; + private Box3JSClientDatabase database; + private Box3JSClientHttp http; + private volatile boolean dbWarningShown; + + private static final Map KEY_MAP = new HashMap<>(); + static { + // Letters a-z + for (char c = 'a'; c <= 'z'; c++) { + KEY_MAP.put(String.valueOf(c), InputConstants.KEY_A + (c - 'a')); + } + // Numbers 0-9 + for (int i = 0; i <= 9; i++) { + KEY_MAP.put(String.valueOf(i), InputConstants.KEY_0 + i); + } + // Function keys f1-f12 + for (int i = 1; i <= 12; i++) { + KEY_MAP.put("f" + i, InputConstants.KEY_F1 + (i - 1)); + } + // Special keys + KEY_MAP.put("space", InputConstants.KEY_SPACE); + KEY_MAP.put("enter", InputConstants.KEY_RETURN); + KEY_MAP.put("escape", InputConstants.KEY_ESCAPE); + KEY_MAP.put("tab", InputConstants.KEY_TAB); + KEY_MAP.put("backspace", InputConstants.KEY_BACKSPACE); + KEY_MAP.put("delete", InputConstants.KEY_DELETE); + KEY_MAP.put("left_shift", InputConstants.KEY_LSHIFT); + KEY_MAP.put("right_shift", InputConstants.KEY_RSHIFT); + KEY_MAP.put("left_ctrl", InputConstants.KEY_LCONTROL); + KEY_MAP.put("right_ctrl", InputConstants.KEY_RCONTROL); + KEY_MAP.put("left_alt", InputConstants.KEY_LALT); + KEY_MAP.put("right_alt", InputConstants.KEY_RALT); + KEY_MAP.put("up", InputConstants.KEY_UP); + KEY_MAP.put("down", InputConstants.KEY_DOWN); + KEY_MAP.put("left", InputConstants.KEY_LEFT); + KEY_MAP.put("right", InputConstants.KEY_RIGHT); + } + + public static Box3JSClientEngine get() { return INSTANCE; } + + private Box3JSClientEngine() {} + + // ── Initialisation ── + + private void init() { + if (initialized) return; + + Context cx = Context.enter(); + cx.setOptimizationLevel(-1); + try { + scope = cx.initStandardObjects(); + + // -- console -------------------------------------------------- + ScriptableObject.putProperty(scope, "_jConsole", + Context.javaToJS(new Box3JSClientConsole(), scope)); + cx.evaluateString(scope, """ + var console = { + log: function() { + var msg = []; + for (var i = 0; i < arguments.length; i++) + msg.push(String(arguments[i])); + _jConsole.log(msg.join(' ')); + }, + debug: function() { + var msg = []; + for (var i = 0; i < arguments.length; i++) + msg.push(String(arguments[i])); + _jConsole.debug(msg.join(' ')); + }, + warn: function() { + var msg = []; + for (var i = 0; i < arguments.length; i++) + msg.push(String(arguments[i])); + _jConsole.warn(msg.join(' ')); + }, + error: function() { + var msg = []; + for (var i = 0; i < arguments.length; i++) + msg.push(String(arguments[i])); + _jConsole.error(msg.join(' ')); + }, + clear: function() {}, + assert: function(condition) { + if (!condition) { + var msg = ['Assertion failed:']; + for (var i = 1; i < arguments.length; i++) + msg.push(String(arguments[i])); + _jConsole.error(msg.join(' ')); + } + } + }; + """, "console-init", 1, null); + + // -- math types (same bindings as server engine) --------------- + ScriptableObject.putProperty(scope, "GameVector3", + new NativeJavaClass(scope, GameVector3.class)); + ScriptableObject.putProperty(scope, "GameBounds3", + new NativeJavaClass(scope, GameBounds3.class)); + ScriptableObject.putProperty(scope, "GameRGBColor", + new NativeJavaClass(scope, GameRGBColor.class)); + ScriptableObject.putProperty(scope, "GameRGBAColor", + new NativeJavaClass(scope, GameRGBAColor.class)); + ScriptableObject.putProperty(scope, "GameQuaternion", + new NativeJavaClass(scope, GameQuaternion.class)); + ScriptableObject.putProperty(scope, "GameEventHandlerToken", + new NativeJavaClass(scope, GameEventHandlerToken.class)); + ScriptableObject.putProperty(scope, "Box3JSQueryResult", + new NativeJavaClass(scope, Box3JSQueryResult.class)); + + // -- client global (lifecycle + server-bound actions) ---------- + ScriptableObject clientObj = (ScriptableObject) cx.newObject(scope); + + // client.onTick(callback) + ScriptableObject.putProperty(clientObj, "onTick", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length > 0 && args[0] instanceof Function fn) { + tickCallbacks.add(() -> { + Context cx2 = Context.enter(); + try { + fn.call(cx2, scope, scope, new Object[0]); + } catch (Exception e) { + LOGGER.error("Client tick callback error", e); + } finally { + Context.exit(); + } + }); + } + return Undefined.instance; + } + }); + + // client.playSound(path, volume, pitch) + ScriptableObject.putProperty(clientObj, "playSound", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length < 1) return Undefined.instance; + String path = args[0].toString(); + float volume = args.length > 1 && args[1] instanceof Number n ? n.floatValue() : 1f; + float pitch = args.length > 2 && args[2] instanceof Number n ? n.floatValue() : 1f; + Minecraft.getInstance().execute(() -> { + var player = Minecraft.getInstance().player; + if (player == null) return; + var rl = net.minecraft.resources.ResourceLocation.tryParse(path); + if (rl == null) return; + var holder = BuiltInRegistries.SOUND_EVENT.getHolder(rl); + holder.ifPresent(h -> player.playNotifySound(h.value(), SoundSource.PLAYERS, volume, pitch)); + }); + return Undefined.instance; + } + }); + + // client.sendCommand(cmd) + ScriptableObject.putProperty(clientObj, "sendCommand", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length < 1) return Undefined.instance; + String cmd = args[0].toString(); + Minecraft.getInstance().execute(() -> { + var conn = Minecraft.getInstance().getConnection(); + if (conn != null) conn.sendCommand(cmd); + }); + return Undefined.instance; + } + }); + + ScriptableObject.putProperty(scope, "client", clientObj); + + // -- input global (keyboard) ----------------------------------- + ScriptableObject inputObj = (ScriptableObject) cx.newObject(scope); + + // input.isKeyDown(key) + ScriptableObject.putProperty(inputObj, "isKeyDown", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length < 1) return false; + String key = args[0].toString().toLowerCase(); + Integer code = KEY_MAP.get(key); + if (code == null) return false; + long window = Minecraft.getInstance().getWindow().getWindow(); + return InputConstants.isKeyDown(window, code); + } + }); + + // input.onKeyPress(key, callback) + ScriptableObject.putProperty(inputObj, "onKeyPress", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length < 2 || !(args[0] instanceof String key) + || !(args[1] instanceof Function fn)) + return Undefined.instance; + String lc = key.toLowerCase(); + keyPressHandlers.computeIfAbsent(lc, + k -> new CopyOnWriteArrayList<>()).add(fn); + registerKeyListener(); + return new GameEventHandlerToken(() -> { + List list = keyPressHandlers.get(lc); + if (list != null) list.remove(fn); + }); + } + }); + + ScriptableObject.putProperty(scope, "input", inputObj); + + // -- ui global (screen overlays) -------------------------------- + ScriptableObject uiObj = (ScriptableObject) cx.newObject(scope); + + // ui.showOverlay(text) + ScriptableObject.putProperty(uiObj, "showOverlay", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length > 0 && args[0] instanceof String text) { + Minecraft.getInstance().execute(() -> { + var player = Minecraft.getInstance().player; + if (player != null) { + player.displayClientMessage( + Component.literal(text), true); + } + }); + } + return Undefined.instance; + } + }); + + // ui.showTitle(title, subtitle, fadeIn?, stay?, fadeOut?) + ScriptableObject.putProperty(uiObj, "showTitle", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length < 2) return Undefined.instance; + String title = args[0] instanceof String s ? s : args[0].toString(); + String subtitle = args[1] instanceof String s ? s : args[1].toString(); + int fadeIn = args.length > 2 && args[2] instanceof Number n ? n.intValue() : 10; + int stay = args.length > 3 && args[3] instanceof Number n ? n.intValue() : 70; + int fadeOut = args.length > 4 && args[4] instanceof Number n ? n.intValue() : 20; + Minecraft.getInstance().execute(() -> { + var conn = Minecraft.getInstance().getConnection(); + if (conn != null) { + conn.setTitlesAnimation( + new ClientboundSetTitlesAnimationPacket(fadeIn, stay, fadeOut)); + conn.setTitleText( + new ClientboundSetTitleTextPacket(Component.literal(title))); + if (!subtitle.isEmpty()) { + conn.setSubtitleText( + new ClientboundSetSubtitleTextPacket(Component.literal(subtitle))); + } + } + }); + return Undefined.instance; + } + }); + + // ui.showActionBar(text) + ScriptableObject.putProperty(uiObj, "showActionBar", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length > 0) { + String text = args[0] instanceof String s ? s : args[0].toString(); + Minecraft.getInstance().execute(() -> { + var player = Minecraft.getInstance().player; + if (player != null) { + player.displayClientMessage(Component.literal(text), true); + } + }); + } + return Undefined.instance; + } + }); + + ScriptableObject.putProperty(scope, "ui", uiObj); + + // -- chat global (messaging) ------------------------------------ + ScriptableObject chatObj = (ScriptableObject) cx.newObject(scope); + + // chat.sendMessage(text) + ScriptableObject.putProperty(chatObj, "sendMessage", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length < 1) return Undefined.instance; + String text = args[0].toString(); + Minecraft.getInstance().execute(() -> { + var conn = Minecraft.getInstance().getConnection(); + if (conn != null) conn.sendChat(text); + }); + return Undefined.instance; + } + }); + + // chat.onMessage(handler) — returns GameEventHandlerToken + ScriptableObject.putProperty(chatObj, "onMessage", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length < 1 || !(args[0] instanceof Function fn)) + return Undefined.instance; + chatMessageHandlers.add(fn); + registerChatListener(); + return new GameEventHandlerToken(() -> chatMessageHandlers.remove(fn)); + } + }); + + ScriptableObject.putProperty(scope, "chat", chatObj); + + // -- remoteChannel global --------------------------------------- + ScriptableObject rcObj = (ScriptableObject) cx.newObject(scope); + + // remoteChannel.sendServerEvent(event) + ScriptableObject.putProperty(rcObj, "sendServerEvent", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length > 0) { + String json = stringify(cx, scope, args[0]); + if (json != null) { + PacketDistributor.sendToServer( + new Box3JSNetwork.ClientEventPayload(currentProject, json)); + } + } + return Undefined.instance; + } + }); + + // remoteChannel.onClientEvent(handler) + ScriptableObject.putProperty(rcObj, "onClientEvent", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length > 0 && args[0] instanceof Function fn) { + String project = currentProject; + clientEventHandlers.computeIfAbsent(project, + k -> new CopyOnWriteArrayList<>()).add(fn); + return new GameEventHandlerToken(() -> { + List list = clientEventHandlers.get(project); + if (list != null) list.remove(fn); + }); + } + return Undefined.instance; + } + }); + + ScriptableObject.putProperty(scope, "remoteChannel", rcObj); + + // -- storage global ------------------------------------------ + storage = new Box3JSClientStorage( + Minecraft.getInstance().gameDirectory, currentProject); + ScriptableObject.putProperty(scope, "storage", + Context.javaToJS(storage, scope)); + + // -- db global ------------------------------------------------ + database = new Box3JSClientDatabase( + Minecraft.getInstance().gameDirectory); + ScriptableObject dbObj = (ScriptableObject) cx.newObject(scope); + ScriptableObject.putProperty(dbObj, "isAvailable", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + return Box3JSClientDatabase.isAvailable(); + } + }); + ScriptableObject.putProperty(dbObj, "sql", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + try { + return database.sql(args); + } catch (RuntimeException e) { + if (!dbWarningShown) { + dbWarningShown = true; + String hint = e.getMessage() != null ? e.getMessage() + : "db API unavailable — missing SQLite driver"; + LOGGER.warn("db.sql() failed: {}", hint); + Minecraft.getInstance().execute(() -> { + var player = Minecraft.getInstance().player; + if (player != null) { + player.displayClientMessage( + Component.literal("§c[Box3JS] db unavailable — install minecraft-sqlite-jdbc mod"), false); + } + }); + } + // Return empty safe result instead of crashing + return new Box3JSQueryResult(0); + } + } + }); + ScriptableObject.putProperty(scope, "db", dbObj); + + // -- http global ---------------------------------------------- + http = new Box3JSClientHttp(); + ScriptableObject httpObj = (ScriptableObject) cx.newObject(scope); + ScriptableObject.putProperty(httpObj, "fetch", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + if (args.length < 1) return null; + String url = args[0].toString(); + @SuppressWarnings("unchecked") + Map options = args.length > 1 && args[1] instanceof Map + ? (Map) args[1] : null; + return http.fetch(url, options); + } + }); + ScriptableObject.putProperty(scope, "http", httpObj); + + // -- regex helpers (pure JS, mirrored from server engine) ---- + cx.evaluateString(scope, + "(function(){" + + "function isSp(c){return c==' '||c=='\\t'||c=='\\n'||c=='\\r'||c=='\\f'||c=='\\v';}" + + "function isDi(c){return c>='0'&&c<='9';}" + + "function isWo(c){return(c>='a'&&c<='z')||(c>='A'&&c<='Z')||(c>='0'&&c<='9')||c=='_';}" + + "function parse(p,f){" + + "var a=[];var i=0;var ic=f.indexOf('i')>=0;" + + "while(i=0;};" + + "}else{" + + "var lit=ch;var low=ic?lit.toLowerCase():lit;var up=ic?lit.toUpperCase():lit;" + + "if(ic)m=function(c){return c==low||c==up;};" + + "else m=function(c){return c==lit;};" + + "i++;" + + "}" + + "var min=1,max=1;" + + "if(i=0&&cnt>=at.max)break;" + + "}" + + "if(cnt0)return {index:i,length:len};" + + "}" + + "return null;" + + "}" + + "function findAll(s,a){" + + "var ms=[];var pos=0;" + + "while(pos=0;" + + "var ms=_ref.findAll(s,a);var rs='';var pos=0;" + + "for(var i=0;i { + if (!tickRegistered) { + NeoForge.EVENT_BUS.addListener( + ClientTickEvent.Post.class, + event -> fireTick() + ); + tickRegistered = true; + LOGGER.debug("Client tick handler registered"); + } + }); + } + + cx.evaluateString(scope, scriptSource, + "client/" + projectName, 1, null); + LOGGER.info("Client script '{}' loaded", projectName); + } catch (Exception e) { + LOGGER.error("Failed to load client script '{}': {}", + projectName, e.getMessage()); + } finally { + Context.exit(); + } + } + + // ── Tick dispatch ── + + private void fireTick() { + for (Runnable cb : tickCallbacks) { + try { + cb.run(); + } catch (Exception e) { + LOGGER.error("Client tick callback error", e); + } + } + } + + private void registerKeyListener() { + if (keyRegistered) return; + Minecraft.getInstance().execute(() -> { + if (keyRegistered) return; + NeoForge.EVENT_BUS.addListener(InputEvent.Key.class, event -> { + if (event.getAction() != InputConstants.PRESS) return; + int code = event.getKey(); + for (var entry : keyPressHandlers.entrySet()) { + Integer mapped = KEY_MAP.get(entry.getKey()); + if (mapped != null && mapped == code) { + for (Function fn : entry.getValue()) { + Context cx = Context.enter(); + try { + fn.call(cx, scope, scope, new Object[0]); + } catch (Exception e) { + LOGGER.error("Key press handler error", e); + } finally { + Context.exit(); + } + } + } + } + }); + keyRegistered = true; + }); + } + + private void registerChatListener() { + if (chatRegistered) return; + Minecraft.getInstance().execute(() -> { + if (chatRegistered) return; + NeoForge.EVENT_BUS.addListener(ClientChatReceivedEvent.class, event -> { + if (chatMessageHandlers.isEmpty()) return; + String message = event.getMessage().getString(); + String sender = event.getSender() != null + ? event.getSender().toString() : ""; + boolean isSystem = event.isSystem(); + for (Function fn : chatMessageHandlers) { + Context cx = Context.enter(); + try { + Object result = fn.call(cx, scope, scope, + new Object[]{message, sender, isSystem}); + if (result instanceof Boolean b && !b) { + event.setCanceled(true); + } + } catch (Exception e) { + LOGGER.error("Chat message handler error", e); + } finally { + Context.exit(); + } + } + }); + chatRegistered = true; + }); + } + + // ── Remote event dispatch ── + + /** + * Called from the network payload handler when a {@code ServerEventPayload} + * arrives (server → client). Dispatches to the render thread. + */ + public void fireClientEvent(String projectName, long tick, String eventJson) { + Minecraft.getInstance().execute(() -> { + List handlers = clientEventHandlers.getOrDefault(projectName, List.of()); + if (handlers.isEmpty()) return; + + Context cx = Context.enter(); + cx.setOptimizationLevel(-1); + try { + scope.put("_arg", scope, eventJson); + Object args = cx.evaluateString(scope, + "JSON.parse(_arg)", "json", 1, null); + scope.delete("_arg"); + + Scriptable eventObj = cx.newObject(scope); + ScriptableObject.putProperty(eventObj, "tick", tick); + ScriptableObject.putProperty(eventObj, "args", args); + + for (Function handler : handlers) { + try { + handler.call(cx, scope, scope, new Object[]{eventObj}); + } catch (Exception e) { + LOGGER.error("Client event handler error: {}", e.getMessage()); + } + } + } finally { + Context.exit(); + } + }); + } + + private static String stringify(Context cx, Scriptable scope, Object value) { + try { + scope.put("_arg", scope, value); + Object result = cx.evaluateString(scope, + "JSON.stringify(_arg)", "json", 1, null); + scope.delete("_arg"); + return result instanceof String s ? s : null; + } catch (Exception e) { + LOGGER.error("Failed to stringify event", e); + return null; + } + } + + // ── Console backend ── + + public static class Box3JSClientConsole { + public void log(String msg) { LOGGER.info("[client] {}", msg); } + public void debug(String msg) { LOGGER.debug("[client] {}", msg); } + public void warn(String msg) { LOGGER.warn("[client] {}", msg); } + public void error(String msg) { LOGGER.error("[client] {}", msg); } + } +} diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientHttp.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientHttp.java new file mode 100644 index 0000000..16ac8ec --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientHttp.java @@ -0,0 +1,301 @@ +package com.box3lab.box3js.client; + +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import org.mozilla.javascript.Function; +import org.slf4j.Logger; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.*; + +/** + * Client-side HTTP fetch API exposed to JS as the global {@code http} object. + * + *

Supports both synchronous (blocking) and async (callback-based) requests. + * Async requests use {@link HttpClient#sendAsync} and deliver callbacks via + * {@link Minecraft#execute}. + */ +public class Box3JSClientHttp { + + private static final Logger LOGGER = LogUtils.getLogger(); + private final HttpClient client; + + public Box3JSClientHttp() { + this.client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + public Response fetch(String url, Map options) { + String method = "GET"; + Map headers = Collections.emptyMap(); + byte[] body = null; + long timeoutMs = 10_000; + String responseType = null; + long maxBodySize = 0; + boolean async = false; + Function onResponse = null; + Function onError = null; + + if (options != null) { + if (options.get("method") instanceof String m) + method = m.toUpperCase(); + if (options.get("responseType") instanceof String rt) + responseType = rt; + if (options.get("maxBodySize") instanceof Number n) + maxBodySize = n.longValue(); + @SuppressWarnings("unchecked") + Map h = (Map) options.get("headers"); + if (h != null) + headers = h; + Object rawBody = options.get("body"); + if (rawBody instanceof String s) { + body = s.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } else if (rawBody instanceof byte[] b) { + body = b; + } + if (options.get("timeout") instanceof Number n) + timeoutMs = n.longValue(); + if (options.get("async") instanceof Boolean b) + async = b; + if (options.get("onResponse") instanceof Function f) + onResponse = f; + if (options.get("onError") instanceof Function f) + onError = f; + } + + HttpRequest request; + try { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofMillis(timeoutMs)); + + for (var entry : headers.entrySet()) { + Object v = entry.getValue(); + if (v instanceof Object[] arr) { + for (Object item : arr) + builder.header(entry.getKey(), String.valueOf(item)); + } else { + builder.header(entry.getKey(), String.valueOf(v)); + } + } + + HttpRequest.BodyPublisher bp; + if (body != null && body.length > 0) { + bp = HttpRequest.BodyPublishers.ofByteArray(body); + } else { + bp = HttpRequest.BodyPublishers.noBody(); + } + builder.method(method, bp); + request = builder.build(); + } catch (Exception e) { + LOGGER.warn("HTTP fetch failed to build request for {}: {}", url, e.getMessage()); + if (async && onError != null) { + final String errMsg = e.getMessage(); + final Function errCb = onError; + Minecraft.getInstance().execute(() -> errCb.call( + org.mozilla.javascript.Context.getCurrentContext(), + errCb, errCb, new Object[]{errMsg})); + } + return async ? null : Response.error(e.getMessage()); + } + + if (async) { + final String rt = responseType; + final long mbs = maxBodySize; + final Function onResp = onResponse; + final Function onErr = onError; + + client.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()) + .thenAccept(response -> { + Response resp = new Response(response, rt, mbs); + Minecraft.getInstance().execute(() -> { + if (onResp != null) { + org.mozilla.javascript.Context cx = + org.mozilla.javascript.Context.enter(); + try { + onResp.call(cx, onResp, onResp, new Object[]{resp}); + } finally { + org.mozilla.javascript.Context.exit(); + } + } + }); + }) + .exceptionally(ex -> { + Minecraft.getInstance().execute(() -> { + String msg = ex.getCause() instanceof HttpTimeoutException + ? "Request timed out" + : ex.getMessage(); + if (onErr != null) { + org.mozilla.javascript.Context cx = + org.mozilla.javascript.Context.enter(); + try { + onErr.call(cx, onErr, onErr, new Object[]{msg}); + } finally { + org.mozilla.javascript.Context.exit(); + } + } + }); + return null; + }); + return null; + } + + try { + HttpResponse response = client.send(request, + HttpResponse.BodyHandlers.ofByteArray()); + return new Response(response, responseType, maxBodySize); + } catch (HttpTimeoutException e) { + LOGGER.warn("HTTP fetch timed out after {}ms: {}", timeoutMs, url); + return Response.timeout(); + } catch (IOException e) { + LOGGER.warn("HTTP fetch failed for {}: {}", url, e.getMessage()); + return Response.error(e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Response.error("Interrupted"); + } + } + + public Response fetch(String url) { + return fetch(url, null); + } + + // ── Response ── + + public static class Response { + private final int status; + private final String statusText; + private final Map> headers; + private final byte[] body; + private final boolean ok; + private final String errorMessage; + private final boolean truncated; + private Object parsedBody; + + Response(HttpResponse r, String responseType, long maxBodySize) { + this.status = r.statusCode(); + this.statusText = switch (status) { + case 200 -> "OK"; + case 201 -> "Created"; + case 204 -> "No Content"; + case 301 -> "Moved Permanently"; + case 302 -> "Found"; + case 304 -> "Not Modified"; + case 400 -> "Bad Request"; + case 401 -> "Unauthorized"; + case 403 -> "Forbidden"; + case 404 -> "Not Found"; + case 405 -> "Method Not Allowed"; + case 408 -> "Request Timeout"; + case 429 -> "Too Many Requests"; + case 500 -> "Internal Server Error"; + case 502 -> "Bad Gateway"; + case 503 -> "Service Unavailable"; + default -> ""; + }; + this.headers = r.headers().map(); + byte[] raw = r.body(); + if (maxBodySize > 0 && raw.length > maxBodySize) { + this.body = new byte[(int) maxBodySize]; + System.arraycopy(raw, 0, this.body, 0, (int) maxBodySize); + this.truncated = true; + } else { + this.body = raw; + this.truncated = false; + } + this.ok = status >= 200 && status < 300; + this.errorMessage = null; + + if (responseType != null && ok && body != null && body.length > 0) { + switch (responseType) { + case "json" -> { + try { this.parsedBody = json(); } catch (Exception ignored) {} + } + case "text" -> this.parsedBody = text(); + case "arrayBuffer" -> this.parsedBody = arrayBuffer(); + } + } + } + + private Response(int status, String statusText, String error) { + this.status = status; + this.statusText = statusText; + this.headers = Collections.emptyMap(); + this.body = null; + this.ok = false; + this.errorMessage = error; + this.truncated = false; + } + + static Response timeout() { + return new Response(408, "Request Timeout", "Request timed out"); + } + + static Response error(String msg) { + return new Response(0, "Error", msg); + } + + public int getStatus() { return status; } + public String getStatusText() { return statusText; } + public boolean getOk() { return ok; } + public boolean getTruncated() { return truncated; } + + public Object getData() { return parsedBody; } + + public Map getHeaders() { + Map flat = new LinkedHashMap<>(); + for (var entry : headers.entrySet()) { + List values = entry.getValue(); + if (values.size() == 1) + flat.put(entry.getKey(), values.get(0)); + else + flat.put(entry.getKey(), values.toArray(new String[0])); + } + return flat; + } + + public String getHeader(String name) { + List values = headers.get(name); + if (values == null || values.isEmpty()) return null; + return values.get(0); + } + + public Object json() { + if (parsedBody instanceof Map || parsedBody instanceof List + || parsedBody instanceof Number || parsedBody instanceof Boolean) + return parsedBody; + if (body == null || body.length == 0) return null; + String raw = new String(body, java.nio.charset.StandardCharsets.UTF_8); + try { + return org.mozilla.javascript.Context.getCurrentContext() + .evaluateString(org.mozilla.javascript.Context.getCurrentContext() + .initStandardObjects(), "(" + raw + ")", "json", 1, null); + } catch (Exception e) { + LOGGER.warn("Failed to parse HTTP response as JSON: {}", e.getMessage()); + return null; + } + } + + public String text() { + if (body == null) return ""; + return new String(body, java.nio.charset.StandardCharsets.UTF_8); + } + + public byte[] arrayBuffer() { + return body != null ? body.clone() : new byte[0]; + } + + public String getErrorMessage() { + return errorMessage != null ? errorMessage : ""; + } + + public void close() {} + } +} diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientStorage.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientStorage.java new file mode 100644 index 0000000..55d4b1b --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/client/Box3JSClientStorage.java @@ -0,0 +1,322 @@ +package com.box3lab.box3js.client; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.mozilla.javascript.Context; +import org.mozilla.javascript.Function; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Client-side persistent key-value storage with ValueEntry/ReturnValue/QueryList support. + * + *

Data is stored per-project under {@code /box3/client-storage/}. + */ +public class Box3JSClientStorage { + + private static final Gson GSON = new Gson(); + private static final Type MAP_TYPE = new TypeToken>() {}.getType(); + + private final Path baseDir; + private final String projectName; + private final Map> cache = new ConcurrentHashMap<>(); + + public Box3JSClientStorage(java.io.File gameDir, String projectName) { + this.baseDir = gameDir.toPath().resolve("box3").resolve("client-storage"); + this.projectName = projectName; + try { Files.createDirectories(baseDir); } catch (IOException ignored) {} + } + + public String getKey() { return ""; } + + public GameDataStorage getDataStorage(String name) { + return new GameDataStorage(resolveName(name)); + } + + private String resolveName(String name) { + return projectName != null ? projectName + "/" + name : name; + } + + // ── ValueEntry ── + + private static class ValueEntry { + Object value; + long updateTime; + long createTime; + String version; + + ValueEntry(Object value, long createTime) { + this.value = value; + this.createTime = createTime; + this.updateTime = createTime; + this.version = Long.toHexString(createTime) + "-" + Integer.toHexString(new Random().nextInt()); + } + } + + // ── ReturnValue ── + + public static class ReturnValue { + public String key; + public Object value; + public double updateTime; + public double createTime; + public String version; + + ReturnValue(String key, ValueEntry entry) { + this.key = key; + this.value = entry.value; + this.updateTime = entry.updateTime; + this.createTime = entry.createTime; + this.version = entry.version; + } + } + + // ── QueryList ── + + public static class QueryList { + public boolean isLastPage; + private final List all; + private final int pageSize; + private int cursor; + + QueryList(List all, int pageSize, int cursor) { + this.all = all; + this.pageSize = pageSize; + this.cursor = Math.max(0, cursor); + this.isLastPage = this.cursor >= all.size(); + } + + public ReturnValue[] getCurrentPage() { + int end = Math.min(cursor + pageSize, all.size()); + if (cursor >= all.size()) return new ReturnValue[0]; + return all.subList(cursor, end).toArray(new ReturnValue[0]); + } + + public void nextPage() { + cursor += pageSize; + isLastPage = cursor >= all.size(); + } + } + + // ── GameDataStorage ── + + public class GameDataStorage { + + private final String name; + private final Path path; + private final Map data; + + GameDataStorage(String name) { + this.name = name; + String[] parts = name.split("/"); + Path dir = baseDir; + for (int i = 0; i < parts.length - 1; i++) { + String seg = sanitize(parts[i]); + if (!seg.isEmpty()) dir = dir.resolve(seg); + } + String file = sanitize(parts[parts.length - 1]); + if (file.isEmpty()) file = "default"; + this.path = dir.resolve(file + ".json"); + this.data = cache.computeIfAbsent(path, p -> { + if (Files.exists(p)) { + try { + String json = Files.readString(p); + Map map = GSON.fromJson(json, MAP_TYPE); + return map != null ? Collections.synchronizedMap(new LinkedHashMap<>(map)) + : Collections.synchronizedMap(new LinkedHashMap<>()); + } catch (IOException e) { + return Collections.synchronizedMap(new LinkedHashMap<>()); + } + } + return Collections.synchronizedMap(new LinkedHashMap<>()); + }); + } + + private String sanitize(String s) { + return s.replaceAll("[^a-zA-Z0-9_.\\-]", "_"); + } + + public String getKey() { return name; } + + private void persist() { + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, GSON.toJson(data)); + } catch (IOException ignored) {} + } + + // ── Basic API ── + + public void set(String key, Object value) { + if (key == null) return; + long now = System.currentTimeMillis(); + synchronized (data) { + ValueEntry existing = data.get(key); + if (existing != null) { + existing.value = value; + existing.updateTime = now; + existing.version = Long.toHexString(now) + "-" + Integer.toHexString(new Random().nextInt()); + } else { + data.put(key, new ValueEntry(value, now)); + } + persist(); + } + } + + public Object get(String key) { + if (key == null) return null; + synchronized (data) { + ValueEntry entry = data.get(key); + return entry != null ? entry.value : null; + } + } + + public String[] keys() { + synchronized (data) { + return data.keySet().toArray(new String[0]); + } + } + + public void update(String key, Function handler) { + if (key == null || handler == null) return; + synchronized (data) { + ValueEntry entry = data.get(key); + if (entry == null) return; + long now = System.currentTimeMillis(); + Context cx = Context.enter(); + try { + entry.value = handler.call(cx, handler, handler, new Object[]{entry.value}); + } finally { + Context.exit(); + } + entry.updateTime = now; + entry.version = Long.toHexString(now) + "-" + Integer.toHexString(new Random().nextInt()); + persist(); + } + } + + public Object remove(String key) { + if (key == null) return null; + synchronized (data) { + ValueEntry entry = data.remove(key); + if (entry != null) { + persist(); + return entry.value; + } + } + return null; + } + + public double increment(String key, double value) { + if (key == null) return 0; + double delta = Double.isNaN(value) ? 1.0 : value; + long now = System.currentTimeMillis(); + synchronized (data) { + ValueEntry entry = data.get(key); + if (entry != null) { + if (entry.value instanceof Number n) { + entry.value = n.doubleValue() + delta; + } else { + entry.value = delta; + } + entry.updateTime = now; + entry.version = Long.toHexString(now) + "-" + Integer.toHexString(new Random().nextInt()); + } else { + entry = new ValueEntry(delta, now); + data.put(key, entry); + } + persist(); + return ((Number) entry.value).doubleValue(); + } + } + + public double increment(String key) { + return increment(key, 1.0); + } + + public QueryList list(Map options) { + List results; + synchronized (data) { + results = new ArrayList<>(); + for (Map.Entry e : data.entrySet()) { + results.add(new ReturnValue(e.getKey(), e.getValue())); + } + } + + int cursor = 0; + int pageSize = 100; + boolean ascending = false; + boolean doSort = false; + Double max = null, min = null; + String constraintTarget = null; + + if (options != null) { + if (options.get("cursor") instanceof Number n) cursor = n.intValue(); + if (options.get("pageSize") instanceof Number n) { + pageSize = Math.max(1, Math.min(100, n.intValue())); + } + if (options.containsKey("ascending")) { + doSort = true; + ascending = Boolean.TRUE.equals(options.get("ascending")); + } + if (options.get("max") instanceof Number n) max = n.doubleValue(); + if (options.get("min") instanceof Number n) min = n.doubleValue(); + if (options.get("constraintTarget") instanceof String s) constraintTarget = s; + } + + final String target = constraintTarget; + final boolean asc = ascending; + + if (doSort) { + results.sort((a, b) -> { + double va = extractSortValue(a.value, target); + double vb = extractSortValue(b.value, target); + int cmp = Double.compare(va, vb); + return asc ? cmp : -cmp; + }); + } + + if (max != null || min != null) { + List filtered = new ArrayList<>(); + for (ReturnValue rv : results) { + double v = extractSortValue(rv.value, target); + if (min != null && v < min) continue; + if (max != null && v > max) continue; + filtered.add(rv); + } + results = filtered; + } + + return new QueryList(results, pageSize, Math.max(0, cursor)); + } + + private double extractSortValue(Object value, String target) { + if (target == null || target.isEmpty()) { + if (value instanceof Number n) return n.doubleValue(); + return 0; + } + Object current = value; + for (String part : target.split("\\.")) { + if (current instanceof Map m) { + current = m.get(part); + } else { + return 0; + } + } + if (current instanceof Number n) return n.doubleValue(); + return 0; + } + + public void destroy() { + synchronized (data) { + cache.remove(path); + try { Files.deleteIfExists(path); } catch (IOException ignored) {} + } + } + } +} diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSDatabase.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSDatabase.java index dbc4b25..33521e9 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSDatabase.java +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSDatabase.java @@ -248,6 +248,14 @@ private void bindParam(PreparedStatement stmt, int index, Object value) throws S } } + /** + * Returns {@code true} if the SQLite JDBC driver ({@code minecraft-sqlite-jdbc}) + * is present. JS devs should check this before calling {@code db.sql()}. + */ + public static boolean isAvailable() { + return SQLITE_AVAILABLE; + } + private static void ensureSqliteAvailable() { if (!SQLITE_AVAILABLE) { throw new IllegalStateException(SQLITE_MISSING_HINT); diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSEventBus.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSEventBus.java index db77627..54b5d65 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSEventBus.java +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSEventBus.java @@ -28,6 +28,7 @@ class Box3JSEventBus { final Map> entityDamageCallbacks = new ConcurrentHashMap<>(); final Map> buttonPressedCallbacks = new ConcurrentHashMap<>(); final Map> messageCallbacks = new ConcurrentHashMap<>(); + final Map> serverEventHandlers = new ConcurrentHashMap<>(); // Tracking state — per-project final Map> voxelContactTracked = new ConcurrentHashMap<>(); @@ -85,6 +86,20 @@ private static T add(Map> map, String project, T cb) { return cb; } + // ---- Server event handlers (remoteChannel) ---- + + Function addServerEventHandler(String project, Function handler) { + return add(serverEventHandlers, project, handler); + } + + void removeServerEventHandler(String project, Function handler) { + remove(serverEventHandlers, project, handler); + } + + List getServerEventHandlers(String project) { + return serverEventHandlers.getOrDefault(project, List.of()); + } + // ---- Remove single callbacks ---- void removeTick(String project, Runnable cb) { remove(tickCallbacks, project, cb); } @@ -132,6 +147,7 @@ void removeProject(String project) { entityDamageCallbacks.remove(project); buttonPressedCallbacks.remove(project); messageCallbacks.remove(project); + serverEventHandlers.remove(project); voxelContactTracked.remove(project); fluidStateTracked.remove(project); entityContactPairs.remove(project); @@ -161,6 +177,7 @@ void clearAll() { entityDamageCallbacks.clear(); buttonPressedCallbacks.clear(); messageCallbacks.clear(); + serverEventHandlers.clear(); previousButtonStates.clear(); voxelContactTracked.clear(); fluidStateTracked.clear(); diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSHttp.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSHttp.java new file mode 100644 index 0000000..4fab8cf --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSHttp.java @@ -0,0 +1,334 @@ +package com.box3lab.box3js.script; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.*; + +import com.mojang.logging.LogUtils; +import net.minecraft.server.MinecraftServer; +import org.mozilla.javascript.Function; +import org.slf4j.Logger; + +/** + * HTTP fetch API exposed to JS as the global {@code http} object. + * + *

Supports both synchronous (blocking) and async (callback-based) requests. + * Async requests use {@link HttpClient#sendAsync} and deliver callbacks via + * {@link MinecraftServer#execute}. + */ +public class Box3JSHttp { + + private static final Logger LOGGER = LogUtils.getLogger(); + private final HttpClient client; + private final MinecraftServer server; + private Box3ScriptEngine engine; + + public Box3JSHttp(MinecraftServer server) { + this.server = server; + this.client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + void setEngine(Box3ScriptEngine engine) { + this.engine = engine; + } + + /** + * Performs an HTTP request. + * + *

By default the request is synchronous (blocks the server tick). + * Pass {@code async: true} and {@code onResponse} / {@code onError} callbacks + * for non-blocking behaviour. + * + * @param url the request URL + * @param options optional JS object with keys: + * method, headers, body, timeout, responseType, maxBodySize, + * async, onResponse, onError + * @return Response for sync, null for async + */ + public Response fetch(String url, Map options) { + // ── extract common options ── + String method = "GET"; + Map headers = Collections.emptyMap(); + byte[] body = null; + long timeoutMs = 10_000; + String responseType = null; + long maxBodySize = 0; + boolean async = false; + Function onResponse = null; + Function onError = null; + + if (options != null) { + if (options.get("method") instanceof String m) + method = m.toUpperCase(); + if (options.get("responseType") instanceof String rt) + responseType = rt; + if (options.get("maxBodySize") instanceof Number n) + maxBodySize = n.longValue(); + @SuppressWarnings("unchecked") + Map h = (Map) options.get("headers"); + if (h != null) + headers = h; + Object rawBody = options.get("body"); + if (rawBody instanceof String s) { + body = s.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } else if (rawBody instanceof byte[] b) { + body = b; + } + if (options.get("timeout") instanceof Number n) + timeoutMs = n.longValue(); + if (options.get("async") instanceof Boolean b) + async = b; + if (options.get("onResponse") instanceof Function f) + onResponse = f; + if (options.get("onError") instanceof Function f) + onError = f; + } + + // ── build request ── + HttpRequest request; + try { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofMillis(timeoutMs)); + + for (var entry : headers.entrySet()) { + Object v = entry.getValue(); + if (v instanceof Object[] arr) { + for (Object item : arr) + builder.header(entry.getKey(), String.valueOf(item)); + } else { + builder.header(entry.getKey(), String.valueOf(v)); + } + } + + HttpRequest.BodyPublisher bp; + if (body != null && body.length > 0) { + bp = HttpRequest.BodyPublishers.ofByteArray(body); + } else { + bp = HttpRequest.BodyPublishers.noBody(); + } + builder.method(method, bp); + request = builder.build(); + } catch (Exception e) { + LOGGER.warn("HTTP fetch failed to build request for {}: {}", url, e.getMessage()); + if (async) { + final String errMsg = e.getMessage(); + final Function errCb = onError; + if (errCb != null) { + server.execute(() -> { + if (engine != null) engine.callFunction(errCb, errMsg); + }); + } + } + return async ? null : Response.error(e.getMessage()); + } + + // ── async path ── + if (async) { + final String rt = responseType; + final long mbs = maxBodySize; + final Function onResp = onResponse; + final Function onErr = onError; + + client.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()) + .thenAccept(response -> { + Response resp = new Response(response, rt, mbs); + server.execute(() -> { + if (onResp != null && engine != null) + engine.callFunction(onResp, resp); + }); + }) + .exceptionally(ex -> { + server.execute(() -> { + String msg = ex.getCause() instanceof HttpTimeoutException + ? "Request timed out" + : ex.getMessage(); + if (onErr != null && engine != null) + engine.callFunction(onErr, msg); + }); + return null; + }); + return null; + } + + // ── sync path ── + try { + HttpResponse response = client.send(request, + HttpResponse.BodyHandlers.ofByteArray()); + return new Response(response, responseType, maxBodySize); + } catch (HttpTimeoutException e) { + LOGGER.warn("HTTP fetch timed out after {}ms: {}", timeoutMs, url); + return Response.timeout(); + } catch (IOException e) { + LOGGER.warn("HTTP fetch failed for {}: {}", url, e.getMessage()); + return Response.error(e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Response.error("Interrupted"); + } + } + + public Response fetch(String url) { + return fetch(url, null); + } + + // ── Response ── + + public static class Response { + private final int status; + private final String statusText; + private final Map> headers; + private final byte[] body; + private final boolean ok; + private final String errorMessage; + private final boolean truncated; + private Object parsedBody; + + Response(HttpResponse r, String responseType, long maxBodySize) { + this.status = r.statusCode(); + this.statusText = switch (status) { + case 200 -> "OK"; + case 201 -> "Created"; + case 204 -> "No Content"; + case 301 -> "Moved Permanently"; + case 302 -> "Found"; + case 304 -> "Not Modified"; + case 400 -> "Bad Request"; + case 401 -> "Unauthorized"; + case 403 -> "Forbidden"; + case 404 -> "Not Found"; + case 405 -> "Method Not Allowed"; + case 408 -> "Request Timeout"; + case 429 -> "Too Many Requests"; + case 500 -> "Internal Server Error"; + case 502 -> "Bad Gateway"; + case 503 -> "Service Unavailable"; + default -> ""; + }; + this.headers = r.headers().map(); + byte[] raw = r.body(); + if (maxBodySize > 0 && raw.length > maxBodySize) { + this.body = new byte[(int) maxBodySize]; + System.arraycopy(raw, 0, this.body, 0, (int) maxBodySize); + this.truncated = true; + } else { + this.body = raw; + this.truncated = false; + } + this.ok = status >= 200 && status < 300; + this.errorMessage = null; + + if (responseType != null && ok && body != null && body.length > 0) { + switch (responseType) { + case "json" -> { + try { + this.parsedBody = json(); + } catch (Exception ignored) { + } + } + case "text" -> this.parsedBody = text(); + case "arrayBuffer" -> this.parsedBody = arrayBuffer(); + } + } + } + + private Response(int status, String statusText, String error) { + this.status = status; + this.statusText = statusText; + this.headers = Collections.emptyMap(); + this.body = null; + this.ok = false; + this.errorMessage = error; + this.truncated = false; + } + + static Response timeout() { + return new Response(408, "Request Timeout", "Request timed out"); + } + + static Response error(String msg) { + return new Response(0, "Error", msg); + } + + public int getStatus() { + return status; + } + + public String getStatusText() { + return statusText; + } + + public boolean getOk() { + return ok; + } + + public boolean getTruncated() { + return truncated; + } + + /** Returns the auto-parsed body when {@code responseType} was set, otherwise null. */ + public Object getData() { + return parsedBody; + } + + public Map getHeaders() { + Map flat = new LinkedHashMap<>(); + for (var entry : headers.entrySet()) { + List values = entry.getValue(); + if (values.size() == 1) + flat.put(entry.getKey(), values.get(0)); + else + flat.put(entry.getKey(), values.toArray(new String[0])); + } + return flat; + } + + /** Returns the value of a single response header, or null if absent. */ + public String getHeader(String name) { + List values = headers.get(name); + if (values == null || values.isEmpty()) return null; + return values.get(0); + } + + /** Parses the body as JSON. Returns null on failure. */ + public Object json() { + if (parsedBody instanceof Map || parsedBody instanceof List + || parsedBody instanceof Number || parsedBody instanceof Boolean) + return parsedBody; + if (body == null || body.length == 0) return null; + String raw = new String(body, java.nio.charset.StandardCharsets.UTF_8); + try { + return org.mozilla.javascript.Context.getCurrentContext() + .evaluateString(org.mozilla.javascript.Context.getCurrentContext() + .initStandardObjects(), "(" + raw + ")", "json", 1, null); + } catch (Exception e) { + LOGGER.warn("Failed to parse HTTP response as JSON: {}", e.getMessage()); + return null; + } + } + + public String text() { + if (body == null) return ""; + return new String(body, java.nio.charset.StandardCharsets.UTF_8); + } + + public byte[] arrayBuffer() { + return body != null ? body.clone() : new byte[0]; + } + + public String getErrorMessage() { + return errorMessage != null ? errorMessage : ""; + } + + public void close() { + // no-op + } + } +} diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSPlayer.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSPlayer.java index 77cb812..f2d1b8b 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSPlayer.java +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSPlayer.java @@ -69,6 +69,12 @@ public GameVector3 getBounds() { public String getName() { return player.getGameProfile().getName(); } public String getUserId() { return player.getUUID().toString(); } + public ServerPlayer getPlayer() { return player; } + + /** Whether this player has Box3JS installed on their client. */ + public boolean hasBox3JSClientMod() { + return com.box3lab.box3js.Box3JS.clientsWithBox3JS.contains(player.getUUID()); + } public int getOpLevel() { return server.getProfilePermissions(player.getGameProfile()); } diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSRemoteChannel.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSRemoteChannel.java new file mode 100644 index 0000000..ae96989 --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3JSRemoteChannel.java @@ -0,0 +1,153 @@ +package com.box3lab.box3js.script; + +import com.box3lab.box3js.Box3JSNetwork; +import net.minecraft.network.ConnectionProtocol; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.neoforge.network.PacketDistributor; +import net.neoforged.neoforge.network.registration.NetworkRegistry; +import org.mozilla.javascript.*; + +import java.util.ArrayList; +import java.util.List; + +/** + * Server-side remote event channel exposed as {@code remoteChannel} global. + * + *

Bridges JS {@code sendClientEvent / broadcastClientEvent / onServerEvent} + * calls to NeoForge custom payloads. JSON-serialises event data so any + * Rhino value can cross the network boundary. + */ +public class Box3JSRemoteChannel { + + private final Box3ScriptEngine engine; + + private static final ResourceLocation SERVER_EVENT_ID = + ResourceLocation.fromNamespaceAndPath("box3js", "server_event"); + + Box3JSRemoteChannel(Box3ScriptEngine engine) { + this.engine = engine; + } + + // ── sendClientEvent(entities, event) ── + + public void sendClientEvent(Object entities, Object event) { + String project = engine.getCurrentProject(); + long tick = engine.getCurrentTick(); + String json = stringify(event); + if (json == null) return; + + var payload = new Box3JSNetwork.ServerEventPayload(project, tick, json); + + for (ServerPlayer sp : resolvePlayers(entities)) { + if (NetworkRegistry.hasChannel( + sp.connection.getConnection(), + ConnectionProtocol.PLAY, + SERVER_EVENT_ID)) { + PacketDistributor.sendToPlayer(sp, payload); + } + } + } + + // ── broadcastClientEvent(event) ── + + public void broadcastClientEvent(Object event) { + String project = engine.getCurrentProject(); + long tick = engine.getCurrentTick(); + String json = stringify(event); + if (json == null) return; + + var payload = new Box3JSNetwork.ServerEventPayload(project, tick, json); + + for (ServerPlayer sp : engine.getServer().getPlayerList().getPlayers()) { + if (NetworkRegistry.hasChannel( + sp.connection.getConnection(), + ConnectionProtocol.PLAY, + SERVER_EVENT_ID)) { + PacketDistributor.sendToPlayer(sp, payload); + } + } + } + + // ── onServerEvent(handler) ── + + public Object onServerEvent(Function handler) { + String project = engine.getCurrentProject(); + Function stored = engine.bus.addServerEventHandler(project, handler); + return new GameEventHandlerToken(() -> + engine.bus.removeServerEventHandler(project, stored)); + } + + // ── Helpers ── + + private String stringify(Object value) { + Context cx = Context.enter(); + try { + Scriptable scope = engine.getScope(); + scope.put("_arg", scope, value); + Object result = cx.evaluateString(scope, + "JSON.stringify(_arg)", "json", 1, null); + scope.delete("_arg"); + return result instanceof String s ? s : null; + } finally { + Context.exit(); + } + } + + /** + * Called by the engine when a {@code ClientEventPayload} arrives from a client. + */ + void fireFromClient(Box3JSPlayer entity, long tick, String eventJson) { + Object args = parse(eventJson); + if (args == null) return; + + String project = engine.getCurrentProject(); + Context cx = Context.enter(); + try { + Scriptable eventObj = cx.newObject(engine.getScope()); + ScriptableObject.putProperty(eventObj, "tick", tick); + ScriptableObject.putProperty(eventObj, "entity", + Context.javaToJS(entity, engine.getScope())); + ScriptableObject.putProperty(eventObj, "args", args); + + for (Function handler : engine.bus.getServerEventHandlers(project)) { + try { + handler.call(cx, engine.getScope(), engine.getScope(), + new Object[]{eventObj}); + } catch (Exception e) { + engine.reportError("ServerEventHandler: " + e.getMessage()); + } + } + } finally { + Context.exit(); + } + } + + private Object parse(String json) { + Context cx = Context.enter(); + try { + Scriptable scope = engine.getScope(); + scope.put("_arg", scope, json); + Object result = cx.evaluateString(scope, + "JSON.parse(_arg)", "json", 1, null); + scope.delete("_arg"); + return result; + } finally { + Context.exit(); + } + } + + @SuppressWarnings("unchecked") + private List resolvePlayers(Object arg) { + List result = new ArrayList<>(); + if (arg instanceof NativeArray na) { + for (Object item : na) { + if (item instanceof Box3JSPlayer gpe && gpe.getPlayer() != null) + result.add(gpe.getPlayer()); + } + } else if (arg instanceof Box3JSPlayer gpe && gpe.getPlayer() != null) { + result.add(gpe.getPlayer()); + } + return result; + } +} diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptCommand.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptCommand.java index af37b3e..27764ef 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptCommand.java +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptCommand.java @@ -206,7 +206,7 @@ private static int startOne(CommandSourceStack source, String project) { engine.withErrorReporter(chatReporter(source)); engine.setCurrentProject(project); try { - engine.eval("require('./app')"); + engine.eval("require('./server')"); } finally { engine.setCurrentProject(null); engine.clearErrorReporter(); @@ -278,7 +278,7 @@ private static LiteralArgumentBuilder reloadCommand() { engine.setCurrentProject(project); try { engine.removeProject(project); - engine.eval("require('./app')"); + engine.eval("require('./server')"); } finally { engine.setCurrentProject(null); engine.clearErrorReporter(); @@ -363,10 +363,10 @@ private static LiteralArgumentBuilder compileCommand() { Path projectDir = scriptDir(ctx.getSource().getServer()) .resolve(project).normalize(); - Path appJs = projectDir.resolve("dist/app.js"); - if (!Files.exists(appJs)) { + Path serverJs = projectDir.resolve("dist/server.js"); + if (!Files.exists(serverJs)) { ctx.getSource().sendFailure( - Component.literal("§cdist/app.js not found — run 'npm run build' first")); + Component.literal("§cdist/server.js not found — run 'npm run build' first")); return 0; } diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptConfig.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptConfig.java index 35f20c3..0632bec 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptConfig.java +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptConfig.java @@ -82,9 +82,9 @@ public void discover(MinecraftServer server) { return; try (var dirs = Files.list(scriptDir)) { dirs.filter(Files::isDirectory).forEach(dir -> { - Path distAppJs = dir.resolve("dist/app.js"); - Path legacyAppJs = dir.resolve("app.js"); - if (Files.exists(distAppJs) || Files.exists(legacyAppJs)) { + Path distServerJs = dir.resolve("dist/server.js"); + Path legacyServerJs = dir.resolve("server.js"); + if (Files.exists(distServerJs) || Files.exists(legacyServerJs)) { String name = dir.getFileName().toString(); projects.putIfAbsent(name, false); } diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptEngine.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptEngine.java index 1d72c1a..d4e2466 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptEngine.java +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/script/Box3ScriptEngine.java @@ -31,6 +31,8 @@ public class Box3ScriptEngine { private Box3JSVoxels voxelsBinding; private Box3JSStorage storageBinding; private Box3JSDatabase dbBinding; + private Box3JSHttp httpBinding; + private Box3JSRemoteChannel remoteChannel; private Box3ScriptSandbox sandbox; private MinecraftServer server; private boolean initialized; @@ -40,6 +42,7 @@ public class Box3ScriptEngine { private long currentTick, prevTick; private Consumer errorReporter; private final Map projectRequires = new HashMap<>(); + private boolean dbWarningShown; public static Box3ScriptEngine get() { return INSTANCE; @@ -54,6 +57,9 @@ public void init(MinecraftServer server) { this.voxelsBinding = new Box3JSVoxels(server, sandbox); this.storageBinding = new Box3JSStorage(server.getServerDirectory().resolve("config"), this); this.dbBinding = new Box3JSDatabase(server.getServerDirectory().resolve("config"), this); + this.httpBinding = new Box3JSHttp(server); + this.httpBinding.setEngine(this); + this.remoteChannel = new Box3JSRemoteChannel(this); setupScope(); initialized = true; } @@ -71,6 +77,9 @@ public static Box3ScriptEngine createStandalone(MinecraftServer server, String p engine.voxelsBinding = new Box3JSVoxels(server, engine.sandbox); engine.storageBinding = new Box3JSStorage(storageRoot, engine); engine.dbBinding = new Box3JSDatabase(storageRoot, engine); + engine.httpBinding = new Box3JSHttp(server); + engine.httpBinding.setEngine(engine); + engine.remoteChannel = new Box3JSRemoteChannel(engine); engine.setupScope(); engine.initialized = true; return engine; @@ -81,6 +90,33 @@ public ScriptableObject getScope() { return scope; } + /** Exposed for remoteChannel payload handler. */ + public MinecraftServer getServer() { + return server; + } + + /** Exposed for remoteChannel. */ + public long getCurrentTick() { + return currentTick; + } + + /** + * Handle an incoming {@code ClientEventPayload} from a player. + * Dispatches to the server thread, sets project context, fires handlers. + */ + public void handleClientEvent(ServerPlayer sender, String projectName, String eventJson) { + server.execute(() -> { + String prev = currentProject; + setCurrentProject(projectName); + try { + var entity = new Box3JSPlayer(sender, server, this); + remoteChannel.fireFromClient(entity, currentTick, eventJson); + } finally { + setCurrentProject(prev); + } + }); + } + /** Execute app.js for enabled projects under config/box3/script/ */ public void autoLoad(MinecraftServer server) { init(server); @@ -96,17 +132,17 @@ public void autoLoad(MinecraftServer server) { .sorted() .forEach(project -> { String name = project.getFileName().toString(); - Path appJs = project.resolve("dist/app.js"); - if (!Files.exists(appJs)) { - appJs = project.resolve("app.js"); + Path serverJs = project.resolve("dist/server.js"); + if (!Files.exists(serverJs)) { + serverJs = project.resolve("server.js"); } - if (Files.exists(appJs) && config.isEnabled(name)) { + if (Files.exists(serverJs) && config.isEnabled(name)) { try { setCurrentProject(name); - eval("require('./app')"); + eval("require('./server')"); LOGGER.info("Auto-loaded project: {}", name); } catch (Exception e) { - LOGGER.error("Failed to auto-load: {}", appJs, e); + LOGGER.error("Failed to auto-load: {}", serverJs, e); } finally { setCurrentProject(null); } @@ -884,6 +920,8 @@ public void reset() { this.dbBinding.closeAll(); } this.dbBinding = new Box3JSDatabase(server.getServerDirectory().resolve("config"), this); + this.httpBinding = new Box3JSHttp(server); + this.httpBinding.setEngine(this); setupScope(); } @@ -894,7 +932,36 @@ private void setupScope() { ScriptableObject.putProperty(scope, "world", Context.javaToJS(worldBinding, scope)); ScriptableObject.putProperty(scope, "voxels", Context.javaToJS(voxelsBinding, scope)); ScriptableObject.putProperty(scope, "storage", Context.javaToJS(storageBinding, scope)); - ScriptableObject.putProperty(scope, "db", Context.javaToJS(dbBinding, scope)); + // -- db — wrapped for graceful fallback if SQLite driver missing -- + ScriptableObject dbObj = (ScriptableObject) cx.newObject(scope); + ScriptableObject.putProperty(dbObj, "isAvailable", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + return Box3JSDatabase.isAvailable(); + } + }); + ScriptableObject.putProperty(dbObj, "sql", new BaseFunction() { + @Override + public Object call(Context cx, Scriptable scope, + Scriptable thisObj, Object[] args) { + try { + return dbBinding.sql(args); + } catch (RuntimeException e) { + if (!dbWarningShown) { + dbWarningShown = true; + LOGGER.warn("db.sql() failed: {}", e.getMessage()); + if (errorReporter != null) { + errorReporter.accept("db unavailable — install minecraft-sqlite-jdbc mod"); + } + } + return new Box3JSQueryResult(0); + } + } + }); + ScriptableObject.putProperty(scope, "db", dbObj); + ScriptableObject.putProperty(scope, "http", Context.javaToJS(httpBinding, scope)); + ScriptableObject.putProperty(scope, "remoteChannel", Context.javaToJS(remoteChannel, scope)); ScriptableObject.putProperty(scope, "_jConsole", Context.javaToJS(new Box3JSConsole(), scope)); cx.evaluateString(scope, "console = {" + @@ -960,6 +1027,136 @@ protected String getCharacterEncoding(java.net.URLConnection c) { " JUMP: 'JUMP' }; " + "GamePlayerWalkState = { NONE: 'NONE', CROUCH: 'CROUCH', WALK: 'WALK', RUN: 'RUN' };", "enums", 1, null); + // Pure-JS regex helpers — Rhino can't load NativeRegExp in MC classloader + cx.evaluateString(scope, + "(function(){" + + "function isSp(c){return c==' '||c=='\\t'||c=='\\n'||c=='\\r'||c=='\\f'||c=='\\v';}" + + "function isDi(c){return c>='0'&&c<='9';}" + + "function isWo(c){return(c>='a'&&c<='z')||(c>='A'&&c<='Z')||(c>='0'&&c<='9')||c=='_';}" + + "function parse(p,f){" + + "var a=[];var i=0;var ic=f.indexOf('i')>=0;" + + "while(i=0;};" + + "}else{" + + "var lit=ch;var low=ic?lit.toLowerCase():lit;var up=ic?lit.toUpperCase():lit;" + + "if(ic)m=function(c){return c==low||c==up;};" + + "else m=function(c){return c==lit;};" + + "i++;" + + "}" + + "var min=1,max=1;" + + "if(i=0&&cnt>=at.max)break;" + + "}" + + "if(cnt0)return {index:i,length:len};" + + "}" + + "return null;" + + "}" + + "function findAll(s,a){" + + "var ms=[];var pos=0;" + + "while(pos=0;" + + "var ms=_ref.findAll(s,a);var rs='';var pos=0;" + + "for(var i=0;i * *

Deployment

@@ -79,9 +80,9 @@ public Box3ScriptCompiler(Path projectDir, Path outputJar, } public void compile() throws Exception { - Path appJs = projectDir.resolve("dist/app.js"); - if (!Files.exists(appJs)) { - throw new FileNotFoundException("dist/app.js not found in " + projectDir + Path serverJs = projectDir.resolve("dist/server.js"); + if (!Files.exists(serverJs)) { + throw new FileNotFoundException("dist/server.js not found in " + projectDir + " — run 'npm run build' first"); } @@ -90,7 +91,7 @@ public void compile() throws Exception { Files.createDirectories(workDir); System.out.println("[1/4] Bundling JS source ..."); - bundleJsSource(appJs, workDir); + bundleJsSource(serverJs, workDir); System.out.println("[2/4] Bundling logo ..."); bundleLogo(workDir); @@ -111,11 +112,20 @@ public void compile() throws Exception { // ── Step 1: Bundle JS source ── private void bundleJsSource(Path jsFile, Path workDir) throws IOException { - String resourcePath = "box3script/" + modId + "/app.js"; + String resourcePath = "box3script/" + modId + "/server.js"; Path dest = workDir.resolve(resourcePath); Files.createDirectories(dest.getParent()); Files.copy(jsFile, dest); System.out.println(" Bundled " + resourcePath); + + // Also bundle client script if present + Path clientJs = projectDir.resolve("dist/client.js"); + if (Files.exists(clientJs)) { + String clientResourcePath = "box3script/" + modId + "/client.js"; + Path clientDest = workDir.resolve(clientResourcePath); + Files.copy(clientJs, clientDest); + System.out.println(" Bundled " + clientResourcePath); + } } // ── Step 2: Bundle logo ── @@ -138,7 +148,7 @@ private void bundleLogo(Path workDir) throws IOException { private void generateModClass(Path genSrcDir) throws IOException { String pkg = "box3script." + modId; String className = capitalize(modId) + "Mod"; - String resourcePath = "box3script/" + modId + "/app.js"; + String resourcePath = "box3script/" + modId + "/server.js"; String src = String.format(""" package %s; diff --git a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/standalone/Box3StandaloneBootstrap.java b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/standalone/Box3StandaloneBootstrap.java index a01fb34..b4cb2ce 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/standalone/Box3StandaloneBootstrap.java +++ b/Box3JS-NeoForge-1.21.1/src/main/java/com/box3lab/box3js/standalone/Box3StandaloneBootstrap.java @@ -1,5 +1,6 @@ package com.box3lab.box3js.standalone; +import com.box3lab.box3js.Box3JSNetwork; import com.box3lab.box3js.script.Box3ScriptEngine; import com.mojang.logging.LogUtils; import net.minecraft.server.level.ServerPlayer; @@ -15,6 +16,7 @@ import net.neoforged.neoforge.event.level.BlockEvent; import net.neoforged.neoforge.event.server.ServerStartedEvent; import net.neoforged.neoforge.event.tick.ServerTickEvent; +import net.neoforged.neoforge.network.PacketDistributor; import org.mozilla.javascript.Context; import org.slf4j.Logger; @@ -29,7 +31,7 @@ * class with hardcoded {@code scriptResource} and {@code projectName}. * The JAR bundles: *
    - *
  • Bundled JS source ({@code box3script//app.js})
  • + *
  • Bundled JS source ({@code box3script//server.js})
  • *
  • {@code META-INF/neoforge.mods.toml} declaring a dependency on box3js
  • *
  • Optional {@code logo.png} for the mod icon
  • *
@@ -44,13 +46,14 @@ public class Box3StandaloneBootstrap { private final String scriptResource; private final String projectName; private Box3ScriptEngine engine; + private String clientScriptSource; /** * Called by the generated {@code @Mod} subclass with hardcoded metadata. * * @param modEventBus the mod's event bus (unused; we use NeoForge.EVENT_BUS) * @param modContainer the mod container (for display name, etc.) - * @param scriptResource resource path to the bundled JS (e.g. {@code box3script/a/app.js}) + * @param scriptResource resource path to the bundled JS (e.g. {@code box3script/a/server.js}) * @param projectName unique project name for scope isolation */ protected Box3StandaloneBootstrap(IEventBus modEventBus, ModContainer modContainer, @@ -64,8 +67,10 @@ protected Box3StandaloneBootstrap(IEventBus modEventBus, ModContainer modContain // ── Player join / leave ── NeoForge.EVENT_BUS.addListener((PlayerEvent.PlayerLoggedInEvent event) -> { - if (engine != null && event.getEntity() instanceof ServerPlayer sp) + if (engine != null && event.getEntity() instanceof ServerPlayer sp) { engine.firePlayerJoin(sp); + sendClientScript(sp); + } }); NeoForge.EVENT_BUS.addListener((PlayerEvent.PlayerLoggedOutEvent event) -> { if (engine != null && event.getEntity() instanceof ServerPlayer sp) @@ -160,6 +165,17 @@ private void onServerStarted(ServerStartedEvent event) { return; } + // Read client script from JAR resource (optional) + String clientResource = "box3script/" + projectName + "/client.js"; + try (InputStream is = getClass().getClassLoader().getResourceAsStream(clientResource)) { + if (is != null) { + clientScriptSource = new String(is.readAllBytes(), StandardCharsets.UTF_8); + LOGGER.info("Client script bundled in JAR, will send to joining players"); + } + } catch (Exception e) { + LOGGER.debug("No client script in JAR: {}", e.getMessage()); + } + Context cx = Context.enter(); try { // esbuild cjs output references module.exports; define the CJS globals @@ -173,6 +189,20 @@ private void onServerStarted(ServerStartedEvent event) { } finally { Context.exit(); } + + // Send client script to already-connected players + if (clientScriptSource != null) { + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + sendClientScript(player); + } + } + } + + private void sendClientScript(ServerPlayer player) { + if (clientScriptSource == null) return; + PacketDistributor.sendToPlayer(player, + new Box3JSNetwork.ClientScriptPayload(projectName, clientScriptSource)); + LOGGER.debug("Sent client script '{}' to {}", projectName, player.getName().getString()); } private void onServerTick(ServerTickEvent.Post event) { diff --git a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/build.mjs b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/build.mjs index 6a3262c..be0f6cf 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/build.mjs +++ b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/build.mjs @@ -1,15 +1,17 @@ import * as esbuild from "esbuild"; import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; -import { writeFileSync, readFileSync, mkdirSync } from "fs"; +import { writeFileSync, readFileSync, mkdirSync, existsSync } from "fs"; import babel from "@babel/core"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const __dirname = dirname(fileURLToPath(import.meta.url)); -const entryFile = resolve(__dirname, "src/app.ts"); +const entryFile = resolve(__dirname, "src/server/app.ts"); const distDir = resolve(__dirname, "dist"); -const outFile = resolve(distDir, "app.js"); +const outFile = resolve(distDir, "server.js"); +const clientEntry = resolve(__dirname, "src/client/app.ts"); +const clientOutFile = resolve(distDir, "client.js"); // ═══════════════════════════════════════════════════════════════ // Babel plugins — Rhino 1.9.1 compatibility transforms @@ -441,6 +443,34 @@ async function runBuild() { } } +const clientBuildOptions = { + entryPoints: [clientEntry], + outfile: clientOutFile, + bundle: true, + format: "cjs", + platform: "neutral", + target: ["rhino1.9.1"], + plugins: [babelRhinoPlugin], + logLevel: "info", +}; + +async function buildClient() { + if (!existsSync(clientEntry)) { + console.log("No src/client/app.ts found, skipping client build."); + return; + } + try { + await esbuild.build({ ...clientBuildOptions, metafile: true }); + + const code = readFileSync(clientOutFile, "utf8"); + writeFileSync(clientOutFile, sanitizeForRhino(code), "utf-8"); + console.log("Client build complete:", clientOutFile); + } catch (err) { + console.error("Client build failed:", err); + process.exit(1); + } +} + // ── Entry ── if (process.argv.includes("--watch")) { @@ -463,6 +493,27 @@ if (process.argv.includes("--watch")) { }); await ctx.watch(); + + if (existsSync(clientEntry)) { + console.log("Client watch mode enabled..."); + const clientCtx = await esbuild.context({ + ...clientBuildOptions, + plugins: [ + babelRhinoPlugin, + { + name: "post-process-plugin", + setup(build) { + build.onEnd(() => { + const code = readFileSync(clientOutFile, "utf8"); + writeFileSync(clientOutFile, sanitizeForRhino(code), "utf-8"); + }); + }, + }, + ], + }); + await clientCtx.watch(); + } } else { - runBuild(); + await runBuild(); + await buildClient(); } diff --git a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/package.json b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/package.json index af0b205..00bf504 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/package.json +++ b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/package.json @@ -14,7 +14,9 @@ "type": "module", "scripts": { "build": "node build.mjs", - "check": "tsc --noEmit", + "check": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.client.json --noEmit", + "check:server": "tsc -p tsconfig.server.json --noEmit", + "check:client": "tsc -p tsconfig.client.json --noEmit", "lint": "eslint src/", "dev": "node build.mjs --watch" }, diff --git a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/src/client/app.ts b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/src/client/app.ts new file mode 100644 index 0000000..5bd3bdb --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/src/client/app.ts @@ -0,0 +1,22 @@ +// Box3JS Client Script +// This script runs on the player's Minecraft client. +// It is automatically sent from the server when a player joins. + +// Called every client tick (20 times per second). +client.onTick(() => { + // Your per-frame logic here +}); + +// Show overlay text in the action bar. +// ui.showOverlay("Hello from client script!"); + +// Play a sound on the client. +// client.playSound("minecraft:block.note_block.pling", 1.0, 1.0); + +// Poll keyboard state. +// if (input.isKeyDown("space")) { ... } + +// Send a chat message. +// chat.sendMessage("Hello!"); + +console.log("[client] loaded!"); diff --git a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/src/app.ts b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/src/server/app.ts similarity index 100% rename from Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/src/app.ts rename to Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/src/server/app.ts diff --git a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/tsconfig.json b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/tsconfig.base.json similarity index 100% rename from Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/tsconfig.json rename to Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/tsconfig.base.json diff --git a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/tsconfig.client.json b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/tsconfig.client.json new file mode 100644 index 0000000..e4a26b5 --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/tsconfig.client.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.base.json", + "include": ["src/client/**/*.ts", "types/shared.d.ts", "types/client.d.ts"] +} diff --git a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/tsconfig.server.json b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/tsconfig.server.json new file mode 100644 index 0000000..a95f426 --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/tsconfig.server.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.base.json", + "include": ["src/server/**/*.ts", "types/shared.d.ts", "types/server.d.ts"] +} diff --git a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/client.d.ts b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/client.d.ts new file mode 100644 index 0000000..ce4496d --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/client.d.ts @@ -0,0 +1,140 @@ +/// + +// ── §0 @zh RemoteChannel 客户端方法(接口合并) @en RemoteChannel client‑side methods (interface merging) ── + +interface RemoteChannel { + /** + * @zh 向服务端发送事件。 + * @en Sends an event to the server. + * @param event - @zh 事件数据(任意 JSON 可序列化的值) @en Event data (any JSON‑serializable value) + */ + sendServerEvent(event: T): void; + + /** + * @zh 注册来自服务端的远程事件处理器。 + * @en Registers a handler for remote events sent from the server. + * @param handler - @zh 回调函数,接收包含 tick / args 的事件对象 @en Callback receiving an event object with tick and args + * @returns @zh GameEventHandlerToken — 调用 .cancel() 取消 @en GameEventHandlerToken — call .cancel() to unsubscribe + */ + onClientEvent( + handler: (event: { + /** @zh 事件到达时的服务端 tick @en Server tick when the event was sent */ + tick: number; + /** @zh 事件数据(已反序列化) @en Event data (deserialised) */ + args: T; + }) => void, + ): GameEventHandlerToken; +} + +// ── §1 @zh 客户端生命周期 & 服务端交互 @en Client lifecycle & server interaction ── + +/** @zh 通过 `client` 访问:生命周期回调、音效、命令发送 @en Accessed via `client`: lifecycle callbacks, sound, commands */ +interface GameClient { + onTick(callback: () => void): void; + + /** + * @zh 向当前客户端播放声音。 + * @en Plays a sound on the client. + * @param path - @zh 声音 ID(如 "minecraft:block.note_block.pling") @en sound ID (e.g. "minecraft:block.note_block.pling") + * @param volume - @zh 音量(0‑1,可选,默认 1) @en volume (0–1, optional, default 1) + * @param pitch - @zh 音高(0.5‑2,可选,默认 1) @en pitch (0.5–2, optional, default 1) + */ + playSound(path: string, volume?: number, pitch?: number): void; + + /** + * @zh 向服务端发送命令(等同于在聊天框输入 / 前缀的命令)。 + * @en Sends a command to the server (equivalent to typing a /command in chat). + * @param cmd - @zh 命令字符串(不需要 / 前缀) @en command string (no leading / needed) + */ + sendCommand(cmd: string): void; +} + +// ── §2 @zh 键盘输入 @en Keyboard input ── + +/** @zh 通过 `input` 访问:键盘输入检测 @en Accessed via `input`: keyboard input detection */ +interface GameInput { + /** + * @zh 检查指定按键当前是否被按下。 + * @en Checks whether a key is currently held down. + * @param key - @zh 按键名称(如 "space", "w", "left_shift", "f1") @en key name (e.g. "space", "w", "left_shift", "f1") + * @returns @zh true 如果按键正在被按住 @en true if the key is held down + */ + isKeyDown(key: string): boolean; + + /** + * @zh 注册按键按下回调(按下瞬间触发一次)。 + * @en Registers a callback fired once when the key is pressed. + * @param key - @zh 按键名称 @en key name + * @param callback - @zh 回调函数 @en callback function (no arguments) + * @returns @zh GameEventHandlerToken — 调用 .cancel() 取消 @en GameEventHandlerToken — call .cancel() to unsubscribe + */ + onKeyPress(key: string, callback: () => void): GameEventHandlerToken; +} + +// ── §3 @zh 屏幕 UI @en Screen UI ── + +/** @zh 通过 `ui` 访问:屏幕文字显示 @en Accessed via `ui`: on‑screen text display */ +interface GameUI { + /** + * @zh 在动作栏(快捷栏上方)显示文字。 + * @en Displays text in the action bar (above the hotbar). + * @param text - @zh 要显示的文字 @en text to display + */ + showOverlay(text: string): void; + + /** + * @zh 显示屏幕标题。 + * @en Displays a screen title. + * @param title - @zh 主标题 @en main title + * @param subtitle - @zh 副标题 @en subtitle + * @param fadeIn - @zh 淡入 tick(可选,默认 10) @en fade‑in ticks (optional, default 10) + * @param stay - @zh 停留 tick(可选,默认 70) @en stay ticks (optional, default 70) + * @param fadeOut - @zh 淡出 tick(可选,默认 20) @en fade‑out ticks (optional, default 20) + */ + showTitle( + title: string, + subtitle: string, + fadeIn?: number, + stay?: number, + fadeOut?: number, + ): void; + + /** + * @zh 在动作栏显示文字(与 showOverlay 相同)。 + * @en Displays text in the action bar (same as showOverlay). + * @param text - @zh 要显示的文字 @en text to display + */ + showActionBar(text: string): void; +} + +// ── §4 @zh 聊天消息 @en Chat messages ── + +/** @zh 通过 `chat` 访问:收发聊天消息 @en Accessed via `chat`: send and receive chat messages */ +interface GameChat { + /** + * @zh 向服务端发送聊天消息。 + * @en Sends a chat message to the server. + * @param text - @zh 消息内容 @en message content + */ + sendMessage(text: string): void; + + /** + * @zh 注册接收聊天消息的处理器。 + * @en Registers a handler for incoming chat messages. + * @param handler - @zh 回调函数(message: 消息文本, sender: 发送者 UUID, isSystem: 是否系统消息) + * 返回 false 可阻止消息显示 @en callback (message, sender, isSystem); return false to suppress display + * @returns @zh GameEventHandlerToken — 调用 .cancel() 取消 @en GameEventHandlerToken — call .cancel() to unsubscribe + */ + onMessage( + handler: (message: string, sender: string, isSystem: boolean) => boolean | void, + ): GameEventHandlerToken; +} + +// ── §5 @zh 全局声明(客户端) @en Global Declarations (client) ── + +declare const client: GameClient; +declare const input: GameInput; +declare const ui: GameUI; +declare const chat: GameChat; + +// storage, console, remoteChannel, db, http — declared in shared.d.ts diff --git a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/globals.d.ts b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/server.d.ts similarity index 63% rename from Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/globals.d.ts rename to Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/server.d.ts index 5a4b1e6..c212856 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/globals.d.ts +++ b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/server.d.ts @@ -1,526 +1,42 @@ -// ── §1 @zh 数学类型 @en Math Types ── +/// -/** - * @zh 三维向量,所有坐标使用世界坐标(方块坐标,非像素)。 - * @en A 3‑dimensional vector with double‑precision components. - * All coordinates are in world space (block coordinates, not pixels). - */ -declare class GameVector3 { - /** @zh X 分量(东‑西) @en X component (east‑west) */ - x: number; - /** @zh Y 分量(上‑下) @en Y component (up‑down) */ - y: number; - /** @zh Z 分量(南‑北) @en Z component (north‑south) */ - z: number; - - /** @zh 创建一个零向量 (0, 0, 0)。 @en Creates a zero vector at origin. */ - constructor(); +// ── §0 @zh RemoteChannel 服务端方法(接口合并) @en RemoteChannel server‑side methods (interface merging) ── +interface RemoteChannel { /** - * @zh 创建一个指定坐标的向量。 - * @en Creates a vector with the given coordinates. - * @param x - @zh X 坐标 @en X coordinate - * @param y - @zh Y 坐标 @en Y coordinate - * @param z - @zh Z 坐标 @en Z coordinate + * @zh 向指定玩家发送客户端事件。 + * @en Sends a client‑side event to the specified player(s). + * @param entities - @zh 单个玩家实体或实体数组 @en A single player entity or an array of them + * @param clientEvent - @zh 事件数据(任意 JSON 可序列化的值) @en Event data (any JSON‑serializable value) */ - constructor(x: number, y: number, z: number); + sendClientEvent(entities: any | any[], clientEvent: T): void; /** - * @zh 设置向量的 X / Y / Z 分量(会改变调用者自身)。 - * @en Sets all three components in‑place (mutates the vector). - * @returns @zh 调用者本身 @en this vector + * @zh 向所有玩家广播客户端事件。 + * @en Broadcasts a client‑side event to every connected player. + * @param clientEvent - @zh 事件数据(任意 JSON 可序列化的值) @en Event data (any JSON‑serializable value) */ - set(x: number, y: number, z: number): GameVector3; - - /** @zh 原地复制 v 的值。 @en Copies values from v in‑place. */ - copy(v: GameVector3): GameVector3; - - /** @zh 深拷贝。 @en Returns a new independent copy. */ - clone(): GameVector3; + broadcastClientEvent(clientEvent: T): void; /** - * @zh 向量加法:this + v。 - * @en Vector addition: this + v. - * @returns @zh 一个新向量 @en a new vector - */ - add(v: GameVector3): GameVector3; - - /** - * @zh 向量减法:this - v。 - * @en Vector subtraction: this - v. - * @returns @zh 一个新向量 @en a new vector - */ - sub(v: GameVector3): GameVector3; - - /** @zh 逐分量乘法(返回新对象)。 @en Component‑wise multiplication (returns new vector). */ - mul(v: GameVector3): GameVector3; - - /** @zh 逐分量除法(返回新对象,除以 0 得 0)。 @en Component‑wise division (divide‑by‑zero → 0). */ - div(v: GameVector3): GameVector3; - - /** - * @zh 标量乘法:每个分量乘以 n。 - * @en Scalar multiplication: each component multiplied by n. - * @returns @zh 一个新向量 @en a new vector - */ - scale(n: number): GameVector3; - - /** @zh 原地缩放。 @en Scale in‑place. */ - scaleEq(n: number): GameVector3; - - /** @zh 反向向量(返回新对象)。 @en Negation (returns new vector). */ - neg(): GameVector3; - - /** @zh 原地反向。 @en Negation in‑place. */ - negEq(): GameVector3; - - /** @zh 原地加法。 @en Addition in‑place. */ - addEq(v: GameVector3): GameVector3; - - /** @zh 原地减法。 @en Subtraction in‑place. */ - subEq(v: GameVector3): GameVector3; - - /** @zh 原地乘法。 @en Multiplication in‑place. */ - mulEq(v: GameVector3): GameVector3; - - /** @zh 原地除法(除以 0 跳过该分量)。 @en Division in‑place (divide‑by‑zero skips that component). */ - divEq(v: GameVector3): GameVector3; - - /** @zh 点积(内积):this · v。 @en Dot (inner) product: this · v. */ - dot(v: GameVector3): number; - - /** @zh 叉积:this × v。 @en Cross product. */ - cross(v: GameVector3): GameVector3; - - /** @zh 向量长度(模)。 @en Magnitude (length) of this vector. */ - mag(): number; - - /** @zh 向量长度的平方(比 `mag()` 更快)。 @en Squared magnitude — faster than `mag()` when comparing distances. */ - sqrMag(): number; - - /** - * @zh 单位化:返回方向相同、长度为 1 的新向量。零向量会返回 (0,0,0)。 - * @en Normalizes this vector; returns a unit vector in the same direction. - * @zh Zero vector returns (0,0,0). - */ - normalize(): GameVector3; - - /** @zh 计算 this 与 v 之间的欧几里得距离。 @en Euclidean distance between this and v. */ - distance(v: GameVector3): number; - - /** @zh 到 v 的平方距离(比 distance() 快,适合排序/比较)。 @en Squared distance to v — faster than distance() for comparisons. */ - sqrDistance(v: GameVector3): number; - - /** - * @zh 线性插值:在 this 和 v 之间按比率 n 插值。 - * @en Linear interpolation between this and v by ratio n. - * @param n - @zh 插值比率(0=this,1=v) @en interpolation factor (0=this, 1=v) - */ - lerp(v: GameVector3, n: number): GameVector3; - - /** @zh 匀速移向目标点(步长 maxDelta,到目标后返回 target 的拷贝)。 @en Moves toward target by at most maxDelta. Returns copy of target when reached. */ - moveTowards(target: GameVector3, maxDelta: number): GameVector3; - - /** @zh 指向 v 的方向向量(已单位化)。 @en Direction vector pointing toward v (normalized). */ - towards(v: GameVector3): GameVector3; - - /** @zh this 与 v 之间的夹角(弧度)。 @en Angle between this and v in radians. */ - angle(v: GameVector3): number; - - /** @zh 近似相等检查(容差 1e‑6)。 @en Approximate equality within 1e‑6 tolerance. */ - equals(v: GameVector3): boolean; - - /** @zh 精确相等检查(分量完全相等)。 @en Exact component‑wise equality. */ - exactEquals(v: GameVector3): boolean; - - /** @zh 逐分量取较大值(返回新对象)。 @en Component‑wise max. */ - max(v: GameVector3): GameVector3; - - /** @zh 逐分量取较小值(返回新对象)。 @en Component‑wise min. */ - min(v: GameVector3): GameVector3; - - /** @zh 是否为零向量(容差 1e‑6)。 @en True if all components are within 1e‑6 of zero. */ - isZero(): boolean; - - /** @zh 逐分量向下取整(常用于方块坐标)。 @en Component‑wise floor (useful for block coordinates). */ - floor(): GameVector3; - - /** @zh 逐分量向上取整。 @en Component‑wise ceil. */ - ceil(): GameVector3; - - /** @zh 限制向量长度不超过 max(超长时返回归一化后缩放到 max 的新向量)。 @en Clamps length to max, returning a new vector. */ - clampLength(max: number): GameVector3; - - /** - * @zh 从球坐标创建向量。 - * @en Creates a vector from spherical coordinates. - * @param mag - @zh 半径 @en radius (magnitude) - * @param phi - @zh 方位角(弧度,绕 Y 轴水平旋转) @en azimuth angle (radians, horizontal rotation around Y) - * @param theta - @zh 仰角(弧度,从水平面起算) @en elevation angle (radians, from horizontal plane) - */ - static fromPolar(mag: number, phi: number, theta: number): GameVector3; - - /** @zh 返回 "(x, y, z)" 格式的字符串表示。 @en Returns a string in "(x, y, z)" format. */ - toString(): string; -} - -/** - * @zh 三维轴对齐包围盒(AABB),由两个对角顶点 lo(最小角)和 hi(最大角)定义。 - * @en Axis‑aligned 3‑dimensional bounding box, - * defined by two opposing corners: lo (minimum corner) and hi (maximum corner). - */ -declare class GameBounds3 { - /** @zh 最小角(三个分量均为最小值)。 @en Lower/minimum corner. */ - lo: GameVector3; - /** @zh 最大角(三个分量均为最大值)。 @en Upper/maximum corner. */ - hi: GameVector3; - - /** @zh 用两个对角顶点构造包围盒。 @en Constructs bounds from two opposing corners. */ - constructor(lo: GameVector3, hi: GameVector3); - - /** @zh 原地设置所有边界。 @en Sets all boundaries in‑place. */ - set( - lox: number, - loy: number, - loz: number, - hix: number, - hiy: number, - hiz: number, - ): GameBounds3; - - /** @zh 原地复制 b 的值。 @en Copies values from b in‑place. */ - copy(b: GameBounds3): GameBounds3; - - /** @zh 判断当前包围盒是否与 other 相交。 @en Returns true if this bounds intersects with other. */ - intersects(other: GameBounds3): boolean; - - /** @zh 计算交集包围盒(无交集返回 null)。 @en Returns the intersection bounds, or null if they don't overlap. */ - intersect(other: GameBounds3): GameBounds3 | null; - - /** @zh 判断点 v 是否位于包围盒内部(含边界)。 @en Returns true if point v is inside (or on the boundary of) this bounds. */ - contains(v: GameVector3): boolean; - - /** @zh 判断是否完全包含另一个包围盒。 @en Whether this bounds fully contains b. */ - containsBounds(b: GameBounds3): boolean; - - /** @zh 返回包围盒中心点。 @en Returns the center point of the bounds. */ - center(): GameVector3; - - /** @zh 返回包围盒尺寸 (hi‑lo)。 @en Returns the size (hi‑lo) as a vector. */ - size(): GameVector3; - - /** @zh 扩展包围盒,各方向扩大 delta(返回新对象)。 @en Returns a new bounds expanded by delta on all sides. */ - expand(delta: number): GameBounds3; - - /** @zh 原地扩展包围盒。 @en Expands the bounds in‑place. */ - expandEq(delta: number): GameBounds3; - - /** @zh 扩展包围盒以包含点 v(原地)。 @en Grows the bounds to include point v in‑place. */ - growToInclude(v: GameVector3): GameBounds3; - - /** @zh 包围盒上距离 v 最近的点(v 在内部时返回投影到表面的点)。 @en Closest point on the bounds surface to v. */ - closestPoint(v: GameVector3): GameVector3; - - /** @zh 平移包围盒(返回新对象)。 @en Translates the bounds by offset (returns new object). */ - move(offset: GameVector3): GameBounds3; - - /** @zh 原地平移包围盒。 @en Translates the bounds in‑place. */ - moveEq(offset: GameVector3): GameBounds3; - - /** @zh 从 GameVector3 数组创建最小包围盒。 @en Creates bounds from an array of GameVector3. */ - static fromPoints(points: GameVector3[]): GameBounds3 | null; - - toString(): string; -} - -// ────────────────────────────────────────────── - -/** - * @zh RGB 颜色,三个通道,每通道 0.0–1.0。 - * @en An RGB color with three channels ranging from 0.0 to 1.0. - */ -declare class GameRGBColor { - /** @zh 红色通道(0.0–1.0)。 @en Red channel. */ - r: number; - /** @zh 绿色通道(0.0–1.0)。 @en Green channel. */ - g: number; - /** @zh 蓝色通道(0.0–1.0)。 @en Blue channel. */ - b: number; - - /** @zh 用指定的 R / G / B 值创建颜色。 @en Creates a color with the given R/G/B values. */ - constructor(r: number, g: number, b: number); - - /** @zh 原地设置所有通道。 @en Sets all three channels in‑place. */ - set(r: number, g: number, b: number): GameRGBColor; - - /** @zh 原地复制另一个颜色的值。 @en Copies values from another color in‑place. */ - copy(o: GameRGBColor): GameRGBColor; - - /** @zh 深拷贝。 @en Returns a new independent copy. */ - clone(): GameRGBColor; - - /** @zh 逐通道加法(返回新对象)。 @en Channel‑wise addition (returns new object). */ - add(o: GameRGBColor): GameRGBColor; - - /** @zh 逐通道减法(返回新对象)。 @en Channel‑wise subtraction (returns new object). */ - sub(o: GameRGBColor): GameRGBColor; - - /** @zh 逐通道乘法(返回新对象)。 @en Channel‑wise multiplication (returns new object). */ - mul(o: GameRGBColor): GameRGBColor; - - /** @zh 逐通道除法(返回新对象,除以 0 得 0)。 @en Channel‑wise division (divide‑by‑zero → 0). */ - div(o: GameRGBColor): GameRGBColor; - - /** @zh 原地加法。 @en Addition in‑place. */ - addEq(o: GameRGBColor): GameRGBColor; - - /** @zh 原地减法。 @en Subtraction in‑place. */ - subEq(o: GameRGBColor): GameRGBColor; - - /** @zh 原地乘法。 @en Multiplication in‑place. */ - mulEq(o: GameRGBColor): GameRGBColor; - - /** @zh 原地除法(除以 0 跳过该通道)。 @en Division in‑place (divide‑by‑zero skips that channel). */ - divEq(o: GameRGBColor): GameRGBColor; - - /** @zh 标量乘法(返回新对象,常用于亮度调整)。 @en Scalar multiply — brighten (n>1) or darken (n<1). */ - scale(n: number): GameRGBColor; - - /** @zh 原地标量乘法。 @en Scalar multiply in‑place. */ - scaleEq(n: number): GameRGBColor; - - /** @zh 在 this 和 o 之间线性插值。 @en Linear interpolation between this and o by ratio n. */ - lerp(o: GameRGBColor, n: number): GameRGBColor; - - /** @zh 近似相等检查(容差 1e‑6)。 @en Approximate equality within 1e‑6 tolerance. */ - equals(o: GameRGBColor): boolean; - - /** @zh 转为 "rgba(r,g,b,1.0)" 格式字符串。 @en Converts to an rgba CSS string. */ - toRGBA(): string; - - /** @zh 生成一个随机 RGB 颜色(每个通道 0–1)。 @en Generates a random RGB color (each channel 0–1). */ - static random(): GameRGBColor; - - toString(): string; -} - -// ────────────────────────────────────────────── - -/** - * @zh RGBA 颜色,四个通道,每通道 0.0–1.0。 - * @en An RGBA color; all four channels range from 0.0 to 1.0. - */ -declare class GameRGBAColor { - /** @zh 红色通道(0.0–1.0)。 @en Red channel. */ - r: number; - /** @zh 绿色通道(0.0–1.0)。 @en Green channel. */ - g: number; - /** @zh 蓝色通道(0.0–1.0)。 @en Blue channel. */ - b: number; - /** @zh Alpha(不透明度),范围 0.0–1.0。 @en Alpha (opacity), range 0.0–1.0. */ - a: number; - - constructor(r: number, g: number, b: number, a: number); - - /** @zh 原地设置所有四个通道。 @en Sets all four channels in‑place. */ - set(r: number, g: number, b: number, a: number): GameRGBAColor; - - /** @zh 原地复制另一个颜色的值。 @en Copies values from another RGBA color in‑place. */ - copy(c: GameRGBAColor): GameRGBAColor; - - /** @zh 深拷贝。 @en Returns a new independent copy. */ - clone(): GameRGBAColor; - - /** @zh 逐通道加法(返回新对象)。 @en Channel‑wise addition (returns new object). */ - add(rgba: GameRGBAColor): GameRGBAColor; - - /** @zh 逐通道减法(返回新对象)。 @en Channel‑wise subtraction (returns new object). */ - sub(rgba: GameRGBAColor): GameRGBAColor; - - /** @zh 逐通道乘法(返回新对象)。 @en Channel‑wise multiplication (returns new object). */ - mul(rgba: GameRGBAColor): GameRGBAColor; - - /** @zh 逐通道除法(返回新对象,除以 0 得 0)。 @en Channel‑wise division (returns new object; divide‑by‑zero → 0). */ - div(rgba: GameRGBAColor): GameRGBAColor; - - /** @zh 原地加法。 @en Addition in‑place. */ - addEq(rgba: GameRGBAColor): GameRGBAColor; - - /** @zh 原地减法。 @en Subtraction in‑place. */ - subEq(rgba: GameRGBAColor): GameRGBAColor; - - /** @zh 原地乘法。 @en Multiplication in‑place. */ - mulEq(rgba: GameRGBAColor): GameRGBAColor; - - /** @zh 原地除法(除以 0 跳过该通道)。 @en Division in‑place (divide‑by‑zero skips that channel). */ - divEq(rgba: GameRGBAColor): GameRGBAColor; - - /** @zh 标量乘法(返回新对象,包括 alpha 通道)。 @en Scalar multiply — all channels including alpha. */ - scale(n: number): GameRGBAColor; - - /** @zh 原地标量乘法。 @en Scalar multiply in‑place. */ - scaleEq(n: number): GameRGBAColor; - - /** @zh 线性插值。 @en Linear interpolation between this and rgba by ratio n. */ - lerp(rgba: GameRGBAColor, n: number): GameRGBAColor; - - /** @zh 近似相等检查(容差 1e‑6)。 @en Approximate equality within 1e‑6 tolerance. */ - equals(rgba: GameRGBAColor): boolean; - - /** - * @zh Alpha 混合:将自身 RGBA 颜色混合到 RGB 背景上。 - * @en Blends this RGBA color onto an RGB background, returning the displayed RGB. - */ - blendEq(rgb: GameRGBColor): GameRGBColor; - - toString(): string; -} - -// ────────────────────────────────────────────── - -/** - * @zh 四元数,用于三维旋转。单位四元数(w²+x²+y²+z²=1)表示纯旋转。 - * @en A quaternion used for 3‑dimensional rotation. - * Unit quaternions represent pure rotations. - */ -declare class GameQuaternion { - /** @zh 实部(标量分量)。 @en Real (scalar) component. */ - w: number; - /** @zh 虚部 X 分量。 @en Imaginary X component. */ - x: number; - /** @zh 虚部 Y 分量。 @en Imaginary Y component. */ - y: number; - /** @zh 虚部 Z 分量。 @en Imaginary Z component. */ - z: number; - - /** @zh 创建单位四元数 (1, 0, 0, 0)。 @en Creates an identity quaternion. */ - constructor(); - - /** @zh 用指定的 w/x/y/z 分量创建四元数。 @en Creates a quaternion with the given w/x/y/z components. */ - constructor(w: number, x: number, y: number, z: number); - - /** @zh 原地设置所有分量。 @en Sets all components in‑place. */ - set(w: number, x: number, y: number, z: number): GameQuaternion; - - /** @zh 原地复制。 @en Copies values from another quaternion in‑place. */ - copy(v: GameQuaternion): GameQuaternion; - - /** @zh 深拷贝。 @en Returns a new independent copy. */ - clone(): GameQuaternion; - - /** @zh 逐分量加法。 @en Component‑wise addition. */ - add(v: GameQuaternion): GameQuaternion; - - /** @zh 逐分量减法。 @en Component‑wise subtraction. */ - sub(v: GameQuaternion): GameQuaternion; - - /** - * @zh 四元数乘法(汉密尔顿积):this × q。注意乘法不满足交换律。 - * @en Hamilton product: this × q. Multiplication is NOT commutative. - */ - mul(q: GameQuaternion): GameQuaternion; - - /** @zh 共轭四元数(对单位四元数等价于逆)。 @en Conjugate of this quaternion (equals inverse for unit quaternions). */ - inv(): GameQuaternion; - - /** @zh 除法:this × q⁻¹。 @en Division: this × q⁻¹. */ - div(q: GameQuaternion): GameQuaternion; - - /** @zh 点积:this · q。 @en Dot product. */ - dot(q: GameQuaternion): number; - - /** @zh 模长(范数)。 @en Magnitude (norm). */ - mag(): number; - - /** @zh 模长平方。 @en Squared magnitude. */ - sqrMag(): number; - - /** @zh 单位化:返回模长为 1 的新四元数。 @en Normalizes this quaternion; returns a unit quaternion. */ - normalize(): GameQuaternion; - - /** - * @zh 球面线性插值(Slerp):在 this 和 q 之间平滑旋转。 - * @en Spherical linear interpolation — smooth rotation between this and q. - * @param t - @zh 插值比率(0=this,1=q) @en interpolation factor (0=this, 1=q) - */ - slerp(q: GameQuaternion, t: number): GameQuaternion; - - /** @zh 返回 this 和 q 之间的角度(弧度)。 @en Angular difference between this and q (in radians). */ - angle(q: GameQuaternion): number; - - /** - * @zh 返回四元数对应的轴‑角表示。 - * @en Decomposes this quaternion into axis‑angle representation. - * @returns @zh 包含 `angle` 和 `axis` 字段的对象 @en object with `angle` and `axis` fields + * @zh 注册来自客户端的远程事件处理器。 + * @en Registers a handler for remote events sent from clients. + * @param handler - @zh 回调函数,接收包含 tick / entity / args 的事件对象 @en Callback receiving an event object with tick, entity, and args + * @returns @zh GameEventHandlerToken — 调用 .cancel() 取消 @en GameEventHandlerToken — call .cancel() to unsubscribe */ - getAxisAngle(): AxisAngle; - - // ── @zh 旋转操作 @en Rotation operations ── - - /** @zh 绕 X 轴旋转(在左侧乘以旋转四元数)。 @en Rotate around X axis. */ - rotateX(rad: number): GameQuaternion; - /** @zh 绕 Y 轴旋转。 @en Rotate around Y axis. */ - rotateY(rad: number): GameQuaternion; - /** @zh 绕 Z 轴旋转。 @en Rotate around Z axis. */ - rotateZ(rad: number): GameQuaternion; - - /** @zh 用此单位四元数旋转向量 v。 @en Rotates vector v by this unit quaternion. */ - rotateVector(v: GameVector3): GameVector3; - - /** @zh 转为 YZX 欧拉角(弧度),x/y/z 分别对应绕 X/Y/Z 轴的旋转角。 @en Decomposes into YZX Euler angles (radians). */ - toEuler(): GameVector3; - - // ── @zh 静态构造器 @en Static constructors ── - - /** @zh 从轴‑角表示创建四元数。 @en Create from axis‑angle representation. */ - static fromAxisAngle(axis: GameVector3, rad: number): GameQuaternion; - - /** @zh 从欧拉角创建四元数(YZX 旋转顺序:Y → Z → X)。 @en Create from Euler angles (YZX rotation order: Y → Z → X). */ - static fromEuler(x: number, y: number, z: number): GameQuaternion; - - /** @zh 计算从向量 a 旋转到向量 b 的最短弧四元数。 @en Shortest‑arc quaternion rotating from vector a to vector b. */ - static rotationBetween(a: GameVector3, b: GameVector3): GameQuaternion; - - /** @zh 构造从 from 看向 to 的朝向四元数(up 为上方向)。 @en Builds a look‑at quaternion orienting -Z from `from` toward `to`. */ - static lookAt(from: GameVector3, to: GameVector3, up: GameVector3): GameQuaternion; - - /** @zh 近似相等检查(容差 1e‑6)。 @en Approximate equality check within 1e‑6 tolerance. */ - equals(v: GameQuaternion): boolean; - - toString(): string; + onServerEvent( + handler: (event: { + /** @zh 事件到达时的服务端 tick @en Server tick when the event arrived */ + tick: number; + /** @zh 发送事件的玩家实体 @en The player entity that sent the event */ + entity: any; + /** @zh 事件数据(已反序列化) @en Event data (deserialised) */ + args: T; + }) => void, + ): GameEventHandlerToken; } -/** - * @zh 轴‑角表示,由 `getAxisAngle()` 返回。 - * @en Axis‑angle representation returned by `getAxisAngle()`. - */ -interface AxisAngle { - /** @zh 旋转角度 (弧度) @en rotation angle in radians */ - angle: number; - /** @zh 旋转轴 (单位向量) @en rotation axis (unit vector) */ - axis: GameVector3; -} - -/** - * @zh 事件处理器令牌,由 `world.onXxx()` 返回。 - * 调用 `cancel()` 取消监听后不可恢复,需重新注册。 - * @en Event handler token returned by `world.onXxx()`. - * Once cancelled via `cancel()`, it cannot be resumed — re-register instead. - */ -declare class GameEventHandlerToken { - /** @zh 取消事件监听 (不可逆) @en Cancels the event listener (irreversible). */ - cancel(): void; - - /** - * @zh 尝试恢复已取消的监听 (会抛出 UnsupportedOperationException)。 - * @en Attempts to resume a cancelled listener — always throws UnsupportedOperationException. - * @throws UnsupportedOperationException 始终抛出 / always thrown - */ - resume(): void; - - /** @zh 返回 true 表示监听仍处于活跃状态 @en Returns true if the listener is still active. */ - active(): boolean; -} +// ── §1 @zh 服务端回调参数 @en Server Callback Parameters ── /** * @zh `onTick` 回调的参数类型。 @@ -537,184 +53,13 @@ interface TickInfo { skip: number; } -// ── §2 @zh 持久化存储 @en Persistent Storage ── - -/** - * @zh JSON 可序列化的值类型,用作 `GameDataStorage` 的默认类型参数。 - * @en Represents any JSON‑serializable value. - * Used as the default type parameter for `GameDataStorage`. - */ -type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }; - -/** - * @zh 数据存储空间(键值持久化),通过 `storage.getDataStorage("name")` 获取。 - * T 指定后所有读写操作自动获得类型检查。 - * - * @en A data‑storage namespace — persistent key‑value store backed by JSON files. - * Obtain via `storage.getDataStorage("name")`; once T is set, all read/write operations are type‑checked. - * - * @example - * ```ts - * const coins = storage.getDataStorage("coins"); - * const balance = coins.get(userId); // number | null - * coins.set(userId, 100); // value must be number - * ``` - */ -interface GameDataStorage { - /** - * @zh 获取存储空间名称 (只读)。 - * @en Returns the read‑only namespace name. - */ - readonly key: string; - - /** - * @zh 存入一个键值对。值必须是可 JSON 序列化的类型。 - * @en Stores a key‑value pair. Value must be JSON‑serializable. - * @param key - @zh 键 @en key - * @param value - @zh 值 @en value (typed to T) - */ - set(key: string, value: T): void; - - /** - * @zh 读取键对应的值, 不存在则返回 null。 - * @en Retrieves the value for a key, or null if it does not exist. - * @returns @zh 存储的值, 或 null @en The stored value, or null - */ - get(key: string): T | null; - - /** - * @zh 获取当前存储空间中的所有键。 - * @en Lists all keys in this storage namespace. - */ - keys(): string[]; - - /** - * @zh 原子更新: 取出当前值, 用 handler(currentValue) 的结果覆盖。 - * @en Atomically updates a value using a callback. - * @param key - @zh 键 @en key - * @param handler - @zh (prevValue) => newValue @en callback receiving the old value, returning the new one - * @remarks 如果键不存在, 不会创建新条目 (遵循 Box3 规范)。 - * If the key does not exist, nothing happens (per Box3 spec). - * - * @example - * const list = storage.getDataStorage("list"); - * list.update("items", (prev) => prev.concat("newItem")); - */ - update(key: string, handler: (prevValue: T) => T): void; +// ── §2 @zh 持久化存储扩展(服务端专用方法) @en Storage Extensions (server‑only methods) ── - /** - * @zh 删除键, 返回旧值 (不存在则返回 null)。 - * @en Removes a key and returns its previous value, or null. - * @returns @zh 被删除的旧值, 或 null @en The previous value, or null - */ - remove(key: string): T | null; - - /** - * @zh 原子递增 (delta 默认为 1)。 - * @en Atomically increments a numeric value by delta (default 1). - * @param key - @zh 键 @en key - * @param delta - @zh 增量(可选,默认 1) @en increment amount (optional, default 1) - * @returns @zh 递增后的新值 @en The new value after incrementing - * @remarks 键不存在时从 0 + delta 开始。 - * If the key doesn't exist, starts from 0 + delta. - */ - increment(key: string, delta?: number): number; - - /** - * @zh 分页查询存储条目。 - * @en Paginated query of stored entries. - * @param options - @zh 查询选项 @en query options - * @param options.cursor - @zh 起始游标(页码) @en starting cursor (page number * pageSize) - * @param options.pageSize - @zh 每页条目数(1‑100,默认 100) @en items per page (1–100, default 100) - * @param options.ascending - @zh 是否升序排列 @en sort ascending if true - * @param options.max - @zh 值的上限过滤 @en maximum value filter - * @param options.min - @zh 值的下限过滤 @en minimum value filter - * @param options.constraintTarget - @zh 排序/过滤的目标路径(如 "a.b.c") @en nested path for sorting/filtering - * @returns @zh 分页结果对象 @en paginated query result - */ - list(options?: { - cursor?: number; - pageSize?: number; - ascending?: boolean; - max?: number; - min?: number; - constraintTarget?: string; - }): QueryList; - - /** - * @zh 销毁该存储空间 (删除对应 JSON 文件)。 - * @en Destroys this storage namespace (deletes the backing JSON file). - */ - destroy(): void; -} - -/** - * @zh 分页查询结果,由 `GameDataStorage.list()` 返回。 - * @en Paginated query result returned by `GameDataStorage.list()`. - */ -interface QueryList { - /** @zh 是否已到达最后一页 @en Whether the last page has been reached. */ - isLastPage: boolean; - - /** - * @zh 获取当前页的条目数组。 - * @en Returns the entries for the current page. - */ - getCurrentPage(): ReturnValue[]; - - /** - * @zh 移动到下一页。 - * @en Advances the cursor to the next page. - */ - nextPage(): void; -} - -/** - * @zh 单个存储条目,包含元数据。 - * @en A single stored entry with metadata. - */ -interface ReturnValue { - /** @zh 键名 @en key name */ - key: string; - /** @zh 值 @en stored value */ - value: T; - /** @zh 更新时间 (Unix 毫秒) @en last‑modified timestamp (Unix ms) */ - updateTime: number; - /** @zh 创建时间 (Unix 毫秒) @en creation timestamp (Unix ms) */ - createTime: number; - /** @zh 版本标识符 (可用于乐观锁) @en version identifier (usable for optimistic locking) */ - version: string; -} - -/** - * @zh 全局存储入口 — 脚本中通过 `storage` 访问。 - * 项目间数据隔离:每个项目自动使用项目名作为存储文件前缀。 - * 跨项目共享:`getGroupStorage` 使用 `__shared__/` 命名空间,所有项目访问同一数据。 - * - * @en Global storage entry point — accessed via `storage` in scripts. - * Per‑project isolation: each project's storage is automatically prefixed with the project name. - * Cross‑project sharing: `getGroupStorage` uses a `__shared__/` namespace visible to all projects. - */ +// Declaration merging: augment GameStorage with server‑only method interface GameStorage { - /** @zh 始终返回空字符串 (MC 本地存储无 key, 只读) @en Always returns "" for MC local storage, readonly. */ - readonly key: string; - - /** - * @zh 打开或创建指定名称的数据存储空间 (项目隔离)。 - * @en Opens or creates a named data‑storage namespace (per‑project isolated). - * @param name - @zh 命名空间(可含 "/" 作为目录分隔) @en namespace (may contain "/" as directory separator) - * @remarks 不同项目使用同一 name 会访问不同文件。 - * Different projects using the same name access different files. - * - * @example - * const coins = storage.getDataStorage("coins"); - * const balance = coins.get(userId); // number | null - */ - getDataStorage(name: string): GameDataStorage; - /** - * @zh 获取跨项目共享存储 — 所有项目通过同一 name 读写同一份数据。 - * @en Shared cross‑project storage — all projects read/write the same data by name. + * @zh 获取跨项目共享存储 — 所有项目通过同一 name 读写同一份数据(服务端专用)。 + * @en Shared cross‑project storage — all projects read/write the same data by name (server‑only). * @param name - @zh 命名空间 @en namespace * @remarks 底层使用 `__shared__/` 前缀, 适合全服排行榜、全局配置等场景。 * Uses `__shared__/` prefix internally; suitable for global leaderboards, shared config, etc. @@ -722,114 +67,6 @@ interface GameStorage { getGroupStorage(name: string): GameDataStorage; } -/** - * @zh SQL 查询结果,支持迭代和 thenable 模式。 - * SELECT 查询返回行数据,INSERT/UPDATE/DELETE 返回受影响行数。 - * - * @en SQL query result — supports iteration and thenable pattern. - * SELECT queries return row data; INSERT/UPDATE/DELETE return affected row count. - * - * @example - * ```ts - * // 获取所有行 / Get all rows - * const rows = db.sql("SELECT * FROM players").rows; - * - * // 逐行迭代 / Iterate row by row - * const result = db.sql("SELECT * FROM players"); - * let row; - * while (!(row = result.next()).done) { - * console.log(row.value.name); - * } - * - * // Thenable 模式 / Thenable pattern - * db.sql("SELECT * FROM players").then((rows) => console.log(rows.length)); - * ``` - */ -interface GameQueryResult> { - /** @zh 所有行 @en All rows */ - readonly rows: T[]; - - /** @zh 第一行,无结果时返回 null @en First row, or null if empty */ - readonly firstRow: T | null; - - /** @zh 列名数组 @en Column name array */ - readonly columnNames: string[]; - - /** @zh 列数 @en Column count */ - readonly columnCount: number; - - /** @zh 行数 (SELECT) @en Row count (for SELECT queries) */ - readonly rowCount: number; - - /** - * @zh 受影响行数 (INSERT/UPDATE/DELETE),SELECT 查询返回 -1。 - * @en Affected row count for INSERT/UPDATE/DELETE; -1 for SELECT queries. - */ - readonly affectedRows: number; - - /** @zh 是否为查询 (SELECT) @en Whether this is a query (SELECT) */ - readonly isQuery: boolean; - - /** - * @zh 返回下一行: `{done: boolean, value: T}`。 - * @en Returns the next row as `{done: boolean, value: T}`. - */ - next(): { done: boolean; value: T }; - - /** @zh 重置内部游标到第一行 @en Resets internal cursor to first row */ - reset(): void; - - /** - * @zh Thenable 支持 — resolve 接收所有行数组。 - * @en Thenable support — resolve receives the full row array. - */ - then(resolve: (rows: T[]) => void, reject?: (err: string) => void): void; -} - -/** - * @zh SQLite 数据库,自动按项目隔离到 `config/box3/data/.db`。 - * 通过全局 `db` 访问,支持 `?` 占位符和 tagged template 两种调用约定。 - * - * @en SQLite database — auto-isolated per project at `config/box3/data/.db`. - * Access via global `db`, supports both `?` placeholder and tagged template calling conventions. - * - * @example - * ```ts - * // 常规查询 / Regular query - * db.sql("SELECT * FROM players WHERE score > ?", 100); - * - * // Tagged template (TS 编译后) / Tagged template (after TS compilation) - * db.sql`SELECT * FROM players WHERE id = ${playerId}`; - * - * // INSERT / UPDATE / DELETE - * db.sql("INSERT INTO log (name, msg) VALUES (?, ?)", "Steve", "hello"); - * db.sql("DELETE FROM temp WHERE created < ?", Date.now() - 86400000); - * ``` - */ -interface GameDatabase { - /** - * @zh 执行 SQL 查询或更新。 - * @en Executes a SQL query or update. - * - * @param sql - @zh SQL 字符串(含 ? 占位符)或字符串数组(模板字面量) @en SQL string (with ? placeholders) or string array (template literal) - * @param params - @zh 参数值 @en Parameter values to bind (number | string | boolean | null | Uint8Array) - * @returns @zh 查询结果 @en query result - * - * @example - * // 指定行类型获得完整类型检查 / Specify row type for full type-checking - * const rows = db.sql("SELECT * FROM players").rows; - * // rows: PlayerRow[] - * - * // 不指定则默认为 Record / Defaults to Record - * const rows = db.sql("SELECT * FROM players").rows; - * // rows: Record[] - */ - sql>( - sql: string | readonly string[], - ...params: (number | string | boolean | null | Uint8Array)[] - ): GameQueryResult; -} - // ── §3 @zh 实体 @en Entity ── /** @@ -1153,9 +390,9 @@ interface GameEntity { * @zh 玩家实体 — `GameEntity` 的子类型,保证 `player` 属性非 null。 * @en A player entity — subtype of `GameEntity` with a guaranteed non‑null `player`. */ -type GamePlayerEntity = GameEntity & { player: GamePlayer }; +type GamePlayerEntity = GameEntity & { player: GamePlayer; hasBox3JSClient(): boolean }; -// ── §4 @zh 玩家 @en Player ── +// ── §5 @zh 玩家 @en Player ── /** * @zh 玩家扩展接口,通过 `entity.player` 访问。 @@ -1592,9 +829,19 @@ interface GamePlayer { * @en Revokes an advancement from this player. */ revokeAdvancement(advancementId: string): void; + + // ── @zh 客户端 Mod 检测 @en Client Mod Detection ── + + /** + * @zh 检查该玩家的客户端是否安装了 Box3JS mod。 + * @en Returns true if this player's client has the Box3JS mod installed. + * @remarks 用于在调用 `remoteChannel.sendClientEvent()` 前检测,避免向未安装的客户端发送。 + * Use before calling `remoteChannel.sendClientEvent()` to avoid sending to unsupported clients. + */ + hasBox3JSClientMod(): boolean; } -// ── §5 @zh 世界 API @en World ── +// ── §6 @zh 世界 API @en World ── /** * @zh 世界控制与事件 — 脚本中通过 `world` 访问。 @@ -1603,7 +850,7 @@ interface GamePlayer { interface GameWorld { // ── @zh 世界属性 @en World properties ── - /** @zh 服务器 MOTD @en Server MOTD string. */ + /** @zh 项目名称 (只读) @en Project name, readonly. */ projectName(): string; /** @zh 服务器 MOTD (可读写, 同 projectName) @en Server MOTD (read/write, alias of projectName). */ @@ -2001,10 +1248,14 @@ interface GameWorld { /** @zh 彩色粒子 (DustParticleOptions)。 @en Colored dust particle. */ spawnParticle( - x: number, y: number, z: number, + x: number, + y: number, + z: number, color: GameRGBColor, count: number, - dx: number, dy: number, dz: number, + dx: number, + dy: number, + dz: number, speed: number, ): void; /** @zh 彩色粒子,GameVector3 重载。 @en Colored dust particle, GameVector3 overload. */ @@ -2012,7 +1263,9 @@ interface GameWorld { pos: GameVector3, color: GameRGBColor, count: number, - dx: number, dy: number, dz: number, + dx: number, + dy: number, + dz: number, speed: number, ): void; @@ -2059,16 +1312,14 @@ interface GameWorld { /** @zh 彩色烟花,GameRGBColor 数组。 @en Colored firework with GameRGBColor array. */ launchFirework( - x: number, y: number, z: number, + x: number, + y: number, + z: number, colors: GameRGBColor[], shape: string, ): void; /** @zh 彩色烟花,GameVector3 + GameRGBColor[] 重载。 @en Colored firework, GameVector3 overload. */ - launchFirework( - pos: GameVector3, - colors: GameRGBColor[], - shape: string, - ): void; + launchFirework(pos: GameVector3, colors: GameRGBColor[], shape: string): void; // ── @zh 闪电 @en Lightning ── @@ -2560,7 +1811,7 @@ interface RaycastResult { entity?: GameEntity; } -// ── §6 @zh 方块操作 @en Voxels ── +// ── §7 @zh 方块操作 @en Voxels ── /** * @zh 方块读写操作 — 脚本中通过 `voxels` 访问。所有坐标使用世界方块坐标(整数)。 @@ -2719,47 +1970,7 @@ interface GameVoxels { setSpawner(pos: GameVector3, entityType: string): void; } -// ── §7 @zh 控制台 @en Console ── - -/** - * @zh 服务端控制台输出 — 脚本中通过 `console` 访问。 - * 输出格式:`[Box3JS] [projectName] `,通过 System.out / System.err 输出到服务端控制台。 - * - * @en Server console output — accessed via `console` in scripts. - * Output format: `[Box3JS] [projectName] `, written via System.out / System.err. - */ -interface GameConsole { - /** @zh 普通日志 @en Info‑level log. */ - log(...args: unknown[]): void; - - /** @zh 调试日志 (前缀 [DEBUG]) @en Debug‑level log (prefixed with [DEBUG]). */ - debug(...args: unknown[]): void; - - /** @zh 警告日志 (前缀 [WARN]) @en Warning‑level log (prefixed with [WARN]). */ - warn(...args: unknown[]): void; - - /** - * @zh 错误日志 (输出到 stderr, 前缀 [ERROR])。 - * @en Error‑level log (written to stderr, prefixed with [ERROR]). - */ - error(...args: unknown[]): void; - - /** - * @zh 清除控制台 (发送 ANSI 清屏序列)。 - * @en Clears the console output (sends ANSI clear‑screen sequence). - */ - clear(): void; - - /** - * @zh 断言: 条件为 false 时输出错误。 - * @en Asserts a condition; logs an error message if the condition is false. - * @param condition - @zh 要测试的条件 @en the condition to test - * @param args - @zh 失败时输出的额外参数 @en additional values to log on failure - */ - assert(condition: boolean, ...args: unknown[]): void; -} - -// ── §8 @zh 运行时枚举常量 @en Runtime Enum Constants ── +// ── §8 @zh 运行时枚举常量(服务端专用) @en Runtime Enum Constants (server‑only) ── /** * @zh 按钮类型常量 — 用于 `world.onButtonPressed()` 的 `button` 参数。 @@ -2807,31 +2018,10 @@ declare const GamePlayerWalkState: { readonly RUN: "RUN"; }; -// ── §9 @zh 全局声明 @en Global Declarations ── +// ── §9 @zh 全局声明(服务端) @en Global Declarations (server) ── /** @zh 世界控制与事件 API @en World control & events */ declare const world: GameWorld; /** @zh 方块读写 API @en Block read & write */ declare const voxels: GameVoxels; - -/** @zh 持久化存储 API @en Persistent key‑value storage */ -declare const storage: GameStorage; - -/** - * @zh SQLite 数据库 API。 - * - * **前置条件:** 需要安装 `minecraft-sqlite-jdbc` 模组来提供 JDBC 驱动。 - * 未安装时,调用 `db.sql()` 会抛出带明确提示的错误,不影响其它 API(`world`、`storage`、`voxels` 等)。 - * - * @en SQLite database API. - * - * **Prerequisite:** Install the `minecraft-sqlite-jdbc` mod to provide the JDBC driver. - * If missing, `db.sql()` throws a clear error without affecting other APIs. - * - * @see https://modrinth.com/plugin/minecraft-sqlite-jdbc - */ -declare const db: GameDatabase; - -/** @zh 服务端控制台输出 @en Server console output */ -declare const console: GameConsole; diff --git a/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/shared.d.ts b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/shared.d.ts new file mode 100644 index 0000000..a8d79e3 --- /dev/null +++ b/Box3JS-NeoForge-1.21.1/src/main/resources/assets/box3js/template/types/shared.d.ts @@ -0,0 +1,1010 @@ +// ── §1 @zh 数学类型(服务端 & 客户端共享) @en Math Types (shared between server & client) ── + +/** + * @zh 三维向量,所有坐标使用世界坐标(方块坐标,非像素)。 + * @en A 3‑dimensional vector with double‑precision components. + * All coordinates are in world space (block coordinates, not pixels). + */ +declare class GameVector3 { + /** @zh X 分量(东‑西) @en X component (east‑west) */ + x: number; + /** @zh Y 分量(上‑下) @en Y component (up‑down) */ + y: number; + /** @zh Z 分量(南‑北) @en Z component (north‑south) */ + z: number; + + /** @zh 创建一个零向量 (0, 0, 0)。 @en Creates a zero vector at origin. */ + constructor(); + + /** + * @zh 创建一个指定坐标的向量。 + * @en Creates a vector with the given coordinates. + * @param x - @zh X 坐标 @en X coordinate + * @param y - @zh Y 坐标 @en Y coordinate + * @param z - @zh Z 坐标 @en Z coordinate + */ + constructor(x: number, y: number, z: number); + + /** + * @zh 设置向量的 X / Y / Z 分量(会改变调用者自身)。 + * @en Sets all three components in‑place (mutates the vector). + * @returns @zh 调用者本身 @en this vector + */ + set(x: number, y: number, z: number): GameVector3; + + /** @zh 原地复制 v 的值。 @en Copies values from v in‑place. */ + copy(v: GameVector3): GameVector3; + + /** @zh 深拷贝。 @en Returns a new independent copy. */ + clone(): GameVector3; + + /** + * @zh 向量加法:this + v。 + * @en Vector addition: this + v. + * @returns @zh 一个新向量 @en a new vector + */ + add(v: GameVector3): GameVector3; + + /** + * @zh 向量减法:this - v。 + * @en Vector subtraction: this - v. + * @returns @zh 一个新向量 @en a new vector + */ + sub(v: GameVector3): GameVector3; + + /** @zh 逐分量乘法(返回新对象)。 @en Component‑wise multiplication (returns new vector). */ + mul(v: GameVector3): GameVector3; + + /** @zh 逐分量除法(返回新对象,除以 0 得 0)。 @en Component‑wise division (divide‑by‑zero → 0). */ + div(v: GameVector3): GameVector3; + + /** + * @zh 标量乘法:每个分量乘以 n。 + * @en Scalar multiplication: each component multiplied by n. + * @returns @zh 一个新向量 @en a new vector + */ + scale(n: number): GameVector3; + + /** @zh 原地缩放。 @en Scale in‑place. */ + scaleEq(n: number): GameVector3; + + /** @zh 反向向量(返回新对象)。 @en Negation (returns new vector). */ + neg(): GameVector3; + + /** @zh 原地反向。 @en Negation in‑place. */ + negEq(): GameVector3; + + /** @zh 原地加法。 @en Addition in‑place. */ + addEq(v: GameVector3): GameVector3; + + /** @zh 原地减法。 @en Subtraction in‑place. */ + subEq(v: GameVector3): GameVector3; + + /** @zh 原地乘法。 @en Multiplication in‑place. */ + mulEq(v: GameVector3): GameVector3; + + /** @zh 原地除法(除以 0 跳过该分量)。 @en Division in‑place (divide‑by‑zero skips that component). */ + divEq(v: GameVector3): GameVector3; + + /** @zh 点积(内积):this · v。 @en Dot (inner) product: this · v. */ + dot(v: GameVector3): number; + + /** @zh 叉积:this × v。 @en Cross product. */ + cross(v: GameVector3): GameVector3; + + /** @zh 向量长度(模)。 @en Magnitude (length) of this vector. */ + mag(): number; + + /** @zh 向量长度的平方(比 `mag()` 更快)。 @en Squared magnitude — faster than `mag()` when comparing distances. */ + sqrMag(): number; + + /** + * @zh 单位化:返回方向相同、长度为 1 的新向量。零向量会返回 (0,0,0)。 + * @en Normalizes this vector; returns a unit vector in the same direction. + * @zh Zero vector returns (0,0,0). + */ + normalize(): GameVector3; + + /** @zh 计算 this 与 v 之间的欧几里得距离。 @en Euclidean distance between this and v. */ + distance(v: GameVector3): number; + + /** @zh 到 v 的平方距离(比 distance() 快,适合排序/比较)。 @en Squared distance to v — faster than distance() for comparisons. */ + sqrDistance(v: GameVector3): number; + + /** + * @zh 线性插值:在 this 和 v 之间按比率 n 插值。 + * @en Linear interpolation between this and v by ratio n. + * @param n - @zh 插值比率(0=this,1=v) @en interpolation factor (0=this, 1=v) + */ + lerp(v: GameVector3, n: number): GameVector3; + + /** @zh 匀速移向目标点(步长 maxDelta,到目标后返回 target 的拷贝)。 @en Moves toward target by at most maxDelta. Returns copy of target when reached. */ + moveTowards(target: GameVector3, maxDelta: number): GameVector3; + + /** @zh 指向 v 的方向向量(已单位化)。 @en Direction vector pointing toward v (normalized). */ + towards(v: GameVector3): GameVector3; + + /** @zh this 与 v 之间的夹角(弧度)。 @en Angle between this and v in radians. */ + angle(v: GameVector3): number; + + /** @zh 近似相等检查(容差 1e‑6)。 @en Approximate equality within 1e‑6 tolerance. */ + equals(v: GameVector3): boolean; + + /** @zh 精确相等检查(分量完全相等)。 @en Exact component‑wise equality. */ + exactEquals(v: GameVector3): boolean; + + /** @zh 逐分量取较大值(返回新对象)。 @en Component‑wise max. */ + max(v: GameVector3): GameVector3; + + /** @zh 逐分量取较小值(返回新对象)。 @en Component‑wise min. */ + min(v: GameVector3): GameVector3; + + /** @zh 是否为零向量(容差 1e‑6)。 @en True if all components are within 1e‑6 of zero. */ + isZero(): boolean; + + /** @zh 逐分量向下取整(常用于方块坐标)。 @en Component‑wise floor (useful for block coordinates). */ + floor(): GameVector3; + + /** @zh 逐分量向上取整。 @en Component‑wise ceil. */ + ceil(): GameVector3; + + /** @zh 限制向量长度不超过 max(超长时返回归一化后缩放到 max 的新向量)。 @en Clamps length to max, returning a new vector. */ + clampLength(max: number): GameVector3; + + /** + * @zh 从球坐标创建向量。 + * @en Creates a vector from spherical coordinates. + * @param mag - @zh 半径 @en radius (magnitude) + * @param phi - @zh 方位角(弧度,绕 Y 轴水平旋转) @en azimuth angle (radians, horizontal rotation around Y) + * @param theta - @zh 仰角(弧度,从水平面起算) @en elevation angle (radians, from horizontal plane) + */ + static fromPolar(mag: number, phi: number, theta: number): GameVector3; + + /** @zh 返回 "(x, y, z)" 格式的字符串表示。 @en Returns a string in "(x, y, z)" format. */ + toString(): string; +} + +/** + * @zh 三维轴对齐包围盒(AABB),由两个对角顶点 lo(最小角)和 hi(最大角)定义。 + * @en Axis‑aligned 3‑dimensional bounding box, + * defined by two opposing corners: lo (minimum corner) and hi (maximum corner). + */ +declare class GameBounds3 { + /** @zh 最小角(三个分量均为最小值)。 @en Lower/minimum corner. */ + lo: GameVector3; + /** @zh 最大角(三个分量均为最大值)。 @en Upper/maximum corner. */ + hi: GameVector3; + + /** @zh 用两个对角顶点构造包围盒。 @en Constructs bounds from two opposing corners. */ + constructor(lo: GameVector3, hi: GameVector3); + + /** @zh 原地设置所有边界。 @en Sets all boundaries in‑place. */ + set( + lox: number, + loy: number, + loz: number, + hix: number, + hiy: number, + hiz: number, + ): GameBounds3; + + /** @zh 原地复制 b 的值。 @en Copies values from b in‑place. */ + copy(b: GameBounds3): GameBounds3; + + /** @zh 判断当前包围盒是否与 other 相交。 @en Returns true if this bounds intersects with other. */ + intersects(other: GameBounds3): boolean; + + /** @zh 计算交集包围盒(无交集返回 null)。 @en Returns the intersection bounds, or null if they don't overlap. */ + intersect(other: GameBounds3): GameBounds3 | null; + + /** @zh 判断点 v 是否位于包围盒内部(含边界)。 @en Returns true if point v is inside (or on the boundary of) this bounds. */ + contains(v: GameVector3): boolean; + + /** @zh 判断是否完全包含另一个包围盒。 @en Whether this bounds fully contains b. */ + containsBounds(b: GameBounds3): boolean; + + /** @zh 返回包围盒中心点。 @en Returns the center point of the bounds. */ + center(): GameVector3; + + /** @zh 返回包围盒尺寸 (hi‑lo)。 @en Returns the size (hi‑lo) as a vector. */ + size(): GameVector3; + + /** @zh 扩展包围盒,各方向扩大 delta(返回新对象)。 @en Returns a new bounds expanded by delta on all sides. */ + expand(delta: number): GameBounds3; + + /** @zh 原地扩展包围盒。 @en Expands the bounds in‑place. */ + expandEq(delta: number): GameBounds3; + + /** @zh 扩展包围盒以包含点 v(原地)。 @en Grows the bounds to include point v in‑place. */ + growToInclude(v: GameVector3): GameBounds3; + + /** @zh 包围盒上距离 v 最近的点(v 在内部时返回投影到表面的点)。 @en Closest point on the bounds surface to v. */ + closestPoint(v: GameVector3): GameVector3; + + /** @zh 平移包围盒(返回新对象)。 @en Translates the bounds by offset (returns new object). */ + move(offset: GameVector3): GameBounds3; + + /** @zh 原地平移包围盒。 @en Translates the bounds in‑place. */ + moveEq(offset: GameVector3): GameBounds3; + + /** @zh 从 GameVector3 数组创建最小包围盒。 @en Creates bounds from an array of GameVector3. */ + static fromPoints(points: GameVector3[]): GameBounds3 | null; + + toString(): string; +} + +// ────────────────────────────────────────────── + +/** + * @zh RGB 颜色,三个通道,每通道 0.0–1.0。 + * @en An RGB color with three channels ranging from 0.0 to 1.0. + */ +declare class GameRGBColor { + /** @zh 红色通道(0.0–1.0)。 @en Red channel. */ + r: number; + /** @zh 绿色通道(0.0–1.0)。 @en Green channel. */ + g: number; + /** @zh 蓝色通道(0.0–1.0)。 @en Blue channel. */ + b: number; + + /** @zh 用指定的 R / G / B 值创建颜色。 @en Creates a color with the given R/G/B values. */ + constructor(r: number, g: number, b: number); + + /** @zh 原地设置所有通道。 @en Sets all three channels in‑place. */ + set(r: number, g: number, b: number): GameRGBColor; + + /** @zh 原地复制另一个颜色的值。 @en Copies values from another color in‑place. */ + copy(o: GameRGBColor): GameRGBColor; + + /** @zh 深拷贝。 @en Returns a new independent copy. */ + clone(): GameRGBColor; + + /** @zh 逐通道加法(返回新对象)。 @en Channel‑wise addition (returns new object). */ + add(o: GameRGBColor): GameRGBColor; + + /** @zh 逐通道减法(返回新对象)。 @en Channel‑wise subtraction (returns new object). */ + sub(o: GameRGBColor): GameRGBColor; + + /** @zh 逐通道乘法(返回新对象)。 @en Channel‑wise multiplication (returns new object). */ + mul(o: GameRGBColor): GameRGBColor; + + /** @zh 逐通道除法(返回新对象,除以 0 得 0)。 @en Channel‑wise division (divide‑by‑zero → 0). */ + div(o: GameRGBColor): GameRGBColor; + + /** @zh 原地加法。 @en Addition in‑place. */ + addEq(o: GameRGBColor): GameRGBColor; + + /** @zh 原地减法。 @en Subtraction in‑place. */ + subEq(o: GameRGBColor): GameRGBColor; + + /** @zh 原地乘法。 @en Multiplication in‑place. */ + mulEq(o: GameRGBColor): GameRGBColor; + + /** @zh 原地除法(除以 0 跳过该通道)。 @en Division in‑place (divide‑by‑zero skips that channel). */ + divEq(o: GameRGBColor): GameRGBColor; + + /** @zh 标量乘法(返回新对象,常用于亮度调整)。 @en Scalar multiply — brighten (n>1) or darken (n<1). */ + scale(n: number): GameRGBColor; + + /** @zh 原地标量乘法。 @en Scalar multiply in‑place. */ + scaleEq(n: number): GameRGBColor; + + /** @zh 在 this 和 o 之间线性插值。 @en Linear interpolation between this and o by ratio n. */ + lerp(o: GameRGBColor, n: number): GameRGBColor; + + /** @zh 近似相等检查(容差 1e‑6)。 @en Approximate equality within 1e‑6 tolerance. */ + equals(o: GameRGBColor): boolean; + + /** @zh 转为 "rgba(r,g,b,1.0)" 格式字符串。 @en Converts to an rgba CSS string. */ + toRGBA(): string; + + /** @zh 生成一个随机 RGB 颜色(每个通道 0–1)。 @en Generates a random RGB color (each channel 0–1). */ + static random(): GameRGBColor; + + toString(): string; +} + +// ────────────────────────────────────────────── + +/** + * @zh RGBA 颜色,四个通道,每通道 0.0–1.0。 + * @en An RGBA color; all four channels range from 0.0 to 1.0. + */ +declare class GameRGBAColor { + /** @zh 红色通道(0.0–1.0)。 @en Red channel. */ + r: number; + /** @zh 绿色通道(0.0–1.0)。 @en Green channel. */ + g: number; + /** @zh 蓝色通道(0.0–1.0)。 @en Blue channel. */ + b: number; + /** @zh Alpha(不透明度),范围 0.0–1.0。 @en Alpha (opacity), range 0.0–1.0. */ + a: number; + + constructor(r: number, g: number, b: number, a: number); + + /** @zh 原地设置所有四个通道。 @en Sets all four channels in‑place. */ + set(r: number, g: number, b: number, a: number): GameRGBAColor; + + /** @zh 原地复制另一个颜色的值。 @en Copies values from another RGBA color in‑place. */ + copy(c: GameRGBAColor): GameRGBAColor; + + /** @zh 深拷贝。 @en Returns a new independent copy. */ + clone(): GameRGBAColor; + + /** @zh 逐通道加法(返回新对象)。 @en Channel‑wise addition (returns new object). */ + add(rgba: GameRGBAColor): GameRGBAColor; + + /** @zh 逐通道减法(返回新对象)。 @en Channel‑wise subtraction (returns new object). */ + sub(rgba: GameRGBAColor): GameRGBAColor; + + /** @zh 逐通道乘法(返回新对象)。 @en Channel‑wise multiplication (returns new object). */ + mul(rgba: GameRGBAColor): GameRGBAColor; + + /** @zh 逐通道除法(返回新对象,除以 0 得 0)。 @en Channel‑wise division (returns new object; divide‑by‑zero → 0). */ + div(rgba: GameRGBAColor): GameRGBAColor; + + /** @zh 原地加法。 @en Addition in‑place. */ + addEq(rgba: GameRGBAColor): GameRGBAColor; + + /** @zh 原地减法。 @en Subtraction in‑place. */ + subEq(rgba: GameRGBAColor): GameRGBAColor; + + /** @zh 原地乘法。 @en Multiplication in‑place. */ + mulEq(rgba: GameRGBAColor): GameRGBAColor; + + /** @zh 原地除法(除以 0 跳过该通道)。 @en Division in‑place (divide‑by‑zero skips that channel). */ + divEq(rgba: GameRGBAColor): GameRGBAColor; + + /** @zh 标量乘法(返回新对象,包括 alpha 通道)。 @en Scalar multiply — all channels including alpha. */ + scale(n: number): GameRGBAColor; + + /** @zh 原地标量乘法。 @en Scalar multiply in‑place. */ + scaleEq(n: number): GameRGBAColor; + + /** @zh 线性插值。 @en Linear interpolation between this and rgba by ratio n. */ + lerp(rgba: GameRGBAColor, n: number): GameRGBAColor; + + /** @zh 近似相等检查(容差 1e‑6)。 @en Approximate equality within 1e‑6 tolerance. */ + equals(rgba: GameRGBAColor): boolean; + + /** + * @zh Alpha 混合:将自身 RGBA 颜色混合到 RGB 背景上。 + * @en Blends this RGBA color onto an RGB background, returning the displayed RGB. + */ + blendEq(rgb: GameRGBColor): GameRGBColor; + + toString(): string; +} + +// ────────────────────────────────────────────── + +/** + * @zh 四元数,用于三维旋转。单位四元数(w²+x²+y²+z²=1)表示纯旋转。 + * @en A quaternion used for 3‑dimensional rotation. + * Unit quaternions represent pure rotations. + */ +declare class GameQuaternion { + /** @zh 实部(标量分量)。 @en Real (scalar) component. */ + w: number; + /** @zh 虚部 X 分量。 @en Imaginary X component. */ + x: number; + /** @zh 虚部 Y 分量。 @en Imaginary Y component. */ + y: number; + /** @zh 虚部 Z 分量。 @en Imaginary Z component. */ + z: number; + + /** @zh 创建单位四元数 (1, 0, 0, 0)。 @en Creates an identity quaternion. */ + constructor(); + + /** @zh 用指定的 w/x/y/z 分量创建四元数。 @en Creates a quaternion with the given w/x/y/z components. */ + constructor(w: number, x: number, y: number, z: number); + + /** @zh 原地设置所有分量。 @en Sets all components in‑place. */ + set(w: number, x: number, y: number, z: number): GameQuaternion; + + /** @zh 原地复制。 @en Copies values from another quaternion in‑place. */ + copy(v: GameQuaternion): GameQuaternion; + + /** @zh 深拷贝。 @en Returns a new independent copy. */ + clone(): GameQuaternion; + + /** @zh 逐分量加法。 @en Component‑wise addition. */ + add(v: GameQuaternion): GameQuaternion; + + /** @zh 逐分量减法。 @en Component‑wise subtraction. */ + sub(v: GameQuaternion): GameQuaternion; + + /** + * @zh 四元数乘法(汉密尔顿积):this × q。注意乘法不满足交换律。 + * @en Hamilton product: this × q. Multiplication is NOT commutative. + */ + mul(q: GameQuaternion): GameQuaternion; + + /** @zh 共轭四元数(对单位四元数等价于逆)。 @en Conjugate of this quaternion (equals inverse for unit quaternions). */ + inv(): GameQuaternion; + + /** @zh 除法:this × q⁻¹。 @en Division: this × q⁻¹. */ + div(q: GameQuaternion): GameQuaternion; + + /** @zh 点积:this · q。 @en Dot product. */ + dot(q: GameQuaternion): number; + + /** @zh 模长(范数)。 @en Magnitude (norm). */ + mag(): number; + + /** @zh 模长平方。 @en Squared magnitude. */ + sqrMag(): number; + + /** @zh 单位化:返回模长为 1 的新四元数。 @en Normalizes this quaternion; returns a unit quaternion. */ + normalize(): GameQuaternion; + + /** + * @zh 球面线性插值(Slerp):在 this 和 q 之间平滑旋转。 + * @en Spherical linear interpolation — smooth rotation between this and q. + * @param t - @zh 插值比率(0=this,1=q) @en interpolation factor (0=this, 1=q) + */ + slerp(q: GameQuaternion, t: number): GameQuaternion; + + /** @zh 返回 this 和 q 之间的角度(弧度)。 @en Angular difference between this and q (in radians). */ + angle(q: GameQuaternion): number; + + /** + * @zh 返回四元数对应的轴‑角表示。 + * @en Decomposes this quaternion into axis‑angle representation. + * @returns @zh 包含 `angle` 和 `axis` 字段的对象 @en object with `angle` and `axis` fields + */ + getAxisAngle(): AxisAngle; + + // ── @zh 旋转操作 @en Rotation operations ── + + /** @zh 绕 X 轴旋转(在左侧乘以旋转四元数)。 @en Rotate around X axis. */ + rotateX(rad: number): GameQuaternion; + /** @zh 绕 Y 轴旋转。 @en Rotate around Y axis. */ + rotateY(rad: number): GameQuaternion; + /** @zh 绕 Z 轴旋转。 @en Rotate around Z axis. */ + rotateZ(rad: number): GameQuaternion; + + /** @zh 用此单位四元数旋转向量 v。 @en Rotates vector v by this unit quaternion. */ + rotateVector(v: GameVector3): GameVector3; + + /** @zh 转为 YZX 欧拉角(弧度),x/y/z 分别对应绕 X/Y/Z 轴的旋转角。 @en Decomposes into YZX Euler angles (radians). */ + toEuler(): GameVector3; + + // ── @zh 静态构造器 @en Static constructors ── + + /** @zh 从轴‑角表示创建四元数。 @en Create from axis‑angle representation. */ + static fromAxisAngle(axis: GameVector3, rad: number): GameQuaternion; + + /** @zh 从欧拉角创建四元数(YZX 旋转顺序:Y → Z → X)。 @en Create from Euler angles (YZX rotation order: Y → Z → X). */ + static fromEuler(x: number, y: number, z: number): GameQuaternion; + + /** @zh 计算从向量 a 旋转到向量 b 的最短弧四元数。 @en Shortest‑arc quaternion rotating from vector a to vector b. */ + static rotationBetween(a: GameVector3, b: GameVector3): GameQuaternion; + + /** @zh 构造从 from 看向 to 的朝向四元数(up 为上方向)。 @en Builds a look‑at quaternion orienting -Z from `from` toward `to`. */ + static lookAt( + from: GameVector3, + to: GameVector3, + up: GameVector3, + ): GameQuaternion; + + /** @zh 近似相等检查(容差 1e‑6)。 @en Approximate equality check within 1e‑6 tolerance. */ + equals(v: GameQuaternion): boolean; + + toString(): string; +} + +/** + * @zh 轴‑角表示,由 `getAxisAngle()` 返回。 + * @en Axis‑angle representation returned by `getAxisAngle()`. + */ +interface AxisAngle { + /** @zh 旋转角度 (弧度) @en rotation angle in radians */ + angle: number; + /** @zh 旋转轴 (单位向量) @en rotation axis (unit vector) */ + axis: GameVector3; +} + +/** + * @zh 事件处理器令牌,由 `world.onXxx()` 返回。 + * 调用 `cancel()` 取消监听后不可恢复,需重新注册。 + * @en Event handler token returned by `world.onXxx()`. + * Once cancelled via `cancel()`, it cannot be resumed — re-register instead. + */ +declare class GameEventHandlerToken { + /** @zh 取消事件监听 (不可逆) @en Cancels the event listener (irreversible). */ + cancel(): void; + + /** + * @zh 尝试恢复已取消的监听 (会抛出 UnsupportedOperationException)。 + * @en Attempts to resume a cancelled listener — always throws UnsupportedOperationException. + * @throws UnsupportedOperationException 始终抛出 / always thrown + */ + resume(): void; + + /** @zh 返回 true 表示监听仍处于活跃状态 @en Returns true if the listener is still active. */ + active(): boolean; +} + +// ── §2 @zh 持久化存储(服务端 & 客户端共享) @en Persistent Storage (shared between server & client) ── + +/** + * @zh JSON 可序列化的值类型,用作 `GameDataStorage` 的默认类型参数。 + * @en Represents any JSON‑serializable value. + * Used as the default type parameter for `GameDataStorage`. + */ +type JSONValue = + | string + | number + | boolean + | null + | JSONValue[] + | { [key: string]: JSONValue }; + +/** + * @zh 数据存储空间(键值持久化),通过 `storage.getDataStorage("name")` 获取。 + * T 指定后所有读写操作自动获得类型检查。 + * + * @en A data‑storage namespace — persistent key‑value store backed by JSON files. + * Obtain via `storage.getDataStorage("name")`; once T is set, all read/write operations are type‑checked. + * + * @example + * ```ts + * const coins = storage.getDataStorage("coins"); + * const balance = coins.get(userId); // number | null + * coins.set(userId, 100); // value must be number + * ``` + */ +interface GameDataStorage { + /** + * @zh 获取存储空间名称 (只读)。 + * @en Returns the read‑only namespace name. + */ + readonly key: string; + + /** + * @zh 存入一个键值对。值必须是可 JSON 序列化的类型。 + * @en Stores a key‑value pair. Value must be JSON‑serializable. + * @param key - @zh 键 @en key + * @param value - @zh 值 @en value (typed to T) + */ + set(key: string, value: T): void; + + /** + * @zh 读取键对应的值, 不存在则返回 null。 + * @en Retrieves the value for a key, or null if it does not exist. + * @returns @zh 存储的值, 或 null @en The stored value, or null + */ + get(key: string): T | null; + + /** + * @zh 获取当前存储空间中的所有键。 + * @en Lists all keys in this storage namespace. + */ + keys(): string[]; + + /** + * @zh 删除键, 返回旧值 (不存在则返回 null)。 + * @en Removes a key and returns its previous value, or null. + * @returns @zh 被删除的旧值, 或 null @en The previous value, or null + */ + remove(key: string): T | null; + + /** + * @zh 销毁该存储空间 (删除对应 JSON 文件)。 + * @en Destroys this storage namespace (deletes the backing JSON file). + */ + destroy(): void; +} + +/** + * @zh 全局存储入口 — 脚本中通过 `storage` 访问。 + * 项目间数据隔离:每个项目自动使用项目名作为存储文件前缀。 + * + * @en Global storage entry point — accessed via `storage` in scripts. + * Per‑project isolation: each project's storage is automatically prefixed with the project name. + */ +interface GameStorage { + /** @zh 始终返回空字符串 (MC 本地存储无 key, 只读) @en Always returns "" for MC local storage, readonly. */ + readonly key: string; + + /** + * @zh 打开或创建指定名称的数据存储空间 (项目隔离)。 + * @en Opens or creates a named data‑storage namespace (per‑project isolated). + * @param name - @zh 命名空间(可含 "/" 作为目录分隔) @en namespace (may contain "/" as directory separator) + * @remarks 不同项目使用同一 name 会访问不同文件。 + * Different projects using the same name access different files. + * + * @example + * const coins = storage.getDataStorage("coins"); + * const balance = coins.get(userId); // number | null + */ + getDataStorage(name: string): GameDataStorage; +} + +// ── §2b @zh 持久化存储扩展(服务端 & 客户端共享) @en Storage Extensions (shared between server & client) ── + +/** + * @zh 分页查询结果,由 `GameDataStorage.list()` 返回。 + * @en Paginated query result returned by `GameDataStorage.list()`. + */ +interface QueryList { + /** @zh 是否已到达最后一页 @en Whether the last page has been reached. */ + isLastPage: boolean; + + /** + * @zh 获取当前页的条目数组。 + * @en Returns the entries for the current page. + */ + getCurrentPage(): ReturnValue[]; + + /** + * @zh 移动到下一页。 + * @en Advances the cursor to the next page. + */ + nextPage(): void; +} + +/** + * @zh 单个存储条目,包含元数据。 + * @en A single stored entry with metadata. + */ +interface ReturnValue { + /** @zh 键名 @en key name */ + key: string; + /** @zh 值 @en stored value */ + value: T; + /** @zh 更新时间 (Unix 毫秒) @en last‑modified timestamp (Unix ms) */ + updateTime: number; + /** @zh 创建时间 (Unix 毫秒) @en creation timestamp (Unix ms) */ + createTime: number; + /** @zh 版本标识符 (可用于乐观锁) @en version identifier (usable for optimistic locking) */ + version: string; +} + +// Declaration merging: augment GameDataStorage with shared advanced methods +interface GameDataStorage { + /** + * @zh 原子更新: 取出当前值, 用 handler(currentValue) 的结果覆盖。 + * @en Atomically updates a value using a callback. + * @param key - @zh 键 @en key + * @param handler - @zh (prevValue) => newValue @en callback receiving the old value, returning the new one + * @remarks 如果键不存在, 不会创建新条目 (遵循 Box3 规范)。 + * If the key does not exist, nothing happens (per Box3 spec). + */ + update(key: string, handler: (prevValue: T) => T): void; + + /** + * @zh 原子递增 (delta 默认为 1)。 + * @en Atomically increments a numeric value by delta (default 1). + * @param key - @zh 键 @en key + * @param delta - @zh 增量(可选,默认 1) @en increment amount (optional, default 1) + * @returns @zh 递增后的新值 @en The new value after incrementing + * @remarks 键不存在时从 0 + delta 开始。 + * If the key doesn't exist, starts from 0 + delta. + */ + increment(key: string, delta?: number): number; + + /** + * @zh 分页查询存储条目。 + * @en Paginated query of stored entries. + * @param options - @zh 查询选项 @en query options + * @param options.cursor - @zh 起始游标(页码) @en starting cursor (page number * pageSize) + * @param options.pageSize - @zh 每页条目数(1‑100,默认 100) @en items per page (1–100, default 100) + * @param options.ascending - @zh 是否升序排列 @en sort ascending if true + * @param options.max - @zh 值的上限过滤 @en maximum value filter + * @param options.min - @zh 值的下限过滤 @en minimum value filter + * @param options.constraintTarget - @zh 排序/过滤的目标路径(如 "a.b.c") @en nested path for sorting/filtering + * @returns @zh 分页结果对象 @en paginated query result + */ + list(options?: { + cursor?: number; + pageSize?: number; + ascending?: boolean; + max?: number; + min?: number; + constraintTarget?: string; + }): QueryList; +} + +// ── §3 @zh 控制台(服务端 & 客户端共享) @en Console (shared between server & client) ── + +/** + * @zh 控制台输出 — 服务端和客户端脚本中均通过 `console` 访问。 + * 服务端输出到 System.out / System.err;客户端输出到客户端日志。 + * + * @en Console output — accessed via `console` in both server and client scripts. + * Server writes to System.out / System.err; client writes to client log. + */ +interface GameConsole { + /** @zh 普通日志 @en Info‑level log. */ + log(...args: unknown[]): void; + + /** @zh 调试日志 (前缀 [DEBUG]) @en Debug‑level log (prefixed with [DEBUG]). */ + debug(...args: unknown[]): void; + + /** @zh 警告日志 (前缀 [WARN]) @en Warning‑level log (prefixed with [WARN]). */ + warn(...args: unknown[]): void; + + /** + * @zh 错误日志 (输出到 stderr, 前缀 [ERROR])。 + * @en Error‑level log (written to stderr, prefixed with [ERROR]). + */ + error(...args: unknown[]): void; + + /** + * @zh 清除控制台 (发送 ANSI 清屏序列)。 + * @en Clears the console output (sends ANSI clear‑screen sequence). + */ + clear(): void; + + /** + * @zh 断言: 条件为 false 时输出错误。 + * @en Asserts a condition; logs an error message if the condition is false. + * @param condition - @zh 要测试的条件 @en the condition to test + * @param args - @zh 失败时输出的额外参数 @en additional values to log on failure + */ + assert(condition: boolean, ...args: unknown[]): void; +} + +// ── §4 @zh 远程事件通道 @en Remote Event Channel ── + +/** + * @zh 远程事件通道 — 在服务端和客户端脚本中均通过 `remoteChannel` 访问。 + * + * 事件数据通过 JSON 序列化传输,支持任意可序列化的类型。 + * + * **服务端方法**(在 `server.d.ts` 中声明): + * `sendClientEvent()` / `broadcastClientEvent()` / `onServerEvent()` + * + * **客户端方法**(在 `client.d.ts` 中声明): + * `sendServerEvent()` / `onClientEvent()` + * + * @en Remote event channel — accessed via `remoteChannel` in both server and client scripts. + * + * Event data is serialized via JSON and supports any serializable type. + * + * **Server‑side methods** (declared in `server.d.ts`): + * `sendClientEvent()` / `broadcastClientEvent()` / `onServerEvent()` + * + * **Client‑side methods** (declared in `client.d.ts`): + * `sendServerEvent()` / `onClientEvent()` + */ +interface RemoteChannel {} + +// ── §5 @zh SQLite 数据库(服务端 & 客户端共享) @en SQLite Database (shared between server & client) ── + +/** + * @zh SQL 查询结果,支持迭代和 thenable 模式。 + * SELECT 查询返回行数据,INSERT/UPDATE/DELETE 返回受影响行数。 + * + * @en SQL query result — supports iteration and thenable pattern. + * SELECT queries return row data; INSERT/UPDATE/DELETE return affected row count. + */ +interface GameQueryResult> { + /** @zh 所有行 @en All rows */ + readonly rows: T[]; + + /** @zh 第一行,无结果时返回 null @en First row, or null if empty */ + readonly firstRow: T | null; + + /** @zh 列名数组 @en Column name array */ + readonly columnNames: string[]; + + /** @zh 列数 @en Column count */ + readonly columnCount: number; + + /** @zh 行数 (SELECT) @en Row count (for SELECT queries) */ + readonly rowCount: number; + + /** + * @zh 受影响行数 (INSERT/UPDATE/DELETE),SELECT 查询返回 -1。 + * @en Affected row count for INSERT/UPDATE/DELETE; -1 for SELECT queries. + */ + readonly affectedRows: number; + + /** @zh 是否为查询 (SELECT) @en Whether this is a query (SELECT) */ + readonly isQuery: boolean; + + /** + * @zh 返回下一行: `{done: boolean, value: T}`。 + * @en Returns the next row as `{done: boolean, value: T}`. + */ + next(): { done: boolean; value: T }; + + /** @zh 重置内部游标到第一行 @en Resets internal cursor to first row */ + reset(): void; + + /** + * @zh Thenable 支持 — resolve 接收所有行数组。 + * @en Thenable support — resolve receives the full row array. + */ + then(resolve: (rows: T[]) => void, reject?: (err: string) => void): void; +} + +/** + * @zh SQLite 数据库,服务端存储在 `config/box3/data/.db`, + * 客户端存储在 `/box3/client-db/.db`。 + * 通过全局 `db` 访问,支持 `?` 占位符和 tagged template 两种调用约定。 + * + * @en SQLite database — server stores at `config/box3/data/.db`, + * client stores at `/box3/client-db/.db`. + * Access via global `db`, supports both `?` placeholder and tagged template calling conventions. + */ +interface GameDatabase { + /** + * @zh 检查 SQLite JDBC 驱动(minecraft-sqlite-jdbc 模组)是否可用。 + * 不可用时调用 {@link sql} 会返回空的错误结果而非抛出异常。 + * @en Returns true if the SQLite JDBC driver (minecraft-sqlite-jdbc mod) is available. + * When unavailable, {@link sql} returns an empty error result instead of throwing. + */ + isAvailable(): boolean; + + /** + * @zh 执行 SQL 查询或更新。 + * @en Executes a SQL query or update. + * + * @param sql - @zh SQL 字符串(含 ? 占位符)或字符串数组(模板字面量) @en SQL string (with ? placeholders) or string array (template literal) + * @param params - @zh 参数值 @en Parameter values to bind (number | string | boolean | null | Uint8Array) + * @returns @zh 查询结果 @en query result + */ + sql>( + sql: string | readonly string[], + ...params: (number | string | boolean | null | Uint8Array)[] + ): GameQueryResult; +} + +// ── §6 @zh HTTP 请求(服务端 & 客户端共享) @en HTTP Request (shared between server & client) ── + +/** @zh HTTP 请求头 @en HTTP request headers */ +type GameHttpFetchHeaders = { + [name: string]: string | string[]; +}; + +/** + * @zh HTTP 请求选项 + * @en HTTP request options + */ +type GameHttpFetchRequestOptions = { + /** @zh 请求超时时间,单位为毫秒(默认 10000) @en Request timeout in milliseconds (default 10000) */ + timeout?: number; + /** @zh 请求方法(默认 GET) @en Request method (default GET) */ + method?: "OPTIONS" | "GET" | "HEAD" | "PUT" | "POST" | "DELETE" | "PATCH"; + /** @zh 请求头 @en Request headers */ + headers?: GameHttpFetchHeaders; + /** @zh 请求体(字符串或 ArrayBuffer) @en Request body (string or ArrayBuffer) */ + body?: string | ArrayBuffer; + /** + * @zh 自动解析响应体("json" | "text" | "arrayBuffer"),结果见 resp.data + * @en Auto-parse response body ("json" | "text" | "arrayBuffer"), result in resp.data + */ + responseType?: "json" | "text" | "arrayBuffer"; + /** + * @zh 响应体最大字节数(0 表示不限制)。超出截断,resp.truncated 设为 true + * @en Max response body bytes (0 = no limit). Exceeding content is truncated, resp.truncated is set to true + */ + maxBodySize?: number; + /** + * @zh 设为 true 启用异步请求(不阻塞)。必须同时提供 onResponse / onError 回调 + * @en Set to true for async request (non-blocking). Must provide onResponse / onError callbacks + */ + async?: boolean; + /** + * @zh 异步请求成功时的回调,参数为 GameHttpFetchResponse + * @en Callback on async request success, receives GameHttpFetchResponse + */ + onResponse?: (resp: GameHttpFetchResponse) => void; + /** + * @zh 异步请求失败时的回调,参数为错误信息字符串 + * @en Callback on async request error, receives error message string + */ + onError?: (err: string) => void; +}; + +/** + * @zh HTTP 请求响应 + * @en HTTP request response + */ +declare class GameHttpFetchResponse { + /** @zh 状态码 @en Status code */ + readonly status: number; + /** @zh 状态码描述 @en Status text description */ + readonly statusText: string; + /** @zh 是否请求成功(状态码 200-299) @en Whether the request was successful (status 200-299) */ + readonly ok: boolean; + /** @zh 错误信息(仅在请求失败时有值) @en Error message (only set on failure) */ + readonly errorMessage: string; + /** @zh 所有响应头(键值对) @en All response headers (key-value map) */ + readonly headers: GameHttpFetchHeaders; + /** + * @zh 自动解析的结果(设置了 responseType 时) @en Auto-parsed result (when responseType was set) + */ + readonly data: any; + /** + * @zh 响应体是否因超过 maxBodySize 被截断 @en Whether the response body was truncated due to maxBodySize + */ + readonly truncated: boolean; + + /** + * @zh 获取指定响应头的值 + * @en Get a single response header value + * @param name - @zh 响应头名称(大小写不敏感) @en Header name (case-insensitive) + * @returns @zh 响应头值,不存在返回 null @en Header value, or null if absent + */ + getHeader(name: string): string | null; + + /** + * @zh 将响应体解析为 JSON 对象 + * @en Parse the response body as a JSON object + * @returns @zh 解析后的对象,解析失败返回 null @en Parsed object, or null on parse failure + */ + json(): any; + + /** + * @zh 返回响应体的文本内容 + * @en Return the response body as text + */ + text(): string; + + /** + * @zh 返回响应体的字节数组 + * @en Return the response body as a byte array + */ + arrayBuffer(): ArrayBuffer; + + /** + * @zh 关闭连接 + * @en Close the connection + */ + close(): void; + + private constructor(); +} + +/** + * @zh HTTP 请求 API + * @en HTTP request API + */ +declare class GameHttpAPI { + /** + * @zh 发送 HTTP 请求 + * @en Send an HTTP request + * + * @param url - @zh 请求地址 @en The request URL + * @param options - @zh 请求配置。设置 `async: true` + `onResponse`/`onError` 回调进行异步请求 @en Request options + * @returns @zh 同步返回结果,异步返回 null @en Response for sync, null for async + */ + fetch( + url: string, + options?: GameHttpFetchRequestOptions, + ): GameHttpFetchResponse; + + private constructor(); +} + +// ── §7 @zh 全局声明(服务端 & 客户端共享) @en Global Declarations (shared between server & client) ── + +/** @zh 持久化存储 API @en Persistent key‑value storage */ +declare const storage: GameStorage; + +/** @zh 控制台输出 @en Console output */ +declare const console: GameConsole; + +/** @zh 远程事件通道(服务端 ↔ 客户端通信) @en Remote event channel (server ↔ client communication) */ +declare const remoteChannel: RemoteChannel; + +/** + * @zh SQLite 数据库 API。 + * + * **前置条件:** 需要安装 `minecraft-sqlite-jdbc` 模组来提供 JDBC 驱动。 + * 未安装时,调用 `db.sql()` 会抛出带明确提示的错误。 + * + * @en SQLite database API. + * + * **Prerequisite:** Install the `minecraft-sqlite-jdbc` mod to provide the JDBC driver. + * If missing, `db.sql()` throws a clear error. + */ +declare const db: GameDatabase; + +/** @zh HTTP 请求 API @en HTTP request API */ +declare const http: GameHttpAPI; diff --git a/Box3JS-NeoForge-1.21.1/src/main/templates/META-INF/neoforge.mods.toml b/Box3JS-NeoForge-1.21.1/src/main/templates/META-INF/neoforge.mods.toml index 7d93cca..2415465 100644 --- a/Box3JS-NeoForge-1.21.1/src/main/templates/META-INF/neoforge.mods.toml +++ b/Box3JS-NeoForge-1.21.1/src/main/templates/META-INF/neoforge.mods.toml @@ -46,7 +46,7 @@ authors="神岛实验室" # The description text for the mod (multi line!) (#mandatory) description=''' -Box3JS 为 Minecraft 引入神奇代码岛风格的 JavaScript 脚本运行时,支持约 200 项 Box3 API 与 90 余项 Minecraft 扩展 API。无需 Java 基础即可编写服务端脚本。 +Box3JS 为 Minecraft 引入神奇代码岛风格的 JavaScript 脚本运行时,支持约 150 项 Box3 API 与 120 余项 Minecraft 扩展 API。无需 Java 基础即可编写服务端脚本。 ''' # The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded.