DOCUMENTATION · 前端集成
前端集成
---
本文写给要把它作为一个「剧情引擎库」嵌入自己的前端页面 / 应用的开发者。 你的目标不是改引擎,而是:用引擎跑剧情、接收事件、展示状态、写自己的 UI。 读完本文你能回答:引擎暴露了哪些 API、如何初始化、如何驱动、如何替换默认 UI、如何嵌进 React/Vue。
怎么用引擎驱动 UI(引擎给语义,皮肤归你)
引擎不含 UI:它不认识"打字机""主题""动效"这些词。它给的是让 UI 做得出来、而且做不坏的那几样语义。
本仓库的演示页(index.html / showcase.html / embed.html)是示例宿主 —— 只展示及格线,不是引擎的一部分。
| UI 想做的事 | 引擎给的语义 |
|---|---|
| 状态变了要有反馈(数值跳动、获得提示) | state:change 事件 + state.watch(path) 按路径订阅 |
| 一句对白要有节奏(打字机 / 停顿) | story:say 事件(speaker / text)+ 节点上的 delayMs |
| 打字机要能被打断 / 跳过 | 等待可跳过(wait.skippable);"第一次点击补完、第二次继续"由你的 UI 决定 |
| 快进 / 暂停 / 倍速 | world.pause() / world.timeScale() / frame.pause() |
| 限时选择要紧张 | choice.timeout(引擎判超时,UI 只负责画倒计时) |
| 世界事件在玩家犹豫时插话 | 引擎会调 ui.renderReset?.() 叫停在途等待 —— 你的 UI 必须在此时把在途的选择以 CHOICE_CANCELLED 结算掉(与超时 CHOICE_TIMEOUT 不同:被打断 = 这次不作数)。少了这一步,剧情会卡在选项上、永远走不到 end |
| 自绘界面(不要任何默认 DOM) | 实现 RuntimeUi 即可 —— 无头测试套件本身就是"一个假 UI" |
| 无障碍 | 引擎不碰动效;prefers-reduced-motion 由你的 UI尊重(示例宿主 src/ui/styles.css 与 src/host/typewriter.ts 里那段可以直接抄) |
想省事:
import { mount } from 'plugin-story-engine'→mount('#app', { story })就把 官方默认 UI(含存档/读档/导出存档/主题/移动端控件/打字机)挂好了(见src/mount.ts)。 想做产品:自己实现RuntimeUi。官方默认 UI 不提供扩展点 —— 这是有意的(不然它会长成"必须用的那套壳")。
一句话:引擎负责"发生了什么",UI 负责"看起来怎样"。 你把 UI 写坏了,不该坏到剧情逻辑上 —— 这就是那条线的意义。
示例宿主里已经把这条线走了一遍:打字机的纯逻辑在
src/host/typewriter.ts(可单测), Renderer 只负责画与计时;状态反馈与主题都在宿主层,引擎一行没动。
1. 定位:这是个引擎,不是整站页面
「插件化剧情引擎」是一个可在任意前端页面中实例化并驱动的运行时库。它自带一个「最小可用」的
DOM 渲染实现 Renderer,但那是参考实现(demo)——你可以用它快速起步,也完全可以自己实现 UI。
三类使用方式:
| 用法 | 配置文件 | 说明 |
|---|---|---|
| 零手写 · 直接跑 | public/ 静态资源 + index.html 里的默认控件 | 引擎自带演示页 |
| 半定制 · 复用管线 | 只 import 引擎核心类,自写 UI | 推荐给前端集成者 |
| 深度定制 · 换加载器 | 甚至自定义 JsPluginLoader / WasmPluginLoader 目录策略 | 进阶 |
核心结论:引擎的「运行时管线」(解析剧情 → 加载插件 → 节点执行 → 状态写入 → 事件广播)与你用什么框架无关。
你在自己的页面里 new 一个 Runtime,给它喂一个满足了 UI 接口的对象,就能跑。
2. 引擎对外暴露的两条边界
前端唯一应直接接触的两样东西:
2.1 Runtime(门面)—— src/runtime/Runtime.ts
构造:new Runtime({ ui, worldIntervalMs? })。ui 必须实现 RuntimeUi(见下)。构造后它内部装配好
EventBus / StateStore / WorldLoop / PluginManager / StoryEngine,并把 events / state / engine /
worldLoop 等挂在实例属性上可读。worldIntervalMs 可选(默认 1000),控制世界循环每拍间隔。
公开方法(源码已核实):
| 方法 | 签名 | 作用 |
|---|---|---|
loadStory | (url, options?: { cacheBust?, autoStart? }) => Promise<StoryDefinition> | 加载 + 校验剧情,并按剧情声明加载插件 |
start | () => Promise<void> | 启动全部插件并从 start 节点开跑 |
restart | () => Promise<void> | 清空状态 + 绕过缓存热加载 + 重启 |
pause | () => void | 暂停主循环 |
shutdown | () => Promise<void> | 停用并卸载全部插件 |
resetState | () => void | 清空 StateStore,不影响插件 |
save / load | (slot?: string) => Promise<void> | localStorage 存档 / 读档(默认 slot1,引擎自动存档用 auto) |
| —— 属性 —— | state / events / engine / logger / pluginManager / worldLoop | 只读访问底层子系统 |
此外有一批只读能力用于 UI:
currentStory/currentStoryUrl(可读 getter)logger:向 UI 输出日志(构造时传入的ui.appendLog会收到LogRecord)
2.2 你只需实现 UI 接口
Runtime 唯一的必填依赖是 ui,它实现 RuntimeUi。这个接口把「引擎想通知 UI」的东西全部抽了出来,
所以你的 UI 可以由任意框架实现。
interface RuntimeUi extends StoryUi {
// —— StoryUi(对白 / 选项 / 等待 / 结束 / 错误)——
setStoryTitle(title: string, storyId: string): void
setStatus(status: string): void
renderSay(speaker: string, text: string): void
renderChoice(
choices: Array<{ index: number; text: string }>,
meta?: { timeoutMs?: number } // 存在即「限时选择」:超时应 resolve CHOICE_TIMEOUT(-1)
): Promise<number> // 返回被选中的 index
renderWait?(): void
renderEnd(summary?: string): void
renderError(message: string): void
// 交给引擎调用插件 UI 组件的挂载器
mountComponent(host: HTMLElement, name: string, props: unknown, host_info: UiHostInfo): void | (() => void)
// —— 运行时 UI ——
renderStatus(snapshot: StateSnapshot): void // 状态栏
clearError(): void
renderPluginList(infos: PluginInfo[]): void
appendLog(record: LogRecord): void
clearPluginComponents(pluginId?: string): void
}最容易踩的点:
renderChoice必须是「等待用户选择 → resolve 一个 index」的异步函数。 引擎会await它,返回值决定剧情走向。用空键/回车可跳过等待(见默认 Renderer 实现)。空格 / 回车不会抢键盘:默认 Renderer 只在这两个键未被别人消费时才会接(
event.defaultPrevented为真就放过),并且会跳过输入框 / 文本域 / 下拉框 / 按钮与contenteditable元素。所以插件挂载的 组件(动作游戏、画布、表单)要在自己的监听里preventDefault()声明「这个键我用了」—— 先声明者优先,不必再用 window 捕获阶段 +stopPropagation()去硬挡。
参考实现:src/ui/Renderer.ts 完整实现了该接口,你可以直接拷一份改样式,或照它的契约自己写。
3. 快速上手(在自己的页面里跑一个剧情)
// 你的入口文件
import { Runtime } from './src/runtime/Runtime'
import { Renderer } from './src/ui/Renderer' // 或你自己实现的 RuntimeUi
const ui = new Renderer()
const runtime = new Runtime({ ui })
// 1. 加载剧情(自动按剧情声明加载插件),并自动开始
await runtime.loadStory('/stories/demo.json', { autoStart: true })
// 2. 需要时:换剧情
await runtime.loadStory('/stories/another.json', { autoStart: true })
// 3. 保存 / 读档
await runtime.save('my-slot')
### 导出 / 导入存档文件
浏览器**会**清掉本地存储(Safari ITP 一两周清退、无痕、用户清数据),客户端拦不住 ——
所以给玩家一个"自己存一份"的出口:
```ts
const text = await runtime.exportSave() // 带信封的 JSON 文本
download(runtime.suggestSaveFileName(), text) // 宿主自己决定怎么给(<a download> / Blob)
// 玩家换了设备 / 换了浏览器:
await runtime.importSave(await file.text()) // 校验 → 迁移 → 写槽位 → 直接读档导入与读档走同一条路(版本检查 + 逐级迁移),所以老版本的存档文件也能读; 拿错文件(不是 JSON / 不是本引擎导出的 / 属于另一部剧情)会明确报错,不会静默把进度弄乱。
await runtime.load('my-slot')
> **若你用自己的 UI**:可以不 `new Renderer()`,而是传给你自己写的 `RuntimeUi` 对象,
> `Runtime` 只会调用这个接口的方法,不直接碰你页面里的其它 DOM —— 因此完全能嵌进框架。
---
## 4. 通过事件驱动你的 UI(推荐的解耦方式)
除了 `RuntimeUi` 的被动回调,你还可以通过 `runtime.events`(`EventBus`)主动订阅引擎事件来驱动 UI。
完整事件名与 payload 见 [EVENTS.md](EVENTS.md)。最常用几个:
```ts
runtime.events.on('story:nodeEnter', ({ nodeId }) => {
// 节点变化 → 例如更新工具栏高亮
})
runtime.events.on('story:say', ({ speaker, text }) => {
// 展示对白
})
runtime.events.on('state:change', ({ snapshot }) => {
// 状态栏 / HUD 刷新(snapshot 是完整 StateSnapshot)
})
runtime.events.on('plugin:crashed', ({ id, error }) => {
// 故障横幅
})
// 世界模拟器:世界每拍都会广播 world:tick,可用它驱动世界视图(如时钟 / HUD)
runtime.events.on('world:tick', ({ tick, elapsedMs }) => {
// 更新「世界时间」
})runtime.state.get('var.x') / .onChange(...) 也可直接读状态并订阅变化。
若你的页面只是展示「世界自己跑」(没有剧情推进需求),也可以不 loadStory,直接
new Runtime({ ui }) 后调用 runtime.worldLoop.start(),让世界循环与插件独立运转。
5. 需要宿主配合的基础设施
在你自己页面上线前,必须保证:
- 跨域隔离(本项目默认关闭,需要时用开关打开):
COOP/COEP是可选开关 (CROSS_ORIGIN_ISOLATION=1),默认不开——因为它买到的只有SharedArrayBuffer(WASM 多线程 / 零拷贝),而代价是静默的:require-corp会拦掉所有不带 CORP 的跨域子资源 (CDN 字体/图标/脚本、第三方 SDK),same-origin会切断window.opener(弹窗登录)。 真要打开,就必须遵守由此产生的纪律(全站资源自托管等)。 详见 CROSS_ORIGIN.md(代价矩阵、纪律清单、三档位、生产部署如何回传头、iframe 嵌入两种拓扑)。 - 静态资源:
public/stories/**与public/plugins/**由你现有的静态服务器暴露同源路径。 剧情里的url相对路径会基于当前页面解析。 - fetch 权限:剧情文件、插件
.wasm/.js均需能被页面fetch(同源或带正确 CORS)。
6. 技术栈无关性 & 框架嵌入示例
Runtime / StateStore / EventBus 都是纯 TS、无 DOM 依赖,可在任意框架中使用;只有 Renderer 依赖 DOM。
因此框架侧只需「实现 RuntimeUi」,用你的渲染方式来对白/选项:
React(示意)
function MyUi({ runtime }: { runtime: Runtime }) {
const [text, setText] = useState('')
useEffect(() => {
const offSay = runtime.events.on('story:say', ({ speaker, text }) => {
setText(`${speaker}: ${text}`)
})
const offNode = runtime.events.on('story:nodeEnter', ({ nodeId }) => {
setText(`[进入节点 ${nodeId}]`)
})
return () => { offSay(); offNode() }
}, [runtime])
return <p>{text}</p>
}Vue(示意)
<script setup>
import { onBeforeUnmount } from 'vue'
const off1 = runtime.events.on('state:change', ({ snapshot }) => { /* 驱动响应式 */ })
onBeforeUnmount(() => off1())
</script>核心互动(选择、继续、存档)建议通过组件按钮调用
runtime上对应方法,或订阅renderChoice由你的 UI 决定何时真正落到一个 index 上。
7. 常见集成问题
| 问题 | 原因 / 解法 |
|---|---|
| 点了按钮没反应 | loadStory 之前先 await;renderChoice 没在适当时机 resolve |
| 插件 UI 组件不出现 | 剧情里要有 ui 节点、插件注册了组件、且 mountComponent 被正确实现(挂到你要的容器) |
| 剧情选了不前进 | renderChoice 返回的 index 必须落在传入数组内 |
| 状态栏不刷新 | Runtime 构造时 ui 没实现 renderStatus 或未订阅 state:change |
| 白屏无日志 | 启动期失败会写 #error-banner,先查日志(runtime.logger / 控制台 window.__storyRuntime) |
| 想绕过默认演示页 | 别 import main.ts;自行 new Runtime,main.ts 只是「演示页的胶水」,引擎核心里不包含它 |
相关链接
- README.md:引擎简介与目录。
- EVENTS.md:UI 可用的事件清单与 payload。
- ARCHITECTURE.md:数据流与模块职责,理解
Runtime各子系统。 - STORY_FORMAT.md:剧情文件如何写。
- CROSS_ORIGIN.md:部署 / iframe 嵌入 / 跨域隔离开关,以及它那静默的代价。
- 参考实现:src/ui/Renderer.ts。
跨窗口 / 跨设备链接(src/link/,引擎侧的协议 + 传输)
用途:让"跑剧情的那一端"和"另一端的控制器"(另一块屏 / 另一台设备 / 一个自动化脚本) 用同一套消息对话。注意:这是能力,不是产品 ✗ —— 两块屏的页面由使用方自己写 ✓。
import { createBroadcastTransport } from './link/transport'
import { attachLinkView } from './link/view'
import { attachLinkController } from './link/controller'
// 视图端(跑剧情的那一端)
const view = attachLinkView(ui, createBroadcastTransport('我的剧情'), {
currentNodeId: () => runtime.engine.currentId,
sendIntent: (name, payload) => runtime.events.emit(`intent:${name}`, payload)
})
new Runtime({ ui: view.ui /* 其余照旧 */ })
// 控制器端(另一块屏)
const controller = attachLinkController(createBroadcastTransport('我的剧情'), {
onChoices: (m) => m.options.forEach((o) => 画一个按钮(o.text, () => controller.pick(o.index))),
onSay: (m) => 显示(m.speaker, m.text)
})- 传输是可注入的 ✓:
createBroadcastTransport(name)(浏览器,BroadcastChannel✓ 不需要窗口引用 ✓) 与createLoopbackTransport()(测试/演练 ✓)。 - 协议与嵌入同一套纪律 ✓:
kind+ 版本 + 类型白名单 + 逐字段校验;不认识就丢 ✗, 版本比本端新也丢 ✗(不猜)。 - 视图端是个 UI 装饰器 ✓:包住任意
StoryUi—— 往外播say/choices/end, 收回来pick(用Promise.race抢在途选择的解析权 ✓,和键盘/触屏是同一条路 ✓)与intent(送进意图通道 ✓)。 引擎完全不知道链接存在 ✓(零内核改动)。 - 没有
BroadcastChannel就抛错 ✗ —— 不静默降级:否则"手机没反应"会变成最难查的那种 bug。