本文档面向两类读者:想用本引擎制作游戏的内容作者(第 4、5 章),以及想理解引擎本身的外包工具 / AI Agent / 维护者(第 1、2、3、6 章)。
本文档基于仓库的 最终真实状态 编写(
pnpm -r test全绿 528 个测试、pnpm -r buildtsc strict 通过)。所有数字均已对照代码核对:MCP 面 = 18 个命名空间 / 100 个工具 / 28 个资源 / 12 个 prompt;Hook = 16 个;内容种类 = 13 种 + 存档快照(非种类)+ 二进制资产表(非种类,D31);自动启动系统 = 12 个。凡文档与代码冲突之处,以代码为准,并在本文第 6.4 节列出修正说明。
nEngine 是一个俯视角 2D 种田游戏的引擎(top-down 2D farming game engine),而不是一个可玩的游戏。它的核心价值不在于渲染性能,而在于三件事:
用一句话概括:引擎提供逻辑系统(机制),创作者只提供数据(YAML 内容文件)。
技术栈:TypeScript + PixiJS(仅渲染层)。Monorepo(pnpm workspaces),核心为无 DOM 依赖的 Node 头less(headless)库。
item.*、crop.*、map.*、save.* …)。"每个功能都有 MCP"是一个由注册层强制的架构约束,而不是一个进程数统计。onDayStart(ctx)、onCropHarvest(ctx)、onPlayerIntent(ctx)…)。Mod 只能接触上下文对象(HookContext),永远不能 import 引擎内部模块。docs/decisions.md)onPlayerIntent 钩子、多任务链(starter-quest → carrot-quest → mayor-talk-quest)、钓鱼小游戏 Mod。apps/pack nengine-pack 把内容(YAML)编译成 bundle.json + 自包含静态网站;createEngine({bundle}) + loadBundle 是引擎机制;无 pack.* MCP(均已文档化)。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(均已文档化)。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 提示词)。┌──────────────────────────────────────────────────────────────┐
│ 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 把世界同步到浏览器。浏览器从不拥有状态,只渲染快照。
| 包 / 目录 | 职责 | 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 校验)。
content/crops/parsnip.yaml)。MCP 面只看到归一化 JSON(D18)。YAML 纯粹是创作界面。
apps/mcp-server 通过 stdio(开发工具链)/ streamable HTTP(运行期调试)暴露 18 个命名空间。这是 AI Agent 建内容、调试状态的通道。apps/mcp-server --ws-port <port> 在同一进程启动 WS 广播服务器,向所有已连接的浏览器视口推送快照 + 事件帧。WS 不是 MCP 命名空间——它服务不同的客户端(渲染视口,不是 Agent 工具)。两个通道可以同时开:一个进程同时服务 MCP 网关和 WS 广播。
每个新子系统都按插件方式添加(D12):
packages/core/src/schema/)。packages/core/src/systems/)。packages/mcp/src/namespaces/)。先写 docs/mcp.md 小节。引擎有 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 音)。
引擎在 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 确定性)。
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 钩子需求"这一判据不成立。
| 命名空间 | 工具数 | 工具要点 | 资源 | 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 |
无 |
关键语义要点:
*.create 写 content/<kind>/<id>.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:要么是单文件内容种类,要么是运行时实体,要么是调试面,没有可指导的创作对象。条件表达式(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 之后求值。match 遍历中先触发器后状态机,共用一个重入保护(本次遍历中动作发出的事件不会被重匹配——级联/死循环不可能)。状态机实例是世界实体 {id: "sm:<machineId>", type: "statemachine", machineId, state},懒创建(首次转移时),save.* 自动持久化。任务进度约定:写 player.questProgress.<questId>。| 方向 | 帧 | 含义 |
|---|---|---|
| 服务器 → 客户端 | {"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)。
sprite 内容种类携带逐帧绘制指令(rect/circle 图层 + #rrggbb 填充)。实体带 spriteRef 字段(作物阶段/建筑定义/动物阶段/玩家),渲染器在快照的 sprites 目录中解析。缺 spriteRef 或未知 id → 回退到纯函数 shapes.ts 的形状绘制(plots 的 tilled/watered/作物阶段色、建筑 footprint 块、npc/player/animal 圆点)。durationTicks 是相对权重,按文档默认 10 fps(DEFAULT_ANIMATION_FPS)折算循环长度;frameAtTime(sprite, elapsedMs, fps?) 纯函数选帧;单帧精灵是静态的。渲染器侧 requestAnimationFrame 时钟,仅当动画帧切换时重绘。建筑 footprint 缩放:0..32 tile 空间按实体 w×h 缩放(非均匀 footprint 下 circle 变椭圆)。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 表示地图外),供画布点击输入。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 不变)。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: [...]}(正常结果,不是错误结果)。前置要求:
apps/mcp-server 入口从 dist 交付,所以先构建再启动服务器)。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
pnpm -r build
(apps/mcp-server 入口从 dist 交付,所以先构建再启动服务器。)
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)。
路径一:浏览器游玩(本地模式)
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 核心推送的快照。
做好游戏后,可以用引擎自带的 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。
本节以
examples/sample-game(Pinefield Valley)为蓝本,按创作顺序分步讲解。示例 YAML 取自真实内容(个别地方简化)。 游戏剧情:春天第一天,你是新农民。第一天开始收到新手礼包(3 防风草种子 + 2 胡萝卜种子),第二天任务链启动(收获 3 个防风草 → 收获胡萝卜 → 与镇长对话),商店买卖、盖鸡舍、养鸡下蛋、对话、天气、钓鱼小游戏 Mod。
游戏 = 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 对。
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 格/行
- ...
要点:
grass(可走)、tilled(预犁地,纯视觉)、water(引擎的 v1 不可走约定,UNWALKABLE_TILES = {"water"})。spawn(玩家出生)。engine.start() 用第一张地图生成玩家实体。map.* 没有创作工具(见 6.4)——地图直接手写 YAML。加载器校验它,map.list/map.get 读取它。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。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}。
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 或浏览器意图):
crop.till {x, y}(幂等,创建 plot:<x>:<y> 实体)crop.sow {x, y, seedItemId: parsnip-seed}(消耗 1 种子,cropItemId 解析为作物定义 id 而非种子 id)crop.water {x, y}(设置 watered: true,不是切换——重复浇安全)time.tick {ticks: 480}(每日推进,waterRequired 阶段未浇水则跳过)crop.harvest {x, y}(要求最终阶段;按 drops 掷掉落进背包,发 crop-harvest)生长是模拟(GrowthSystem.step),不是 MCP 工具——MCP 的 crop.stage 只用于调试强推阶段。
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)。引擎只发事件,不送任何东西。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。blocksMovement: true 的建筑同时挡 NPC 移动。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)。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} 手动触发。
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(增量动作延后到语言层)。
其他常用状态机模式:
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。fishing-treat):mod:fish-caught 是 Mod 发的自定义事件,状态机用 on: mod:fish-caught 匹配(见 5.15)。调试工具:behavior.statemachine.status {machineId}(started: false 表示还没转移过)、behavior.statemachine.transition {machineId, to}(强制转移)。
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))。dialogue-end,移除 dialogue:active 实体)——避免 headless 软锁。actions 进入时运行;选项 actions 选中时运行(同一 5 种动作集)。{id: "dialogue:active", type: "dialogue", dialogueId, nodeId, payload?} 上(可存档)。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 的游戏正常启动(系统退化,调用抛清晰错误)。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 / 缺素材 → 渲染器回退形状绘制(现有内容零影响)。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 之一必填。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 犁地"行为
},
},
};
要点:
ctx.emit("mod:fish-caught", ...) 发自定义总线事件,让内容层(状态机 fishing-treat 用 on: mod:fish-caught 匹配)接住——第 3 次捕获送一个蛋。这就是 D5 逃生舱分工:内容匹配事件、Mod 处理命令式逻辑。onPlayerIntent 是唯一 payload 钩子:意图负载是瞬态的(不在世界状态里),Mod 只能通过钩子的 payload 读到。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 注册的行为。MCP 客户端驱动整局(示例序列)——连上 stdio 后:
server.info — 确认内容已加载(map farm、crops carrot+parsnip、statemachines 7 个、dialogue mayor、shop general-store…)。time.advance {ticks: 480} — 第一天开始:新手礼包到账、starter-quest 激活、天气掷出、Mod 生成 npc:mayor。item.query_inventory — 查看礼包。npc.list — 看到 Mod 生成的镇长。weather.get — 看到掷出的天气。crop.till {x: 4, y: 4} → crop.sow {x: 4, y: 4, seedItemId: parsnip-seed} → crop.water {x: 4, y: 4}。time.tick {ticks: 480} — 生长一天(重复浇水)。crop.harvest {x: 4, y: 4} → 任务完成(behavior.statemachine.status {machineId: starter-quest})。shop.sell {shopId: general-store, itemId: parsnip, qty: 3} → 金币。shop.buy {shopId: general-store, itemId: carrot-seed, qty: 1}。placement.place {buildingId: coop, x: 10, y: 4}。animal.spawn {animalId: chicken, x: 9, y: 3}。animal.stage {id: animal:chicken:1, stageIndex: 2} + 每日 animal.feed。time.tick {ticks: 480} → animal-produce → chicken-egg 机器发蛋。dialogue.start {dialogueId: mayor} → dialogue.choose {index: 2}(仅任务完成后出现)。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 从启动到钓鱼完整驱动一遍。
素材 = 引擎内容,但不是内容种类:把 .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 完全不变)。
三处消费点:
image 层:{ shape: image, src: 资产id, x?, y?, w?, h? }。x/y 是图像左上角在精灵 0..32 贴图空间里的位置(默认 0,0);w/h 是图像在贴图空间里的尺寸,裁剪永不超出源图(比原图小 = 从源图左上角裁这么多像素;比原图大 = 整图放大;缺省 = 自然像素尺寸)。原点是源图左上角(v1 只支持单块贴图,精灵表/图集格式是文档化未来路径)。示例:作物阶段、鸡舍、围栏都用了 image 层(有的叠了程序化根/叶点缀)。tiles 映射:map 定义可加 tiles: { grass: grass-1, water: water-1, tilled: tilled-1 }——渲染时对应 tile id 用图像贴图绘制(对齐瓦片像素框),缺省/未映射回退到 tileColor(id) 调色板。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)都改用了资产;玩家与鸡保持程序化(两种路径并存演示)。
save.snapshot 拿走完整世界快照;save.restore 放回。快照含 rngState(RNG 锚),恢复时重新锚定种子。时间系统状态 v1 不序列化(只序列化世界实体)。createEngine({seed: 42}) 让整个世界随机确定性化(天气掷取、收获掉落)。rng.get 看当前 {seed, calls};rng.set {seed} 重置。同种子 → 相同世界——回放、确定性测试、调试全靠它。做好一个游戏后,可以用引擎自带的打包 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/引擎表面变化)。
| 路径 | 用途 |
|---|---|
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 的完整游玩/驱动说明 |
| 决策 | 一句话 |
|---|---|
| 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 禁用)。
| 目的 | 工具 |
|---|---|
| 查看引擎/内容/子系统 | 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 |
content/assets/ 是单块贴图按 id 引用(image 层从源图左上角裁剪,无 sprite-sheet 帧矩形);图集格式是文档化未来路径。sound.src 只支持 .ogg(v1)——decodeAudioData 解码;其他音频格式未支持。synth 合成路径不变。MovementSystem + PlacementSystem)。set-state 是绝对赋值,没有增量动作——示例任务的 +N 金币用平值授予表达(增量动作延后)。map.* 没有创作工具——地图直接手写 YAML(加载器会校验)。import 任意本地 .mjs;Mod 在 Node 侧验证(apps/mcp-server / 测试)。save.restore 不回放 RNG 抽取——重新锚定种子但 calls 重置(已文档化语义)。MovementSystem.moveTo(直接复用共享地图/放置助手)——npc-move 行为与测试不受影响。nengine-pack --desktop 生成可运行的 Electron 壳(out/desktop/,npm i && npm start);安装器(electron-builder)、自定义图标、旁路无头核心(Mod + 多人游戏玩法)是文档化的未来路径(D36 已补安装器)。nengine-pack 是离线构建期工具,无运行时引擎能力;桌面壳只包同样的 bundle 页面,也没有任何 MCP/引擎表面变化。编写本文时逐项核对,发现以下文档与代码的偏差(以代码为准,本文按代码描述):
| 位置 | 文档声称 | 代码事实 | 本文处理 |
|---|---|---|---|
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 |
(完)