NarraLeaf

运行时接口

游戏侧的接口——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.datareadJson(namespace)——读取随游戏一同发布的插件存储
game.configget(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.readapp.game.state 就是 undefined——没有东西可调用,也没有东西可捕获。请检查它是否存在,不要用 try 包起来

整份契约就这么多。安装提示列出的正是 app.game 上存在的那些域,所以作者批准的内容和你的插件能做的事,在构造上就是同一个集合。这些权限永远不用你手写——Studio 会从 contributes 派生它们,手写的权限是一种 清单错误

十项能力

能力授予用途
storeapp.game.store插件作用域的持久键值存储,存放在玩家的存档旁边
eventsapp.game.events订阅游戏生命周期与故事事件
state.readapp.game.state——getonChange读取并观察故事变量
state.writeapp.game.state.set写入故事变量。隐含 state.read
saves.readapp.game.saves——listIdsreadMetadata列出存档槽位并读取它们的元数据
saves.writeapp.game.saves.write / .load覆盖一个槽位,或替换正在进行的游玩进程
ui.overlayapp.game.ui.overlay在游戏之上绘制一个元素
assetsapp.game.assets把打包好的资源 id 解析成 URL
localeapp.game.locale读取并观察游戏的显示语言
story.compileapp.game.story注册一个编译遍历,它能看到每一个场景,并在它的行周围注入引擎动作。十项里最重的一项:一个 pass 会跑遍工程的每一个场景、看见每场里谁在说话,并能在它没写过的行旁边放动作。它不是 state.write 的变相,也不隐含它 —— pass 只造动作,不跑动作

这份列表是封闭的。无法识别的能力字符串会让清单校验失败,而不是被忽略,所以拼错永远不会被读成“什么都没申请”

其中两项刻意把一个域一分为二:

  • state.write 隐含 state.read 只声明 write 会低报插件实际能做的事——凡是你能写的,你都能观察。Studio 会替你补上 state.read
  • saves.writesaves.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.gamesetup(app) 收到的 app.game同一个对象。一套 API,一组能力,节点内外一致

子页面

运行时接口没有什么

  • 没有 hostAdapter 节点的 ctx 只带 paramsresolveInputeventNameeventPayloadsignalgame——别无其他。早期构建通过 ctx.hostAdapter.blueprintRuntime.hostApi 泄漏了宿主完整的内部 API;那条路径已经没了。现在一切都走 ctx.game,而它恰好就是清单声明的那些
  • 没有编辑器服务。 app.servicesapp.privilegedui 套件只属于 studio 入口。runtime 入口是游戏代码
  • 没有 react-dom/client 宿主以 external 的形式提供 reactreact-dom 和 JSX 运行时,但插件绝不能挂载自己的 React root——第二个 root 会在同一棵树上和宿主的打架。返回元素,让宿主去渲染
  • 没有编辑器 i18n。 app.services.i18n 是编辑器的 UI 语言。游戏通过 NarraLeaf 自己的本地化系统来本地化面向玩家的内容;运行时接口只告诉你当前是哪个 locale
  • 没有清理生命周期。 游戏进程只加载一次插件,从不卸载,所以 setup 没有要返回的清理函数,register 返回 void

本页目录