API 参考
studio 与 runtime 两个插件接口的逐方法说明,附带示例与注意事项
两个入口点,均从纯类型包 narraleaf-studio 导入:
narraleaf-studio/plugin——编辑器接口,由studio入口使用narraleaf-studio/runtime——游戏接口,由runtime入口使用
约定
以下约定适用于整个 studio 接口:
- 注册会返回一个清理函数。 每个
register/registerMany返回一个PluginCleanup,它只移除自己注册的内容。宿主也会追踪每次注册,所以即使你从不调用清理函数,卸载插件也会回收所有内容。清理函数是幂等的 - id 带命名空间。 你注册的任何 id 或类型都必须以你的插件 id 为前缀(
yourname.plugin.thing)。注册未加前缀的 id 会抛出错误 - 命令式调用返回其自身的值。
editors.open、notifications.*、i18n.format*和blueprintNodes.notifyDynamicSelectOptionsChanged是操作,而非注册——它们不返回清理函数 - 一个例外:
blueprintNodes.register/registerMany返回void。节点定义在整个会话期间持续存在——移除某个定义会让已打开文档中的节点变成孤儿——所以没有需要清理的东西
runtime 接口完全没有清理生命周期:游戏进程只加载一次插件,且永不卸载,所以它的 register 调用返回 void
narraleaf-studio/plugin
definePlugin
import { definePlugin } from "narraleaf-studio/plugin";
export default definePlugin({
setup(app) {
// register things
return () => { /* optional cleanup */ };
},
});setup(app) 在工作区窗口加载插件时运行。它可以是异步的。返回一个在卸载时运行的清理函数;不返回也没问题,因为宿主本来就会追踪每次注册
app 携带:
| 属性 | 说明 |
|---|---|
app.plugin | 身份信息:id、name、version、publisher |
app.manifest | 规范化后的清单 |
app.services | 精选的 API 接口(见下文) |
app.privileged | 由清单权限控制的提权能力(文件系统、bash) |
services.i18n
对编辑器语言的只读访问,用于本地化你自己的字符串
const i18n = app.services.i18n.createTranslator({
messages: { en: { hi: "Hi" }, zh: { hi: "你好" } },
fallbackLocale: "en",
});
i18n.t("hi"); // follows the editor locale
const stop = app.services.i18n.onLocaleChange(locale => {
// re-render your UI for the new locale
});| 成员 | 说明 |
|---|---|
locale | 当前生效的编辑器语言代码 |
onLocaleChange(fn) | 语言切换时以新的 locale 触发。返回一个清理函数 |
createTranslator(bundle) | 基于你自己的 { locale: { key: string } } 表构建的翻译器。t(key, params?) 按当前 → 回退 → key 的顺序解析,并填充 {placeholders} |
formatNumber / formatDate / formatList | 绑定到编辑器语言的 Intl 格式化器 |
这是编辑器的 UI 语言。它不是游戏面向玩家的语言——runtime 接口没有 i18n;游戏通过它自己的系统进行本地化
services.storage
按插件划分的 JSON 存储,作用域限定在项目内
await app.services.storage.writeJson("state", { count: 1 });
const data = await app.services.storage.readJson<{ count: number }>("state");
// readJson returns null when nothing is stored yet.services.assets
读取项目的资源,并将其转换为用于预览的对象 URL
const images = app.services.assets.list(AssetType.Image);
const url = await app.services.assets.createObjectUrl(images[0]);
// ... use url ...
app.services.assets.revokeObjectUrl(url);getMap()、list(type)、get(type, id)、fetch(asset)、createObjectUrl(asset)、revokeObjectUrl(url)。及时释放你创建的对象 URL,避免内存泄漏
services.ui.panels
侧边栏面板和底部面板
const off = app.services.ui.panels.register({
id: `${PLUGIN_ID}.panel`,
title: "My Panel",
position: PanelPosition.Left,
component: () => <MyPanel />,
});
// off() removes it; unload removes it too.register(panel) 和 registerMany(panels) 返回清理函数
services.ui.actions
工具栏和菜单操作,以及操作组(下拉菜单 / 原生菜单项)
app.services.ui.actions.register({
id: `${PLUGIN_ID}.doThing`,
label: "Do Thing",
onClick: workspace => { /* ... */ },
});
app.services.ui.actions.registerGroup({ id: `${PLUGIN_ID}.menu`, label: "My Menu", actions: [/* ... */] });register、registerMany 和 registerGroup 返回清理函数。插件的操作组只能局限在自己的菜单中——它无法并入 Studio 的原生 Edit 菜单,也无法占用标准命令角色
services.ui.editors
打开和关闭编辑器标签页。命令式——标签页对用户可见,因此卸载时绝不会被强制关闭
app.services.ui.editors.open({ id: `${PLUGIN_ID}.doc`, title: "Doc", component: MyEditor });
app.services.ui.editors.close(`${PLUGIN_ID}.doc`);services.ui.keybindings
const off = app.services.ui.keybindings.register({
id: `${PLUGIN_ID}.save`,
key: "mod+s",
handler: () => { /* ... */ },
});register 和 registerMany 返回清理函数。mod 在 macOS 上是 ⌘,在其他平台上是 Ctrl
services.ui.notifications
即发即忘的 toast 提示。info、success、warning、error
app.services.ui.notifications.success("Saved");services.widgets
注册 UI 编辑器的控件模块(编辑器侧)。同一控件类型的游戏侧渲染器需从 runtime 入口注册
const off = app.services.widgets.register(myWidgetModule);
app.services.widgets.get(type);
app.services.widgets.list();
app.services.widgets.has(type);register / registerMany 返回清理函数。控件的 type 必须在 contributes.widgets 中声明
services.story.actions
场景编辑器选板中用于插入故事块的操作。这些块是标准的故事块——插入后文档不再依赖该插件
app.services.story.actions.register({
id: `${PLUGIN_ID}.insertNote`,
label: "Insert Note",
createBlock: () => ({ /* a story block */ }),
});register 和 registerMany 返回清理函数
services.blueprintNodes
app.services.blueprintNodes.register(def); // void — session-persistent
app.services.blueprintNodes.registerMany(defs); // void
const off = app.services.blueprintNodes.registerDynamicSelectOptionsSource(
`${PLUGIN_ID}.items`,
() => [{ value: "a", label: "A" }],
);
app.services.blueprintNodes.notifyDynamicSelectOptionsChanged();register/registerMany添加编辑器定义(以及它们用于编辑器内预览的execute)。它们返回void:节点定义一旦注册就无法移除。每个类型都必须在contributes.blueprintNodes中声明registerDynamicSelectOptionsSource(id, provider)为某个kind: "select"的检查器参数提供支持,其选项来自实时的插件状态;它返回一个清理函数。当该状态变化时,调用notifyDynamicSelectOptionsChanged(),以便已打开的节点卡片刷新
ui 套件
import { ui } from "narraleaf-studio/plugin";Studio 自己的组件,让面板与编辑器主题保持一致:ui.Button、ui.IconButton、ui.Input、ui.TextArea、ui.Select、ui.Switch、ui.Card(及其子组件)、ui.Modal(及其子组件)、ui.AssetSelector,以及 ui.Panel.* 布局原语(Root、Header、Toolbar、Section、Row、EmptyState)
narraleaf-studio/runtime
defineRuntimePlugin
import { defineRuntimePlugin } from "narraleaf-studio/runtime";
export default defineRuntimePlugin({
setup(app) {
app.game.blueprintNodes.registerMany(createNodes());
},
});setup(app) 在每个游戏进程(开发模式、预览、生产环境)中运行一次。它可以是异步的。没有清理返回值——游戏进程不会卸载插件
app 携带 app.plugin、app.manifest 和 app.game
app.game 有四个始终存在的成员——blueprintNodes、widgets、data、log——以及一组受能力控制的命名空间,它们只在 contributes.runtimeCapabilities 声明过之后才存在。未声明的命名空间是 undefined,而不是一个会抛错的方法。完整模型见 Runtime API
game.blueprintNodes
app.game.blueprintNodes.register({ type, execute });
app.game.blueprintNodes.registerMany(defs);为每个节点类型注册游戏侧的 execute。传入你在 studio 侧注册的同一份 BlueprintNodeDef[](一个共享模块)——只会用到 type、displayName 和 execute。每个类型都必须在 contributes.blueprintNodes 中声明。返回 void
节点上下文
execute(ctx) 收到的就是下面这些,别无其他:
| 字段 | 类型 | 说明 |
|---|---|---|
ctx.params | Record<string, unknown> | 在节点上填写的静态参数值 |
ctx.resolveInput? | (pinId: string) => unknown | 沿着连线读取该节点声明过的某个数据输入引脚。惰性求值;引脚未连线或未声明时返回 undefined |
ctx.eventName? | string | 节点运行在事件图中时,当前处理的事件槽 |
ctx.eventPayload? | Record<string, unknown> | 该事件的载荷 |
ctx.signal? | AbortSignal | 执行被取消时进入中止状态。长时间运行的节点应当遵守它 |
ctx.game | RuntimePluginGame | 与 setup(app) 收到的 app.game 完全是同一个对象 |
execute: async ctx => {
const message = String(ctx.params.message ?? "");
const wired = ctx.resolveInput?.("value");
// Undeclared or unavailable here: the namespace is absent, so skip the work.
await ctx.game.store?.set("lastMessage", message);
return { nextPort: "next" };
}没有 ctx.hostAdapter 这个东西。早期版本通过 ctx.hostAdapter.blueprintRuntime.hostApi 把宿主完整的内部 API 泄漏了出去——存档、本地化、退出应用——清单里什么都没声明,安装时也什么都没展示给作者。那条路径已经没有了。节点触及的任何东西,都要经由 ctx.game,而它恰好就是 contributes 声明的那组能力
game.widgets
app.game.widgets.register({ type, render });
app.game.widgets.registerMany(defs);为某个控件类型注册游戏侧渲染器。render 收到的 props 与 Studio 传给内置元素渲染器的相同。每个类型都必须在 contributes.widgets 中声明。返回 void
game.data
const catalog = app.game.data.readJson<Catalog>("yourname.plugin.catalog");对随游戏一并发布的插件存储的只读访问,范围是 contributes.runtimeData 中声明的那些命名空间。同步——数据随包一起分发,没有什么需要 await。当命名空间未被声明、项目从未写入过,或游戏早于该数据被发布时,返回 null
game.log
app.game.log("info", "loaded"); // "info" | "warning" | "error"以 [plugin:{id}] 前缀写入游戏宿主日志。开发模式把它送到窗口控制台;预览和生产环境把它送到游戏进程日志
受能力控制的命名空间
只有当 contributes.runtimeCapabilities 声明了它并且当前环境能够支撑它时,下面这些才存在。完整参考:Runtime API
| 命名空间 | 能力 | 参考 |
|---|---|---|
game.store | store | store |
game.events | events | events |
game.state | state.read(set 需要 state.write) | state |
game.saves | saves.read(write / load 需要 saves.write) | saves |
game.ui.overlay | ui.overlay | ui.overlay |
game.assets | assets | assets & locale |
game.locale | locale | assets & locale |
game.sidecar | 无——只需一个非空的 contributes.sidecars | sidecar |
game.story | story.compile | Runtime API |
game.navigation | 无——只需一个非空的 contributes.externalLinks | Runtime API |
runtime 接口上没有 i18n
runtime 入口运行在游戏中——在生产环境里没有编辑器,也没有编辑器语言,因此把这样一个接口暴露出来并不具备可移植性。游戏通过 NarraLeaf 的游戏本地化系统来本地化自己面向玩家的内容,而不是通过插件的 runtime API
注意事项
- 把宿主模块标记为 external。
narraleaf-studio/plugin、narraleaf-studio/runtime和 React 相关包都由宿主提供。打包进你自己的副本会破坏加载。模板的 esbuild 配置已经列出了它们 - 蓝图节点需要两个入口才能在发布游戏中工作——studio 用于选板,runtime 用于执行。只在 studio 注册的话,只会在编辑器预览中运行,一旦导出就什么都不做
- 检查命名空间是否存在,不要去 catch。 未声明或不可用的能力在
app.game上是缺失的,所以app.game.store?.set(...)就是全部的防护。没有什么可以try - 编辑器无法支撑任何能力。 同一个
execute也会在编辑器画布上运行,那里每一个受控命名空间都不存在。假定有游戏在跑的节点在那里什么都不会做——请把它写成这件事无所谓的样子 - 节点定义在整个会话中是永久的。
blueprintNodes.register按设计返回void;你无法注销某个节点类型 - 绝不要手写派生权限。
runtime、sidecar和buildDependency权限都来自contributes;把其中任何一个写进permissions[]都会让清单校验失败