NarraLeaf

サービス

複数のシーンにまたがって共有される、カスタムかつストーリーに登録されたロジックのための `Service` 抽象クラス。アクション、シリアライズ、非同期処理を扱う

Service は、複数のシーンにまたがって共有できるカスタムアクション、振る舞い、その他任意のカスタムロジックを作成するための抽象クラスです。

使用するには、これを継承し、コンストラクター内で各アクションのハンドラーを登録し、データをゲームと一緒に保存するために serialize と deserialize を実装します。

import {Service} from "narraleaf-react";

例

シンプルなギャラリーを実装するカスタムサービスの例を示します。

type GalleryActions = {
    add: [name: string];
};

type GalleryData = {
    unlocked: string[];
};

class Gallery extends Service<GalleryActions, GalleryData> {
    // カスタムデータ
    unlocked: string[] = [];

    constructor() {
        super();

        // アクションハンドラーを登録する。name の型は GalleryActions から決まる
        this.on("add", (ctx, name) => {
            this.unlocked.push(name);
        });
    }

    /* データをゲームと一緒に保存し、セーブから復元する */
    serialize(): GalleryData {
        return {
            unlocked: [...this.unlocked],
        };
    }

    deserialize(data: GalleryData): void {
        this.unlocked = [...data.unlocked];
    }

    /* カスタムサービスロジック */
    add(name: string) {
        // アクションをトリガーする
        return this.trigger("add", name);
    }
}

このサービスは、ゲーム内で次のように使用できます:

const gallery = new Gallery();
story.registerService("gallery", gallery);

myScene.action([
    gallery
        .add("image1")
        .add("image2")
        .add("image3"),
]);

カスタムサービスの作成

サービスの実装

サービスを実装するには、Service クラスを継承します。2 つの型パラメーターは、サービスが扱うアクションと、サービスが保存するデータです。

type MyCustomActions = {
    myAction: [arg0: string, arg1: number];
};

type MyCustomData = {
    count: number;
};

class MyCustomService extends Service<MyCustomActions, MyCustomData> {
    count = 0;

    /**
     * サービスをデータにシリアライズする
     *
     * **注:** データは JSON にシリアライズ可能でなければならない。保存するものがない場合は null を返す
     */
    serialize(): MyCustomData | null {
        return {count: this.count};
    }

    /**
     * サービスにデータを読み込む
     * @param data serialize が返したデータ
     */
    deserialize(data: MyCustomData): void {
        this.count = data.count;
    }
}
  • serialize は、ゲームをセーブするたびに呼ばれます。また、プレイヤーが後で戻れるよう、行やメニューがバックログに記録されるたびにも呼ばれます。サービスの状態そのものではなく、そのコピーを返してください。エンジンは serialize が返したものを保持するため、状態への参照をそのまま返すと、サービスと一緒に変わり続けてしまいます。
  • deserialize は、セーブをロードしたときに、serialize が返したデータとともに呼ばれます。セーブにそのサービスのデータがない場合(null を返していた、またはサービスを登録する前に作られたセーブである場合)は呼ばれず、サービスは今の状態のままです。
  • 保存するものがないサービスは、serialize で null を返し、deserialize は空のままにします。

アクションの登録

アクションを登録するには、コンストラクター内で on メソッドを使用します。ハンドラーはアクションが実行されるたびに(セーブのロード後も含めて)名前で検索されるため、ストーリーが始まる前に登録されている必要があります。ハンドラーが見つからないアクションは、警告を記録して何もしません。

class MyCustomService extends Service<MyCustomActions, MyCustomData> {
    constructor() {
        super();

        // ctx、arg0、arg1 の型は MyCustomActions から決まる
        this.on("myAction", (ctx, arg0, arg1) => {
            // カスタムロジック
        });
    }

    /* serialize と deserialize */
}

ctx については、ServiceHandlerCtx を参照してください。

ハンドラーとトリガーの型チェックは、アクションの型定義に基づきます。型定義なしで宣言したサービス(extends Service)は、任意のアクション名と任意の引数を受け付けます。

非同期アクション

Promise を返すハンドラは、セリフの 1 行と同じように、その Promise が確定するまでストーリーを待たせます。それ以外の値を返すハンドラでは、ストーリーはすぐに先へ進みます。

this.on("load", async (ctx, url: string) => {
    const response = await fetch(url, { signal: ctx.signal });
    const data = await response.json();
    if (ctx.signal.aborted) return; // 待っている間にキャンセルされた:ゲームには触らない

    this.data = data;
});

Promise が reject された場合、ストーリーはこのアクションで止まり、エラーが報告されます。

1.3.0 以降、ハンドラは Promise を返せば必ず待たれます。以前は async と宣言された関数だけが待たれていました。Promise を返す普通の関数((ctx) => fetch(url))は、ストーリーが待たずに実行され、reject された Promise はストーリーを何も報告しないまま永遠に止めていました。

ストーリーを待たせている間、ハンドラはプレイヤーの操作を 2 つの形で知ります。ゲームがそこから離れるときはキャンセルされ、プレイヤーがスキップしたときは急ぐよう求められます。

キャンセル

プレイヤーがその手前まで巻き戻したとき、ストーリーがそこからジャンプしたとき、それが実行されていた Control.any で別の分岐が先に終わったとき、またはセーブデータをロードしたとき、その実行はキャンセルされ、ctx.signal が中断されます。これは標準の AbortSignal なので、fetch など、それを受け取るものにそのまま渡せます。

キャンセルは拒否できず、ハンドラを待つこともありません。シグナルが発火した時点で、ゲームはすでに先へ進んでいます。実行中の処理(ネットワークリクエスト、タイマー、アニメーションなど)を止め、それまでに生じた副作用を戻すのはスクリプト側の責任です。そうしておくことで、巻き戻した後に同じアクションを再び実行しても、まったく同じ結果になります。await のたびに、ゲームに触る前に ctx.signal.aborted を確認してください。キャンセルされた実行が最後に出す reject(fetch の AbortError など)は報告されません。

スキップ

プレイヤーがスキップしたとき(スキップキーを一度押す・押し続ける、またはホストが skipDialog や fastForward を呼んだとき)、ストーリーを待たせているハンドラは ctx.skip を通じて急ぐよう求められます。求められても終了はしません。スキップされることが何を意味するかは、ハンドラ自身が決めます。

応え方方法スキップキーの長押しfastForward
すぐに終えるctx.skip.onRequest の中で、行っていたことを最終状態にして戻る先へ進む先へ進む
無視する何もしない。1.3.0 より前に書かれたハンドラはすべてこれハンドラを待つ最大 stepTimeout 待ち、"stalled" を報告
拒否するctx.skip.refuse(message?)ハンドラを待つただちに止まり、"refused" を報告
type MinigameActions = {
    countdown: [seconds: number];
    fishing: [];
};

class Minigames extends Service<MinigameActions, null> {
    constructor() {
        super();

        // すぐに終える:スキップするとカウントダウンがすぐに終わる。
        this.on("countdown", async (ctx, seconds) => {
            await new Promise<void>(resolve => {
                const timer = setTimeout(resolve, seconds * 1000);
                ctx.skip.onRequest(() => {
                    clearTimeout(timer);
                    resolve();
                });
                ctx.signal.addEventListener("abort", () => clearTimeout(timer));
            });
        });

        // 拒否する:早送りはプレイヤーの代わりにミニゲームを遊べないので、ここで止まる。
        this.on("fishing", async (ctx) => {
            ctx.skip.refuse("waiting for the fishing minigame");
            await playFishing(ctx.signal);
        });
    }

    serialize() {
        return null;
    }

    deserialize() {
    }
}
  • onRequest は、プレイヤーが初めてスキップしたときに 1 回、初めてスキップモードでスキップしたとき(request.forced)にもう 1 回呼ばれます。これはセリフが区別しているのと同じで、スキップキーを一度押すと行を次の一時停止まで表示し、押し続けると一時停止も越えて進みます。スキップモードが繰り返すたびに呼ばれることはありません。
  • refuse は拒否を取り下げる関数を返します。拒否は重ねられ、すべて取り下げるとアクションは再びスキップできるようになります。メッセージは人のためのもので、ログに出力され、fastForward から返されます。エンジンはこれをもとに何も判断しません。
  • ctx.skip.fastForwarding は、ゲームがいま早送り中かどうかを示します。ハンドラは開始時にこれを確認して、最終状態へ直行できます。
  • 要求はブロードキャストされ、ステージ上の他のものと同じように届きます。Control.all の分岐や非同期分岐で実行されているハンドラも、プレイヤーがスキップするたびに求められます。ステージのクリックは要求ではありません。
  • 求められるのは Promise を返すハンドラだけです。同期ハンドラは、プレイヤーが求められるより前に終わっています。

すべてのメンバーは ServiceHandlerCtx を参照してください。

ctx.signal と ctx.skip は 1.3.0 以降で利用可能です。ctx.signal と同じ場面で発火する ctx.onAbort は非推奨です。

アクションのトリガー

アクションをトリガーするには、trigger メソッドを使用します。

const service = new MyCustomService();

scene.action([
    service.trigger("myAction", "foo", 123)
]);

あるいは、アクションをメソッドの中にラップすることもできます。

class MyCustomService extends Service<MyCustomActions, MyCustomData> {
    myAction(arg0: string, arg1: number) {
        return this.trigger("myAction", arg0, arg1);
    }
}

const service = new MyCustomService();

scene.action([
    service.myAction("foo", 123),

    service
        .myAction("foo", 123)  // アクションはラップされ、チェーン可能になる
        .myAction("bar", 456), // チェーンの挙動はゲームが自動的に管理する
]);

サービスへのアクセス

サービスを作成した後は、名前を付けてストーリーに登録します。登録することで、サービスのデータがセーブに含まれ、UI から名前で見つけられるようになります。

const story = new Story(/* ... */);
story.registerService("gallery", gallery);

コンポーネントからは useLiveGame を通して読み取ります。

function GalleryPage() {
    const liveGame = useLiveGame();
    const gallery = liveGame.story?.getService<Gallery>("gallery");

    return (
        <ul>
            {gallery?.unlocked.map((name) => (
                <li key={name}>{name}</li>
            ))}
        </ul>
    );
}

その名前でサービスが登録されていない場合、getService はエラーを投げます。

公開メソッド

on<K extends StringKeyOf<Content>>

アクションハンドラーを登録します。

class MyCustomService extends Service<MyCustomActions, MyCustomData> {
    constructor() {
        super();

        this.on("myAction", (ctx, arg0, arg1) => {
            // カスタムロジック
        });
    }
}
  • key: K - アクションキー
  • handler: ServiceHandler<Content[K]> - ServiceHandlerCtx とアクションの引数を受け取って呼ばれる。Promise を返すハンドラーは、それが確定するまでストーリーを待たせる。非同期アクションを参照
  • 戻り値: this

チェーン可能なメソッド

trigger<K extends StringKeyOf<Content>>

アクションをトリガーします。

const service = new MyCustomService();

scene.action([
    service.trigger("myAction", "foo", 123)
]);
  • key: K - アクションキー
  • ...args: Content[K] - アクションの引数

このページの目次