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.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,字段见 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.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.paramsRecord<string, unknown>在节点上填写的静态参数值
ctx.resolveInput?(pinId: string) => unknown沿着连线读取该节点声明过的某个数据输入引脚。惰性求值;引脚未连线或未声明时返回 undefined
ctx.eventName?string节点运行在事件图中时,当前处理的事件槽
ctx.eventPayload?Record<string, unknown>该事件的载荷
ctx.signal?AbortSignal执行被取消时进入中止状态。长时间运行的节点应当遵守它
ctx.gameRuntimePluginGame与 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.paramsRecord<string, unknown>这一行的 params:createBlock 写入的内容,加上作者在属性面板里的修改。每次运行都是一份新的拷贝
ctx.signalAbortSignalrun 还没结束时,玩家回退到这一行之前或读取了存档,它就会被中止
ctx.gameRuntimePluginGame与 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.storestorestore
game.eventseventsevents
game.statestate.read(set 需要 state.write)state
game.savessaves.read(write / load 需要 saves.write)saves
game.ui.overlayui.overlayui.overlay
game.assetsassetsassets & locale
game.localelocaleassets & locale
game.menumenuRuntime API
game.diagnosticsdiagnosticsRuntime API
game.processprocess.memoryRuntime API
game.sidecar无——只需一个非空的 contributes.sidecarssidecar
game.storystory.compileRuntime API
game.navigation无——只需一个非空的 contributes.externalLinksRuntime 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 提交的文字
成员签名说明
currentreadonly string游戏当前使用的语言
onChange(listener: (locale: string) => void) => RuntimePluginCleanup切换语言时触发
text(key: string) => string | null项目的某个翻译键在该语言中的文字。项目没有声明该键时为 null
words(id: string, text: string) => string本插件在 studio 入口用 registerWords 提交的文字:id 所指单元沿语言回退链找到的译文,否则为 text。游戏只带译文不带源文,所以由插件传入原文。插件只能读到自己的文字

见 assets & locale


注意事项

  • 把宿主模块标记为 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[] 都会让清单校验失败

本页目录