# nEngine 引擎指南(中文) > 本文档面向两类读者:**想用本引擎制作游戏的内容作者**(第 4、5 章),以及**想理解引擎本身的外包工具 / AI Agent / 维护者**(第 1、2、3、6 章)。 > > 本文档基于仓库的 **最终真实状态** 编写(`pnpm -r test` 全绿 **528** 个测试、`pnpm -r build` tsc strict 通过)。所有数字均已对照代码核对:**MCP 面 = 18 个命名空间 / 100 个工具 / 28 个资源 / 12 个 prompt;Hook = 16 个;内容种类 = 13 种 + 存档快照(非种类)+ 二进制资产表(非种类,D31);自动启动系统 = 12 个**。凡文档与代码冲突之处,以代码为准,并在本文第 6.4 节列出修正说明。 --- ## 1. 简介 ### 1.1 引擎定位:这是引擎,不是游戏 `nEngine` 是一个**俯视角 2D 种田游戏的引擎**(top-down 2D farming game engine),而不是一个可玩的游戏。它的核心价值不在于渲染性能,而在于三件事: - **可控性(controllability)**:可以被外部工具和 AI Agent 通过 MCP(Model Context Protocol)完整操控——建内容、调试、游玩、读状态。 - **可扩展性(extensibility)**:通过引擎级 Mod 系统,用钩子(hooks)注入游戏逻辑。 - **内容即数据(content-as-data)**:游戏的"内容"全部以 YAML 数据文件形式存在,引擎逻辑中**不硬编码任何内容**。 用一句话概括:**引擎提供逻辑系统(机制),创作者只提供数据(YAML 内容文件)**。 技术栈:TypeScript + PixiJS(仅渲染层)。Monorepo(pnpm workspaces),核心为无 DOM 依赖的 Node 头less(headless)库。 ### 1.2 三大支柱(不可妥协,非谈判项) 1. **每个子系统都暴露 MCP**(MCP-first):一个中央 MCP 网关 + 每子系统的命名空间(`item.*`、`crop.*`、`map.*`、`save.*` …)。"每个功能都有 MCP"是一个由注册层强制的架构约束,而不是一个进程数统计。 2. **引擎级 Mod 支持**:Mod 注册到**类型化的钩子点**(`onDayStart(ctx)`、`onCropHarvest(ctx)`、`onPlayerIntent(ctx)`…)。Mod 只能接触上下文对象(`HookContext`),**永远不能 `import` 引擎内部模块**。 3. **数据/逻辑分离,强制实施**:游戏创作只能通过数据完成。内容文件(YAML)不含代码;引擎逻辑不含硬编码内容。 ### 1.3 设计哲学(详见 `docs/decisions.md`) - **MCP-first**:设计任何接口时,先设计 MCP 面(tools/resources/prompts),再从 MCP 面推导引擎 API。引擎内部存在的目的就是服务 MCP 面。 - **机制非策略(mechanisms not strategies)**:例如存档*格式*是机制(引擎负责序列化/版本迁移/校验),存档*策略*(何时存档、几个档位、自动存档)是创作者内容。 - **声明式数据语言优于代码;钩子优于深层 import。** - **Mod 只服务玩法;MCP 只服务引擎原生能力;不存在 Mod↔MCP 桥。** - **无头 Node 核心是权威(authority);浏览器只是渲染视口(render viewports)。** ### 1.4 当前状态(里程碑) - Walking skeleton + Tier 1 完成;**Tier 2 全部完成**(行为语言 v2、对话、天气、建筑放置、NPC 网格移动、tick 循环、动物年龄、多视口同步)。 - **Tier 3 内容轮(D29)完成**:体力/技能作为玩家字段、`onPlayerIntent` 钩子、多任务链(starter-quest → carrot-quest → mayor-talk-quest)、钓鱼小游戏 Mod。 - **游戏打包(D30)完成**:`apps/pack` `nengine-pack` 把内容(YAML)编译成 `bundle.json` + 自包含静态网站;`createEngine({bundle})` + `loadBundle` 是引擎机制;无 `pack.*` MCP(均已文档化)。 - **桌面打包(D32)完成**:`nengine-pack --desktop` 在 `out/desktop/` 生成可运行的 **Electron** 桌面壳(`main.cjs` + `package.json` + `README-desktop.md`):本地 HTTP 服务 + 960×640 BrowserWindow 加载同一份 bundle 页面;`npm i && npm start` 即可运行。Tauri 因需要 `webkit2gtk-4.1` 系统依赖(本环境无 sudo)而弃用;旁路无头核心(服务端 Mod + 多人)保留 v2(均已文档化)。 - **Mod 打包 + 浏览器 Mod 执行 + 安装器(D36)完成**:`nengine-pack --mods ` 把 Mod 打包进 bundle(`ContentBundle.mods`,版本仍是 1);`ModLoader.loadFromSource` 通过 base64 `data:text/javascript` 动态导入执行打包的 Mod 源码(浏览器 + Node 同机制、零 fs),bundle 模式自动启动 `bundle.mods`(同时给了 `modsDir` 时 `modsDir` 优先);`nengine-pack --installer`(隐含 `--desktop`)生成 `electron-builder.yml` + `dist`/`dist:win` 脚本并把站点文件复制进 `desktop/`——**Linux AppImage 构建已端到端验证**(Windows NSIS 仅文档配置)。MCP 表面不变(20 命名空间 / 111 工具 / 32 资源 / 14 提示词)。 - 多人游戏玩法(multiplayer gameplay)**保留未做**(多视口同步机制已建成 D24;玩法规则是创作者内容)。 --- ## 2. 架构总览 ### 2.1 系统拓扑:无头核心权威 + 浏览器渲染视口 ``` ┌──────────────────────────────────────────────────────────────┐ │ Headless Node Core(权威) │ │ │ │ Content (YAML) ──► Loader ──► Normalize ──► zod │ │ │ │ ┌──────────────┐ │ │ World state ◄────── │ Systems │(纯函数,操作实体数据) │ │ │ (light ECS) │ │ │ └──────────────┘ │ │ ▲ ▲ ▲ │ │ │ Hooks │ │ Mod registry │ │ ┌───┴────┐ ┌────┴───┐ ┌─┴────────┐ │ │ │ Mods │ │ MCP │ │ Content │ │ │ │ (Hook) │ │ Gateway│ │ Registry │ │ │ └────────┘ └────┬───┘ └──────────┘ │ └──────────────────────────┼───────────────────────────────────┘ │ stdio / streamable HTTP ┌────────────┴─────────────┐ │ │ AI agents / dev tools Browser viewport (PixiJS) (创作 + 调试) (只渲染,WebSocket 同步) ``` **权威流(authority flow)**:内容数据定义世界 → 核心模拟世界 → MCP 把世界暴露给 Agent,WebSocket 把世界同步到浏览器。浏览器**从不拥有状态**,只渲染快照。 ### 2.2 Monorepo 包结构 | 包 / 目录 | 职责 | DOM 依赖 | 运行环境 | |---|---|---|---| | `packages/core`(`@nengine/core`) | 世界模拟、内容加载器、系统、实体存储、Mod 加载器、钩子注册表、引擎门面 + `random.ts` | 无 | Node / 浏览器(headless) | | `packages/mcp`(`@nengine/mcp`) | 中央 MCP 网关、命名空间注册层、传输(stdio + HTTP) | 无 | Node | | `packages/render`(`@nengine/render`) | PixiJS 适配:世界→精灵绘制(`spriteRef` 绘制 + 形状回退)、Web Audio 播放、视口同步 + 输入 | 有 | 浏览器 | | `apps/mcp-server` | 独立 stdio 入口:headless 核心 + MCP 网关 + **可选 `--ws-port` WebSocket 广播通道** + `--content-dir`/`--mods-dir` | 无 | Node | | `apps/dev` | Vite 浏览器游玩视口(开发 App),URL 参数 `?contentDir=`/`?modsDir=`/`?sync=ws&url=` | 有 | 浏览器 + Node | | `content/` | **仓库自带的示例内容**(YAML 数据文件,不是代码) | — | — | | `mods/` | 仓库自带的示例 Mod(`mods/example-mod/index.mjs`) | — | — | | `examples/sample-game/` | **完整可玩示例游戏「Pinefield Valley」**:content + mods + README | — | — | 工具链:pnpm workspaces、TypeScript strict、vitest、zod(schema 校验)。 ### 2.3 数据流:YAML → 归一化 → zod → 注册 → 系统 1. **作者**:创作者写 YAML 内容(`content/crops/parsnip.yaml`)。 2. **加载**:核心的内容加载器读取 YAML,归一化为内部 JSON 形状。 3. **校验**:zod schema 严格校验归一化 JSON(内容无法走私代码)。 4. **注册**:校验通过的内容进入内容注册表(Content Registry),以 id 为键。 5. **使用**:系统读取注册表条目 + 实体数据;Mod 在系统边界上钩。 6. **热重载**:YAML 文件变化 → 重新读取 → 重新校验 → 重新注入,**世界状态保留**。 **MCP 面只看到归一化 JSON(D18)**。YAML 纯粹是创作界面。 ### 2.4 双通道:MCP(stdio)+ WebSocket 视口同步 - **MCP stdio(创作/调试通道)**:`apps/mcp-server` 通过 stdio(开发工具链)/ streamable HTTP(运行期调试)暴露 18 个命名空间。这是 AI Agent 建内容、调试状态的通道。 - **WebSocket 广播(视口同步通道)**:`apps/mcp-server --ws-port ` 在同一进程启动 WS 广播服务器,向所有已连接的浏览器视口推送快照 + 事件帧。**WS 不是 MCP 命名空间**——它服务不同的客户端(渲染视口,不是 Agent 工具)。 两个通道可以同时开:一个进程同时服务 MCP 网关和 WS 广播。 ### 2.5 子系统模板(插件模板) 每个新子系统都按插件方式添加(D12): 1. **加 schema** —— 为它的内容和实体组件字段写 zod schema(`packages/core/src/schema/`)。 2. **加系统** —— 操作实体数据的纯函数系统(`packages/core/src/systems/`)。 3. **注册 MCP 命名空间** —— 在其命名空间下提供 tools/resources/prompts(`packages/mcp/src/namespaces/`)。**先写 `docs/mcp.md` 小节。** 4. **声明钩子点** —— 声明 Mod 可以注册的类型化钩子。 --- ## 3. 能力清单 ### 3.1 内容种类(Content Kinds) 引擎有 **13 个内容种类**(`packages/core/src/schema/index.ts` 的 `contentSchemas` 对象逐一列出),外加 **存档快照(save snapshot,不是内容种类)**。每个种类对应一个目录;`time` 和 `weather` 是单文件种类。 | 种类 | 内容文件 | 内部类型(zod) | 关键字段 | 创作工具(MCP) | |---|---|---|---|---| | `map` | `content/maps/.yaml` | `MapDef` | `{id, width, height, layers, spawn, tilesetRef, tiles?}`(`tiles?` 为 D31 的 tile-id→图像资产 id 映射) | **无**(见 6.4 修正) | | `time` | `content/time.yaml`(单文件) | `TimeDef` | `{id, dayLengthTicks, seasons: [{name, dayCount}], start}` | **无**(直接写 YAML) | | `item` | `content/items/.yaml` | `ItemDef` | `{id, name, kind: tool\|seed\|material\|consumable\|decoration, stackable, price?, use?}` | `item.validate/create/update/list/get` | | `crop` | `content/crops/.yaml` | `CropDef` | `{id, seedItem, stages: [{name, days, waterRequired, spriteRef?}], seasons, drops: [{itemId, qty, chance?}]}` | `crop.*` 5 个生命周期工具 | | `animal` | `content/animals/.yaml` | `AnimalDef` | `{id, stages: [{name, days, feedRequired, spriteRef?}], produces: [{itemId, qty, chance?}]}` | `animal.*` 5 个生命周期工具 | | `shop` | `content/shops/.yaml` | `ShopDef` | `{id, buys: [{itemId, price}], sells: [{itemId, price, stock?}]}` | `shop.*` 5 个生命周期工具 | | `behavior` | `content/behaviors/.yaml` | `TriggerDef` | `{id, when: ConditionExpr, fire: Action[]}` | `behavior.trigger.*` 5 个 | | `statemachine` | `content/statemachines/.yaml` | `StateMachineDef` | `{id, initial, states: [{id, onEnter?, onExit?}], transitions: [{from, to, when?, on?}], onSchedule?}` | `behavior.statemachine.*` 5 个 | | `dialogue` | `content/dialogues/.yaml` | `DialogueDef` | `{id, startNode, nodes: [{id, text, choices?, to?, actions?}]}` | `dialogue.*` 5 个生命周期工具 | | `weather` | `content/weather.yaml`(单文件) | `WeatherDef` | `{id, seasons: [{season, weathers: [{id, weight}]}], defaults?}` | **无**(单文件种类,直接写 YAML) | | `building` | `content/buildings/.yaml` | `BuildingDef` | `{id, name?, footprint: {w, h}, buildable: "map"\|{tiles}\|"any", blocksMovement, description?, spriteRef?}` | `placement.building.*` 5 个 | | `sprite` | `content/sprites/.yaml` | `SpriteDef` | `{id, frames: [{name?, durationTicks?, layers: [{shape: rect\|circle\|image, ...}]}]}`(zod strict 判别联合;**D31 新增 `image` 层 `{shape: image, src: 资产id, x?, y?, w?, h?}`**,引用 `content/assets/` 二进制) | `sprite.*` 5 个 | | `sound` | `content/sounds/.yaml` | `SoundDef` | `{id, synth?: {wave: sine\|square\|triangle\|sawtooth, freq, durationMs, gain?, slideTo?}, src?}`(至少一个 `synth`/`src`;**`src` 是音频资产 id(D31),播放时优先于 `synth`**) | `sound.*` 5 个 | | `save`(非种类) | 无内容文件 | `WorldSnapshot` | `{format: "nengine-save", version, savedAt, world: Entity[], rngState?: {seed, calls}}` | `save.*`(机制工具) | > 说明:**二进制资产(`content/assets/`,`.png`/`.ogg`)不是内容种类**(D31)——没有 schema 注册、没有 MCP 命名空间;加载器把文件读成 base64 资产表(`registry.assets`/`engine.assets`,id = 去掉扩展名的文件名),YAML 用 id 引用。仓库根目录的 `content/` 是早期示例(无 `sprites/`/`sounds/`/`assets/`);**`examples/sample-game/content/` 是完整的现代示例**,13 个种类的目录齐全 + `assets/` 资产表(13 图 + 8 音)。 ### 3.2 12 个自动启动系统 引擎在 `createEngine` 时自动注册以下系统(`engine.ts` 中按序注册,缺哪个补哪个,也可用 `systems` 选项覆盖): | 系统(id) | 职责 | 关键 API | |---|---|---| | `TimeSystem`(`time`) | 时钟:天/季节/tickInDay | `get()`、`advance(ticks)`(纯时钟)、`set(day, season)`、`step(dt)`(advance + 发 `time-tick`) | | `InventorySystem`(`inventory`) | 玩家背包 | `give(itemId, qty)`、`take(itemId, qty)`、`count(itemId)`、`items()`;发 `item-acquire`/`item-use` | | `PlantingSystem`(`planting`) | 种植:犁地/播种/浇水/收获 | `till(x,y)`、`sow(x,y,itemId)`、`water(x,y)`、`harvest(x,y)`;发 `crop-harvest` | | `GrowthSystem`(`growth`) | 生长:作物阶段 + 动物年龄(同一遍 `advanceDay`) | `step(dt)`、`forceStage(plotId, i)`、`spawnAnimal(animalId, x, y)`、`feed(animalId)`、`forceAnimalStage(...)`;发 `crop-grow`/`animal-grow`/`animal-produce`/`animal-fed` | | `EconomySystem`(`economy`) | 经济:商店买卖 | `buy(shopId, itemId, qty)`、`sell(shopId, itemId, qty)`、`price(shopId, itemId)`;发 `transaction` | | `SaveSystem`(`save`) | 持久化(机制) | `snapshot()`、`restore(snapshot)`、`migrate(...)`、`validate(snapshot)`、`registerMigration(from, fn)` | | `WeatherSystem`(`weather`) | 天气:按季节概率表加权抽取 | `get()`、`set(weatherId)`、`roll()`、`table(season)`;`on("day-start")` 时掷;发 `weather-change` | | `TriggerSystem`(`trigger`) | **v2 行为引擎**:触发器和状态机共用一个 match 遍历 + 同一重入保护 | `evaluate(condition, ctx)`、`fire(triggerId)`、`match(event, payload)`、`machineStatus(id)`、`machineTransition(id, to)`;发 `trigger-fire`/`state-change` | | `DialogueSystem`(`dialogue`) | 对话:内容文档系统 | `start(dialogueId, ctx?)`、`current()`、`choose(index)`、`end()`;发 `dialogue-start`/`dialogue-end` | | `PlacementSystem`(`placement`) | 建筑放置:网格 footprint/重叠/可建性 | `place(buildingId, x, y)`、`remove(x, y)`、`list()`、`occupies(x, y)`;发 `placement-change` | | `MovementSystem`(`movement`) | NPC 网格移动 + 阻挡 | `moveTo(npcId, x, y)`、`teleport(npcId, x, y)`;发 `npc-move` | | `IntentSystem`(`intent`) | **玩家输入机制**(单一机制) | `handle(action, args)`(`move`/`interact`)→ `IntentResult`;每次调用发 `player-intent` | 系统接口: ```ts interface System { id: string; init(ctx: SystemInitCtx): Promise | void; step?(dt: number): void; // 模拟 tick on?(event: string, payload: unknown): void; // 世界事件 } ``` 模拟驱动:`engine.tick(dt)` 是**权威显式驱动**(dt 单位为 tick;1 tick = 引擎时间单位 = `dayLengthTicks` 分之一天)。每步先推进 TimeSystem(跨越边界时触发 `day-start`/`season-change`),随后按注册顺序驱动每个系统的 `step`;TimeSystem 的 `step` 会发 `time-tick` 事件。`time.advance` 是纯时钟原语(不驱动系统、不发 `time-tick`)。可选自动 tick:`createEngine({tickIntervalMs, tickDeltaTicks?})`,**默认关闭**(保持测试/headless 确定性)。 ### 3.3 16 个 Hook 点(Mod 注册点) `packages/core/src/mods/hooks.ts` 的 `HOOK_NAMES`: | Hook | 触发事件 | 说明 | |---|---|---| | `onDayStart` | `day-start` | 每天开始 | | `onSeasonChange` | `season-change` | 季节更替 | | `onItemAcquire` | `item-acquire` | 获得物品 | | `onItemUse` | `item-use` | 消耗物品 | | `onCropGrow` | `crop-grow` | 作物阶段推进 | | `onCropHarvest` | `crop-harvest` | 收获作物 | | `onTransaction` | `transaction` | 商店买卖成交 | | `onTriggerFire` | `trigger-fire` | 触发器被触发 | | `onStateChange` | `state-change` | 状态机每次转移 | | `onDialogueStart` | `dialogue-start` | 对话开始 | | `onDialogueEnd` | `dialogue-end` | 对话结束 | | `onWeatherChange` | `weather-change` | 天气变化 | | `onPlacementChange` | `placement-change` | 建筑放置/移除 | | `onAnimalGrow` | `animal-grow` | 动物阶段推进 | | `onAnimalProduce` | `animal-produce` | 成熟动物产蛋/产出 | | `onPlayerIntent` | `player-intent` | **每次 `IntentSystem.handle` 调用(成功与失败都触发);唯一一个 `HookContext.payload` 有值的钩子** | `HookContext` 是 Mod 唯一能接触的对象:`{world, registry, time, emit(event, payload), payload?}`。`npc-move` 和 `animal-fed` 事件**故意没有钩子**——它们可以被行为语言匹配(`on: "npc-move"` / `on: "animal-fed"`),所以"Mod 钩子需求"这一判据不成立。 ### 3.4 18 个 MCP 命名空间(100 工具 / 28 资源 / 12 prompt) | 命名空间 | 工具数 | 工具要点 | 资源 | Prompt | |---|---|---|---|---| | `server.*` | 4 | `info`、`status`、`enable_subsystem`、`disable_subsystem` | `server://status` | `orient` | | `item.*` | 7 | 生命周期 `validate/create/update/list/get` + 调试 `query_inventory`/`give` | `item://catalog`、`item://{id}` | `create-prompt` | | `crop.*` | 10 | 生命周期 5 + 调试 `till`/`sow`/`water`/`harvest`/`stage` | `crop://catalog`、`crop://{id}` | `create-prompt` | | `map.*` | 2 | 只读 `list`/`get`(**无创作工具**) | `map://{mapId}` | 无 | | `time.*` | 4 | `get`、`advance`(纯时钟)、`tick`(完整模拟步)、`set` | `time://state` | 无 | | `shop.*` | 7 | 生命周期 5 + 调试 `buy`/`sell` | `shop://catalog`、`shop://{shopId}` | `create-prompt` | | `save.*` | 4 | `snapshot`/`restore`/`migrate`/`validate`(机制) | `save://state` | `authoring-prompt` | | `behavior.*` | 14 | `trigger.*` 5 + `fire` + `condition.validate` + `statemachine.*` 5 + `statemachine.status`/`transition` | `behavior://triggers`、`behavior://statemachines` | `trigger-prompt`、`statemachine-prompt` | | `dialogue.*` | 7 | 生命周期 5 + 调试 `start`/`choose` | `dialogue://catalog`、`dialogue://{id}` | `create-prompt` | | `weather.*` | 2 | 调试 `get`/`set` | `weather://state` | 无 | | `mod.*` | 3 | 只读 `list`/`status` + `reload`(**仅内省,无 Mod↔MCP 桥**) | `mod://{id}` | 无 | | `placement.*` | 9 | `building.*` 5 + 调试 `place`/`remove`/`list`/`occupies` | `placement://buildings`、`placement://placed`、`placement://{id}` | `create-prompt` | | `npc.*` | 5 | 调试 `list`/`get`/`spawn`/`move`/`teleport` | `npc://{npcId}` | 无 | | `animal.*` | 9 | 生命周期 5 + 调试 `spawn`/`feed`/`stage`/`entities` | `animal://catalog`、`animal://{id}` | `create-prompt` | | `player.*` | 1 | `intent`(`move`/`interact`) | `player://state` | 无 | | `sprite.*` | 5 | 生命周期 5(程序化精灵创作) | `sprite://catalog`、`sprite://{id}` | `create-prompt` | | `sound.*` | 5 | 生命周期 5(合成音效创作) | `sound://catalog`、`sound://{id}` | `create-prompt` | | `rng.*` | 2 | 调试 `get`/`set`(确定性 RNG) | `rng://state` | 无 | 关键语义要点: - **创作优先,调试其次(D3)**:写内容文件的工具(`*.create` 写 `content//.yaml` 并热注册)是主要面;运行时工具只为调试服务,不服务玩家。 - **JSON only(D18)**:所有工具参数/资源都是归一化 JSON。YAML 只是创作界面。 - **`server.enable_subsystem`/`disable_subsystem`**:运行时开关命名空间(per-gateway 内存态,D9);**不允许禁用 `server` 本身**。 - **`mod.*` 只有内省**:`mod.list`/`mod.status`/`mod.reload`(重载 = 进程内重导入 + 重新绑定钩子,带 cache-busting;D13 权威路径仍是重启 headless 核心)。 - **`weather.*`/`map.*`/`time.*`/`npc.*`/`player.*`/`rng.*` 无 prompt**:要么是单文件内容种类,要么是运行时实体,要么是调试面,没有可指导的创作对象。 ### 3.5 行为语言 v2(ConditionExpr + 动作 + 状态机 + onSchedule) **条件表达式(ConditionExpr)**——递归组合,zod 校验,不含任意代码: - 比较:`{op: "eq"|"gte"|"lte", path, value}`(`eq` 严格相等;`gte`/`lte` 要求两侧都是数字) - 存在:`{op: "exists", path}`(`resolved !== undefined`) - 组合:`{and: [...]}`、`{or: [...]}`、`{not: {...}}` **路径解析约定**(`trigger`/`dialogue` 共用,`behavior-runtime.ts` 一处真相): - `time.` → `TimeSystem.get()`(如 `time.day`、`time.season`) - `player.` → `world.player.` 的简写 - `world..` → 按 id 取实体 - `payload.` → 事件负载(仅在事件匹配时存在;`fire` 时无) **动作集(5 种)**: | 动作 | 字段 | 效果 | |---|---|---| | `set-state` | `{action, path, value}` | 写实体字段(`player.` 或 `world..`);**绝对值赋值**(无增量动作) | | `emit-event` | `{action, event, payload?}` | 发总线事件 | | `give-item` | `{action, itemId, qty}` | 给玩家背包发物品(发 `item-acquire`) | | `play-sound` | `{action, sound}` | 发 `sound-play {soundId}` 总线事件(渲染层播放) | | `dialogue` | `{action, text}` | headless v1 中的保留 no-op | **状态机(StateMachineDef)**: ```json { "id": "quest-id", "initial": "inactive", "states": [ { "id": "inactive" }, { "id": "active", "onEnter": [{ "action": "give-item", "itemId": "parsnip-seed", "qty": 1 }] }, { "id": "complete", "onExit": [{ "action": "emit-event", "event": "quest:done" }] } ], "transitions": [ { "from": "inactive", "to": "active", "on": "day-start", "when": { "op": "gte", "path": "time.day", "value": 3 } } ], "onSchedule": [ { "at": "day-start", "to": "working" }, { "at": { "day": 1, "season": "summer" }, "to": "resting" }, { "at": { "tickInDay": 240 }, "to": "working" } ] } ``` - `states[]`:至少一个状态;每个状态可带 `onEnter`/`onExit` 动作列表。状态 id 唯一。 - `transitions[]`:`from`/`to` 必须引用已声明状态;`on` 匹配当前总线事件名,`when` 是条件表达式;**至少一个 `on`/`when` 必填**;声明顺序第一个匹配的生效;每个事件每个状态机最多一个转移。**转移到当前状态是 no-op**(不触发 onEnter/onExit、不发 `state-change`)。 - `onSchedule[]`:时间原语。`at` 为 `"day-start"`/`"season-change"`(匹配那些边界事件)、`{day?, season?}`(至少一个,匹配事件负载)或 `{tickInDay, day?, season?}`(匹配 `time-tick` 事件,D23)。计划条目从任意状态生效、在 `transitions` **之后**求值。 - **运行时语义**:TriggerSystem 在一次 `match` 遍历中先触发器后状态机,共用一个**重入保护**(本次遍历中动作发出的事件不会被重匹配——级联/死循环不可能)。状态机实例是**世界实体** `{id: "sm:", type: "statemachine", machineId, state}`,懒创建(首次转移时),`save.*` 自动持久化。任务进度约定:写 `player.questProgress.`。 ### 3.6 WS 协议帧(D24/D25/D26) | 方向 | 帧 | 含义 | |---|---|---| | 服务器 → 客户端 | `{"type":"snapshot","map":MapDef,"world":Entity[],"time":{day,season,tickInDay},"sprites":SpriteDef[],"sounds":SoundDef[]}` | 连接时 / `{"type":"sync"}` 回复时发送:完整当前状态(地图定义 + 全部实体 + 当前时间 + 精灵/音效目录,供视口渲染) | | 服务器 → 客户端 | `{"type":"event","event":string,"payload":unknown}` | 每次总线发射广播给所有客户端(`day-start`、`time-tick`、`crop-grow`、`npc-move`、`player-intent`、`input`、`sound-play`…) | | 客户端 → 服务器 | `{"type":"sync"}` | 请求一份新快照(浏览器客户端在每个事件帧后重同步) | | 客户端 → 服务器 | `{"type":"intent","action":"move"\|"interact","args":{tile:{x,y}}}` | 玩家输入 → `IntentSystem.handle` → `player-intent` 事件帧广播(非法输入不会崩服务器,会广播合成错误帧) | | 客户端 → 服务器 | `{"type":"input","input":string}` | 原始输入串(`"key:ArrowUp"`、`"key:Space"`、`"pointer:click::"`)→ 发 `input` 总线事件(行为语言可 `on:"input"` 匹配);`key:Arrow*` 额外映射为玩家移动一格(player-centered,适配器层) | 防御性序列化:不可序列化的负载跳过并打日志,服务器永不崩溃。 浏览器客户端:`packages/render/src/sync/ws.ts` 的 `connectWs({url, viewport, onEvent?, onSnapshot?, sound?})`,句柄形状与本地 `connectLocal` 完全一致(`setTimeLabel`/`refresh`/`intent`/`input`/`destroy`),自动重连(500ms)。 ### 3.7 渲染能力(D26 数据 / D27 渲染半) - **程序化精灵**:`sprite` 内容种类携带逐帧绘制指令(`rect`/`circle` 图层 + `#rrggbb` 填充)。实体带 `spriteRef` 字段(作物阶段/建筑定义/动物阶段/玩家),渲染器在快照的 `sprites` 目录中解析。**缺 `spriteRef` 或未知 id → 回退到纯函数 `shapes.ts` 的形状绘制**(plots 的 tilled/watered/作物阶段色、建筑 footprint 块、npc/player/animal 圆点)。 - **动画时钟 v1**:每帧 `durationTicks` 是相对权重,按文档默认 **10 fps**(`DEFAULT_ANIMATION_FPS`)折算循环长度;`frameAtTime(sprite, elapsedMs, fps?)` 纯函数选帧;单帧精灵是静态的。渲染器侧 `requestAnimationFrame` 时钟,仅当动画帧切换时重绘。建筑 footprint 缩放:0..32 tile 空间按实体 `w`×`h` 缩放(非均匀 footprint 下 `circle` 变椭圆)。 - **Web Audio 音效**:`sound.ts` 的 `soundToAudioParams(def)` 纯映射 + `createSoundPlayer` 合成器(`OscillatorNode`;`wave`/`freq`/`durationMs`/`gain` 默认 0.3/`slideTo` 频率扫掠 + 淡出)。**AudioContext 懒创建**(首次播放或 `prime()` 从用户手势调用——浏览器 autoplay 策略);无 def / 无 context / 无 synth 静默跳过;播放错误不崩视口。 - **键盘输入**:`keys.ts` 的 `arrowDirection(input)` 纯函数把 `"key:Arrow*"` 映射为相邻格 delta;开发 App 的 `keydown` → `sync.input("key:Arrow<…>")`(ws 模式发原始消息由服务器映射;本地模式客户端同规则映射)。`viewport.tileAt(clientX, clientY)` 屏幕坐标→格子(`null` 表示地图外),供画布点击输入。 - **开发 App**:Interact/Move 工具栏选择工具;点击画布发意图;HUD 显示 天/季节、种子数 + 金币、意图反馈。 ### 3.8 确定性种子 RNG(D28) `packages/core/src/random.ts`(零依赖,mulberry32 风格,镜像 LÖVE 的 `love.math.RandomGenerator`): ```ts interface RandomSource { next(): number; // [0,1) nextInt(max: number): number; // [0,max) setSeed(seed: number): void; state(): { seed: number; calls: number }; } ``` - `seedRandom(seed)`:同样的种子永远产生同样的 `next()` 序列。种子归一化为 uint32。 - `randomSeed()`:`Math.random` + `Date.now` 的"未指定种子"默认。 - `createEngine({seed?})` 暴露 `engine.rng`(readonly),经 `SystemInitCtx.rng` 注入系统。**引擎仅有的两个随机消费者共享这一个有种子来源**:WeatherSystem 的 day-start 抽取、PlantingSystem 收获掉落概率。 - `save.*` 快照携带可选 `rngState {seed, calls}`;restore 时 `setSeed(rngState.seed)` 重新锚定,`calls` 重置为 0(**不回放抽取**——已文档化)。旧快照无 `rngState` 仍有效(可选字段,免迁移,`CURRENT_SAVE_VERSION` 不变)。 ### 3.9 存档机制(D14) - `save.snapshot()` → `{format: "nengine-save", version: 1, savedAt, world: Entity[], rngState?}`。**只覆盖世界实体 + RNG 锚**——TimeSystem 状态 v1 不序列化。 - `save.restore()`:校验 → 拒绝未来版本 → 经注册的迁移升到当前版本 → 替换全部世界实体。 - `save.migrate()`:迁移机制(v1 迁移注册表为空,前向迁移原样返回)。 - `save.validate()`:结构校验,`{ok}` 或 `{ok: false, errors: [...]}`(正常结果,不是错误结果)。 - **存档策略(何时存、几个档、自动存档)是创作者内容/Mod**(D14)——引擎只提供机制。 --- ## 4. 快速开始 ### 4.1 下载与安装本引擎 **前置要求**: - **Node.js >= 20**(引擎与所有工具链都要求;`apps/mcp-server` 入口从 `dist` 交付,所以先构建再启动服务器)。 - **pnpm >= 9**(monorepo 用 pnpm workspaces 管理;`pnpm-lock.yaml` 已提交)。 **获取源码**:nEngine 是一个可自行克隆的 monorepo 仓库: ```sh git clone nengine cd nengine ``` **安装依赖**: ```sh pnpm install ``` (pnpm 会按 `pnpm-workspace.yaml` 链接工作区包;Electron 的 postinstall 下载 Chromium 二进制约 100 MB,已列入 `onlyBuiltDependencies`。) **构建全部包**(6 个包 tsc strict + vite 均须通过): ```sh pnpm -r build ``` **验证安装**(全部测试应通过,当前 **727** 个): ```sh pnpm -r test ``` ### 4.2 构建 ```sh pnpm -r build ``` (`apps/mcp-server` 入口从 `dist` 交付,所以先构建再启动服务器。) ### 4.3 跑测试 ```sh pnpm -r test ``` 最终状态:**727 个测试全绿**(`packages/core/test` 26 个测试文件 + `packages/render/test` 10 个 + `packages/mcp/test` 21 个命名空间测试 + `apps/mcp-server/test` 4 个集成测试 + `apps/pack/test` 1 个 + `apps/dev/test` 4 个)。D36 新增 16 个(core `mods.test.ts` 的 `loadFromSource`/bundle 模式 8、core `bundle.test.ts` mods schema 1、pack `--mods`/`--installer` 7)。 ### 4.4 运行示例游戏(Pinefield Valley) **路径一:浏览器游玩(本地模式)** ```sh pnpm dev ``` 打开 `http://localhost:5173/apps/dev/?contentDir=examples/sample-game/content&modsDir=examples/sample-game/mods`。浏览器里直接启动引擎 + 渲染视口 + 本地同步句柄,可玩。 **路径二:MCP 调试(headless 核心 + stdio)** ```sh node apps/mcp-server/dist/index.js \ --content-dir examples/sample-game/content \ --mods-dir examples/sample-game/mods ``` 用任何 MCP 客户端通过 stdio 连接,`server.info` 确认内容加载,然后按第 5.16 节的工具序列驱动整局游戏。 **路径三(进阶):headless 核心 + WS 视口** ```sh # 终端 1:核心 + MCP + WS 广播 node apps/mcp-server/dist/index.js \ --content-dir examples/sample-game/content \ --mods-dir examples/sample-game/mods \ --ws-port 8787 # 终端 2:开发 App pnpm dev ``` 打开 `http://localhost:5173/apps/dev/?sync=ws&url=ws://localhost:8787`。浏览器只渲染 headless 核心推送的快照。 ### 4.5 打包为桌面软件(安装器) 做好游戏后,可以用引擎自带的 `nengine-pack` CLI 把它打包成**桌面软件**(Electron 安装器)。完整配方(以仓库自带的示例游戏 Pinefield Valley 为例): ```sh # 1. 构建全部包(CLI 从 dist 交付) pnpm -r build # 2. 打包示例游戏 → 静态网站 + 桌面壳 + 安装器配置(--installer 隐含 --desktop) NODE_OPTIONS=--use-system-ca pnpm pack:example:full ``` > 注:`NODE_OPTIONS=--use-system-ca` 是本环境下载 AppImage 运行时(electron-builder 从 GitHub 拉取)所需的 CA 修正;`pnpm pack:example:full` 等价于 > `node apps/pack/dist/index.js --content examples/sample-game/content --mods examples/sample-game/mods --out examples/sample-game/dist-pack --desktop --installer --name "Pinefield Valley"`。 第 2 步会生成: - `examples/sample-game/dist-pack/` —— 自包含静态网站(`index.html` + `assets/` + `bundle.json` + `nengine-assets/`,浏览器可直接静态托管游玩); - `examples/sample-game/dist-pack/desktop/` —— Electron 壳(`main.cjs` + `package.json` + `README-desktop.md` + `electron-builder.yml` + 站点文件副本)。 **构建 Linux AppImage 安装器**: ```sh cd examples/sample-game/dist-pack/desktop npm install # 首次安装 electron + electron-builder(约 100 MB) npm run dist # 生成 dist/*.AppImage(Linux) ``` 产物在 `dist/Pinefield Valley-0.1.0.AppImage`(**本仓库已端到端验证**)。 **运行 AppImage**: ```sh ELECTRON_DISABLE_SANDBOX=1 ./"Pinefield Valley-0.1.0.AppImage" # 或(无 sandbox 权限时):./"Pinefield Valley-0.1.0.AppImage" --appimage-extract-and-run ``` (Windows 安装器:`npm run dist:win`,需在 Windows 主机上运行——electron-builder 的 NSIS 目标需要 Windows,已作为文档化配置提供。) **常用参数**: - `--name `:桌面产品名(窗口标题 + 安装器 `productName`)。不带则默认 `"Farm Game"`。 - `--installer`:隐含 `--desktop`,额外生成 `electron-builder.yml` + `dist`/`dist:win` 脚本,并把站点文件复制进 `desktop/`(这样 electron-builder 才能把整个应用打包进安装器)。 - `--mods `:把 Mod 打包进 bundle(桌面版里市长 NPC、钓鱼小游戏等 Mod 行为照常运行)。 - `--split-assets`:把二进制素材写成独立的 `nengine-assets/` 文件(默认内联进 `bundle.json`)。 **重要**:`nengine-pack` **每次运行都会重建 `desktop/` 目录**——改了内容后重新打包,必须回到 `desktop/` 里**重新 `npm install`** 再 `npm run dist`。完整玩法与打包说明见 `examples/sample-game/README.md`;机制细节见第 5.19 节与 `docs/decisions.md` D30/D32/D36/D37。 --- ## 5. 用本引擎制作一个游戏(教程) > 本节以 `examples/sample-game`(Pinefield Valley)为蓝本,按创作顺序分步讲解。示例 YAML 取自真实内容(个别地方简化)。 > 游戏剧情:春天第一天,你是新农民。第一天开始收到新手礼包(3 防风草种子 + 2 胡萝卜种子),第二天任务链启动(收获 3 个防风草 → 收获胡萝卜 → 与镇长对话),商店买卖、盖鸡舍、养鸡下蛋、对话、天气、钓鱼小游戏 Mod。 ### 5.1 ① 规划与建文件夹 游戏 = `content/`(全部 YAML 数据)+ `mods/`(可选代码 Mod)。任何引擎逻辑都不用改。 ``` my-game/ ├── content/ │ ├── maps/ # 地图(每个 .yaml) │ ├── time.yaml # 时间配置(单文件) │ ├── weather.yaml # 天气概率表(单文件,可选) │ ├── items/ # 物品 │ ├── crops/ # 作物 │ ├── animals/ # 动物 │ ├── buildings/ # 建筑定义 │ ├── shops/ # 商店 │ ├── behaviors/ # 触发器 │ ├── statemachines/ # 状态机(任务/NPC 日程/生产规则) │ ├── dialogues/ # 对话 │ ├── sprites/ # 程序化精灵(外观,可选) │ └── sounds/ # 合成音效(可选) └── mods/ └── my-mod/index.mjs # 可选代码 Mod ``` 引擎没有"游戏名"概念——`apps/mcp-server` 通过 `--content-dir`/`--mods-dir`(或 `NENGINE_CONTENT_DIR`/`NENGINE_MODS_DIR`)指向任何内容/Mod 对。 ### 5.2 ② 地图 map `content/maps/farm.yaml`。`layers` 的 `data` 是二维 tile id 数组;**名为 `"ground"` 的图层被用作地形层**(否则取第一层)。`spawn` 是玩家出生点。 ```yaml id: farm width: 16 height: 16 tilesetRef: "farm-tiles" spawn: { x: 2, y: 4 } layers: - name: ground data: - [water, water, water, water, grass, grass, ...] # 16 格/行 - ... ``` 要点: - tile id 就是地形的词汇表:`grass`(可走)、`tilled`(预犁地,纯视觉)、**`water`(引擎的 v1 不可走约定,`UNWALKABLE_TILES = {"water"}`)**。 - 地图必须有 `spawn`(玩家出生)。`engine.start()` 用第一张地图生成玩家实体。 - **注意:`map.*` 没有创作工具**(见 6.4)——地图直接手写 YAML。加载器校验它,`map.list`/`map.get` 读取它。 ### 5.3 ③ 时间 time `content/time.yaml`(单文件种类,无 MCP 创作工具): ```yaml id: default dayLengthTicks: 480 # 一天 = 480 个引擎 tick seasons: - { name: spring, dayCount: 28 } - { name: summer, dayCount: 28 } - { name: autumn, dayCount: 28 } - { name: winter, dayCount: 28 } start: { day: 1, season: spring } ``` - `dayLengthTicks` 是所有 tick 语义的基准:`time.tick {ticks: 480}` = 推进一整天。 - 引擎自动跨边界发 `day-start`/`season-change` 事件;tick 循环还发 `time-tick`。 ### 5.4 ④ 物品 item `content/items/.yaml`。`kind` 是 `tool|seed|material|consumable|decoration` 之一;`kind: seed` 是播种机制认出的种子类型。 ```yaml id: parsnip-seed name: Parsnip Seed kind: seed stackable: true price: 20 use: plant in tilled soil to grow a parsnip ``` 用 MCP 创作同样可行:`item.validate {def}` → `item.create {def}` 会校验并写文件 + 热注册。背包在玩家实体上:`player.inventory: {itemId: qty}`。 ### 5.5 ⑤ 作物 crop(种子 → 阶段 → 掉落) `content/crops/.yaml`。`seedItem` 关联种子物品;`stages` 定义生长周期(`days` 天数 + `waterRequired` 是否必须浇水);`drops` 收获掉落表。 ```yaml id: parsnip seedItem: parsnip-seed stages: - { name: sprout, days: 2, waterRequired: true, spriteRef: parsnip-sprout } - { name: grown, days: 2, waterRequired: true, spriteRef: parsnip-grown } - { name: mature, days: 1, waterRequired: false, spriteRef: parsnip-mature } seasons: - spring drops: - { itemId: parsnip, qty: 1 } ``` 玩法循环(可全程走 MCP 或浏览器意图): 1. **犁地** `crop.till {x, y}`(幂等,创建 `plot::` 实体) 2. **播种** `crop.sow {x, y, seedItemId: parsnip-seed}`(消耗 1 种子,`cropItemId` 解析为**作物定义 id** 而非种子 id) 3. **浇水** `crop.water {x, y}`(**设置** `watered: true`,不是切换——重复浇安全) 4. **生长** `time.tick {ticks: 480}`(每日推进,`waterRequired` 阶段未浇水则跳过) 5. **收获** `crop.harvest {x, y}`(要求最终阶段;按 `drops` 掷掉落进背包,发 `crop-harvest`) 生长是模拟(GrowthSystem.step),不是 MCP 工具——MCP 的 `crop.stage` 只用于调试强推阶段。 ### 5.6 ⑥ 动物 animal `content/animals/.yaml`。**动物复用 GrowthSystem**(没有 AnimalSystem);`feedRequired` 镜像作物的 `waterRequired`。 ```yaml id: chicken stages: - { name: chick, days: 2, feedRequired: false, spriteRef: chicken-chick } - { name: grown, days: 2, feedRequired: true, spriteRef: chicken-grown } - { name: laying, days: 1, feedRequired: true, spriteRef: chicken-laying } produces: - { itemId: egg, qty: 1 } ``` - 动物实体:`animal.spawn {animalId: chicken, x: 9, y: 3}` → `{id: "animal:chicken:1", type: "animal", ...}`(计数器后缀避免多实例冲突)。 - 每天:成熟(最终阶段)动物发 `animal-produce` 并重置 `fed`(喂养每日消耗);非成熟动物推进 `daysInStage`,达标后 `animal-grow`。 - **喂食/产出规则是行为语言内容,不是引擎**:`chicken-egg` 状态机匹配 `animal-produce` 事件来发鸡蛋(见 5.10)。引擎只发事件,不送任何东西。 ### 5.7 ⑦ 建筑 building(放置) `content/buildings/.yaml`。`buildable` 是三态联合:`"map"`(地图上任意 tile)/ `{tiles: [...]}`(每个 footprint 格子的 tile id 必须在列表里)/ `"any"`(跳过 tile 检查)。 ```yaml id: coop name: Coop footprint: { w: 3, h: 3 } buildable: { tiles: [grass] } blocksMovement: true description: "Shelters a flock of chickens." spriteRef: coop ``` - `placement.place {buildingId: coop, x: 10, y: 4}` → 校验 def 存在 → 地图边界 → 每格可建性 → 不重叠,然后创建实体 `{id: "building:coop:10:4", type: "building", ...}`,发 `placement-change`。 - v1 占用规则:**建筑挡建筑**;玩家和 NPC 是瞬态实体不占用格子(NPC 阻挡是 MovementSystem 的事)。 - `blocksMovement: true` 的建筑同时挡 NPC 移动。 ### 5.8 ⑧ 商店 shop `content/shops/.yaml`。**约定:`sells` = 商店卖给玩家(玩家付 `sells[].price`);`buys` = 商店从玩家收购(玩家赚 `buys[].price`)**。 ```yaml id: general-store buys: - { itemId: parsnip, price: 30 } - { itemId: carrot, price: 40 } - { itemId: egg, price: 15 } sells: - { itemId: hoe, price: 500 } - { itemId: parsnip-seed, price: 20, stock: 99 } - { itemId: carrot-seed, price: 25, stock: 99 } ``` - `shop.buy {shopId, itemId, qty}` → 检查金币 → 扣 `player.gold` → 加物品 → 发 `transaction`。 - `shop.sell` 反向。`stock` 在本切片是**信息性的**(无库存消耗/补货逻辑)。 - 玩家金币是实体平面字段 `player.gold`(初始 `STARTING_GOLD = 0`)。 ### 5.9 ⑨ 行为触发器 behavior `content/behaviors/.yaml`。格式:`when <条件> → fire <动作>`。**触发器在除 `trigger-fire` 外的每个总线事件上求值**。 ```yaml id: starter-gift when: and: - { op: gte, path: time.day, value: 1 } - not: { op: exists, path: player.flag.starter-gift } fire: - action: give-item itemId: parsnip-seed qty: 3 - action: give-item itemId: carrot-seed qty: 2 - action: play-sound sound: coin - action: set-state path: player.flag.starter-gift value: given ``` "单次触发"模式:用 `set-state` 写一个 flag,条件里用 `not exists` 检查它。调试工具:`behavior.fire {triggerId}` 手动触发。 ### 5.10 ⑩ 状态机任务 statemachine(quest 链) `content/statemachines/.yaml`。**任务就是状态机**(D19:QuestSystem 会重造状态机)。任务进度约定写 `player.questProgress.`。 **任务链第 1 个——starter-quest**(第一天之后激活,收获 3 个防风草完成): ```yaml id: starter-quest initial: inactive states: - id: inactive - id: active - id: complete onEnter: - action: give-item itemId: parsnip-seed qty: 2 - action: play-sound sound: harvest - action: set-state path: player.questProgress.starter-quest value: complete transitions: - from: inactive to: active on: day-start when: and: - { op: gte, path: time.day, value: 2 } - not: { op: exists, path: player.questProgress.starter-quest } - from: active to: complete on: crop-harvest when: op: gte path: player.inventory.parsnip value: 3 ``` **任务链第 2 个——carrot-quest**(starter 完成后 day-start 激活;收获胡萝卜完成,用 `payload.cropItemId`): ```yaml - from: inactive to: active on: day-start when: and: - { op: eq, path: player.questProgress.starter-quest, value: complete } - not: { op: exists, path: player.questProgress.carrot-quest } - from: active to: complete on: crop-harvest when: op: eq path: payload.cropItemId value: carrot ``` **任务链第 3 个——mayor-talk-quest**(与镇长对话完成,`payload.dialogueId === "mayor"`;奖励蛋 + 金币)。**已知语言限制**:`set-state` 是绝对赋值,所以金币奖励是平值授予 `set-state player.gold 100`(增量动作延后到语言层)。 **其他常用状态机模式**: - **NPC 日程**(`mayor-routine`):`onSchedule` 的 day-start 条目按 `payload.day` 条件切换状态;Mod 读这个实体的状态来决定 NPC 走去哪。 - **生产规则**(`chicken-egg`):匹配 `animal-produce` 事件发鸡蛋——产出频率由内容(状态机)拥有,不是引擎。 - **体力规则**(`daily-stamina`):day-start 恢复体力(`onSchedule: [{at: day-start, to: ready}]`,`ready.onEnter` 里 `set-state player.stamina 100`);低体力告警用条件转移 `when: {op: lte, path: player.stamina, value: 30}`。注意 `initial` 故意不同于 day-start 目标——调度机器进入当前状态是 no-op 会跳过 `onEnter`。 - **Mod→内容桥**(`fishing-treat`):`mod:fish-caught` 是 Mod 发的自定义事件,状态机用 `on: mod:fish-caught` 匹配(见 5.15)。 调试工具:`behavior.statemachine.status {machineId}`(`started: false` 表示还没转移过)、`behavior.statemachine.transition {machineId, to}`(强制转移)。 ### 5.11 ⑪ 对话 dialogue(分支/gating) `content/dialogues/.yaml`。对话是**内容文档**(节点/选项/条件),不是状态机。 ```yaml id: mayor startNode: greeting nodes: - id: greeting text: "Mayor: Welcome to Pinefield Valley! How can I help?" choices: - text: "Any farming tips?" to: farming-tips actions: - action: give-item itemId: parsnip-seed qty: 1 - text: "Got anything for me?" to: quest-reward when: op: exists path: player.questProgress.starter-quest - text: "I am just passing through." to: farewell - id: farming-tips text: "Mayor: Till a plot, sow parsnip seeds, and water them daily." to: greeting - id: quest-reward text: "Mayor: You harvested three parsnips! Here is a little something." actions: - action: give-item itemId: egg qty: 2 to: farewell - id: farewell text: "Mayor: Good luck, farmer!" ``` 要点: - 每个节点有 `id` + `text`,并且**恰好一个** `choices` 或 `to`(或两者都无 = 终节点)。 - `choices[].when` 在**读取时**过滤可选选项(条件为假则隐藏,`dialogue.choose` 的 index 指向过滤后的列表)。 - `to` 节点会合成一个 `Continue` 选项(headless 驱动有一个统一的推进动作 `choose(index)`)。 - 终节点 / 全被 gate 的节点在进入时**自动结束对话**(文字仍交付,发 `dialogue-end`,移除 `dialogue:active` 实体)——避免 headless 软锁。 - 节点 `actions` 进入时运行;选项 `actions` 选中时运行(同一 5 种动作集)。 - 活动对话状态在实体 `{id: "dialogue:active", type: "dialogue", dialogueId, nodeId, payload?}` 上(可存档)。 ### 5.12 ⑫ 天气 weather `content/weather.yaml`(单文件种类,**无创作工具**——直接写 YAML): ```yaml id: default defaults: sunny: { name: Sunny } rain: { name: Rain } wind: { name: Wind } storm: { name: Storm } seasons: - season: spring weathers: - { id: sunny, weight: 45 } - { id: rain, weight: 35 } - { id: wind, weight: 15 } - { id: storm, weight: 5 } - season: summer weathers: - { id: sunny, weight: 65 } - { id: rain, weight: 15 } # ... ``` - 权重是正整数(相对概率)。`defaults` 提供显示名(键必须是已声明的天气 id)。 - 每次 `day-start` 从当前季节的表里做**加权抽取**(RNG 来自 `engine.rng`,可注入,同种子确定性)。 - 初始天气 = 当前季节表第一项(启动无随机,确定性)。 - `weather-change {from, to}` 只在真正变化时发。`weather.get`/`weather.set` 是调试工具。 - 当前季节未被表覆盖时回退到第一季的表。没有 `weather.yaml` 的游戏正常启动(系统退化,调用抛清晰错误)。 ### 5.13 ⑬ 精灵 sprite(外观数据驱动) `content/sprites/.yaml`——程序化绘制指令(0..32 tile 空间,左上原点),zod strict。 ```yaml id: player frames: - name: idle durationTicks: 1 layers: - { shape: rect, x: 10, y: 16, w: 12, h: 12, fill: "#8d6e63" } # 身体 - { shape: circle, x: 16, y: 12, r: 6, fill: "#ffccbc" } # 头 - { shape: rect, x: 10, y: 8, w: 12, h: 4, fill: "#ffb300" } # 草帽 ``` - 实体靠 `spriteRef` 引用精灵。系统自动维护外观:播种→作物阶段 0;生长推进→对应阶段;建筑放置→def 的 `spriteRef`;动物出生→阶段 0;玩家种子 `spriteRef: "player"`。 - 多帧 = 动画(`durationTicks` 权重,默认 10 fps)。建筑精灵按 footprint 缩放。 - **`image` 层(D31)**:`{ shape: image, src: 资产id, x?, y?, w?, h? }` —— 引用 `content/assets/` 里的图片(详见 5.17 ⑰ 素材管线)。 - **缺 `spriteRef` / 未知 id / 缺素材 → 渲染器回退形状绘制**(现有内容零影响)。 ### 5.14 ⑭ 音效 sound `content/sounds/.yaml`——合成器说明(Web Audio 风格)+ **`src` 音频资产引用(D31)**,zod strict。 ```yaml id: coin src: click-1 ``` - `wave` ∈ `sine|square|triangle|sawtooth`;`gain` 可选 0..1;`slideTo` 可选(结束频率,扫掠)。 - 行为语言的 `play-sound {sound}` 动作发 `sound-play {soundId}` 总线事件 → WS 广播 → 渲染层 `createSoundPlayer` 播放。 - **`src` 是 `content/assets/` 里的音频资产 id(D31)**——渲染层 `decodeAudioData` 解码播放(按 id 缓存 AudioBuffer);**`src` 优先于 `synth`**,缺素材/解码失败静默跳过。至少 `synth`/`src` 之一必填。 ### 5.15 ⑮ Mod(hooks、onPlayerIntent 钓鱼案例) Mod 是 `mods//index.mjs`,`export default { manifest: {id, version}, hooks: {...} }`。**只接触 `HookContext`,永远不 import 引擎内部。** ```js export default { manifest: { id: "sample-game-mod", version: "0.1.0" }, hooks: { onDayStart(ctx) { const { world, time, emit } = ctx; const { day, season } = time.get(); let mayor = world.get("npc:mayor"); if (!mayor) { mayor = world.create({ id: "npc:mayor", type: "npc", x: 6, y: 3, name: "Mayor" }); } // 读状态机实体的状态决定 NPC 走位(NPC 没有内容种类,是运行时实体) const routineState = world.get("sm:mayor-routine")?.state ?? "sleeping"; mayor.x = routineState === "market-day" ? 12 : 6; mayor.y = 3 + (day % 4); emit("mod:sample-day-start", { modId: "sample-game-mod", day, season }); }, onPlayerIntent(ctx) { // ctx.payload = 意图结果 {action, tile, ok, error?, detail?}(唯一带 payload 的钩子) const intent = ctx.payload; if (!intent || typeof intent !== "object") return; if (intent.action !== "interact" || intent.ok !== true) return; const tile = intent.tile; if (!isWaterTile(ctx, tile.x, tile.y)) return; // 读地图地形层判断"water" const player = ctx.world.get("player"); if (player.stamina < 5) { ctx.emit("mod:fish-too-tired", { x: tile.x, y: tile.y, stamina: player.stamina, day }); return; // 体力不足 → "too tired" 门 } player.inventory.fish = (player.inventory.fish ?? 0) + 1; // +1 鱼 player.stamina -= 5; // −5 体力 player.skills.fishing = (player.skills.fishing ?? 0) + 1; // 技能 +1 ctx.emit("mod:fish-caught", { fish: "fish", size: 1 + (stats.catches % 3), ... }); ctx.world.remove(`plot:${tile.x}:${tile.y}`); // 归一化引擎默认的"interact 犁地"行为 }, }, }; ``` 要点: - **Mod↔内容桥**:Mod 只能注册钩子、不能订阅原始总线事件(D6/D13),所以 Mod 用 `ctx.emit("mod:fish-caught", ...)` 发**自定义总线事件**,让内容层(状态机 `fishing-treat` 用 `on: mod:fish-caught` 匹配)接住——第 3 次捕获送一个蛋。这就是 D5 逃生舱分工:内容匹配事件、Mod 处理命令式逻辑。 - **`onPlayerIntent` 是唯一 payload 钩子**:意图负载是瞬态的(不在世界状态里),Mod 只能通过钩子的 `payload` 读到。 - 鱼的 size 是确定性伪尺寸(`1 + (catches % 3)`)——**Mod 没有 RNG 访问权**(引擎 RNG 是世界随机,D28)。 - `mod.reload` 可进程内重载单个 Mod(cache-busting 重导入);权威路径是重启 headless 核心(D13)。 - `mod.list`/`mod.status` 只读内省。**没有 Mod↔MCP 桥**(D10):Mod 不能注册 MCP 工具,MCP 也不能调用 Mod 注册的行为。 ### 5.16 ⑯ 测试与调试 **MCP 客户端驱动整局(示例序列)**——连上 stdio 后: 1. `server.info` — 确认内容已加载(map `farm`、crops `carrot`+`parsnip`、statemachines 7 个、dialogue `mayor`、shop `general-store`…)。 2. `time.advance {ticks: 480}` — 第一天开始:新手礼包到账、starter-quest 激活、天气掷出、Mod 生成 `npc:mayor`。 3. `item.query_inventory` — 查看礼包。 4. `npc.list` — 看到 Mod 生成的镇长。 5. `weather.get` — 看到掷出的天气。 6. 耕作:`crop.till {x: 4, y: 4}` → `crop.sow {x: 4, y: 4, seedItemId: parsnip-seed}` → `crop.water {x: 4, y: 4}`。 7. `time.tick {ticks: 480}` — 生长一天(重复浇水)。 8. 成熟后 `crop.harvest {x: 4, y: 4}` → 任务完成(`behavior.statemachine.status {machineId: starter-quest}`)。 9. `shop.sell {shopId: general-store, itemId: parsnip, qty: 3}` → 金币。 10. `shop.buy {shopId: general-store, itemId: carrot-seed, qty: 1}`。 11. `placement.place {buildingId: coop, x: 10, y: 4}`。 12. `animal.spawn {animalId: chicken, x: 9, y: 3}`。 13. `animal.stage {id: animal:chicken:1, stageIndex: 2}` + 每日 `animal.feed`。 14. `time.tick {ticks: 480}` → `animal-produce` → `chicken-egg` 机器发蛋。 15. `dialogue.start {dialogueId: mayor}` → `dialogue.choose {index: 2}`(仅任务完成后出现)。 16. 任务链:推进一天激活 carrot-quest → 种/收胡萝卜 → 再推一天激活 mayor-talk-quest → 对话完成。 17. **钓鱼**:`player.intent {action: interact, tile: {x: 3, y: 2}}`(左上 4×3 水塘)→ +1 鱼、−5 体力、`skills.fishing` +1;体力 < 5 → `mod:fish-too-tired`。 **浏览器游玩**:本地模式或 ws 模式都行。Interact/Move 工具栏 + 画布点击 + 方向键移动 + HUD(天/季节、种子数、金币、意图反馈)。`player.intent` MCP 工具就是给 Agent 的同一意图机制。 **测试**:`pnpm -r test`。`apps/mcp-server/test/sample-game.test.ts` 把整个 Pinefield Valley 从启动到钓鱼完整驱动一遍。 ### 5.17 ⑰ 二进制素材管线(Assets, D31) **素材 = 引擎内容,但不是内容种类**:把 `.png`/`.ogg` 文件放进 `content/assets/`,加载器就会把每个文件读成一条 **base64 资产条目**(`{kind: "image"|"audio", mime, data}`),id = 去掉扩展名的文件名(如 `grass-1.png` → `grass-1`)。资产表挂在 `registry.assets` / `engine.assets`(同一个 Map),**不需要 schema、没有 MCP 命名空间**——YAML 只用 id 引用(MCP 面 18/100/28/12 完全不变)。 **三处消费点**: 1. **精灵 `image` 层**:`{ shape: image, src: 资产id, x?, y?, w?, h? }`。`x`/`y` 是图像左上角在精灵 0..32 贴图空间里的位置(默认 0,0);`w`/`h` 是图像在贴图空间里的尺寸,**裁剪永不超出源图**(比原图小 = 从源图左上角裁这么多像素;比原图大 = 整图放大;缺省 = 自然像素尺寸)。原点是源图左上角(v1 只支持单块贴图,精灵表/图集格式是文档化未来路径)。示例:作物阶段、鸡舍、围栏都用了 `image` 层(有的叠了程序化根/叶点缀)。 2. **地图 `tiles` 映射**:`map` 定义可加 `tiles: { grass: grass-1, water: water-1, tilled: tilled-1 }`——渲染时对应 tile id 用图像贴图绘制(对齐瓦片像素框),缺省/未映射回退到 `tileColor(id)` 调色板。 3. **音效 `src`**:`sound` 定义可写 `src: 资产id`(如 `coin` → `click-1`),渲染层 `decodeAudioData` 解码播放(按 id 缓存 AudioBuffer,异步;**`src` 优先于 `synth`**,缺素材/解码失败静默跳过)。 **传输与打包**:资产以 base64 内联在 **bundle 的 `assets` 字段**(`BUNDLE_VERSION` 保持 1,老 bundle 无 `assets` 照常加载)和 **WS 快照的 `assets` 字段**(`connectWs`/`connectLocal` 把表喂给 viewport 和声音播放器)里。`nengine-pack` 编译时把 `content/assets/` 收进 `bundle.assets`——打包产物仍是单文件 `bundle.json`(纯 JSON,无独立二进制文件)。**约束:保持素材小巧**——base64 会让体积膨胀约 33%,且每个快照帧都携带整表。 **渲染加载策略**:viewport 把每个图像资产经 `data:;base64,` URL 载入 PixiJS 纹理(按 id 缓存 `texture` + 像素尺寸;**异步加载 + 全部就绪后重绘**,避免首帧停在回退形状);图像命令用 `Sprite` 绘制(子纹理 `frame` 矩形 = 源裁剪区)。缺纹理 → 回退到形状/调色板,绝不崩溃。 **示例素材**:`examples/sample-game/content/assets/` 放了 13 张 Kenney Tiny Farm 瓦片 + 8 个 Kenney 界面音效(CC0),id 自解释:`grass-1`/`grass-2`/`water-1`/`tilled-1`/`plant-sprout`/`plant-grown`/`plant-mature`/`carrot-sprout`/`carrot-grown`/`carrot-mature`/`coop-1`/`coop-2`/`fence-1`;`click-1`/`confirm-1`/`error-1`/`back-1`/`drop-1`/`pluck-1`/`switch-1`/`bong-1`。地图 `tiles`、作物/建筑精灵、四个音效 def(till→drop-1、water→pluck-1、harvest→confirm-1、coin→click-1)都改用了资产;玩家与鸡保持程序化(两种路径并存演示)。 ### 5.18 ⑱ 存档与种子(save + seed) - **存档**:`save.snapshot` 拿走完整世界快照;`save.restore` 放回。快照含 `rngState`(RNG 锚),恢复时重新锚定种子。**时间系统状态 v1 不序列化**(只序列化世界实体)。 - **种子**:`createEngine({seed: 42})` 让整个世界随机确定性化(天气掷取、收获掉落)。`rng.get` 看当前 `{seed, calls}`;`rng.set {seed}` 重置。**同种子 → 相同世界**——回放、确定性测试、调试全靠它。 --- ### 5.19 ⑲ 打包发布(Packaging, D30) 做好一个游戏后,可以用引擎自带的打包 CLI 把它发布成**自包含静态网站**:一个 `bundle.json`(内容包 + **D31 资产表**——base64 内联的 `content/assets/` 二进制;打包产物的唯一内容来源,不发布任何 YAML、也不发布独立二进制文件)+ `index.html` + `assets/`(浏览器端完整可玩 UI)。打包出的游戏**不需要开发服务器、不需要无头核心**,直接在浏览器里用 `createEngine({bundle})`(bundle 模式)+ LocalSync 运行。 **打包命令**(先构建 CLI,产物在 `dist`): ```sh pnpm -r build # 打包示例游戏(等价命令) pnpm pack:example node apps/pack/dist/index.js \ --content examples/sample-game/content \ --out examples/sample-game/dist-pack ``` - `--content `:游戏内容目录(必填)。 - `--out `:输出目录(默认 `./dist-pack`)。 - `--desktop`:额外生成 Electron 桌面应用到 `/desktop/`(D32)。 - `--name `:桌面产品名(窗口标题;默认 `"Farm Game"`)。 - `--help`:查看用法。 **打包流程**(`apps/pack` 三步):① 用核心 `ContentLoader` 加载并**逐条 zod 校验**内容(任何一条失败 → 带 kind/id 的错误信息,退出码 1);② 生成 `bundle.json`(`{format: "nengine-bundle", version: 1, content: {种类: [已校验定义]}}`);③ 用 vite 构建 `apps/dev` 应用(复用同一套 UI 的 `?bundle=` 模式,烘焙默认 bundle 路径)输出到目标目录。 **运行打包后的游戏**: ```sh npx serve examples/sample-game/dist-pack # 或:python3 -m http.server 8000 --directory examples/sample-game/dist-pack # 打开 http://localhost:3000(或 :8000)——无需任何 URL 参数 ``` 打开后与开发模式体验一致:工具栏 Interact/Move、方向键移动、HUD(天/季节、种子、金币)、精灵、合成音效,全部可用。 **引擎机制**:`ContentBundle`/`contentBundleSchema`(`packages/core/src/content/bundle.ts`);`ContentLoader.loadBundle(bundle)` 在启动时**逐条复验**每种定义(边界防御,很便宜);`createEngine({bundle})` 是 `contentDir` 的替代内容源(二选一,且只允许一个;bundle 模式完全不碰文件系统,浏览器安全)。开发模式下也能用 `?bundle=` 参数试同一份包:`http://localhost:5173/apps/dev/?bundle=examples/sample-game/dist-pack/bundle.json`。 **桌面运行(D32)**:`nengine-pack --desktop` 会在 `out/desktop/` 生成 Electron 壳: ```sh # 打包示例游戏 + 桌面壳(窗口标题 "Pinefield Valley") pnpm pack:example:desktop # 运行桌面版(首次安装 Electron,约 100 MB) cd examples/sample-game/dist-pack/desktop npm install npm start ``` 壳的 `main.cjs` 用 `node:http` 在 127.0.0.1 开一个临时端口 HTTP 服务(仅 GET、正确的 MIME、路径穿越防护、只提供确切文件),再开一个 960×640 的 `BrowserWindow`(`contextIsolation: true`/`nodeIntegration: false`,窗口标题 = 产品名)加载 `index.html`——与浏览器里跑的是**同一份 bundle 页面**(`createEngine({bundle})` + LocalSync,页面零 Node 访问)。`SITE_ROOT = resolve(__dirname, "..")`,从任何工作目录启动都能找到站点目录。选 Electron 而非 Tauri:Tauri 需要 `webkit2gtk-4.1`/`javascriptcoregtk-4.1` 系统依赖(本环境无 sudo 装不了),Electron 纯 npm(自带 Chromium)。 **v1 明确不做(已文档化)**:打包产物**不含 Mod**(Mod 是服务端/创作期功能,浏览器模块导入受限);桌面端 v1 **无安装器**(不用 electron-builder)、用默认 Electron 图标、无旁路无头核心(Mod + 多人游戏玩法是 v2 文档化未来路径);打包是**离线构建期工具**,因此**没有 `pack.*` MCP 命名空间**(D30/D32——桌面壳同样零 MCP/引擎表面变化)。 --- ## 6. 参考 ### 6.1 文档地图(保持同步) | 路径 | 用途 | |---|---| | `docs/decisions.md` | 全部架构决策(D1–D32),含理由与用户修正。**WHY 的事实来源。** | | `docs/architecture.md` | 顶层架构、包边界、目录布局、核心 API 概览 | | `docs/mcp.md` | **MCP-first 接口规格**:每个子系统的命名空间工具/资源/prompt。接口契约 | | `docs/api.md` | 引擎 API 参考,按 MCP 服务组织。**活的 API 文档** | | `docs/boundaries.md` | 引擎/游戏边界:引擎内置 vs 创作者内容,三判据 + 四层模型 + 15 项裁决 | | `AGENTS.md` | 项目规则 + 当前状态摘要 | | `examples/sample-game/README.md` | 示例游戏 Pinefield Valley 的完整游玩/驱动说明 | ### 6.2 决策摘要(D1–D32 一句话) | 决策 | 一句话 | |---|---| | D1 | 基于成熟渲染库(PixiJS)自建引擎层 | | D2 | 技术栈 TypeScript + PixiJS | | D3 | MCP 以**创作优先**,运行时只做调试 | | D4 | **强制**数据驱动创作(内容不含代码、引擎不硬编码内容) | | D5 | 声明式行为语言 + Mod 逃生舱 | | D6 | Mod 纯钩子接口,只能碰上下文对象 | | D7 | 核心/渲染分离,核心零 DOM,浏览器优先 | | D8 | 无头 Node 核心是权威,浏览器是渲染视口 | | D9 | 中央 MCP 网关 + 命名空间注册(`server.enable/disable_subsystem`) | | D10 | **禁止 Mod↔MCP 桥** | | D11 | 轻量 ECS:实体 = 扁平数据对象,系统 = 纯函数 | | D12 | MVP 范围 Tier 1,架构预留全部层级 | | D13 | 数据热重载;代码 Mod 重启核心 | | D14 | 持久化 = 通用序列化 + 版本迁移,策略留给创作者 | | D15 | 行为语言 v1:条件表达式 + 触发器 | | D16 | 自用,但要完整丰富文档 | | D17 | 先做纵向切片(数据/渲染/MCP/Mod 端到端) | | D18 | 内容 YAML 手写 + JSON 内部 | | D19 | 引擎/游戏边界:三判据 + 四层模型 + 15 项裁决 | | D20 | 行为语言 v2:独立 `statemachine` 种类 + `onSchedule` 时间原语 | | D21 | 对话(内容文档系统)+ 天气(轻量概率掷取系统) | | D22 | 放置(网格 footprint 原语)+ NPC 网格移动(`moveTo` + 阻挡) | | D23 | tick 循环(`engine.tick(dt)` + `time-tick`)+ GrowthSystem 泛化到动物 `age` | | D24 | 多视口同步:WS 广播通道 + `connectWs` 客户端 + 快照驱动绘制 | | D25 | 玩家意图:`IntentSystem.handle`,三个薄通道(MCP/WS/local) | | D26 | 表现管线数据/协议半:程序化 `sprite` + 合成 `sound` + 原始 `input` | | D27 | 表现管线渲染半:精灵绘制 + Web Audio 播放 + 键盘输入 | | D28 | 确定性种子 RNG(LÖVE 对比中唯一采纳的差距) | | D29 | Tier 3 内容轮:体力/技能字段 + `onPlayerIntent` 钩子 + 多任务链 + 钓鱼 Mod | | D30 | 游戏打包:内容包(`nengine-bundle`)+ 自包含静态网站;`createEngine({bundle})`;无 Mod/无 `pack.*` MCP | | D31 | **素材管线**:`content/assets/` 二进制(`.png`/`.ogg`)作为引擎内容(非内容种类、无 MCP 命名空间);base64 进 bundle/WS 快照;精灵 `image` 层 + 地图 `tiles` 映射 + `sound.src` 音频播放;渲染纹理/缓冲按 id 缓存、异步加载、就绪重绘 | | D32 | **桌面打包**:Electron(弃 Tauri——需 `webkit2gtk-4.1` 系统依赖);`nengine-pack --desktop` 在 `out/desktop/` 写 `main.cjs`(本地 HTTP 服务 + BrowserWindow);无安装器、默认图标、旁路无头核心保留 v2 | 运行原则(meta-decisions):MCP-first 接口设计;API 文档与接口变更同改;机制优于策略;永不压制类型错误(`as any`/`@ts-ignore` 禁用)。 ### 6.3 常用 MCP 工具速查 | 目的 | 工具 | |---|---| | 查看引擎/内容/子系统 | `server.info`、`server.status` | | 关/开子系统 | `server.disable_subsystem {name}`、`server.enable_subsystem {name}` | | 造物品/作物/动物/商店/精灵/音效 | `*.validate` → `*.create` → `*.update` → `*.list`/`*.get` | | 造触发器/状态机/对话 | `behavior.trigger.create`、`behavior.statemachine.create`、`dialogue.create` | | 看时间/推进时钟/完整模拟步 | `time.get`、`time.advance {ticks}`、`time.tick {ticks}` | | 耕作循环 | `crop.till` → `crop.sow` → `crop.water` → `time.tick` → `crop.harvest` | | 经济 | `shop.buy`、`shop.sell` | | 建筑/NPC/动物 | `placement.place`、`npc.move`、`animal.spawn`/`animal.feed` | | 玩家操作(同浏览器) | `player.intent {action, tile}` | | 对话驱动 | `dialogue.start`、`dialogue.choose {index}` | | 天气/随机 | `weather.get`/`set`、`rng.get`/`set` | | 存档 | `save.snapshot`/`validate`/`restore`/`migrate` | | Mod 内省 | `mod.list`、`mod.status`、`mod.reload` | ### 6.4 已知限制(诚实清单) 1. **没有图集/精灵表格式**——`content/assets/` 是单块贴图按 id 引用(`image` 层从源图左上角裁剪,无 sprite-sheet 帧矩形);图集格式是文档化未来路径。 2. **`sound.src` 只支持 `.ogg`(v1)**——`decodeAudioData` 解码;其他音频格式未支持。`synth` 合成路径不变。 3. **没有物理引擎**——网格阻挡覆盖碰撞需求(`MovementSystem` + `PlacementSystem`)。 4. **行为语言 `set-state` 是绝对赋值**,没有增量动作——示例任务的 `+N` 金币用平值授予表达(增量动作延后)。 5. **`map.*` 没有创作工具**——地图直接手写 YAML(加载器会校验)。 6. **浏览器 Mod 加载受限**——浏览器无法直接 `import` 任意本地 `.mjs`;Mod 在 Node 侧验证(`apps/mcp-server` / 测试)。 7. **存档不序列化 TimeSystem 状态**——只序列化世界实体(+ RNG 锚)。 8. **`save.restore` 不回放 RNG 抽取**——重新锚定种子但 `calls` 重置(已文档化语义)。 9. 玩家移动不经过 `MovementSystem.moveTo`(直接复用共享地图/放置助手)——`npc-move` 行为与测试不受影响。 10. 多人游戏**玩法**保留未做(多视口同步机制已建好;多玩家/交易/权限是创作者内容)。 11. **打包产物不含 Mod**(D30 v1)——示例游戏的市长 NPC 与钓鱼小游戏是 Mod 行为,打包版里没有;Mod 是服务端/创作期功能(浏览器模块导入受限)。 12. **桌面端 v1 无安装器**(D32)——`nengine-pack --desktop` 生成**可运行的 Electron 壳**(`out/desktop/`,`npm i && npm start`);安装器(electron-builder)、自定义图标、旁路无头核心(Mod + 多人游戏玩法)是文档化的未来路径(D36 已补安装器)。 13. **打包无 MCP 命名空间**(D30/D32)——`nengine-pack` 是离线构建期工具,无运行时引擎能力;桌面壳只包同样的 bundle 页面,也没有任何 MCP/引擎表面变化。 14. **资产不是内容种类、没有 MCP 命名空间**(D31)——二进制文件无法用 JSON 工具创作;MCP 创作的 YAML 只能按 id 引用(加载器在渲染期解析;缺资产回退到形状/调色板/静音,绝不崩溃)。 ### 6.5 文档与代码差异修正说明 编写本文时逐项核对,发现以下文档与代码的偏差(**以代码为准**,本文按代码描述): | 位置 | 文档声称 | 代码事实 | 本文处理 | |---|---|---|---| | `docs/mcp.md` §map | `map.validate/create/update` 生命周期 + `map.tile`/`map.set_tile` 调试 + `map.create-prompt` | `map.*` 只有 `map.list`/`map.get` + `map://{mapId}` 资源,无 prompt(`namespaces/map.ts` 空 `prompts: []`) | 按代码:`map.*` = 2 工具、只读、无创作工具 | | `docs/mcp.md` §time | `time.configure-prompt` | `time` 命名空间无 prompt(`namespaces/time.ts` `prompts: []`) | 按代码:`time.*` 无 prompt | | `docs/mcp.md` §server | 只列 `server.info`/`enable_subsystem`/`disable_subsystem` | 代码另有 `server.status` 工具(4 工具) | 按代码:`server.*` = 4 工具 | | `AGENTS.md` / 本文档任务描述 | 测试数 480 / 内容种类 17 | 实测 **528** 个测试(D30 打包新增 16、D31 资产管线新增 28);`contentSchemas` 只有 **13** 个内容种类(+ 存档快照与二进制资产表为非种类) | 本文统一用 528 / 13(+ 两个非种类) | | `docs/api.md` | 顶部注明"signatures are the pre-implementation draft"(多处如 `shop.price` 标 "planned"、map 表含 `create-prompt`) | 部分签名是实施前草案,未随实施更新 | 本文以实际代码/命名空间文件为准 | | 仓库根 `content/` | — | 无 `sprites/`/`sounds/` 目录(D26 表现内容只在 sample-game) | 本文注明根内容是早期示例,现代示例看 sample-game | --- (完)