DOCUMENTATION · JS 插件开发
JS 插件开发
本文介绍如何用 TypeScript / JavaScript 编写一个 JS 插件:目录结构、manifest 字段、 JsPlugin 接口、PluginContext 完整 …
目录结构
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"
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | ✅ | 全局唯一 id,建议反向域名。重复加载同 id 会跳过 |
name | string | ✅ | 展示名(UI 插件列表使用) |
version | string | ✅ | 语义化版本 |
type | "js" | "wasm" | ✅ | 插件类型;JS 插件填 "js" |
entry | string | ✅ | 入口文件,相对 manifest.json 所在目录 |
apiVersion | number | ✅ | 目标 API 版本,必须等于 1(PLUGIN_API_VERSION),否则拒绝加载 |
permissions | string[] | ➖ | 申请的权限,缺省为 []。未知权限名会导致加载失败 |
description | string | ➖ | 说明文字 |
author | string | ➖ | 作者 |
timeoutMs | number | ➖ | 单次调用超时(毫秒,正数)。缺省用管理器默认值(3000ms) |
export | string | ➖ | 显式指定要使用的模块导出名(见下) |
requires | string[] | ➖ | 依赖的其它插件 id:必须先加载,且必须存在(缺失则本插件加载失败并说明缺了谁) |
order | object | ➖ | 加载顺序提示(不要求对方存在):{ "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 依次尝试:
manifest.export指定名:export const plugin = { init, ... }。 若该项是函数,会调用它并以返回值作为插件对象。- 默认导出:
export default { init, ... }。 - 模块命名空间本身:
export function init(ctx) {}、export function start() {}等具名钩子。 - 工厂函数:
export function createPlugin() { return { init, ... } }(可为 async)。
若模块导出了内容但没有任何钩子,会按空插件处理并记录一条警告;完全无法识别时抛
PLUGIN_IMPORT_FAILED。
PluginContext API 完整说明
ctx 只在 init(ctx) 中提供。所有 API 都做权限校验(见 PERMISSIONS.md)。
基础字段
| 成员 | 类型 | 说明 |
|---|---|---|
ctx.pluginId | string | 当前插件 id |
ctx.pluginName | string | 插件展示名 |
ctx.apiVersion | number | 宿主 API 版本(= 1) |
ctx.permissions | readonly Permission[] | 已授权权限列表 |
ctx.logger | Logger | 带 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()。它会让同一局没法复现、存档也存不住「随机到哪一步了」。
引擎的随机源由一颗种子驱动,种子与内部状态随存档一起走,于是:
- 同一颗种子必然给出同一串数(可复现、可测试、可回放);
fork(label)让不同子系统(掉落 / 天气 / 突变)各有一条独立流 —— 任何一方多抽几次都不会影响另一方,也就不会出现「加了一处随机,整局结果全变」。 同一插件里反复fork('drop')拿到的是同一条流(会继续往前推进)。
ctx.capabilities(宿主能力探测)
const { wasm, simd, threads } = ctx.capabilities
// 重计算插件据此显式降档:网格 1024² → 512²、粒子数对半
const grid = threads ? 1024 : 512| 字段 | 含义 |
|---|---|
wasm | WebAssembly 可用 |
simd | WebAssembly SIMD(simd128)可用 |
sharedArrayBuffer | SharedArrayBuffer 存在 |
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 表示这一下被系统取消(浏览器抢走手势、页面被切走),不是正常抬起 |
两条硬保证,插件可以依赖:
- 抬起一定会回到当时接收
down的那个认领 —— 即使pointercancel时事件目标已经不在你的scope里。 所以你不需要自己写"超时清理",也不会出现"手指卡住、角色一直朝一个方向跑"。 - 窗口失焦 / 页面切到后台时,引擎会把"还按着"的键与手指全部释放
(给还按着的键补
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[]:本插件注册的完整组件名(含插件前缀)- 注册名会加插件前缀:
greeter→com.example.hello:greeter,避免插件互相覆盖。 host是宿主提供的隔离容器,组件只能操作它内部;不要访问document.body等全局目标。api是受权限校验的上下文(pluginId/logger/state/events)。- 返回的卸载函数中请注销自己注册的监听器。
剧情的用法:
{ "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:controlctx.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 })- 分组:
bgm/sfx/voice各自一套音量与静音 —— "音乐关掉、音效留着"是互动剧最基本的需求。 默认并发上限:bgm1、voice2、其它 8;可用configureGroup()改。 - 音量是三层相乘:
这一路 × 分组 × 全局(全局那层由setVolume/setMuted管)。 改任何一层,正在响的声音会立刻跟着变。 - 并发上限:满了就按
priority踢掉最弱的(同优先级踢更早的);新来的比在响的都弱 → 这次不播 (句柄的playing === false)。音频不该抛异常打断剧情。 - 淡变:
fadeInMs/fadeOutMs/fadeMs。WebAudio 路径用原生 ramp(采样级精确); 退化成<audio>时用定时器做线性淡变。 - 切后台:宿主会在页面隐藏时停掉所有声音(
assets.suspended),不会自动恢复 —— 回到前台自己重放 BGM。
权限:播放自己的声音只要
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 保存到模块级变量。
事件监听与注销
ctx.events.on(name, handler)/ctx.events.once(name, handler)返回注销函数。- 事件回调的第二个参数
meta形如{ name, source, pluginId? }。 - 监听器异常被隔离:一个监听器抛错不会影响其它监听器;但会累加插件的失败计数,
达到阈值(默认 3 次)会触发熔断,请自行
try/catch。 - 插件卸载时,
EventBus.removeOwner(pluginId)会清理该插件登记的全部监听器, 但主动在dispose里注销是更好的习惯(尤其当监听器持有外部资源时)。
权限声明与校验
- 在
manifest.json的permissions声明;未声明的能力调用会抛PluginPermissionError。 - 写插件前先列一张「用到哪些 ctx API → 需要哪些权限」的清单,避免运行时才发现被拒。
- 详见 PERMISSIONS.md。
完整 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"]
}调试与日志
页面内调试面板
- 状态栏:实时显示
var/flag/item/actor。 - 插件列表:显示每个插件的 id、类型、状态徽章、已注册能力、错误信息。
- 事件日志:所有日志(含插件日志)按
plugin:<id>scope 展示,出错时高亮。
控制台调试
页面在 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 })常见调试套路
- 命令没被调用:先看插件列表里状态是否为
running、能力里是否有该命令; 再检查剧情plugin节点的plugin字段是否与注册者一致。 - 权限被拒:日志里会有一条
插件尝试执行 "...",但缺少权限 xxx,已拒绝, 按提示补 manifest 权限后刷新页面。 - 插件加载失败:插件列表该项会显示红色错误文本;控制台还有
[plugin-manager] 插件加载失败:...。 - 热替换:改完
index.js后刷新页面即可,无需重启 dev server。
日志全部带插件 id(
[plugin:<id>]/ scopeplugin:<id>),这是排查「哪个插件闯的祸」的第一线索。