运行时接口
游戏侧的接口——runtime 入口能触及什么、能力如何为它把关,以及未声明的域为何根本就不在那里
运行时 API 是 narraleaf-studio/runtime 接口:插件在运行中的游戏里能触及的一切——开发模式窗口、预览,以及导出的生产构建。插件的蓝图节点在这里执行,控件在这里渲染,它也在这里存储数据、监听故事、并在游戏之上绘制内容
一切都挂在一个对象上:app.game,它交给 setup(app),也以 ctx.game 的形式交给每个节点的 execute
import { defineRuntimePlugin } from "narraleaf-studio/runtime";
export default defineRuntimePlugin({
setup(app) {
app.game.blueprintNodes.registerMany(createNodes());
app.game.log("info", "runtime bindings registered");
},
});五个成员始终存在
| 成员 | 说明 |
|---|---|
game.blueprintNodes | 为你的节点类型 register / registerMany 游戏侧的 execute |
game.widgets | 为你的控件类型 register / registerMany 游戏侧的渲染器 |
game.data | readJson(namespace)——读取随游戏一同发布的插件存储 |
game.config | get(key) —— 作者为你的 contributes.buildConfig 字段填了什么。与 game.data 同理始终存在:声明一个字段不会给插件任何能力,所以这里没有可把关的能力 |
game.log | 向宿主日志写入 log(level, message) |
你注册的每个类型都必须在 contributes.blueprintNodes / contributes.widgets 中声明,而 game.data 只看得到 contributes.runtimeData 里列出的命名空间。参见 清单参考
其余一切都由能力把关
除这四项之外,app.game 上的每个命名空间只有在你的清单于 contributes.runtimeCapabilities 中声明过它时才存在:
{
"contributes": {
"runtimeCapabilities": ["store", "events", "state.read"]
}
}未声明的域是从对象上缺席,而不是一个会抛错的方法。如果你没有声明 state.read,app.game.state 就是 undefined——没有东西可调用,也没有东西可捕获。请检查它是否存在,不要用 try 包起来
整份契约就这么多。安装提示列出的正是 app.game 上存在的那些域,所以作者批准的内容和你的插件能做的事,在构造上就是同一个集合。这些权限永远不用你手写——Studio 会从 contributes 派生它们,手写的权限是一种 清单错误
十项能力
| 能力 | 授予 | 用途 |
|---|---|---|
store | app.game.store | 插件作用域的持久键值存储,存放在玩家的存档旁边 |
events | app.game.events | 订阅游戏生命周期与故事事件 |
state.read | app.game.state——get、onChange | 读取并观察故事变量 |
state.write | app.game.state.set | 写入故事变量。隐含 state.read |
saves.read | app.game.saves——listIds、readMetadata | 列出存档槽位并读取它们的元数据 |
saves.write | app.game.saves.write / .load | 覆盖一个槽位,或替换正在进行的游玩进程 |
ui.overlay | app.game.ui.overlay | 在游戏之上绘制一个元素 |
assets | app.game.assets | 把打包好的资源 id 解析成 URL |
locale | app.game.locale | 读取并观察游戏的显示语言 |
story.compile | app.game.story | 注册一个编译遍历,它能看到每一个场景,并在它的行周围注入引擎动作。十项里最重的一项:一个 pass 会跑遍工程的每一个场景、看见每场里谁在说话,并能在它没写过的行旁边放动作。它不是 state.write 的变相,也不隐含它 —— pass 只造动作,不跑动作 |
这份列表是封闭的。无法识别的能力字符串会让清单校验失败,而不是被忽略,所以拼错永远不会被读成“什么都没申请”
其中两项刻意把一个域一分为二:
state.write隐含state.read。 只声明write会低报插件实际能做的事——凡是你能写的,你都能观察。Studio 会替你补上state.readsaves.write比saves.read更重,并且与它分开:它能覆盖一个槽位,或放弃一次游玩进程。安装提示会原原本本这么写给作者看
sidecar 没有能力字符串
当且仅当 contributes.sidecars 非空时,app.game.sidecar 才存在。声明这个 sidecar 就是那次申请,所以没有额外的东西可以忘
把关是一个交集
一个域会出现,当且仅当清单声明了它 并且 当前环境能支撑它。前一半是批准,后一半是物理现实——浏览器没有子进程可以启动,编辑器也没有游戏可以读
当某个已声明的能力在这里没有支撑时,该命名空间缺席,宿主会向日志写一条警告,说明是哪一个、为什么。你的插件照样会加载
| 域 | 桌面构建 | Web 导出 | Android / iOS | 预览 | 开发模式 | 编辑器内预览 |
|---|---|---|---|---|---|---|
store | 是 | 是(IndexedDB) | 是 | 是 | 是 | 否 |
events | 是 | 部分——见下文 | 部分 | 是 | 是 | 否 |
state | 是 | 是 | 是 | 是 | 是 | 否 |
saves | 是 | 是 | 是 | 是 | 是 | 否 |
ui.overlay | 是 | 是 | 是 | 是 | 是 | 否 |
assets | 是 | 是 | 是 | 是 | 否 | 否 |
locale | 是 | 是 | 是 | 是 | 是 | 否 |
sidecar | 是 | 从不 | 从不 | 是 | 否 | 否 |
events.closeRequested在 Web 上从不触发——那里没有窗口可关。请用events.available(name),而不是假定存在桌面外壳- 开发模式不等于预览。 预览跑的是发行游戏跑的同一套外壳;开发模式窗口是一个直接驱动项目的 Studio 窗口。两项能力在那里不同:
assets缺席,因为那里的资源解析要走异步 IPC,而这项能力的签名是同步的url();sidecar缺席,因为开发模式窗口不托管任何子进程。这两项都请在预览里验证 - 编辑器内预览(在编辑器画布上运行节点)根本没有游戏,所以每个受把关的域都缺席。那里
game.data.readJson也返回null,调用game.blueprintNodes.register会抛错——注册属于 studio 入口的app.services.*。同一份execute会在两处运行,这正是节点必须降级、而不能想当然的原因
写会降级的节点
因为缺席的域就是 undefined,防护手段就是普通的可选链——不用嗅探环境,也不用 try
execute: async ctx => {
// 未声明,或此处不可用:跳过这段工作,让流程继续。
await ctx.game.store?.set("seen", true);
if (ctx.game.events?.available("closeRequested")) {
// 仅桌面
}
return { nextPort: "next" };
}节点 execute 里的 ctx.game 与 setup(app) 收到的 app.game 是同一个对象。一套 API,一组能力,节点内外一致
子页面
store
插件作用域的持久键值存储,开新游戏也不会丢
events
十三个游戏与故事事件,以及各环境下的可用性
state
读取、写入并观察 scene / saved / persistent 故事变量
saves
列出并读取存档槽位;用更重的能力覆盖或载入某一个
ui.overlay
在游戏之上绘制元素——以及它唯一去不了的地方
assets 与 locale
解析打包资源的 URL,并跟随玩家的语言
sidecar
在作者的游戏里随附一个原生子进程,并用 NDJSON 与它对话
运行时接口没有什么
- 没有
hostAdapter。 节点的ctx只带params、resolveInput、eventName、eventPayload、signal和game——别无其他。早期构建通过ctx.hostAdapter.blueprintRuntime.hostApi泄漏了宿主完整的内部 API;那条路径已经没了。现在一切都走ctx.game,而它恰好就是清单声明的那些 - 没有编辑器服务。
app.services、app.privileged和ui套件只属于 studio 入口。runtime 入口是游戏代码 - 没有
react-dom/client。 宿主以 external 的形式提供react、react-dom和 JSX 运行时,但插件绝不能挂载自己的 React root——第二个 root 会在同一棵树上和宿主的打架。返回元素,让宿主去渲染 - 没有编辑器 i18n。
app.services.i18n是编辑器的 UI 语言。游戏通过 NarraLeaf 自己的本地化系统来本地化面向玩家的内容;运行时接口只告诉你当前是哪个 locale - 没有清理生命周期。 游戏进程只加载一次插件,从不卸载,所以
setup没有要返回的清理函数,register返回void