DOCUMENTATION · 架构总览
架构总览
本文描述「插件化剧情引擎」的整体架构、各模块职责、数据流、插件生命周期、错误隔离与权限模型。 阅读顺序建议:先看整体架构整体架构,再看数据流数据流剧情加载--节点执行--插件调用-…
整体架构
引擎分为四层:运行时装配层 → 引擎核心层 → 插件层 → 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依赖方向(重要)
StoryEngine不直接调用插件处理器,而是通过PluginGateway接口调用,由PluginManager实现 「超时 + 异常隔离 + 权限 + 熔断」。这样引擎无法被插件卡死。PluginManager与StoryEngine互相依赖(管理器要用引擎做效果落地与跳转), 通过bindEngine()延迟绑定打破构造顺序上的循环。types.ts与StoryEngine.ts之间只有import type(编译期擦除),不产生运行时循环依赖。- 插件只能拿到
PluginContext暴露的受控 API,拿不到StateStore/ 注册表的内部引用。
模块职责
Runtime(运行时门面)
- 装配子系统:
EventBus→ 注册表 →PluginManager→StoryEngine→ 回填bindEngine。 - 加载剧情:
fetch+StoryEngine.validateStory校验(先校验再落地,避免半途失败破坏当前剧情), 按剧情plugins声明调用pluginManager.loadMany(),并卸载当前剧情未声明的插件。 - 存档 / 读档:多槽位
localStorage(前缀story-engine:save:)+ 节点级自动存档(槽位auto,120ms 节流)。 - 错误上报:把
EventBus的监听器异常转发到日志,并调用pluginManager.notePluginFailure()统一失败计数口径。 - 对外 API:
loadStory/start/restart/pause/shutdown/resetState/save/load/listSaves/debugReport。 - 存档(
SaveData,当前version: 2)除状态与剧情位置外,还带上确定性随机源的种子与内部状态 与各插件的持久数据(PluginDriver.snapshot()的返回值)—— 读档后能接着原来的时间线走。 - Promise 语义:
start()/load()/loadStory({ autoStart })只保证「剧情已经开跑」, 不保证剧情结束 —— 剧情停在say/choice上是正常状态,那时主循环的 Promise 不会 resolve。 要等结束请听story:end事件。(这三处内部都是void主循环,不会再挂住调用方。)
WorldLoop(世界循环)
「世界模拟器」的核心驱动器。它不关心任何具体剧情、不认识任何业务语义,只做两件事:
- 以固定节拍(
intervalMs,默认 1000ms)推进世界脉冲:拍号 +1 → 依次执行已注册的「世界规则」 (入参是节拍开始时拍摄的状态快照与{ tick, elapsedMs }通知)→ 广播world:tick。 - 规则注册表
rules按 owner 归属,插件卸载时自动回收它注册的规则。
单条规则抛错会被捕获并记录(错误隔离),异步规则的失败也不阻塞节拍;超时提示阈值
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(剧情引擎)
validateStory(raw):校验id/start/nodes,检查每个节点有type、next指向的节点存在。- 内置节点处理器:
say/choice/jump/set/if/wait/emit/plugin/ui/observe/end。 - 插件自定义节点:
resolveHandler(type)先查内置表,再查registries.nodes,命中则转交PluginGateway.callNodeHandler()。 - 表达式与插值:
testCondition/evaluate/renderText,出错时降级(条件按false、插值回退原文),不让剧情中断。 - 效果落地:
applyEffects()支持内置set/setFlag/addItem/removeItem/emit, 其它类型交给插件的效果处理器;单个效果失败不影响整批(错误隔离)。 - 主循环:
run()顺序执行节点,遇到say/choice/wait会awaitUI; 有maxSteps(默认 100000)防止死循环;内部捕获异常并转成story:error+ UI 错误提示,不向外抛出。 - observe 节点(剧情被动响应):
load()时armObservers()把剧情里全部observe节点登记为对应 世界事件的候选观察者;事件到达时按condition过滤、取priority最高者,把currentNodeId改写为其next,并调用ui.renderReset()唤醒在途的 UI 等待(say 的「继续」/ wait 的定时器),实现「世界事件 随时插话」;换剧情时disarmObservers()统一撤销旧订阅。
StateStore(状态仓库)
- 四个命名空间:
var(变量)、flag(布尔标志)、item(物品数量)、actor(角色状态)。 - 路径点号分隔:
get('flag.hasKey')显式命名空间;get('gold')省略时落在var。 - 语义化封装:
getFlag/setFlag/itemCount/addItem/removeItem/hasItem/getActor/setActorField。 - 每次写入都通知
onChange订阅者(Runtime 借此刷新状态栏并广播state:change)。update(path, fn)提供安全的读-改-写(读当前值 → 改 → 写回):避免「整块读进局部变量、 整块写回」在一拍之内被第二个参与者覆盖 —— 这是《血月角斗场》里实际撞到的竞态 (内核发命中事件 → 人格插件算完突变发回来 → 内核本拍结束时整块写回,把突变冲掉)。 监听器拿到的snapshot是一次全量深拷贝,所以连续量要批处理:batch(fn)把「同一路径写 N 次」 压成一次通知(支持嵌套),patch(obj)= 批量 set;watch(path, cb)是按路径(前缀匹配)订阅, 让面板只关心自己那一支、不必每秒全量重画。 snapshot()/restore()用于存档;写入时会deepClone,避免外部引用污染内部状态。
EventBus(事件总线)
on/once/off/emit,支持通配符*(WILDCARD)。- 每个监听器记录
owner(通常是插件 id):removeOwner(owner)可一次性清理某插件的全部监听器。 - 每个监听器独立
try/catch,异常交给setErrorReporter注入的回调上报 —— 这是「插件崩溃不拖垮主引擎」的第一道防线。 emit时复制目标监听器集合,避免监听器内部on/off影响本次遍历。
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.html | iframe + 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.ts | JSON 视图用的检查: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(资产仓库)
图片 / 音频 / JSON / 文本的加载与生命周期。四个决定:
- loader 可插拔(
registerLoader(kind, loader)):内置只注册当前环境支持的 ——json/text走fetch(node 也有),image需要Image,audio优先AudioContext.decodeAudioData、退化为<audio>。所以 node 单测能注入假 loader, 把去重 / 引用计数 / 进度 / 失败这些逻辑确定性地测出来(不必开浏览器)。 - 按
kind+url去重:同一份资产只真正加载一次;并发的重复请求共享同一个 Promise。 - 引用计数 + owner:
owners记「每个插件各持有几次」,总数为 0 才unload; 插件卸载时releaseOwner()自动归还。 - 音量/静音在引擎侧:所有经此加载的声音共用一套
volume/muted(effectiveVolume()),宿主一个按钮就能全局静音,不必去翻每个插件的音量。
RandomSource(确定性随机)
文件:src/runtime/RandomSource.ts
线性同余随机源。存在的唯一理由是可复现:给定 (seed, state) 就能算出下一拍、下一局、
下一个抉择点 —— 这是「涌现模拟」「肉鸽」这类玩法能调试、能回放的前提。
插件侧通过 ctx.random 使用(next / int / pick / fork(label));
fork 按 label 派生独立流并记忆化,不同子系统互不干扰。
种子与状态随存档走(SaveData.random)。构造时会先空转几轮(热身)——
LCG 的首个输出是种子的线性函数,不热身的话相邻种子会给出高度相关的开局。
ViewCanvas(受管画布)
给插件用的纯视图原语: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)
- 手写词法分析 + 递归下降语法分析,生成 AST 后求值,绝不使用
eval/new Function。 - 支持字面量、状态引用、
+ - * /、== != > >= < <=、! && ||、括号分组。 - 内置函数:
has/defined/len/num/str/bool。 - 编译结果带缓存(
compileCache),重复求值开销极小。 interpolate()实现{{ 表达式 }}文本插值,支持\{{转义。
Registry(注册表)
- 引擎持有 5 张注册表:
commands/conditions/effects/nodes/uiComponents。 - 每个条目记录
owner:默认禁止抢占式覆盖(重名抛RegistryConflictError),同一 owner 可覆盖自己。 removeOwner(owner)用于插件卸载时一次性回收全部能力。- 引擎内置能力 owner 为
__engine(ENGINE_OWNER)。
PluginManager(插件管理器)
文件:src/plugins/PluginManager.ts
validateManifest():校验 manifest 结构、apiVersion兼容性、权限名合法性。load():加载单个插件,流程见插件生命周期。loadMany():批量加载,单个失败不影响其它插件,返回{ loaded, failed, skipped }。invoke():核心调用包装,统一做超时、异常归一化、连续失败熔断。callCommand/callNodeHandler/callConditionHandler/callEffectHandler:实现PluginGateway,供引擎调用。buildApi():构造传给插件处理器的CommandApi;关键安全点是其中state/events一律使用 「按该插件权限校验过的 API」,因此插件在命令处理器里也无法绕过权限。- 查询 API:
list/getInfo/getContext/isAvailable/getLoadList/listCapabilities。
加载器(JsPluginLoader / WasmPluginLoader)
- JsPluginLoader.ts:
import(entryUrl),支持 default 导出、manifest.export指定导出、 具名钩子导出、createPlugin()工厂;import 被try/catch包裹。 - WasmPluginLoader.ts:
fetch→ magic 校验 →WebAssembly.instantiate(bytes, { env })→ 校验必需导出 →WasmInstance封装内存读写 →WasmPluginDriver把 ABI 消息包装成PluginDriver。 详见 ABI.md。
PluginContext(插件上下文)
文件:src/plugins/PluginContext.ts
createStateApi/createEventApi:每次调用先做权限校验,未授权抛PluginPermissionError。createWorldApi:registerRule/publish/subscribe,分别要求world:register/world:emit/world:listen;registerRule在无 WorldLoop 的环境会抛错。三者底层复用 EventBus / WorldLoop, 与剧情 JSON、WASM 插件完全平权。makeRegisterApi:命令 / 条件 / 效果 / 节点的注册统一走同一套校验与 owner 归属。- UI 组件注册单独实现:注册名加插件前缀(
greeter→com.example.hello:greeter), 并在注册时用包装器把「受权限校验的 state / events」注入插件渲染器(宿主无法伪造 api)。 storyApi:jumpTo/end需要story:control权限;currentNodeId/isEnded只读。- 返回的
cleanup()会回收该插件注册的全部能力与事件监听。
UI 渲染层(Renderer)
- 实现
StoryUi:setStoryTitle/renderSay/waitForAdvance/renderChoice/renderWait/renderEnd/renderError/setStatus/mountComponent。 - 实现
RuntimeUi附加能力:状态栏renderStatus、插件列表renderPluginList、日志appendLog、 错误横幅clearError、组件清理clearPluginComponents。 - 插件 UI 组件隔离:每个组件挂载到独立
div,组件只能操作自己的容器; 渲染抛错只显示在该容器内,不影响剧情;clearPluginComponents(pluginId?)支持按插件卸载。
数据流:剧情加载 → 节点执行 → 插件调用 → 状态更新 → 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()关键点:
- 剧情文件先校验后落地,非法剧情不会破坏当前正在运行的剧情。
- 节点执行是 async 顺序的:
say/choice/wait会awaitUI 的用户操作。 - 插件调用的返回值驱动引擎:
message以对白形式渲染,effects由引擎落地,next覆盖节点跳转,stop终止循环。 - 状态变更是单一入口:所有写入都经过
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 / 弹警示关键点:
- 规则先跑、广播后发:
WorldLoop.tick()先执行已注册规则,再广播world:tick,顺序固定可复现。 - 三路平权:JS(规则 / 订阅)、WASM(事件代理)、JSON(observe)都挂在同一条 EventBus 与同一个 StateStore 上,彼此通过世界事件协作,引擎核心不识别任何业务语义。
- 剧情可以被「打断」:observe 触发时改写
currentNodeId并renderReset(),主循环在节点等待 被打断后按新位置继续 —— 世界事件随时可以插话。 - 错误隔离沿用同一套:世界规则单条抛错不影响其它规则与广播;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 --> [*]| 阶段 | 触发 | 插件侧钩子 | 说明 |
|---|---|---|---|
registered | load() 登记 manifest | — | 已登记但未加载 |
loading | 下载 / 实例化中 | — | JS:import();WASM:fetch + instantiate |
initializing | 实例化成功 | init(ctx) | 注册命令 / 条件 / 效果 / 节点 / UI 组件,订阅事件 |
ready | init 完成 | — | 已可被调用,但未 start |
running | startAll() / start() | start() | 引擎开始跑剧情 |
stopped | stop() | stop() | 可再次 start() |
disposed | dispose() | dispose() | 调用 dispose 后无条件回收注册能力(即使 dispose 抛错) |
error | 加载失败 / init 失败 / 熔断 | — | 错误状态,拒绝执行并已回收能力 |
生命周期钩子的调用时机
init(ctx):PluginManager.load()内,实例化之后、标记ready之前。只有这里能拿到ctx。start():startAll()(Runtime.start 时)或手动start(id)。没有ctx参数,因此适合做无状态的事。stop():stopAll()/stop(id),可再次start()。dispose():dispose(id)/disposeAll()/ 熔断 / 加载失败回滚。之后引擎会cleanup()回收能力。
WASM 插件目前没有独立的
start/stop消息,驱动里保留钩子以便未来扩展 ABI。
错误隔离与超时机制
引擎有三道防线,确保「插件崩溃不拖垮主引擎」:
第一道:事件总线逐个隔离
EventBus.emit 对每个监听器单独 try/catch,异常经 setErrorReporter 上报。
一个插件的事件监监听器抛错,其它监听器与引擎流程不受影响。
第二道:PluginManager.invoke 统一包装
所有对插件的调用(init / start / stop / dispose / 命令 / 节点 / 条件 / 效果)
都经过 invoke(pluginId, label, task):
- 超时:
withTimeout(task, timeoutMs),超时抛PluginTimeoutError。timeoutMs取 manifest 的timeoutMs,缺省用defaultTimeoutMs(默认 3000ms)。 - 异常归一化:任何非
PluginError的异常都会被包装成带pluginId的PluginError。 - 连续失败熔断:失败次数达到
maxConsecutiveFailures(默认 3)时, 插件被标记为error并teardown()(回收全部能力),广播plugin:crashed。 成功后失败计数清零。
withTimeout无法真正杀死一个正在执行的 Promise(JS 语言限制), 但宿主会立刻放弃等待它的结果,从而保证主引擎不被卡死。
第三道:StoryEngine 主循环自兜底
StoryEngine.run() 内部捕获异常,转成 story:error 事件 + UI 错误横幅,不向外抛出,
避免一个坏节点把宿主页面搞崩。效果落地同样逐条 try/catch,单条失败不影响整批。
WASM 侧的额外防护
- magic 校验:文件头不是
\0asm时给出明确错误(常见于服务器返回 HTML 404 页)。 - 必需导出校验:缺少
alloc/plugin_handle等导出时给出明确错误。 - 指针越界校验:
WasmInstance.assertRange()防止插件返回越界指针导致宿主崩溃。 - 内存归还:宿主调用
plugin_handle后在finally里调用dealloc,避免入参内存泄漏。 - 权限拒绝:
host_emit/host_get_state/host_set_state未授权时返回错误码并记录警告。
权限模型
核心原则:插件不能直接篡改主引擎状态,只能通过受权限校验的 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"]- 声明:插件在
manifest.json的permissions数组声明所需权限;validateManifest会拒绝未知权限名。 - 授权:加载时把权限列表放进
ReadonlySet<Permission>,透传给PluginContext。 - 校验:
PluginContext里每个 API 调用前都requirePermission(), 未授权则记录警告并抛PluginPermissionError(被PluginManager.invoke隔离)。 - 不可绕过:即使插件在命令处理器 / UI 组件里,拿到的也是同一套受校验 API(
buildApi保证)。 - 闭环:插件卸载时按
owner回收能力与监听器,权限不会「残留生效」。
完整权限列表与安全建议见 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:jump | jump 节点跳转 | from / to |
story:branch | if 节点命中分支 | 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 订阅,所以不会因为装了看门狗就让循环一直跑)。
三条工程决定:
- 只判断与广播,不偷偷改玩法:输出档位(
high/medium/low)与policy()(世界节拍倍率、 渲染上限)。引擎自动采纳的只有世界节拍这一个旋钮;渲染上限等交给宿主(onQualityChange)。 自动化降档很容易把玩法改坏,所以它是"可被采纳的建议"。 - 必须防抖:连续
downgradeAfter帧超预算才降;连续upgradeAfter帧明显富余 (用掉预算 <upgradeHeadroom)才升。宁可晚一点降,也不要来回跳。 - 确定性:
sample(frameMs)是纯输入(不读时钟、不开定时器)→ 可单测、可在回放里复现。
状态来源追踪(src/runtime/StateProvenance.ts)
诊断包(src/runtime/Diagnostics.ts)+ 三个"真实世界"洞的补丁
runtime.exportDiagnostics() 把排查要的东西打成一个 JSON ✓:版本 + 剧情/种子/输入日志 + 指纹(⑦)
- 时间输入(②)+ 审计流水(④)+ 开销表(⑥)+ 来源表(②)+ 漏网审计(⑤)✓ —— 一律截断(别让包长成 100MB ✓)。 它是指纹/审计/开销/来源四条能力的收口 ✓:玩家说卡住了,让他点一下导出、把包发过来 ✓。
同一批还补了三个"真实世界才会暴露"的洞 ✗:
- ③ 后台限流:浏览器会限流后台标签页的定时器 →
wait(30s)可能拖到几分钟 ✓。 引擎不替作者决定怎么办,只发story:waitOverrun(要了多久 / 实际多久 / 差多少 ✓)。 - ④ 多标签页/多设备:存档带上
origin(写入者 ✓)+compareSaveEnvelopes()纯函数分四种关系 (same / 左新 / 右新 / diverge:不同来源各自往下走了 ✗ —— 引擎给事实与工具,合并策略归产品 ✓)。 - ⑤ 切剧情的漏网:
runtime.leakReport()报"切剧情后还挂着几个插件定时器、属于谁" ✓ (现象是"玩久了越来越卡",属最难查那类 ✓)。
状态来源追踪
统计回答"这个路径被写了多少次" ✓;这里回答"最后这一次是谁写的、上一步在哪个节点" ✓
—— 也就是因果 ✓(排查"这个 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 <指纹> ✓)。
- 指纹必须是"稳定序列化"的结果 ✗:
JSON.stringify依赖键的插入顺序("谁先写"的巧合 ✗), 拿它当指纹会在无关紧要的地方红 ✓。所以键排序 ✓、NaN/Infinity/-0有固定记法 ✓。 - 输入日志必须投影掉易变字段 ✗:日志里带
at: Date.now()(调试用 ✓)—— 直接拿整条日志算指纹,同样的操作两次运行也会得到不同指纹 ✓,看起来像"引擎不确定", 其实是日记字段在动 ✗。同理,任何"两份输入日志相等吗"的断言都是偶发红 ✗(这一轮我就中过一次 ✓)。 投影只保留"决定这一局怎么走"的字段 ✓(见stableInputs())。 - 截断必须进指纹 ✗:剧情"永远不结束"(例如一份选择里全选第 0 项会绕回 ✓)时,
跑到哪儿停下取决于给了多少让步 ⇒ 两次运行的节点序列长度不同 ✓,看着像不一致、其实是同一件事被截在别处 ✓。
所以结果里带
truncated✓,且它参与指纹 ✓。
无头回放不加载插件 ✗(插件要动态 import 资源 URL,那是宿主的活 ✓)——CLI 的适用范围就此到此为止 ✓。
边界:我们做引擎,不做产品
一句话:这个仓库的产出是引擎(以及它的托管接口),不是某个互动剧产品 ✓。
- 是引擎的:
src/runtime/(运行时)、src/plugins/(插件系统)、src/link/(跨窗口链接协议)、src/embed/(嵌入协议)、src/host/(宿主侧纯逻辑零件:打字机、复盘排版、插件清单)、 库形态与类型声明。 - 是演示 / 参考,不是产品 ✗:
index.html(示例宿主)、showcase.html(展示页)、editor.html(编辑器)、public/stories/*(示例剧情)、public/plugins/*(示例插件)。 它们存在的意义是证明引擎能这么用,而且被引擎的测试真的跑过 ✓ —— 谁要产品,拿引擎自己写壳 ✓。 - 所以新东西先问一句:"这是引擎的能力,还是一个壳?" ✓
能力进
src/(配测试与文档);壳只做到能证明能力为止 ✗,别往产品方向长。
(这条是维护者明确要求记下来的 ✗ —— 项目很容易往"做一个互动剧网站"漂移,那不在范围里。)
空转保护(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 存档 ──> 交给引擎读- 迁移器只升一级(
from → from + 1),多级由 runner 串起来 —— 每级都能单独测; - 引擎内置它自己格式演进的迁移(v1→v2 补
random/pluginState,并如实记一条 "随机序列会从新种子开始");宿主/项目的私有字段由使用方registerSaveMigration()自己迁 (注册的同版本迁移优先于内置); - 比引擎新的存档直接拒绝(
SAVE_VERSION_TOO_NEW):试读的代价是静默改坏玩家进度; - 缺某一级(
SAVE_MIGRATION_MISSING)或迁移器抛错(SAVE_MIGRATION_FAILED) 都是"报清楚卡在哪一级",不硬读。
音频混合(AssetStore 的音频部分)
音频有两层用法,别混:
| 层 | 入口 | 管什么 |
|---|---|---|
| 素材 | ctx.assets.sound(url) → AssetHandle<Sound> | 加载与去重、引用计数;play({loop, volume}) 直接播 |
| 混音 | ctx.assets.play(url, options) → SoundHandle | 分组(BGM/音效/人声各自的音量与静音)、淡入淡出、并发上限 + 优先级、切后台停声 |
几个工程决定:
- 音量三层相乘:
这一路 × 分组 × 全局。改任何一层,正在响的声音立刻跟着变 (早先的实现只改字段、不动正在响的 gain,于是"下一条才生效" —— 已修)。 - 并发满了按优先级踢:踢掉最弱的(同优先级踢更早的);新来的更弱就这次不播
(返回
playing === false的句柄)。音频不该抛异常打断剧情。 - 淡变交给后端:WebAudio 用原生
linearRampToValueAtTime(采样级精确);<audio>退化路径用定时器做线性淡变。引擎侧只负责算目标音量。 - 切后台停声(
Runtime在装配阶段挂visibilitychange):页面隐藏即setSuspended(true)—— 停掉在响的、并拒绝新的播放请求。不自动恢复:回到前台由游戏自己重放 BGM。 - 权限:播放自己的声音只要
assets:load;而改分组音量/静音/暂停/按组停止需要audio:mix—— 它们改的是"玩家整体听到什么",不只影响你自己那份声音(最小权限)。