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 类。它的两个类型参数分别是服务处理的操作和服务保存的数据:

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 时,故事会停在这里等它结算,就像等一句对话那样;返回其他值时,故事立即继续。

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 被拒绝,故事会停在这个操作上,并报告错误。

自 1.3.0 起,只要处理函数返回 promise 就会被等待。此前只有声明为 async 的函数才会被等待:直接返回 promise 的普通函数(如 (ctx) => fetch(url))运行后故事不会等它;promise 被拒绝时故事会永远停在那里,而且不报告任何错误。

在故事等待它的这段时间里,处理函数会通过两种方式得知玩家的操作:游戏离开它时,它会被取消;玩家跳过时,它会被催促。

取消

以下情况会取消一次运行:玩家回退到它之前、故事跳转离开它、它所在的 Control.any 被其他分支抢先完成,或者读取了存档。此时 ctx.signal 会被中止。它是标准的 AbortSignal,可以直接交给 fetch 等接受它的 API。

取消不能拒绝,也不会等待处理函数:信号触发时,游戏已经继续往下走了。脚本有义务停止仍在进行的工作(网络请求、定时器、动画等),并撤回已经产生的副作用,以确保回退后再次执行同一操作时得到完全相同的结果。每次 await 之后,先检查 ctx.signal.aborted 再去动游戏。被取消的运行最后抛出的拒绝(例如 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 在玩家第一次跳过时调用一次,在第一次以跳过模式跳过(request.forced)时再调用一次。这与对话的区分一致:单按跳过键会把一句话显示到下一个停顿处,长按则连停顿一起跳过。跳过模式下的每次重复都不会再调用它。
  • 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), // 游戏将自动管理链式行为
]);

访问服务

创建服务后,要用一个名称把它注册到故事中。注册之后,服务的数据才会进入存档,界面也才能按名称找到它。

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] - 操作参数

本页目录