DOCUMENTATION · JS 插件开发

JS 插件开发

本文介绍如何用 TypeScript / JavaScript 编写一个 JS 插件:目录结构、manifest 字段、 JsPlugin 接口、PluginContext 完整 …

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

▶ 在工作台打开:shout 插件 + 演示剧本插件与剧本一并直达——亲手体验「无插件回退原文 → 有插件生效」

目录结构

public/plugins/my-plugin/
├── manifest.json      # 插件清单(必需)
├── index.js           # 入口文件(文件名由 manifest.entry 决定)
└── ...                # 其它资源(可被入口文件以相对路径引用)

浏览器通过静态资源路径访问,因此放在 public/ 下的文件会被原样提供。 入口文件必须是合法的 ES Module(export default { ... } 或具名导出)。


从脚手架开始(推荐)

npm run create-plugin -- com.example.mygame

它会生成一套能跑的东西(不是空模板):

public/plugins/mygame/manifest.json   最小权限集的清单
public/plugins/mygame/index.js        插件骨架(命令 / 条件 / 效果各一个,附宿主能力速查)
public/stories/mygame.json            用到它的剧情(say / plugin / if / end)
tests/mygame.test.ts                  用无头套件跑通这条剧情的测试

并在 public/stories/index.json 里登记该剧情(保持仓库自洽 —— 漏登记会被 npm run story:lint 抓到)。 跑完 npm test 确认全绿,然后照着注释改成你自己的玩法。常用选项: --root <目录>(默认当前目录)、--name <展示名>、--no-story、--no-test、--force。

manifest.json 字段说明

{
  "id": "com.example.hello",
  "name": "Hello Plugin",
  "version": "1.0.0",
  "type": "js",
  "entry": "index.js",
  "apiVersion": 1,
  "permissions": ["state:read", "state:write", "commands:register", "events:listen"],
  "description": "示例插件",
  "author": "your-name",
  "timeoutMs": 3000,
  "export": "plugin"
}
字段类型必填说明
idstring✅全局唯一 id,建议反向域名。重复加载同 id 会跳过
namestring✅展示名(UI 插件列表使用)
versionstring✅语义化版本
type"js" | "wasm"✅插件类型;JS 插件填 "js"
entrystring✅入口文件,相对 manifest.json 所在目录
apiVersionnumber✅目标 API 版本,必须等于 1(PLUGIN_API_VERSION),否则拒绝加载
permissionsstring[]➖申请的权限,缺省为 []。未知权限名会导致加载失败
descriptionstring➖说明文字
authorstring➖作者
timeoutMsnumber➖单次调用超时(毫秒,正数)。缺省用管理器默认值(3000ms)
exportstring➖显式指定要使用的模块导出名(见下)
requiresstring[]➖依赖的其它插件 id:必须先加载,且必须存在(缺失则本插件加载失败并说明缺了谁)
orderobject➖加载顺序提示(不要求对方存在):{ "before": [...], "after": [...] }
scope"story" | "app"➖生命周期归属。story(默认)随剧情切换卸载;app 跟着运行时走(跨剧情常驻,例如设置面板、统计)

依赖与顺序:剧情声明的插件列表里,顺序默认就是加载顺序;如果两个插件有协作关系(例如 「内核」与「人格层」),用 requires 把关系写清楚,比靠注释与直觉可靠得多 —— loadMany 会按 requires / order 做一次稳定排序再加载,缺依赖的插件会被明确拒绝。 出现环时不会让加载整体失败,只是保持原序(这类问题靠日志发现)。


JsPlugin 接口

所有钩子都是可选的。引擎通过 JsPluginDriver 调用它们。

import type { PluginContext } from './types'

export interface JsPlugin {
  /** 初始化:注册命令 / 条件 / 效果 / UI 组件,订阅事件。唯一能拿到 ctx 的钩子 */
  init?(ctx: PluginContext): void | Promise<void>
  /** 启动:引擎开始运行剧情前调用 */
  start?(): void | Promise<void>
  /** 停止:剧情结束或用户暂停时调用(可再次 start) */
  stop?(): void | Promise<void>
  /** 卸载:释放资源;宿主随后会自动清理该插件注册的一切能力 */
  dispose?(): void | Promise<void>
  /** 导出插件自己的持久数据(存档用,可选;宿主只搬运,不看内容) */
  snapshot?(): unknown
  /** 恢复插件自己的持久数据(读档用,可选) */
  restore?(data: unknown): void | Promise<void>
}

插件自己的数据:snapshot / restore

插件不该把游戏状态放在自己的闭包里 —— Runtime.restart() 不会重新 init, 内存里的东西会一路漂下去(这也是最容易出「状态回滚了、插件还在原来那一步」错位的地方)。 确实只属于插件自己的数据(计数器、随机游标、缓存的战报)请这样存:

const local = { best: 0 }   // 模块级:既是内存,也随进程活

export default {
  init(ctx) {
    ctx.commands.register('finish', () => {
      local.best = Math.max(local.best, Number(ctx.state.get('var.score', 0)) || 0)
      return { ok: true }
    })
  },
  snapshot() { return { best: local.best } },        // 存档时被调用
  restore(data) { if (data) local.best = data.best } // 读档时被调用
}

snapshot() 的返回值会原样存进存档(SaveData.pluginState[pluginId]),读档时交回 restore()。 引擎对内容不做任何解释;单个插件的快照 / 恢复失败只记日志,不影响整份存档。

支持的四类导出形态(按优先级)

JsPluginLoader.pickPluginObject 依次尝试:

  1. manifest.export 指定名:export const plugin = { init, ... }。 若该项是函数,会调用它并以返回值作为插件对象。
  2. 默认导出:export default { init, ... }。
  3. 模块命名空间本身:export function init(ctx) {}、export function start() {} 等具名钩子。
  4. 工厂函数:export function createPlugin() { return { init, ... } }(可为 async)。

若模块导出了内容但没有任何钩子,会按空插件处理并记录一条警告;完全无法识别时抛 PLUGIN_IMPORT_FAILED。


PluginContext API 完整说明

ctx 只在 init(ctx) 中提供。所有 API 都做权限校验(见 PERMISSIONS.md)。

基础字段

成员类型说明
ctx.pluginIdstring当前插件 id
ctx.pluginNamestring插件展示名
ctx.apiVersionnumber宿主 API 版本(= 1)
ctx.permissionsreadonly Permission[]已授权权限列表
ctx.loggerLogger带 plugin:<id> 前缀的日志器
ctx.hasPermission(p)(p: Permission) => boolean查询是否拥有某权限

ctx.logger

ctx.logger.debug('调试信息')
ctx.logger.info('普通信息')
ctx.logger.warn('警告')
ctx.logger.error('错误')
ctx.logger.child('loader')   // 派生作用域 -> "plugin:com.example.hello/loader"

控制台输出的 scope 一定是 plugin:<pluginId>,便于定位来源;同一行日志也会进入页面右侧「事件日志」面板。

ctx.state(需要 state:read / state:write)

ctx.state.get('flag.hasKey')            // unknown,不存在返回 undefined
ctx.state.get('var.gold', 0)            // 带兜底值
ctx.state.has('item.torch')             // boolean
ctx.state.set('var.gold', 10)           // 写入(自动创建中间对象)
ctx.state.getFlag('hasKey')             // boolean 快捷方法
ctx.state.setFlag('hasKey', true)       // 默认值为 true
ctx.state.itemCount('torch')            // number
ctx.state.addItem('torch', 1)           // 返回新数量,不会小于 0
ctx.state.removeItem('torch', 1)
ctx.state.getActor('hero')              // Record<string, unknown> | undefined
ctx.state.setActorField('hero', 'hp', 90)
ctx.state.snapshot()                    // 只读快照 { var, flag, item, actor }
ctx.state.watch('var.arena', () => {})  // 按路径订阅(前缀匹配,只在这一支变化时回调)
ctx.state.batch(() => {                 // 批处理:结束时按路径去重通知一次
  ctx.state.set('var.a', 1)
  ctx.state.set('var.b', 2)
})
ctx.state.patch({ 'var.a': 1, 'var.b': 2 })  // 一次写多个键
ctx.state.update('var.hits', (n) => Number(n ?? 0) + 1)  // 安全的读-改-写(返回新值)

把权限收窄到一条路径:manifest 里可以写作用域限定写法,只动自己那一块状态:

{ "permissions": ["state:read", "state:write:var.arena"] }

这样只能写 var.arena 与它的后代,写别处会被拒。snapshot() / batch() 会触达任意路径, 只认未限定的基础权限;patch() 会逐个键检查。详见 PERMISSIONS.md。

别把整块状态读进局部变量再整块写回。只要一拍之内有第二个参与者(另一个插件的世界规则、 或发回来的事件处理器),后写的那次就会整块覆盖先写的 —— 这是本项目真踩过的坑:

// ✗ 会丢修改:两个参与者都这么做时,先写的被覆盖
const box = ctx.state.get('var.box')
ctx.state.set('var.box', { ...box, a: 1 })

// ✓ 各自只改自己那条路径(或整条路径走 update),修改会正确叠加
ctx.state.update('var.box.a', () => 1)

状态路径规则:var.* / flag.* / item.* / actor.* 四个命名空间; 省略命名空间时默认落在 var(例如 get('gold') 等价于 get('var.gold'))。

为什么要有 batch / watch:每次写入都会通知订阅者,而每个订阅者拿到的快照都是一次全量深拷贝,

且 Runtime 会据此重建整块状态栏。一帧里连续写几十个键时,成本会被放大几十倍。batch 把「同一路径 写 N 次」压成一次通知;watch 让你只关心自己那一支状态,不必每秒全量重画。

ctx.random(确定性随机)

ctx.random.next()            // [0, 1)
ctx.random.int(1, 6)         // [1, 6] 含两端
ctx.random.pick(['a', 'b'])  // 等概率取一个(空数组会明确报错)
ctx.random.fork('drop')      // 派生一条独立流(不同 label 互不影响)

别用 Math.random()。它会让同一局没法复现、存档也存不住「随机到哪一步了」。 引擎的随机源由一颗种子驱动,种子与内部状态随存档一起走,于是:

ctx.capabilities(宿主能力探测)

const { wasm, simd, threads } = ctx.capabilities
// 重计算插件据此显式降档:网格 1024² → 512²、粒子数对半
const grid = threads ? 1024 : 512
字段含义
wasmWebAssembly 可用
simdWebAssembly SIMD(simd128)可用
sharedArrayBufferSharedArrayBuffer 存在
crossOriginIsolated页面处于跨域隔离(COOP/COEP 生效)
threads多线程档真正可用(= sharedArrayBuffer && crossOriginIsolated)

只读、不需要权限。它治的是静默降级:有 SAB 但没隔离时多线程会悄悄变单线程。 降的是负载,不是实现语言 —— 「WASM 换 JS 兜底」对重计算没有意义。详见 ARCHITECTURE.md 的 Capabilities 与 CROSS_ORIGIN.md。

ctx.frame(帧循环:固定步长逻辑 + 渲染帧)

实时玩法的节拍。与 ctx.world(每 1000ms 一拍)分工明确:

// 逻辑:固定步长(默认 16.67ms),与帧率无关 —— 物理/判定写这里
const offStep = ctx.frame.onStep(({ step, tick }) => {
  hero.x += speed * step
})

// 渲染:每显示帧一次,用 alpha 在两步之间插值 —— 只做"画"
const offFrame = ctx.frame.onFrame(({ alpha, dt, frame }) => {
  const x = prevX + (hero.x - prevX) * alpha
  draw(x)
})
成员说明
ctx.frame.onStep(fn)订阅固定步长逻辑推进({ step, tick, elapsed })
ctx.frame.onFrame(fn)订阅渲染帧({ dt, alpha, elapsed, frame },alpha 用来插值)
ctx.frame.tick / ctx.frame.elapsed已推进的逻辑步数 / 累计逻辑时间
ctx.frame.pause() / resume() / timeScale(n)改节拍(需要 world:register,与世界节拍同一套说法)

没有订阅时不烧帧(最后一个订阅退掉就自动停表);改节拍需要 world:register。 在 node(无 requestAnimationFrame)里不会自驱,但帧循环的数学仍可手动推进 —— 因此可测。

ctx.input(认领式输入所有权)

// 接管空格与方向键;scope 决定「在哪个元素里生效」
const release = ctx.input.claim(
  {
    onKeyDown: ({ code, repeat }) => { if (code === 'Space' && !repeat) dash() },
    onKeyUp: ({ code }) => { if (code === 'Space') stopDash() },
    onPointerDown: ({ x, y }) => aimAt(x, y)
  },
  { keys: ['Space', 'ArrowLeft', 'ArrowRight'], pointer: true, scope: canvasHost }
)

ctx.input.isDown('ArrowLeft') // 只有认领过它的插件才问得到 true
release()

不要再自己 window.addEventListener('keydown', ...):引擎独占一层捕获阶段监听并仲裁, 被认领的事件会被 preventDefault + stopPropagation —— 于是「谁按住了谁能用」是明确的, 引擎自己的「继续」键(空格/回车)也不会再和你的动作游戏抢(它根本收不到已被认领的事件)。

仲裁规则说明
只看声明只有声明了这些键/指针的 claim 会收到事件
优先级priority 大的赢;同优先级时后认领的赢(通常=最后打开的那个界面)
作用域带 scope 的 claim 只在事件目标位于该元素内时生效(指针必须给,键盘可选)

ctx.input.available 在非浏览器环境下为 false(认领照常登记、只是没有真实事件源)。

触屏:指针事件带 pointerId / pointerType / cancelled

触屏(和笔)走的是同一套指针认领,但有三个字段必须知道:

字段含义为什么需要
pointerId手指/笔的编号(同一次按下到抬起内稳定)多点触控靠它区分:左手推方向 + 右手按动作是两个不同的 id;鼠标通常恒为 1
pointerType'mouse' / 'touch' / 'pen'需要按输入类型区别对待时用(例如触屏要画更大的命中区)
cancelled只在 onPointerUp 上出现true 表示这一下被系统取消(浏览器抢走手势、页面被切走),不是正常抬起

两条硬保证,插件可以依赖:

  1. 抬起一定会回到当时接收 down 的那个认领 —— 即使 pointercancel 时事件目标已经不在你的 scope 里。 所以你不需要自己写"超时清理",也不会出现"手指卡住、角色一直朝一个方向跑"。
  2. 窗口失焦 / 页面切到后台时,引擎会把"还按着"的键与手指全部释放 (给还按着的键补 onKeyUp、给还按着的手指补 onPointerUp({ cancelled: true }))。 手机上接个电话、切个 App 都是常态,别在这些时候指望收到正常的 up。

屏幕控件(虚拟方向键 / 动作键):同一套键码

触屏设备没有键盘。引擎的屏幕控件(createTouchControls,宿主侧已默认装上)把手指动作 翻译成和键盘一样的键码:方向区发 ArrowUp / ArrowLeft / ArrowDown / ArrowRight, 动作键默认 Space(继续)/ KeyA / KeyB(宿主可配)。

所以插件不需要为触屏写第二套逻辑 —— 你原来认领 ArrowLeft,插上手指就能用。 (方向区像摇杆:从按下点算偏移,超过死区取主轴。)

手柄:当前不做(需要 Gamepad API 轮询 + 映射表 + 各家差异处理,收益只有主机玩家那一小块)。 插件卸载时认领会自动注销。

ctx.view(受管画布)

const view = ctx.view.canvas(host, { logical: { width: 1000, height: 620 }, background: '#06090f' })

view.onFrame(({ dt, elapsed, frame }) => {
  const g = view.ctx2d                     // 已按 DPR 与缩放设好变换,直接按逻辑坐标画
  g.fillRect(10, 10, 40, 40)
})
成员说明
ctx.view.canvas(host, options?)在容器里创建一块受管画布;options.logical 传了逻辑尺寸就自动等比缩放并居中(letterbox),background / clear / autoResize 可选
view.canvas / view.ctx2d原生画布与 2d 上下文
view.onFrame(fn)注册每帧回调(dt 秒 / elapsed 秒 / frame 计数);返回注销函数。没有订阅时不跑 rAF
view.destroy()停止循环、断开尺寸监听、移除画布(插件卸载时引擎会自动调)

引擎只管「一块按容器自动缩放、带 DPR、带帧回调、能干净收摊的画布」,不提供任何绘制 API —— 画什么完全是你的事。它替你处理的正是最容易漏的几件事:DPR 适配、容器尺寸变化、以及卸载时收摊 (漏了就是「幽灵 rAF」:插件没了循环还在跑)。

在 node 单测里 ctx.view 会明确报「没有 DOM」,而不是隐晦地炸在 undefined 上。

ctx.assets(需要 assets:load)

图片 / 音频 / JSON / 文本的加载,按 kind+url 去重、引用计数、进度与全局音量:

// 加载(同一份资产只真正加载一次,多个插件共享)
const tile = await ctx.assets.image('/assets/tile.png')
const sfx = await ctx.assets.sound('/assets/hit.wav')
const level = await ctx.assets.load('json', '/assets/level-1.json')

ctx.view.ctx2d.drawImage(tile.value, 0, 0)     // image 的值就是 HTMLImageElement
sfx.value.play({ volume: 0.8 })                // audio 的值是 Sound
console.log(level.value.tiles.length)

ctx.assets.setVolume(0.5)                      // 全局音量(对所有声音生效)
ctx.assets.setMuted(true)                      // 宿主一个按钮就能全局静音
ctx.assets.progress()                          // { pending, loaded, failed, total } —— 做加载页用
成员说明
ctx.assets.load(kind, url)加载任意已注册类型的资产
ctx.assets.image(url) / ctx.assets.sound(url)两个常用快捷方式
ctx.assets.release(handle)释放一次引用(归零才真正卸载)
ctx.assets.available / supports(kind)当前环境支持什么(node 里只有 json/text)
ctx.assets.volume() / setVolume(v) / isMuted() / setMuted(m)全局音量与静音(引擎侧统一管,不必每个插件自己乘系数)
ctx.assets.progress()加载进度快照

引用计数与生命周期:插件卸载时它会持有的引用会被自动归还(归零才 unload), 所以忘了 release 也不会泄漏;但主动 release 能让内存在关卡切换时就降下来。

别再自己 new Image() / new Audio():那样同一份资产会被加载多遍、失败没人管、 音量各管各的(宿主想加个静音按钮都无从下手)。loader 是可插拔的 —— 宿主(或另一个插件)可以用 runtime.assets.registerLoader('atlas', ...) 注册新的资产类型。

ctx.storage(需要 storage:read / storage:write)

插件私有的跨剧情 / 跨局持久存储(按插件 id 命名空间隔离的 localStorage):

const best = ctx.storage.get('bestScore', 0)      // 不存在时返回兜底值
ctx.storage.set('bestScore', 1234)                // 返回是否写入成功
ctx.storage.keys()                                // 本插件命名空间下的全部键
ctx.storage.remove('bestScore')
ctx.storage.available                             // 非浏览器 / 无痕环境下为 false

与 ctx.state 的分工:state 是剧情运行时状态(随存档走、随剧情重置清空),storage 是持久数据 (最高纪录、设置、统计)。别把纪录写进 state —— 重开一局会被一起清掉。

ctx.events(需要 events:listen / events:emit)

const off = ctx.events.on('story:nodeEnter', (payload, meta) => {
  // payload 为事件负载;meta = { name, source, pluginId? }
})
ctx.events.once('story:end', () => { /* 只触发一次 */ })

ctx.events.emit('my:event', { hello: 'world' })   // 需要 events:emit

off()   // 手动注销;引擎在插件卸载时也会兜底回收

on / once 返回注销函数。所有监听器都带 owner = pluginId,插件卸载时由 EventBus.removeOwner 一次性清理。

ctx.commands(需要 commands:register)

const off = ctx.commands.register('greet', (args, api) => {
  return {
    ok: true,                                    // 是否成功,默认 true
    message: `你好,${args.name}!`,               // 以对白形式渲染
    effects: [{ type: 'addItem', item: 'torch' }],// 由引擎落地
    data: { name: args.name },                   // 任意结构化数据
    next: 'someNode',                            // 覆盖节点 next(可选)
    stop: false                                  // 是否终止引擎循环(可选)
  }
}, '向玩家问好')   // 第三个参数是可选的描述

ctx.commands.list()   // string[]:本插件注册的命令名
off()                 // 注销

命令处理器签名:

type CommandHandler = (
  args: Record<string, unknown>,
  api: CommandApi
) => CommandResult | void | Promise<CommandResult | void>

CommandApi 提供:api.pluginId、api.story(只读剧情信息)、api.state、api.events、 api.logger、api.nodeId、api.node。这些 state / events 同样是受权限校验的 API。

命令被谁调用:剧情 plugin 节点("command": "greet"),或其它插件 / 宿主通过 PluginManager.callCommand()。剧情可以用 "plugin": "com.example.hello" 限定插件, 若命令实际由别的插件注册,会抛 COMMAND_NOT_FOUND。

效果落地被执行两次吗? 不会。JS 插件返回的 effects 由引擎落地一次; WASM 插件由驱动落地并向上返回空数组,避免重复。

ctx.conditions(需要 conditions:register)

ctx.conditions.register('has_torch', (args, api) => api.state.itemCount('torch') >= 1, '是否持有火把')
ctx.conditions.list()

处理器返回 boolean(Promise<boolean> 也可)。宿主通过 PluginManager.callConditionHandler 调用。

ctx.effects(需要 effects:register)

ctx.effects.register('cheer', (effect, api) => {
  const n = Number(api.state.get('var.cheerCount', 0)) || 0
  api.state.set('var.cheerCount', n + 1)
}, '统计欢呼次数')

当任意命令 / 节点返回 effects: [{ "type": "cheer" }] 时,引擎会调用它。 内置效果类型(set / setFlag / addItem / removeItem / emit)优先级更高, 插件无法覆盖;其它类型才走插件效果处理器。

ctx.nodes(需要 nodes:register)

注册自定义剧情节点类型:

ctx.nodes.register('note', (node, api) => {
  const text = typeof node.text === 'string' ? node.text : ''
  api.state.set('var.lastNote', text)                 // 需要 state:write
  api.events.emit('hello:note', { text, nodeId: api.nodeId })  // 需要 events:emit
  return { next: typeof node.next === 'string' ? node.next : null }
}, '记录一条旁白')

处理器返回 { next?: string | null; stop?: boolean }。 返回 next 覆盖节点的 next;返回 null 表示明确结束;不返回则使用节点自身的 next。 内置节点类型不可被覆盖:内置类型优先于插件注册的同名类型。

剧情中的用法:

{ "type": "note", "text": "森林的低语渐渐平息。", "next": "nextNode" }

ctx.ui(需要 ui:register)

注册 UI 组件:

ctx.ui.register('greeter', (host, props, api) => {
  const box = document.createElement('div')
  box.textContent = String(props.title ?? '问候')
  host.appendChild(box)

  // 返回卸载函数(宿主在组件离开或插件卸载时调用)
  return () => box.remove()
}, '展示问候信息')

ctx.ui.list()   // string[]:本插件注册的完整组件名(含插件前缀)

剧情的用法:

{ "type": "ui", "component": "greeter", "props": { "title": "旅店老板" }, "wait": true, "next": "intro" }

组件名既可用完整名 com.example.hello:greeter,也可用唯一短名 greeter (若多个插件注册了同短名,引擎会跳过并警告)。

ctx.story(currentNodeId / isEnded 需要 story:read;jumpTo / end 需要 story:control)

ctx.story.currentNodeId()   // string | null,只读
ctx.story.isEnded()         // boolean,只读
ctx.story.jumpTo('forest')  // 需要 story:control
ctx.story.end('提前结束')    // 需要 story:control

ctx.world(需要 world:register / world:emit / world:listen)

世界模拟器给 JS 插件的三条通道。它们底层复用同一个 WorldLoop / EventBus / StateStore, 与剧情 JSON(observe 节点)和 WASM 插件(world:tick 事件代理)完全平权。

// 1) 注册世界规则:每拍执行一次(需要 world:register + state:write 才能写状态)
const offRule = ctx.world.registerRule('temperature_drift', (_snapshot, notice) => {
  ctx.state.set('var.temperature', Math.round(Math.sin(notice.tick / 3) * 10) / 10)
})
// 返回注销函数;插件卸载时宿主也会自动回收

// 2) 发布世界事件:供其它插件 / 剧情 observe 订阅(需要 world:emit)
ctx.world.publish('alert:emergency', { level: 3, targets: ['a', 'b', 'c'] })

// 3) 订阅世界事件:通常用于驱动 UI 实时刷新(需要 world:listen)
const offSub = ctx.world.subscribe('alert:emergency', (payload) => {
  // 与剧情 JSON 的 observe 节点监听的是同一条事件 —— 三路平权
})
API权限说明
ctx.world.registerRule(name, handler)world:register注册「每 tick 计算什么」。handler 入参为 (StateSnapshot, WorldTickNotice),返回注销函数
ctx.world.publish(event, payload?)world:emit向世界发布事件(等价 events.emit,但走世界语义,供 observe 订阅)
ctx.world.subscribe(event, handler)world:listen订阅世界事件;返回注销函数
ctx.world.isPaused—(只读)世界节拍是否处于暂停
ctx.world.pause(reason?)world:register暂停世界节拍;暂停期间 step() 仍可手动推进
ctx.world.resume()world:register恢复世界节拍
ctx.world.timeScale(scale)world:register设置倍速:实际间隔 = 基准间隔 / scale
ctx.world.step(ticks?)world:register手动推进 n 拍(确定性快进 / 单步调试,暂停时也能用)

世界规则处理器拿不到 ctx(与 start / stop 一样),需要通过闭包引用 init 阶段保存的 API。


ctx.assets 放声音:分组 / 淡变 / 并发与优先级 / 切后台

加载与播放是两回事:ctx.assets.sound(url) 只拿到素材(去重、引用计数); 要按"分组"播、要淡入淡出、要限制并发,就用 ctx.assets.play() —— 它返回句柄,可以单独控制:

const bgm = await ctx.assets.play('/audio/theme.mp3', {
  group: 'bgm',      // 分组(默认 sfx):决定用哪套音量/静音与并发上限
  loop: true,
  fadeInMs: 800,     // 淡入
  volume: 0.8,       // 这一路的相对音量(与分组、全局相乘)
  priority: 3        // 并发满时:数值大的留下
})
bgm.setVolume(0.4, { fadeMs: 300 })
bgm.stop({ fadeOutMs: 600 })

权限:播放自己的声音只要 assets:load;而 setGroupVolume / setGroupMuted / configureGroup / stopGroup / setSuspended 需要 audio:mix —— 因为它们改的是"玩家整体听到什么",不只影响你自己那份声音(最小权限)。

ctx.intents(需要 events:emit)

把「玩家的操作意图」交给剧情,自带背压:

// 面板按钮:能不能按,先问一下
if (ctx.intents.canSend('move')) ctx.intents.send('move', { dir: 'n' })

// 或者直接发,看返回值给玩家反馈
const accepted = ctx.intents.send('move', { dir: 'n' })
if (!accepted) showHint('现在还不能动')
成员说明
ctx.intents.isIdle剧情是否停在可受理意图的等待点(say / wait)
ctx.intents.canSend(name)现在发这个意图会被受理吗(UI 据此置灰按钮)
ctx.intents.send(name, payload?)发送意图,返回是否被受理

引擎只在两个条件同时成立时受理:剧情停在 say / wait(空闲),且剧情里有 observe 节点在监听 intent:<name>(见 STORY_FORMAT.md)。停在 choice 上时不受理 —— 那时玩家 正在做选择,插队会让新旧接口错位。受理后引擎广播 intent:<name>,由 observe 节点把剧情路由到对应 节点,所以剧情侧不需要任何新节点类型。

为什么不当成普通事件发?因为普通事件没有「现在能不能操作」的语义:面板只能自己判剧情空闲、 自己拼一串 if 分派链 —— 那正是这个原语要消灭的重复。

ctx.schedule(宿主定时器)

// 秒为单位;返回取消函数。剧情重载 / 插件卸载时会被自动取消
const off = ctx.schedule(1.5, () => {
  ctx.state.set('var.tip', '冷却结束')
})
off() // 需要提前取消时调用
方法说明
ctx.schedule(seconds, handler, { repeat? })排一个属于本插件的定时器;repeat: true 表示按同样间隔重复触发

它和裸 setTimeout 的区别是生命周期正确:Runtime.loadStory() / restart() 会让全部在途 定时器失效,插件卸载时按 owner 回收 —— 避免「幽灵定时器」在剧情重载后醒来,去改新剧情不该有的 状态或操作已经不存在的 UI。回调抛错会被上报到引擎日志并计入该插件的失败计数(不影响其它定时器)。


生命周期钩子调用时机

PluginManager.load()
  ├─ import(entryUrl)                    → 拿到 JsPlugin 对象
  ├─ createPluginContext()               → 构造受权限校验的 ctx
  ├─ driver.init(ctx)          ← init    → 注册能力 / 订阅事件(此处才拿得到 ctx)
  ├─ 状态置为 ready
  └─ 广播 plugin:loaded

Runtime.start()
  └─ pluginManager.startAll()
       └─ driver.start()       ← start   → 无参数,做无状态的事

剧情结束 / 暂停 / Runtime.shutdown()
  └─ driver.stop()             ← stop    → 可再次 start

卸载 / 熔断 / 加载失败回滚
  ├─ driver.dispose()          ← dispose → 释放自己持有的资源
  └─ cleanup()                           → 引擎兜底回收注册能力与事件监听

注意:只有 init 有 ctx。若 start / stop / dispose 需要访问宿主能力, 请在 init 中把需要的 API 保存到模块级变量。


事件监听与注销


权限声明与校验


完整 JS 插件示例

仓库内置示例:public/plugins/hello-js/index.js (注册命令 greet、节点 note、条件 has_torch、效果 cheer、UI 组件 greeter,并监听引擎事件)。

下面是一个独立可复制的最小插件,演示命令 + 事件监听:

/**
 * 最小 JS 插件示例:注册 greet 命令并监听节点事件。
 * manifest.json 需要:state:read, state:write, commands:register, events:listen
 */
const state = { offs: [] }

export default {
  /** @param {import('../../../src/plugins/types').PluginContext} ctx */
  init(ctx) {
    // 1) 注册命令:剧情 { "type": "plugin", "command": "greet" } 会调用
    ctx.commands.register('greet', (args, api) => {
      const name = typeof args.name === 'string' ? args.name : '旅行者'
      const gold = Number(api.state.get('var.gold', 0)) || 0

      api.state.setFlag('greeted', true)           // 需要 state:write
      api.logger.info(`greet 被调用:name=${name},金币=${gold}`)

      return {
        ok: true,
        message: `你好,${name}!`,
        effects: [{ type: 'addItem', item: 'torch', count: 1 }],
        data: { name, gold }
      }
    }, '向玩家问好')

    // 2) 监听事件(需要 events:listen)
    state.offs.push(
      ctx.events.on('story:nodeEnter', (payload) => {
        const data = payload
        if (data && typeof data.nodeId === 'string') {
          ctx.logger.debug(`进入节点:${data.nodeId}`)
        }
      })
    )
  },

  start() { console.log('[plugin] 已启动') },
  stop() { console.log('[plugin] 已停止') },

  dispose() {
    for (const off of state.offs.splice(0)) off()
    console.log('[plugin] 已卸载')
  }
}

配套 manifest.json:

{
  "id": "com.example.minimal",
  "name": "Minimal Plugin",
  "version": "1.0.0",
  "type": "js",
  "entry": "index.js",
  "apiVersion": 1,
  "permissions": ["state:read", "state:write", "commands:register", "events:listen"]
}

调试与日志

页面内调试面板

控制台调试

页面在 window.__storyRuntime 暴露了运行时对象:

__storyRuntime.pluginManager.list()               // 全部插件信息
__storyRuntime.pluginManager.getInfo('com.example.hello')
__storyRuntime.pluginManager.listCapabilities()   // 各插件注册的能力
__storyRuntime.state.snapshot()                   // 当前状态
__storyRuntime.registries.commands.names()        // 已注册命令
__storyRuntime.events.emit('story:end')           // 手动触发事件(测试监听器)
__storyRuntime.loadStory('/stories/demo.json', { cacheBust: true, autoStart: true })

常见调试套路

  1. 命令没被调用:先看插件列表里状态是否为 running、能力里是否有该命令; 再检查剧情 plugin 节点的 plugin 字段是否与注册者一致。
  2. 权限被拒:日志里会有一条 插件尝试执行 "...",但缺少权限 xxx,已拒绝, 按提示补 manifest 权限后刷新页面。
  3. 插件加载失败:插件列表该项会显示红色错误文本;控制台还有 [plugin-manager] 插件加载失败:...。
  4. 热替换:改完 index.js 后刷新页面即可,无需重启 dev server。

日志全部带插件 id([plugin:<id>] / scope plugin:<id>),这是排查「哪个插件闯的祸」的第一线索。