NarraLeaf

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.opennotifications.*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身份信息:idnameversionpublisher
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: [/* ... */] });

registerregisterManyregisterGroup 返回清理函数。插件的操作组只能局限在自己的菜单中——它无法并入 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: () => { /* ... */ },
});

registerregisterMany 返回清理函数。mod 在 macOS 上是 ⌘,在其他平台上是 Ctrl

services.ui.notifications

即发即忘的 toast 提示。infosuccesswarningerror

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 */ }),
});

registerregisterMany 返回清理函数

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.Buttonui.IconButtonui.Inputui.TextAreaui.Selectui.Switchui.Card(及其子组件)、ui.Modal(及其子组件)、ui.AssetSelector,以及 ui.Panel.* 布局原语(RootHeaderToolbarSectionRowEmptyState


narraleaf-studio/runtime

defineRuntimePlugin

import { defineRuntimePlugin } from "narraleaf-studio/runtime";

export default defineRuntimePlugin({
    setup(app) {
        app.game.blueprintNodes.registerMany(createNodes());
    },
});

setup(app) 在每个游戏进程(开发模式、预览、生产环境)中运行一次。它可以是异步的。没有清理返回值——游戏进程不会卸载插件

app 携带 app.pluginapp.manifestapp.game

app.game 有四个始终存在的成员——blueprintNodeswidgetsdatalog——以及一组受能力控制的命名空间,它们只在 contributes.runtimeCapabilities 声明过之后才存在。未声明的命名空间是 undefined,而不是一个会抛错的方法。完整模型见 Runtime API

game.blueprintNodes

app.game.blueprintNodes.register({ type, execute });
app.game.blueprintNodes.registerMany(defs);

为每个节点类型注册游戏侧的 execute。传入你在 studio 侧注册的同一份 BlueprintNodeDef[](一个共享模块)——只会用到 typedisplayNameexecute。每个类型都必须在 contributes.blueprintNodes 中声明。返回 void

节点上下文

execute(ctx) 收到的就是下面这些,别无其他:

字段类型说明
ctx.paramsRecord<string, unknown>在节点上填写的静态参数值
ctx.resolveInput?(pinId: string) => unknown沿着连线读取该节点声明过的某个数据输入引脚。惰性求值;引脚未连线或未声明时返回 undefined
ctx.eventName?string节点运行在事件图中时,当前处理的事件槽
ctx.eventPayload?Record<string, unknown>该事件的载荷
ctx.signal?AbortSignal执行被取消时进入中止状态。长时间运行的节点应当遵守它
ctx.gameRuntimePluginGamesetup(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.storestorestore
game.eventseventsevents
game.statestate.readset 需要 state.writestate
game.savessaves.readwrite / load 需要 saves.writesaves
game.ui.overlayui.overlayui.overlay
game.assetsassetsassets & locale
game.localelocaleassets & locale
game.sidecar无——只需一个非空的 contributes.sidecarssidecar
game.storystory.compileRuntime API
game.navigation无——只需一个非空的 contributes.externalLinksRuntime API

runtime 接口上没有 i18n

runtime 入口运行在游戏中——在生产环境里没有编辑器,也没有编辑器语言,因此把这样一个接口暴露出来并不具备可移植性。游戏通过 NarraLeaf 的游戏本地化系统来本地化自己面向玩家的内容,而不是通过插件的 runtime API


注意事项

  • 把宿主模块标记为 external。 narraleaf-studio/pluginnarraleaf-studio/runtime 和 React 相关包都由宿主提供。打包进你自己的副本会破坏加载。模板的 esbuild 配置已经列出了它们
  • 蓝图节点需要两个入口才能在发布游戏中工作——studio 用于选板,runtime 用于执行。只在 studio 注册的话,只会在编辑器预览中运行,一旦导出就什么都不做
  • 检查命名空间是否存在,不要去 catch。 未声明或不可用的能力在 app.game 上是缺失的,所以 app.game.store?.set(...) 就是全部的防护。没有什么可以 try
  • 编辑器无法支撑任何能力。 同一个 execute 也会在编辑器画布上运行,那里每一个受控命名空间都不存在。假定有游戏在跑的节点在那里什么都不会做——请把它写成这件事无所谓的样子
  • 节点定义在整个会话中是永久的。 blueprintNodes.register 按设计返回 void;你无法注销某个节点类型
  • 绝不要手写派生权限。 runtimesidecarbuildDependency 权限都来自 contributes;把其中任何一个写进 permissions[] 都会让清单校验失败

本页目录