DOCUMENTATION · 前端集成

前端集成

---

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

本文写给要把它作为一个「剧情引擎库」嵌入自己的前端页面 / 应用的开发者。 你的目标不是改引擎,而是:用引擎跑剧情、接收事件、展示状态、写自己的 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:

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. 需要宿主配合的基础设施

在你自己页面上线前,必须保证:

  1. 跨域隔离(本项目默认关闭,需要时用开关打开):COOP/COEP 是可选开关 (CROSS_ORIGIN_ISOLATION=1),默认不开——因为它买到的只有 SharedArrayBuffer (WASM 多线程 / 零拷贝),而代价是静默的:require-corp 会拦掉所有不带 CORP 的跨域子资源 (CDN 字体/图标/脚本、第三方 SDK),same-origin 会切断 window.opener(弹窗登录)。 真要打开,就必须遵守由此产生的纪律(全站资源自托管等)。 详见 CROSS_ORIGIN.md(代价矩阵、纪律清单、三档位、生产部署如何回传头、iframe 嵌入两种拓扑)。
  2. 静态资源:public/stories/** 与 public/plugins/** 由你现有的静态服务器暴露同源路径。 剧情里的 url 相对路径会基于当前页面解析。
  3. 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 只是「演示页的胶水」,引擎核心里不包含它

相关链接

用途:让"跑剧情的那一端"和"另一端的控制器"(另一块屏 / 另一台设备 / 一个自动化脚本) 用同一套消息对话。注意:这是能力,不是产品 ✗ —— 两块屏的页面由使用方自己写 ✓。

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