DOCUMENTATION · 架构总览

架构总览

本文描述「插件化剧情引擎」的整体架构、各模块职责、数据流、插件生命周期、错误隔离与权限模型。 阅读顺序建议:先看整体架构整体架构,再看数据流数据流剧情加载--节点执行--插件调用-…

同步自 docs/ARCHITECTURE.md(870 行) · commit b2b4124 · 读源文件 ↗


整体架构

引擎分为四层:运行时装配层 → 引擎核心层 → 插件层 → UI 层。

flowchart TB
  subgraph App["应用层"]
    Main["main.ts<br/>装配 + 事件绑定"]
    Html["index.html<br/>舞台 / 状态栏 / 插件列表 / 日志"]
  end

  subgraph RuntimeLayer["运行时装配层(Runtime)"]
    Runtime["Runtime.ts<br/>装配 / 加载剧情 / 存档读档"]
  end

  subgraph Core["引擎核心层"]
    Engine["StoryEngine.ts<br/>剧情解析与节点执行"]
    State["StateStore.ts<br/>var / flag / item / actor"]
    Bus["EventBus.ts<br/>发布订阅 + 异常隔离"]
    Expr["Expression.ts<br/>条件 DSL 安全求值"]
    Reg["Registry.ts<br/>按 owner 归属的注册表"]
    WorldLoop["WorldLoop.ts<br/>世界循环(每 tick 驱动规则 + 广播 world:tick)"]
  end

  subgraph PluginLayer["插件层"]
    PM["PluginManager.ts<br/>生命周期 / 权限 / 超时 / 熔断"]
    JsL["JsPluginLoader.ts"]
    WasmL["WasmPluginLoader.ts"]
    Ctx["PluginContext.ts<br/>受权限校验的宿主能力"]
  end

  subgraph UILayer["UI 渲染层"]
    Renderer["Renderer.ts<br/>对白 / 选项 / 状态栏 / 组件挂载"]
  end

  subgraph Plugins["插件(动态加载)"]
    JsPlugin["JS 插件<br/>public/plugins/hello-js/index.js"]
    WasmPlugin["Rust WASM 插件<br/>public/plugins/hello-wasm/plugin.wasm"]
  end

  Main --> Runtime
  Main --> Renderer
  Runtime --> Engine
  Runtime --> PM
  Runtime --> State
  Runtime --> Bus
  Runtime --> WorldLoop
  Engine --> Expr
  Engine --> Reg
  Engine -->|PluginGateway| PM
  PM --> Ctx
  PM --> JsL --> JsPlugin
  PM --> WasmL --> WasmPlugin
  Engine -->|StoryUi| Renderer
  State -->|state:change| Bus
  WorldLoop -->|world:tick| Bus
  Bus --> Renderer
  Renderer --> Html

依赖方向(重要)


模块职责

Runtime(运行时门面)

文件:src/runtime/Runtime.ts

WorldLoop(世界循环)

文件:src/runtime/WorldLoop.ts

「世界模拟器」的核心驱动器。它不关心任何具体剧情、不认识任何业务语义,只做两件事:

单条规则抛错会被捕获并记录(错误隔离),异步规则的失败也不阻塞节拍;超时提示阈值 ruleSlownessMs(默认 100ms)用于标记「慢规则」。tick() 也可手动推进(测试 / 快进用), start() / stop() 幂等。

拍内写入的注意事项:规则的入参是节拍开始时的状态快照,所以规则里读到的快照不包含 本拍其它规则刚写的值。要让多个参与者对同一路径的修改正确叠加,请用 ctx.state.update() (或只改自己的那条路径),不要在插件里缓存整块状态再整块写回。

节拍控制(暂停 / 恢复 / 倍速 / 单步):pause(reason?) / resume() 停表与继续(幂等, 分别广播 world:pause / world:resume);configure({ intervalMs, timeScale }) 调整基准间隔与 倍速(实际间隔 = intervalMs / timeScale,非法值会被忽略,运行中改动会立刻重排下一拍); step(n) 一次推进 n 拍,暂停期间也能用(确定性快进 / 单步调试)。插件侧对应 ctx.world.pause/resume/isPaused/timeScale/step(需要 world:register —— 能驱动世界,才能改它的节奏); 剧情可在顶层声明 "world": { "intervalMs": 500, "timeScale": 2 },由 Runtime 在加载时应用。

平权设计:JS 插件(ctx.world.registerRule / subscribe)、WASM 插件(监听 world:tick)、 剧情 JSON(observe 节点)共享同一条 world:tick 信号与同一个 StateStore —— 三者都是「听世界说话」。

StoryEngine(剧情引擎)

文件:src/runtime/StoryEngine.ts

StateStore(状态仓库)

文件:src/runtime/StateStore.ts

EventBus(事件总线)

文件:src/runtime/EventBus.ts

PluginTimers(插件定时器)

文件:src/runtime/PluginTimers.ts

ctx.schedule(秒, fn) 的背后。用**代数(generation)**管理插件排下的定时器: Runtime.loadStory() / restart() 让全部在途定时器失效,插件卸载时按 owner 回收, 运行时关闭时清空。治的是「幽灵定时器」—— 剧情重载后旧回调醒来,去改新剧情不该有的状态、 或操作一个已经不存在的 UI(引擎对 UI 侧的同类问题用的是 runToken,这里是同一思路的通用化)。

FrameLoop(帧循环)与 InputRouter(输入路由)

文件:src/runtime/FrameLoop.ts、src/runtime/InputRouter.ts、src/runtime/TouchControls.ts

触屏(TouchControls):屏幕上的方向区(像摇杆:从按下点算偏移、超过死区取主轴)与动作键, 把手指动作翻译成与键盘相同的键码喂给同一个 InputRouter —— 所以插件零改动就能用触屏。 触屏专属的三件都在输入层处理:pointerId(多点触控区分手指)、pointercancel (抬起必回到接收 down 的那个认领,否则"角色一直朝一个方向跑")、失焦/切后台释放全部按下状态。 手柄不做(Gamepad API 需要轮询 + 映射表 + 各家差异,收益只有主机玩家那一小块)。

引擎里有两个时间,分工明确:

时钟谁在用典型用途
WorldLoop(默认 1000ms 一拍)世界规则 / observe / WASM 插件模拟、生态、资源、长周期状态机
FrameLoop(固定逻辑步默认 16.67ms + 渲染帧)实时玩法插件(ctx.frame)动作、弹幕、手感 —— 逻辑走 onStep(帧率无关),渲染走 onFrame(带 alpha 插值)

FrameLoop 用经典的「固定步长累加器 + 插值渲染」:逻辑可复现、渲染不抖。它有三个工程决定: 没有 rAF 也能跑(node 里靠 advance() 手动推,所以数学可测)、没有订阅就不烧帧(防幽灵 rAF)、 一帧最多补 5 个逻辑步(卡顿后宁可慢放也不补步螺旋,超限记 starved)。累加器用毫秒 + 容差 比较 —— 浮点减法在步边界上会漂(实测 60ms/20ms 会算成 2 步)。

InputRouter 把「谁拥有这个键」变成引擎的事:插件认领自己关心的输入,引擎独占一层 捕获阶段监听并仲裁(priority 大的赢,同级后认领的赢;带 scope 的只在目标位于其内才生效)。 认领成功的事件会被 preventDefault + stopPropagation,所以引擎自己的「继续」键与其它插件都收不到 —— 这就是以前「插件与引擎抢空格」的根治办法(当时只能靠捕获 + stopPropagation 硬挡)。 非浏览器环境下整体降级(available === false),测试用 handleKey() / handlePointer() 直接驱动。

对外形态:库与嵌入(src/index.ts、src/embed/)

引擎有两张"给别人用"的脸:

形态文件说明
库src/index.ts、vite.lib.config.ts、tsconfig.lib.json、dist-lib/契约面:该导出的东西只在这一个文件里列(其余都是实现细节,改它等于改 API)。npm run build:lib → dist-lib/rne.js(ESM)+ dist-lib/types/**;构建不拷贝 public/(库不该带演示资产)。项目决定不发 npm registry(private: true)、只走 git,所以产物入库:克隆即可用、零构建、离线可用;代价是"产物可能过期",由 pre-push 钩子的同步检查堵住
嵌入src/embed/、embed.html、embed-demo.htmliframe + postMessage。protocol.ts 是纯数据 + 纯函数(消息形状 / 命令白名单 / 校验),child.ts 把 Runtime 包成服务,parent.ts 是 createEmbedHost(请求-答复配对、超时、事件分发),browser.ts 只是 window/iframe 的薄适配

关键工程决定:消息表面是注入的(EmbedSurface)—— 于是「命令路由 / 白名单 / 答复对应 / 超时 / 来源校验」全都能在 node 里测掉, 不必开浏览器点一遍(tests/embed.test.ts 里用一对假 surface 把父页和子页接起来, 子页里放一个真的 Runtime)。

剧本编辑器(src/editor/,宿主应用而不是引擎的一部分)

编辑器中间栏有三种模式,共享同一份模型:表单(点选式)、文本(SCRIPT_TEXT.md 的语法, 文本是真源)、JSON(引擎真正加载的那份剧情,可直接改,校验复用 StoryEngine.validateStory)。

两类"编辑器不认识"的东西都要原样留住: ① 整节点(plugin / observe / wait / ui…)→ 收成保留段(CarrySegment); ② 能映射的节点上多出来的字段(say 的 effects、choice 的 timeoutMs)→ 收成节拍的 extra.fields,其中的引用进 extra.refPatches。两者的引用都在导出时按位置重新指向。

文本模式还有一条:文本是"从零重造模型",所以 preserveEditorOnlyData() 会在解析后把 extra 与保留段接回去 —— 仅当那一场的节拍种类序列完全一致(结构没动);结构变了就不接并如实报告。 (宁可说"没保住",也不把 A 的附加字段安到 B 身上。)

导入时编辑器表达不了的节点(plugin / observe / wait / ui…)会收成保留段 (CarrySegment)原样带走,导出时按位置接回图里 —— 并带一张引用补丁表: 保留段里的引用记的是"指向哪个位置"(某个节拍 / 某一场 / 紧接着的下一项 / 另一段保留段 / 结束), 导出时按当前结构重新解析成新 id(被引用的节点会被重新编号)。节点 id 变了也不会指空。

导入(import.ts)还另有一条容易想错的规则:场的边界只由真正的跳转目标决定 (target / else / options[].next / branches[].next / timeoutNext), 线性 next 不算跳转。搞错这条的后果很重 —— 被跳转的节点会被前一场吞掉, 它的跳转目标就被映射回"自己所在的那一场",自指 → 死循环(见 tests/editor.test.ts 里的回归测试)。

文件:src/editor/、页面入口 editor.html

给编剧用的写作工具。它和 src/main.ts 是同一类东西 —— 引擎的一个宿主, 所以它复用 Runtime / StoryEngine / RuntimeUi,而引擎核心一行都没为它改过。

模块职责
script-model.ts编剧模型:场(可带场标题行「内景 · 门厅 · 夜」、可属某幕)/ 动作·舞台指示 / 对白(可带表演提示、可多段)/ 写入状态(设置:path = 表达式)/ 选择 / 条件分支 / 跳转 / 结局
compile.ts编剧模型 → 剧情 JSON:line→say、choice→choice、branch→if、goto→jump、ending→end;产物交给 validateStory 兜底
import.ts剧情 JSON → 编剧模型(有损):能还原的还原,还原不来的明确列进 unsupported;条件还原不了形状就原样保留为"高级条件"(不猜)
health.ts结构体检:引用不存在的场 / 一个结局都没有(硬错);没人进的场 / 点了就结束的选项 / 只会跳回自己 / 空对白(提醒);条件语法复用引擎的表达式求值器
export-script.ts两种导出:可打印 HTML(A4 + 印刷体排式:人物名居中、对白缩进、动作缩进加括号、转场右对齐、每场可选另起一页 —— 浏览器 Ctrl+P 存 PDF)与增强版 Markdown(进仓库 / diff)。HTML 里所有文本都转义(导出的文件不能变成可执行的东西);页码不用 CSS 硬做(Chromium 不支持 @page 页码盒),改为每场标注「场 N/M」并在页面上说明
reader-ui.ts干净阅读模式:实现 RuntimeUi,但只有对白 / 选项 / 继续 —— 没有状态栏、插件列表、事件日志(那些是给开发者看的)
history.ts撤销 / 重做:存快照而不是命令对象(剧本很小,快照最不容易撤错)。只覆盖结构性改动,打字交给浏览器原生的 textarea 撤销
search.ts全文搜索:对白 / 说话人 / 选项 / 结局名,返回带位置与上下文片段的命中列表
text-format.ts文本剧本格式的 parse / stringify(文本优先写作的底座;完整语法见 SCRIPT_TEXT.md)。支持幕(# 幕名)、缩进续段(同一说话人连续多段)、表演提示(铁颚(低声):…)。真源是文本,模型是它的产物 —— 所以往返幂等 parse(stringify(doc)) === doc 是硬契约(由测试钉住)。解析出错不抛异常,返回带行号的诊断(编剧一边打字一边看错误)
check-story-json.tsJSON 视图用的检查:JSON 语法错误定位到行列,其余交给引擎自己的 StoryEngine.validateStory(不另立一套校验规则)
publish.ts一键落地:从标题派生剧情 id(派不出来用确定性短哈希兜底)、生成 index.json 条目、按 id 合并现有 index(别人的条目一条不动)。story.id 与 index 条目的 id 由同一个函数算 —— 从根上不可能对不上(story:lint 专门查这条)
main.ts三栏界面(大纲 / 本场内容 / 体检+试读)+ 草稿自动保存 + 导入导出 + 快捷键(Ctrl+Z / Ctrl+S / Ctrl+F)+ 光标管理(加完节拍直接能打字)

三条设计约束:不碰引擎核心(预览直接复用 Runtime)、不引框架(原生 DOM + TS, 与仓库其它界面一致)、不隐藏损失(有损导入会挂横幅)。

从这场试读没有新机制:编译 → engine.load(story) → engine.restore({ nodeId }) → engine.resume() —— 就是引擎「读档恢复执行位置」那条路(nodeIdOfScene() 保证编辑器与 compile.ts 用的是同一个节点 id 约定)。

一个实现细节值得记:界面是"结构性改动整块重绘、打字只改模型"—— 否则每敲一个字就重绘,会把输入光标顶掉(这类编辑器的经典 bug)。

开发工具:插件脚手架

文件:scripts/create-plugin.mjs(npm run create-plugin -- <plugin-id>)

生成「插件清单 + 插件骨架 + 一条用到它的剧情 + 一个无头测试」四件套,并自动登记进 public/stories/index.json。设计原则是产物必须能跑:tests/scaffold.test.ts 会在临时目录里 生成一整套并真的加载运行、断言状态被改对 —— 所以脚手架不会腐烂(改了引擎之后测试会立刻发现)。

AssetStore(资产仓库)

文件:src/runtime/AssetStore.ts

图片 / 音频 / JSON / 文本的加载与生命周期。四个决定:

RandomSource(确定性随机)

文件:src/runtime/RandomSource.ts

线性同余随机源。存在的唯一理由是可复现:给定 (seed, state) 就能算出下一拍、下一局、 下一个抉择点 —— 这是「涌现模拟」「肉鸽」这类玩法能调试、能回放的前提。 插件侧通过 ctx.random 使用(next / int / pick / fork(label)); fork 按 label 派生独立流并记忆化,不同子系统互不干扰。 种子与状态随存档走(SaveData.random)。构造时会先空转几轮(热身)—— LCG 的首个输出是种子的线性函数,不热身的话相邻种子会给出高度相关的开局。

ViewCanvas(受管画布)

文件:src/runtime/ViewCanvas.ts

给插件用的纯视图原语:ctx.view.canvas(host, options) 返回一块受管画布 —— DPR 适配、容器尺寸变化(ResizeObserver)、rAF 帧循环、卸载收摊全由引擎负责; 插件只注册 onFrame 拿 ctx2d 自己画。引擎不提供绘制 API、不关心画什么。 变换数学抽成纯函数 computeViewTransform()(可单测,不需要 DOM)。 没有帧订阅时不跑 rAF;插件卸载时由 PluginContext 的 cleanup 统一 destroy() —— 消灭「幽灵 rAF」。

Capabilities(宿主能力探测)

文件:src/runtime/Capabilities.ts

runtime.capabilities / ctx.capabilities(只读、不需权限)给出 { wasm, simd, sharedArrayBuffer, crossOriginIsolated, threads },构造时还会把结论写进日志。

它解决的是静默降级:SharedArrayBuffer 存在但页面没跨域隔离时,多线程通路会悄悄变成单线程 (docs/CROSS_ORIGIN.md 里点过这个坑:恢复压测资产时忘了开隔离,那条通路就静默降级了)。 有了探测,重计算插件可以显式降档 —— 网格 1024² → 512²、粒子数对半。 注意降的是负载,不是实现语言:把 WASM 换成 JS 对重计算没有意义(性能差一个数量级)。

可观测性(dev 调试)

引擎默认不留痕(追踪有额外开销),需要时再打开:

能力打开方式看什么
事件轨迹Runtime({ ui, trace: true }) 或 events.enableTrace(limit)最近 N 条事件:名字 / 来源 / pluginId / 命中监听器数 / 抛错数 / payload 摘要(只描述形状,不存内容)
状态写入热点随 trace: true 一起开,或 state.enableWriteStats()哪些路径被反复写(getWriteStats() 按次数倒序)—— 一眼看出该用 batch() 的地方
observe 决策轨迹始终记录(最近 50 条),engine.getObserverTrace()事件到了但剧情没动时逐条说明原因:不属于当前剧情 / once 已用掉 / 冷却中(含毫秒数)/ 条件不满足(含表达式)/ 优先级低于谁

| 回放素材 | 始终记录(最近 200 条),runtime.replayLog() | 本局种子 + 玩家/UI 输入序列(选项按 index、意图按 name)。 配合确定性世界(ctx.random + 固定规则顺序)即可重跑同一局 |

三种视图由 runtime.debugReport() 一次性汇总;dev 里也可以直接 ?trace=1 打开 (见 src/main.ts),然后在控制台调 __storyRuntime.debugReport()。

重放一局:const { seed, inputs } = runtime.replayLog() → 用同一颗种子建运行时 → 把 inputs 按序喂回去(kind: 'intent' 用 ctx.intents.send / engine.deliverIntent; kind: 'choice' 在该节点选出对应 index)→ 轨迹与结局必然一致。

Expression(条件表达式 DSL)

文件:src/runtime/Expression.ts

Registry(注册表)

文件:src/runtime/Registry.ts

PluginManager(插件管理器)

文件:src/plugins/PluginManager.ts

加载器(JsPluginLoader / WasmPluginLoader)

PluginContext(插件上下文)

文件:src/plugins/PluginContext.ts

UI 渲染层(Renderer)

文件:src/ui/Renderer.ts


数据流:剧情加载 → 节点执行 → 插件调用 → 状态更新 → UI 渲染

sequenceDiagram
  participant U as 用户
  participant M as main.ts
  participant R as Runtime
  participant E as StoryEngine
  participant PM as PluginManager
  participant P as 插件(JS/WASM)
  participant S as StateStore
  participant UI as Renderer

  U->>M: 打开页面
  M->>R: new Runtime({ ui })
  M->>R: loadStory('/stories/demo.json', { autoStart:true })
  R->>R: fetch + validateStory
  R->>E: engine.load(story)
  E-->>UI: setStoryTitle()
  E-->>M: 事件 story:loaded
  R->>PM: loadMany(story.plugins)
  PM->>P: import() / instantiate()
  P->>PM: init(ctx) 注册命令/节点/UI 组件
  PM-->>M: 事件 plugin:loaded
  R->>PM: startAll()
  R->>E: engine.start()
  loop 每个节点
    E-->>M: 事件 story:nodeEnter
    E->>UI: renderSay / renderChoice / ...
    UI-->>U: 显示对白 / 选项
    alt plugin 节点
      E->>PM: callCommand(name, args)
      PM->>P: 调用命令处理器(带超时/熔断)
      P-->>PM: CommandResult{message, effects}
      PM-->>E: 结果
      E->>E: applyEffects(result.effects)
    end
  end
  E->>S: state.set(...)
  S-->>M: onChange -> 事件 state:change
  M->>UI: renderStatus(snapshot)
  E-->>M: 事件 story:end
  M->>UI: renderEnd()

关键点:

  1. 剧情文件先校验后落地,非法剧情不会破坏当前正在运行的剧情。
  2. 节点执行是 async 顺序的:say / choice / wait 会 await UI 的用户操作。
  3. 插件调用的返回值驱动引擎:message 以对白形式渲染,effects 由引擎落地, next 覆盖节点跳转,stop 终止循环。
  4. 状态变更是单一入口:所有写入都经过 StateStore,再通过 state:change 广播到 UI。

数据流:世界循环(世界模拟器)

与上面的「剧情推进」并行,WorldLoop 是一条独立的节拍线:剧情不主动前进,而是被动听世界说话。

sequenceDiagram
  participant W as WorldLoop
  participant S as StateStore
  participant B as EventBus
  participant JS as JS 插件(world 规则)
  participant WASM as WASM 插件(状态机)
  participant E as StoryEngine(observe)
  participant UI as Renderer

  W->>W: tick(): 拍号 +1,拍摄状态快照
  W->>JS: 依次调用已注册世界规则 handler(snapshot, notice)
  JS->>S: 规则里写回状态(world:register + state:write)
  JS->>B: publish('alert:emergency') 发布世界事件(world:emit)
  W->>B: 广播 world:tick { tick, elapsedMs }
  B->>WASM: 事件代理 → WASM 每拍推进状态机
  WASM->>S: host_set_state 回写 world.sim.*
  WASM->>B: host_emit 发布高层世界事件
  B->>E: observe 候选筛选(condition + priority)→ 跳转 next
  E->>UI: renderReset() 唤醒在途等待 → 旁白插话
  B->>JS: ctx.world.subscribe 收到事件 → 刷新 UI / 弹警示

关键点:

  1. 规则先跑、广播后发:WorldLoop.tick() 先执行已注册规则,再广播 world:tick,顺序固定可复现。
  2. 三路平权:JS(规则 / 订阅)、WASM(事件代理)、JSON(observe)都挂在同一条 EventBus 与同一个 StateStore 上,彼此通过世界事件协作,引擎核心不识别任何业务语义。
  3. 剧情可以被「打断」:observe 触发时改写 currentNodeId 并 renderReset(),主循环在节点等待 被打断后按新位置继续 —— 世界事件随时可以插话。
  4. 错误隔离沿用同一套:世界规则单条抛错不影响其它规则与广播;WASM 每拍执行也走既有 事件代理 + 失败计数熔断。

插件生命周期

stateDiagram-v2
  [*] --> registered: 登记 manifest
  registered --> loading: load() 开始
  loading --> initializing: 实例化成功
  initializing --> ready: init 完成
  ready --> running: start()
  running --> stopped: stop()
  stopped --> running: start() 再次启动
  running --> disposed: dispose()
  ready --> disposed: dispose()
  loading --> error: 加载失败
  initializing --> error: init 失败
  running --> error: 连续失败熔断
  error --> disposed: 清理
  disposed --> [*]
阶段触发插件侧钩子说明
registeredload() 登记 manifest—已登记但未加载
loading下载 / 实例化中—JS:import();WASM:fetch + instantiate
initializing实例化成功init(ctx)注册命令 / 条件 / 效果 / 节点 / UI 组件,订阅事件
readyinit 完成—已可被调用,但未 start
runningstartAll() / start()start()引擎开始跑剧情
stoppedstop()stop()可再次 start()
disposeddispose()dispose()调用 dispose 后无条件回收注册能力(即使 dispose 抛错)
error加载失败 / init 失败 / 熔断—错误状态,拒绝执行并已回收能力

生命周期钩子的调用时机

WASM 插件目前没有独立的 start / stop 消息,驱动里保留钩子以便未来扩展 ABI。


错误隔离与超时机制

引擎有三道防线,确保「插件崩溃不拖垮主引擎」:

第一道:事件总线逐个隔离

EventBus.emit 对每个监听器单独 try/catch,异常经 setErrorReporter 上报。 一个插件的事件监监听器抛错,其它监听器与引擎流程不受影响。

第二道:PluginManager.invoke 统一包装

所有对插件的调用(init / start / stop / dispose / 命令 / 节点 / 条件 / 效果) 都经过 invoke(pluginId, label, task):

withTimeout 无法真正杀死一个正在执行的 Promise(JS 语言限制), 但宿主会立刻放弃等待它的结果,从而保证主引擎不被卡死。

第三道:StoryEngine 主循环自兜底

StoryEngine.run() 内部捕获异常,转成 story:error 事件 + UI 错误横幅,不向外抛出, 避免一个坏节点把宿主页面搞崩。效果落地同样逐条 try/catch,单条失败不影响整批。

WASM 侧的额外防护


权限模型

核心原则:插件不能直接篡改主引擎状态,只能通过受权限校验的 API。

flowchart LR
  P["插件代码"] -->|ctx.state.set| A["受权限校验的 State API"]
  P -->|ctx.commands.register| B["受权限校验的注册 API"]
  P -->|ctx.events.emit| C["受权限校验的 Event API"]
  A -->|有 state:write| S["StateStore"]
  A -->|无权限| X["抛 PluginPermissionError"]
  B -->|有 commands:register| R["commands 注册表"]
  C -->|有 events:emit| E["EventBus"]

完整权限列表与安全建议见 PERMISSIONS.md。


事件一览

引擎广播的事件(可在控制台或插件里用 ctx.events.on 监听):

事件名触发时机负载要点
story:loaded剧情校验通过并加载storyId / title / nodeCount
story:nodeEnter进入一个节点storyId / nodeId / node
story:nodeExit节点执行完成nodeId / result
story:say显示对白(含插件 message)nodeId / speaker / text
story:choice玩家做出选择nodeId / index / text / option
story:jumpjump 节点跳转from / to
story:branchif 节点命中分支nodeId / condition / next
story:observe世界事件触发 observe 跳转event / nodeId / next / payload
story:pause剧情暂停nodeId
story:end剧情结束storyId / lastNodeId
story:error剧情或效果出错error(+ nodeId / source)
engine:start / engine:stop主循环开始 / 结束storyId / nodeId / ended
state:change任意状态写入snapshot
world:start / world:stop世界循环启动 / 停止intervalMs / tick
world:pause / world:resume世界节拍暂停 / 恢复tick(+ reason)
world:tick世界循环每拍广播tick / elapsedMs
plugin:loaded插件加载成功info
plugin:status插件状态变化id / status
plugin:error插件调用失败id / error / failures
plugin:crashed插件熔断禁用id / error
plugin:disposed插件卸载完成id

关键设计取舍

取舍原因
引擎通过 PluginGateway 间接调用插件引擎无法被插件卡死;超时与熔断逻辑集中在一处
import type 而非 import 打破循环依赖编译期擦除,无运行时开销
WASM 直接走 C ABI,不用 wasm-bindgen减少胶水层与工具链依赖,便于运行时动态加载
跨边界统一 UTF-8 JSON 字符串稳定、可调试、跨语言,serde_json 一处解析
表达式自建 DSL 而非 eval剧情文件不可执行任意脚本
注册表按 owner 归属插件卸载时无需引擎硬编码清理逻辑
UI 组件走 mountComponent 而非直接调用组件被隔离在独立 DOM 容器,异常不外溢
世界循环独立于剧情主循环剧情「等待」时世界仍持续运转;world:tick 是唯一节拍信号,JS / WASM / JSON 三路平权
observe 通过改写 currentNodeId + renderReset 打断等待世界事件可随时插话,且不引入新的控制通道(复现同一套状态 + 事件)
意图通道(ctx.intents)复用 observe,不新增节点类型只在「剧情空闲(say / wait)」且「有 observe 在听 intent:<name>」时受理,受理失败返回 false 形成背压 —— 面板不必再自己判空闲、拼分派链

宿主页面、展示与一行挂载(showcase.html、mount.ts、src/host/、PerfOverlay.ts)

宿主层有五个页面入口:index.html(引擎演示)、editor.html(剧本编辑器)、embed.html(嵌入子页)、 embed-demo.html(嵌入父页演示)、showcase.html(展示页:故事库 + 内嵌演示 + 发布三步 —— 把能力串成一条线,让人一分钟看懂这个项目;它内嵌那块用的是公开的 createEmbedHost, 所以这页本身也是库形态的一次实测)。

一行挂载(src/mount.ts)是给"懒得自己写 UI"的人的可选入口:把现成的 Renderer(官方默认 UI) 与 DOM 模板拼在任意容器里,顺手接好屏幕控件、性能面板、主题与工具栏(按钮全走引擎公开 API)。 它不是引擎的一部分:Runtime 的构造函数里没有它、也不 import 它 ✓;官方默认 UI 有意不提供扩展点 ✓ (想定制就自己实现 RuntimeUi —— 这条是为了不让它长成"必须用的那套壳")。 styles.css 用 ?inline 内联进产物,所以 dist-lib/ 里没有单独的 CSS 文件("一个文件"才成立)。

示例宿主的宿主侧实现放在 src/host/:typewriter.ts(打字机的纯逻辑 ✓)与 debrief.ts(把「你没走的路 / 因为你」排成几行 —— 排版也抽成纯函数,措辞与取舍是这功能的一半 ✓) 与 plugin-catalog.ts(从 manifest 排"能干什么 / 要哪些权限"的表,给审核第三方插件用 ✓)。

插件生命周期的一条契约:stop() 之后可以再次 start() —— 而 Runtime.restart()("重新开始")必须是"停 → 清状态 → 重新加载 → 再启动"这个完整序列 ✗: 漏了停、或 startAll() 只启动 ready 而不认 stopped,都会让"每局初始化"静默失效 (见 CHANGELOG 的修复条目 —— 这是靠一份真剧情逼出来的 bug)。

typewriter.ts 的定位是打字机的纯逻辑 (只算"这一帧显示哪几个字",自己不定时 → 可单测)。它的定位是"示例,不是引擎": 观感属于宿主,引擎只负责给语义(见 docs/FRONTEND.md 的"怎么用引擎驱动 UI")。

PerfOverlay.ts 是 ?perf=1 打开的只读性能浮层:它接的是结构化小接口 (perfReport() + onQualityChange())而不是 Runtime 类型(避免循环依赖), 只负责"让数字被看见",不提供调参入口(调参是宿主的事)。

性能看门狗(PerfWatchdog.ts)

"这一帧花了多久"只有引擎知道(逻辑步 + 渲染回调都在它手里)。没有它,性能问题的表现形式是 "某些手机上莫名卡",开发者拿不到任何数字。帧循环把每帧实测耗时交给看门狗 (FrameLoopDeps.onCost —— 用回调而不是 handler 订阅,所以不会因为装了看门狗就让循环一直跑)。

三条工程决定:

状态来源追踪(src/runtime/StateProvenance.ts)

诊断包(src/runtime/Diagnostics.ts)+ 三个"真实世界"洞的补丁

runtime.exportDiagnostics() 把排查要的东西打成一个 JSON ✓:版本 + 剧情/种子/输入日志 + 指纹(⑦)

同一批还补了三个"真实世界才会暴露"的洞 ✗:

状态来源追踪

统计回答"这个路径被写了多少次" ✓;这里回答"最后这一次是谁写的、上一步在哪个节点" ✓ —— 也就是因果 ✓(排查"这个 flag 怎么就 true 了"要的正是这一句 ✓)。 来源串是现成的:引擎早就用 set:<nodeId> / choice:<nodeId>#<n> / plugin:<id>:<command> 在 applyEffects 里打了标 ✓,这里只是把它记下来 ✓,不新造坐标系统 ✗。 两处记录:引擎的效果漏斗(by: node ✓)与插件的状态 API(by: plugin ✓)。 每路径只留最后一条 ✓ + 有上限的流水 ✓(60fps 插件撑不爆 ✓);explain(path) 给一句人话 ✓。

每插件开销归因与配额(src/plugins/PluginCostTracker.ts)

看门狗答的是"这一帧超没超" ✓;PluginCostTracker 答的是"是哪个插件花的" ✓ (插件是第三方代码,归因才是需要的那一半 ✓)。所有插件调用都过 PluginManager.invoke() 那道门, 在那里计时 ✓。三条取舍:口径与看门狗一致(budgetMs / 超预算 ✓)✗ 不另造词; 滚动窗口均值 + 最小样本 ✗(一次倒霉调用不判死插件 ✓); 默认只量不罚 ✓(没声明预算的插件永不被停 —— 否则引擎升个级就可能停掉线上插件 ✓), 声明了才执行,而执行方式是复用已有的熔断(状态标 error → 之后 invoke() 直接拒绝 ✓)。

表达式函数注册(src/runtime/ExpressionFunctions.ts)

DSL 要能长大,而且长在受管的地方 ✓:注册表不许遮蔽内置 ✗、名字先到先得 ✓、可选声明参数个数 ✓; 引擎的求值 scope 上挂一个 callFunction ✓ 指过去(内置优先 ✓)—— 于是同一份注册表同时服务 插件(ctx.expressions ✓)与宿主(runtime.expressionFunctions ✓)。 一处语义变化:未知函数名从编译期拒绝改到求值期报错(否则插件注册的函数写不进剧情 ✗)✓。

运行期权限审计(src/plugins/AuditLog.ts)

静态清单(manifest 说要哪些权限)是许诺 ✓;运行期审计是证据 ✓。 所有"过权限门"的尝试都记一笔(拒绝也记 ✓),环形有上限 ✗(高频插件不能把流水撑爆 ✓), 并按「插件 + 权限」聚合计数 ✓。插点在 PluginContext 里状态/存储两处的 requirePermission, 以及管理器的 UI 注入路径 ✓ —— 于是"谁读了 actor.铁颚.信任"是可查的,而不是靠猜 ✓。

插件之间的契约总线(src/plugins/ContractRegistry.ts)

插件之间不该靠共享状态说话 ✗(谁都能读、也能悄悄改别人写的 flag ✓),也不该靠事件广播猜 (单向、没人知道谁在听 ✗)。契约总线把这件事变成声明:manifest 里 contracts: { provides, requires } ✓,运行期用 ctx.contracts.provide/call ✓, 缺依赖在启动时就报"不启动 + error" ✗(而不是运行时"某个东西没反应" ✓)。 名字先到先得、卸载自动撤销、并且 audit() 能查出"声明了没实现 / 实现了没声明 / 依赖没人提供" ✓。 细节见 docs/CHANGELOG.md 里 ③ 那一条与 tests/plugin-manager.test.ts。

跨宿主一致性:运行指纹与无头回放

确定性是这个引擎的旗舰承诺 ✓ —— 但在这之前,它只在 node 里被验过 ✗。 src/runtime/fingerprint.ts + src/host/headless*.ts 把它变成可检查的东西 ✓: 一场跑完得到一个指纹,换台宿主跑同一条命令,指纹必须一样 ✓(npm run replay -- --story … --expect <指纹> ✓)。

无头回放不加载插件 ✗(插件要动态 import 资源 URL,那是宿主的活 ✓)——CLI 的适用范围就此到此为止 ✓。

边界:我们做引擎,不做产品

一句话:这个仓库的产出是引擎(以及它的托管接口),不是某个互动剧产品 ✓。

(这条是维护者明确要求记下来的 ✗ —— 项目很容易往"做一个互动剧网站"漂移,那不在范围里。)

空转保护(createLoopGuard)

wait(可跳过)用 Promise.race 撞 UI.waitForAdvance():只要后者立刻 resolve (自动放行的 UI,或玩家按住"继续"不放),"可跳过的等待"就被无限跳过 —— 若 next 指向自己,主循环会空转,还会饿死宏任务(页面上其它定时器全部停摆 ✗)。

createLoopGuard() 把这件事从"隐形地烧 CPU"变成"说得清的错误":同一节点连续重入超过阈值 (默认 200)即判定死循环并抛 StoryRuntimeError,load() 时重置。

两个实现决定:① 按"连续重入同一节点"而不是"多久没等过时间"(正常剧情也会一连串 say/set/if 快速走过,后者会误判 ✗);② 判定抽成纯函数 —— 因为真触发空转时宏任务会被饿死, "在引擎里跑一次真空转"的测试自己就挂死 ✗,所以逻辑必须能离开事件循环被测死 (引擎级那条改用"只让微任务跑"来绕)。

另有一个已修的相关缺陷:observe 跳转会调 ui.renderReset?.() 唤醒在途等待, 而那个钩子以前只放行 say 的「继续」与在途 wait,不处理在途的 choice ✗ —— 于是"玩家犹豫时世界插话"走不通。现在 UI 契约多了 CHOICE_CANCELLED 哨兵(见 FRONTEND.md)。

存档文件(SaveFile.ts)

导出的是一个带信封的 JSON(format: 'rne-save' + 文件格式版本 + 导出时的存档版本 + data), 不是裸的 SaveData。理由很实在:导入时最常见的问题是拿错文件, 带信封就能当场说清"这不是 JSON" / "这不是本引擎导出的" / "来自更新的引擎"。

导入走与读档同一条路(Runtime.resolveSaveData() → 版本检查 + 逐级迁移), 所以老版本的存档文件也能读;属于另一部剧情的文件会被明确拒绝(SAVE_FILE_WRONG_STORY), 而不是"导入成功但进度是乱的"。

为什么要有这个功能:浏览器会清掉本地存储(Safari 的 ITP、无痕模式、用户清数据), 这是设计行为、客户端阻止不了。最便宜的对策就是让玩家自己存一份文件。

存档迁移(SaveMigrations.ts)

SaveData 带 version,但光有版本号没用:老存档读进来新增字段是 undefined、旧字段可能是另一个含义 —— "读档成功"其实是静默错读。所以读档走一条明确的路径:

v1 存档 ──(1→2)──> v2 存档 ──> 交给引擎读

音频混合(AssetStore 的音频部分)

音频有两层用法,别混:

层入口管什么
素材ctx.assets.sound(url) → AssetHandle<Sound>加载与去重、引用计数;play({loop, volume}) 直接播
混音ctx.assets.play(url, options) → SoundHandle分组(BGM/音效/人声各自的音量与静音)、淡入淡出、并发上限 + 优先级、切后台停声

几个工程决定: