engine-guide-zh.md 71 KB

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 <dir> 把 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 <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/<id>.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/<id>.yaml ItemDef {id, name, kind: tool\|seed\|material\|consumable\|decoration, stackable, price?, use?} item.validate/create/update/list/get
crop content/crops/<id>.yaml CropDef {id, seedItem, stages: [{name, days, waterRequired, spriteRef?}], seasons, drops: [{itemId, qty, chance?}]} crop.* 5 个生命周期工具
animal content/animals/<id>.yaml AnimalDef {id, stages: [{name, days, feedRequired, spriteRef?}], produces: [{itemId, qty, chance?}]} animal.* 5 个生命周期工具
shop content/shops/<id>.yaml ShopDef {id, buys: [{itemId, price}], sells: [{itemId, price, stock?}]} shop.* 5 个生命周期工具
behavior content/behaviors/<id>.yaml TriggerDef {id, when: ConditionExpr, fire: Action[]} behavior.trigger.* 5 个
statemachine content/statemachines/<id>.yaml StateMachineDef {id, initial, states: [{id, onEnter?, onExit?}], transitions: [{from, to, when?, on?}], onSchedule?} behavior.statemachine.* 5 个
dialogue content/dialogues/<id>.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/<id>.yaml BuildingDef {id, name?, footprint: {w, h}, buildable: "map"\|{tiles}\|"any", blocksMovement, description?, spriteRef?} placement.building.* 5 个
sprite content/sprites/<id>.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/<id>.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

系统接口:

interface System {
  id: string;
  init(ctx: SystemInitCtx): Promise<void> | 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/<kind>/<id>.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.<field> → TimeSystem.get()(如 time.day、time.season)
  • player.<field> → world.player.<field> 的简写
  • world.<entityId>.<field> → 按 id 取实体
  • payload.<key> → 事件负载(仅在事件匹配时存在;fire 时无)

动作集(5 种):

动作 字段 效果
set-state {action, path, value} 写实体字段(player.<field> 或 world.<id>.<field>);绝对值赋值(无增量动作)
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):

{
  "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:<machineId>", type: "statemachine", machineId, state},懒创建(首次转移时),save.* 自动持久化。任务进度约定:写 player.questProgress.<questId>。

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:<x>:<y>")→ 发 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):

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 仓库:

git clone <nEngine 仓库地址> nengine
cd nengine

安装依赖:

pnpm install

(pnpm 会按 pnpm-workspace.yaml 链接工作区包;Electron 的 postinstall 下载 Chromium 二进制约 100 MB,已列入 onlyBuiltDependencies。)

构建全部包(6 个包 tsc strict + vite 均须通过):

pnpm -r build

验证安装(全部测试应通过,当前 727 个):

pnpm -r test

4.2 构建

pnpm -r build

(apps/mcp-server 入口从 dist 交付,所以先构建再启动服务器。)

4.3 跑测试

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)

路径一:浏览器游玩(本地模式)

pnpm dev

打开 http://localhost:5173/apps/dev/?contentDir=examples/sample-game/content&modsDir=examples/sample-game/mods。浏览器里直接启动引擎 + 渲染视口 + 本地同步句柄,可玩。

路径二:MCP 调试(headless 核心 + stdio)

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 视口

# 终端 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 为例):

# 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 安装器:

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:

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 <name>:桌面产品名(窗口标题 + 安装器 productName)。不带则默认 "Farm Game"。
  • --installer:隐含 --desktop,额外生成 electron-builder.yml + dist/dist:win 脚本,并把站点文件复制进 desktop/(这样 electron-builder 才能把整个应用打包进安装器)。
  • --mods <dir>:把 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/           # 地图(每个 <id>.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 是玩家出生点。

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 创作工具):

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/<id>.yaml。kind 是 tool|seed|material|consumable|decoration 之一;kind: seed 是播种机制认出的种子类型。

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/<id>.yaml。seedItem 关联种子物品;stages 定义生长周期(days 天数 + waterRequired 是否必须浇水);drops 收获掉落表。

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:<x>:<y> 实体)
  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/<id>.yaml。动物复用 GrowthSystem(没有 AnimalSystem);feedRequired 镜像作物的 waterRequired。

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/<id>.yaml。buildable 是三态联合:"map"(地图上任意 tile)/ {tiles: [...]}(每个 footprint 格子的 tile id 必须在列表里)/ "any"(跳过 tile 检查)。

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/<id>.yaml。约定:sells = 商店卖给玩家(玩家付 sells[].price);buys = 商店从玩家收购(玩家赚 buys[].price)。

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/<id>.yaml。格式:when <条件> → fire <动作>。触发器在除 trigger-fire 外的每个总线事件上求值。

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/<id>.yaml。任务就是状态机(D19:QuestSystem 会重造状态机)。任务进度约定写 player.questProgress.<questId>。

任务链第 1 个——starter-quest(第一天之后激活,收获 3 个防风草完成):

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):

  - 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/<id>.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):

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/<id>.yaml——程序化绘制指令(0..32 tile 空间,左上原点),zod strict。

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/<id>.yaml——合成器说明(Web Audio 风格)+ src 音频资产引用(D31),zod strict。

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/<id>/index.mjs,export default { manifest: {id, version}, hooks: {...} }。只接触 HookContext,永远不 import 引擎内部。

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:<mime>;base64,<data> 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):

pnpm -r build

# 打包示例游戏(等价命令)
pnpm pack:example
node apps/pack/dist/index.js \
  --content examples/sample-game/content \
  --out examples/sample-game/dist-pack
  • --content <dir>:游戏内容目录(必填)。
  • --out <dir>:输出目录(默认 ./dist-pack)。
  • --desktop:额外生成 Electron 桌面应用到 <out>/desktop/(D32)。
  • --name <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 路径)输出到目标目录。

运行打包后的游戏:

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 壳:

# 打包示例游戏 + 桌面壳(窗口标题 "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

(完)