NarraLeaf

API リファレンス

studio と runtime、2 つのプラグインサーフェスをメソッド単位で解説し、例と落とし穴を添える

型のみの narraleaf-studio パッケージからインポートする 2 つのエントリーポイントがあります。

  • 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 は登録ではなく操作であり、クリーンアップを返しません。
  • 1 つだけ例外があります。 blueprintNodes.register / registerMany は void を返します。ノード定義はセッションを通じて永続するため――1 つを取り除くと開いているドキュメント内のノードが孤立してしまいます――破棄すべきものが何もないのです。

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)言語切り替え時に新しいロケールとともに発火します。クリーンアップを返します。
createTranslator(bundle)あなた自身の { locale: { key: string } } テーブルに基づく翻訳器。t(key, params?) はアクティブ → フォールバック → キー自身の順で解決し、{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。英数字と、その間に置く .、_、-。同じ文字である間は変えないでください。翻訳はこの id に結び付きます。
textプロジェクトのソース言語での文字。
context省略可。プレイヤーがその文字を目にする場所で、翻訳者向けの情報です。例:File › Save。省略時はプラグイン名。

英字を含む項目は、翻訳テーブルの Interface text(画面のテキスト)にプラグイン名でまとめて 1 行ずつ並びます。翻訳単位は plugin:<pluginId>/<id> です。runtime エントリーは app.game.locale.words(id, text) でプレイヤーの言語の文字を読み戻します。ui.WordsField は、Studio 自身のラベルと同じ Direct(直接入力)か Translation key(翻訳キー)の選択を作者に提供します。

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

送りっぱなしのトースト通知です。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

ストーリーブロックを挿入する、シーンエディターのパレットアクションです。ブロックは、プラグインがなくてもドキュメントに残る標準のストーリーブロックか、runtime エントリーが game.storyActions で実行するプラグイン独自の行(action: "plugin")のどちらかです。

app.services.story.actions.register({
    id: `${PLUGIN_ID}.insertNote`,
    label: "Insert Note",
    createBlock: () => ({ /* a story block */ }),
});

register と registerMany はクリーンアップを返します。createBlock は作者がコマンドの後に入力したテキストを initialText として受け取り、任意の describe(params) がプラグインの行に表示されるテキストを決めます。keywords はどの表示言語でもアクションに届くコマンド語、params はプラグインの行で Properties パネルに表示するフィールドの一覧です(どちらも 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) はゲームプロセス(Dev Mode、Preview、Production)ごとに 1 回実行されます。非同期でもかまいません。クリーンアップの返り値はありません――ゲームプロセスはプラグインをアンロードしないためです。

app は app.plugin、app.manifest、app.game を持ちます。

app.game には常に存在する 6 つのメンバー(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ノードが宣言したデータ入力ピンの 1 つを、配線されたエッジをたどって読み取ります。遅延評価で、ピンが未配線または未宣言の場合は 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" };
}

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 が書き込んだものに、作者が Properties パネルで行った編集が加わります。実行のたびに新しいコピーが渡されます。
ctx.signalAbortSignalrun がまだ終わっていないうちに、プレイヤーがこの行より前に巻き戻したとき、またはセーブを読み込んだときに中断されます。
ctx.gameRuntimePluginGamesetup(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}] という接頭辞付きでゲームホストのログに書き込みます。Dev Mode ではウィンドウのコンソールに送られ、Preview と Production ではゲームプロセスのログに送られます。

能力によってゲートされる名前空間

これらはそれぞれ、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.sidecars があればよいsidecar
game.storystory.compileRuntime API
game.navigationなし――空でない contributes.externalLinks があればよいRuntime API

プレイヤーの言語

runtime エントリーはゲームの中で実行されます――Production にはエディターもエディターロケールも存在しないため、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[] に書き込むと、マニフェストの検証に失敗します。

このページの目次