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 语言。它不是游戏面向玩家的语言:玩家读到的文字通过 services.localization 在项目的翻译表中翻译,在游戏中通过 game.locale 读回
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.localization
项目的翻译键(只读),以及提交插件自己的文字以供翻译的入口
const keys = app.services.localization.listKeys(); // [{ name, sourceText }]
const off = app.services.localization.registerWords({
list: () => store.entries().map(entry => ({
id: `entry.${entry.id}.name`,
text: entry.name,
context: entry.group,
})),
subscribe: listener => store.subscribe(listener),
});| 成员 | 签名 | 说明 |
|---|---|---|
listKeys | () => PluginLocalizationKeyEntry[] | 每个键的 name 与 sourceText。键加载完成之前为空 |
registerWords | (source: PluginWordsSource) => PluginCleanup | 把插件显示给玩家的文字交给翻译表与导出 |
插件显示给玩家的文字,要么指定项目的某个翻译键,按键翻译;要么直接提供文字,再用 registerWords 提交。PluginWordsSource 是 { list(), subscribe? }:list() 返回 PluginWordsEntry 对象,subscribe(listener) 返回取消订阅的函数;list() 的结果会变化时调用 listener
PluginWordsEntry | 说明 |
|---|---|
id | 插件自己给这段文字的 id:字母和数字,中间可用 .、_ 或 -。只要还是同一段文字,就保持不变;译文挂在它上面 |
text | 文字,使用项目的源语言 |
context | 可选。玩家在哪里看到这段文字,供译者参考,例如 File › Save。缺省为插件名 |
每个含字母的条目在翻译表的界面文本中按插件名占一行,翻译单元是 plugin:<pluginId>/<id>。runtime 入口用 app.game.locale.words(id, text) 读回玩家语言中的文字。ui.WordsField 为作者提供与 Studio 自己的文字相同的直接输入或翻译键选择
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 中声明。模块的 logicApi 声明控件的蓝图事件;见 控件事件
services.story.actions
场景编辑器选板中用于插入故事块的操作。块可以是标准的故事块,插件不在之后文档照样保留它;也可以是插件自己的行(action: "plugin"),由 runtime 入口经 game.storyActions 运行
app.services.story.actions.register({
id: `${PLUGIN_ID}.insertNote`,
label: "Insert Note",
createBlock: () => ({ /* a story block */ }),
});register 和 registerMany 返回清理函数。createBlock 以 initialText 收到作者在命令后面输入的文字,可选的 describe(params) 决定插件行显示的文字。keywords 是任何界面语言下都能直达这个动作的命令词,params 列出插件行在属性面板里显示的字段(两项都由 Studio 1.6.0 及以后的版本读取)。见 story
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中声明。定义的类型是PluginBlueprintNodeDef,字段见 blueprintNodesregisterDynamicSelectOptionsSource(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.WordsField,以及 ui.Panel.* 布局原语(Root、Header、Toolbar、Section、Row、EmptyState)
ui.WordsField 是玩家读到的文字的字段,提供与 Studio 自己的文本和按钮文字相同的选择、键列表与就地编辑键。它是受控组件:value 是 { text, key },选择直接输入时 key 为 null,onChange 收到下一个值。直接输入的文字由插件用 registerWords 提交后翻译;取自键的文字按键翻译
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、storyActions、data、config、log),以及一组受能力控制的命名空间,它们只在 contributes.runtimeCapabilities 声明过之后才存在。未声明的命名空间是 undefined,而不是一个会抛错的方法。完整模型见 Runtime API
game.blueprintNodes
app.game.blueprintNodes.register({ type, execute });
app.game.blueprintNodes.registerMany(defs);为每个节点类型注册游戏侧的 execute。传入你在 studio 侧注册的同一份 PluginBlueprintNodeDef[](一个共享模块);只会用到 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" };
}execute 返回 { nextPort?, nextPorts?, outputValues? },或者什么都不返回;见 返回值
没有 ctx.hostAdapter 这个东西。早期版本通过 ctx.hostAdapter.blueprintRuntime.hostApi 把宿主完整的内部 API 泄漏了出去——存档、本地化、退出应用——清单里什么都没声明,安装时也什么都没展示给作者。那条路径已经没有了。节点触及的任何东西,都要经由 ctx.game,而它恰好就是 contributes 声明的那组能力
game.storyActions
app.game.storyActions.register({ id, run });
app.game.storyActions.registerMany(defs);注册故事走到插件的故事动作所插入的行时要运行的处理。id 就是 studio 入口注册这个动作时用的 id。返回 void
run(ctx) 收到:
| 字段 | 类型 | 说明 |
|---|---|---|
ctx.params | Record<string, unknown> | 这一行的 params:createBlock 写入的内容,加上作者在属性面板里的修改。每次运行都是一份新的拷贝 |
ctx.signal | AbortSignal | run 还没结束时,玩家回退到这一行之前或读取了存档,它就会被中止 |
ctx.game | RuntimePluginGame | 与 setup(app) 收到的 app.game 是同一个对象 |
app.game.storyActions.register({
id: `${PLUGIN_ID}.play`,
run: async ({ params, signal, game }) => {
// Show something, wait for the player, write the result.
game.state?.set?.("saved", String(params.variable), "win");
},
});- 故事等待。
run返回后,或者它返回的 promise 结束后,下一行才开始。点击、推进键、自动模式和跳过都不会越过这一行 run进行中存的档,读取时会重新执行这一行。run必须能从头开始signal中止时,撤下run放到屏幕上的东西并停止。之后它做的任何事都不会回到故事里- 抛出的错误会写进游戏日志,故事继续
- id 必须以插件 id 开头。不需要任何能力:只有作者放下的行才会运行它,而且只是这个插件自己的行
- 在编辑器里
app.game.storyActions会抛错,因为编辑器不运行故事
完整的例子见示例:石头剪刀布
game.widgets
app.game.widgets.register({ type, render, logicApi });
app.game.widgets.registerMany(defs);为某个控件类型注册游戏侧渲染器。render 收到 RuntimeWidgetRendererProps,与 studio 控件模块的 render 收到的 props 相同;见 渲染 props。传入 studio 模块声明的同一份 logicApi,否则控件的事件在 Dev Mode 和游戏里接不到任何图。每个类型都必须在 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.menu | menu | Runtime API |
game.diagnostics | diagnostics | Runtime API |
game.process | process.memory | Runtime API |
game.sidecar | 无——只需一个非空的 contributes.sidecars | sidecar |
game.story | story.compile | Runtime API |
game.navigation | 无——只需一个非空的 contributes.externalLinks | Runtime API |
玩家的语言
runtime 入口运行在游戏中——在生产环境里没有编辑器,也没有编辑器语言,因此 runtime 接口上没有 i18n。声明了 locale 能力时,app.game.locale 跟随玩家的语言,并读取项目中已翻译的文字:
const title = app.game.locale?.text("menu.title"); // 翻译键;项目没有该键时为 null
const name = app.game.locale?.words("entry.cg01.name", "Sunset"); // 用 registerWords 提交的文字| 成员 | 签名 | 说明 |
|---|---|---|
current | readonly string | 游戏当前使用的语言 |
onChange | (listener: (locale: string) => void) => RuntimePluginCleanup | 切换语言时触发 |
text | (key: string) => string | null | 项目的某个翻译键在该语言中的文字。项目没有声明该键时为 null |
words | (id: string, text: string) => string | 本插件在 studio 入口用 registerWords 提交的文字:id 所指单元沿语言回退链找到的译文,否则为 text。游戏只带译文不带源文,所以由插件传入原文。插件只能读到自己的文字 |
注意事项
- 把宿主模块标记为 external。
narraleaf-studio/plugin、narraleaf-studio/runtime和 React 相关包都由宿主提供。打包进你自己的副本会破坏加载。模板的 esbuild 配置已经列出了它们 - 蓝图节点需要两个入口才能在发布游戏中工作——studio 用于选板,runtime 用于执行。只在 studio 注册的话,它在编辑器预览中运行,而在 Dev Mode 和游戏里,图会在这个节点处报错停止
- 检查命名空间是否存在,不要去 catch。 未声明或不可用的能力在
app.game上是缺失的,所以app.game.store?.set(...)就是全部的防护。没有什么可以try - 编辑器无法支撑任何能力。 同一个
execute也会在编辑器画布上运行,那里每一个受控命名空间都不存在。假定有游戏在跑的节点在那里什么都不会做——请把它写成这件事无所谓的样子 - 节点定义在整个会话中是永久的。
blueprintNodes.register按设计返回void;你无法注销某个节点类型 - 绝不要手写派生权限。
runtime、sidecar和buildDependency权限都来自contributes;把其中任何一个写进permissions[]都会让清单校验失败