Files
template/docs/advanced/iii/loading-system.md
T

16 KiB
Raw Blame History

加载系统

一次加载把游戏运行所需的外部数据取到内存:核心配置、地图与图块、道具与敌人,以及作者追加的资源。这些数据的来源、大小和用途各不相同,却要放进同一个进度条里等待。数据端因此把加载拆成三层:

  • 单个资源:一个文件、一次请求、一份结果,是最小单位。
  • 任务集合:把若干资源放在一起推进,累计任务数与字节数。
  • 阶段编排:决定先加载哪一批、每批完成后允许追加什么,最后统一收尾。

三层依次向上依赖。本篇先给出整体模型,再解释模型里的每个概念,最后说明配置、钩子与自定义内容。

系统模型

对象 负责什么 什么时候会用到
LoadTask 代表一个资源:一个地址、一种读取方式、一个处理结果 一般由加载器创建,扩展时才直接接触
LoadManager 收集一批任务,推进它们并累计任务数与字节数 读进度、显示进度条
MotaDataLoader 按阶段登记并加载核心配置、额外配置和自定义内容 登记配置、注册钩子、追加自定义内容

一个任务只关心自己的地址与结果,管理器只关心这一批还有多少没完成,加载器只关心阶段的先后。

flowchart TD
    L[MotaDataLoader<br/>决定先加载哪一批] --> M[LoadManager<br/>汇总任务与进度]
    M --> T[LoadTask<br/>处理一个资源]
    T --> F[取数据<br/>把文件读进内存]
    T --> P[处理数据<br/>转成业务对象]

核心概念

下面从最底层的「响应」开始,自下而上解释模型里用到的接口与类型。

IResponseLike:响应的最小契约

取数据的结果不是一个裸文件,而是一个响应对象。IResponseLike 只要求三样信息:url(最终地址)、headers(可按名称读取响应头)、body(可以为 null,不为空时能按块读取)。在此之上,它要求能把响应体转换成五种形式之一:arrayBuffer()、bytes()、blob()、json()、text()。

浏览器的标准 Response 在结构上满足这份契约,所以网页端不需要额外包装。自定义 starter 可以返回自己的对象,但必须完整提供上述成员;只实现实际会用到的读取方法在类型上并不成立。

ILoadTaskStarter:决定去哪里、怎么取

starter 是「取数据」的适配点,只有一个方法:

start(request: ILoadTaskRequest): Promise<IResponseLike>

ILoadTaskRequest 包含 url、可选的 method、body 和 headers。starter 不解析内容,也不决定业务含义,只负责按请求配置拿到响应。网页端的 WebLoadStarter 直接调用 fetch(request.url, { method, body, headers }),把得到的 Response 原样返回。

LoadDataType:原始响应按什么格式读

dataType 告诉任务「把响应体读成什么」。它不影响请求本身,只决定交给处理器的原始值类型。五个取值与产物如下:

LoadDataType 产物
LoadDataType.ArrayBuffer ArrayBuffer
LoadDataType.Uint8Array Uint8Array
LoadDataType.Blob Blob
LoadDataType.Text string
LoadDataType.JSON 解析后的 JSON 值(any)

前三种保留二进制,Text 做文本解码,JSON 在文本解码后再执行 JSON.parse。选错类型不会在请求阶段报错,而是在读取或解析阶段失败,因此它必须和文件的实际内容一致。

ILoadTaskProcessor:把原始值变成业务结果

processor 负责「处理数据」:process(response, task): Promise<R>。第一个参数是上一步按 dataType 读出的原始值,第二个参数是任务本身,需要地址、标识符或任务钩子时可以从这里取。返回的 R 就是任务最终兑现的结果。

processor 可以做校验、兼容旧格式、补默认值,也可以把文本解析成结构化对象。任务允许不设置 processor,此时原始值会直接作为结果;配置加载总是设置 processor,因为它还要去掉 JSONC 前缀。

LoadTask<T, R>:把一个资源串起来

LoadTask 把前面四件事收在一起:identifier(任务名)、url、dataType,以及 starter 与 processor。T 是 dataType 的类型参数,R 是处理结果类型。

  • start() 返回异步迭代器,每加载一段就产出一个已加载字节数;它会先向 starter 要响应,再按 dataType 读取,最后交给 processor。
  • loaded() 返回 Promise<R>,处理完成时兑现。
  • getLoadedData() 读取已完成的结果,未完成时是 null。

任务本身不写配置表,也不决定自己何时开始;这两件事分别由加载器和阶段顺序负责。

概念 一句话职责 关键成员
IResponseLike 响应的最小契约 url、headers、body、五种读取方法
ILoadTaskStarter 决定去哪里取 start(request)
LoadDataType 原始值按什么格式读 五个枚举值
ILoadTaskProcessor 原始值转业务结果 process(response, task)
LoadTask 把一个资源串起来 start()、loaded()

一次加载的阶段

MotaDataLoader.start() 按固定顺序推进,前一段没有完成就不会进入下一段:

  1. 加载核心配置(addCoreConfig 登记的任务)。
  2. 触发 onCoreConfigLoaded,此时可以追加额外配置与自定义内容。
  3. 加载额外配置(addExtraConfig 登记的任务)。
  4. 触发 onExtraConfigLoaded,此时可以继续追加自定义内容。
  5. 加载自定义内容 / 自定义任务(addCustomContent / addCustomTask 登记的任务)。
  6. 全部任务完成,loaded() 兑现。
flowchart TD
    A[核心配置] --> B[onCoreConfigLoaded]
    B --> C[额外配置]
    C --> D[onExtraConfigLoaded]
    D --> E[自定义内容 / 自定义任务]
    E --> F[loaded 兑现]

阶段顺序决定了两条最常用的规则:

  • addExtraConfig 必须在额外配置的收集循环之前调用:加载开始前,或 onCoreConfigLoaded 里都可以;放到 onExtraConfigLoaded 已经错过本次加载。
  • addCustomContent 与 addCustomTask 必须在自定义内容的收集循环之前调用:加载开始前,或两个加载钩子里都可以;加载结束后再追加不会被读取。优先使用 addCustomContent,只有手里已经有现成任务实例时才用低一层的 addCustomTask。

loader.start() 在一次运行中只会推进一次,第二次调用直接返回,所以错过窗口没有补读机会。

配置文件与 getConfig

配置文件是带系统前缀的 JSONC:注释集中在开头,最后一行注释固定为 // --- SYSTEM PREFIX END --- //,之后才是 JSON。加载器按这行标记切分,再对后半段做 JSON.parse。前缀块只供人阅读,标记之前的内容不会成为字段;完全没有标记时,整个文件按 JSON 解析。

// Type: src/data/types IQuestConfig
// 前缀块可以写任意说明,加载器不会读取这些文字
// --- SYSTEM PREFIX END --- //
{
    "quests": {
        "main-01": {
            "title": "初入魔塔",
            "reward": 100
        }
    }
}

两类配置的职责与路径作用域不同:

方法 用途 url 相对于谁 开发环境中的实际路径
addCoreConfig(id, url) 核心配置,决定内容清单与顶层信息 src 入口传 content/core.jsonc,读 src/content/core.jsonc
addExtraConfig(id, url) 额外配置,按核心配置给出的内容目录展开 src 入口传 content/enemy.jsonc,读 src/content/enemy.jsonc

addCoreConfig 与 addExtraConfig 的路径都相对于 src:默认入口传入的正是 content/core.jsonc,额外配置也按 src 下的实际位置填写,例如传 content/quest/quest.jsonc 得到的是 src/content/quest/quest.jsonc。构建后 src 前缀会被去掉,content 目录仍在游戏根目录下,配置文件路径由加载器统一改写,不需要自己补环境前缀。

默认的 DefaultDataLoaderHook 会读取核心配置里的内容目录,追加 enemy、item、map-list、tile 等额外配置。

getConfig<T>(id) 只有在配置表中存在对应项时才返回值:从未登记、任务尚未完成或加载失败时都返回 null。配置文件已经跑过处理器、但标记出现多次或 JSON 解析失败时,处理器会产出空对象并照样存进配置表,所以返回 {}。空对象是 truthy,if (!config) 拦不住它。判断「没拿到配置」用 config === null,判断「拿到了但没有内容」再用字段检查。

注册一个加载钩子

钩子接口是 IMotaDataLoaderHooks,用 loader.addHook(hook) 注册。回调可以是 async,加载器会在阶段结束时统一等待;注册返回的控制器可以 unload() 注销。

import {
    ICoreState,
    IMotaDataLoader,
    IMotaDataLoaderHooks
} from '@user/data-state';

/** 在核心配置加载后追加任务配置 */
class QuestConfigHook implements IMotaDataLoaderHooks {
    async onCoreConfigLoaded(loader: IMotaDataLoader): Promise<void> {
        // 额外配置的 url 相对于 src
        loader.addExtraConfig('quest', 'content/quest/quest.jsonc');
    }
}

/** 通常写在插件的 awake 或初始化代码里 */
export function registerQuestConfigHook(state: ICoreState): void {
    // 注册后立即参与本次加载;需要提前移除时调用返回控制器的 unload()
    state.loader.addHook(new QuestConfigHook());
}

同一阶段登记的配置并行加载,完成先后不保证。需要读取另一份配置的内容时,把逻辑放到 onExtraConfigLoaded 之后,或等插件 afterLoad;不要假设先登记的配置先完成。

加载自定义内容

addCustomContent 用于追加一个 src 下的文件,并直接拿到处理后的结果。它替你创建 LoadTask、使用加载器自己的 starter,再把任务放进自定义内容这一批,因此不需要手动拼装任务。

addCustomContent<T, L extends LoadDataType>(
    init: ICustomContentInit<T, L>
): Promise<T>;

ICustomContentInit 的四个字段:

字段 含义
identifier 任务标识,不能与已有加载任务重复
url 文件路径,相对于 src;文件必须放在 src 下,格式不限
dataType 按什么格式读取响应
processor 把原始值转换成 T 的处理器

返回的 Promise<T> 在任务处理完成后兑现,结果不会写进 getConfig 的配置表。调用时机是加载开始前、onCoreConfigLoaded 或 onExtraConfigLoaded;加载完成后再调用会被忽略,任务不会执行,返回的 Promise 也不会兑现。

一个完整的用法如下:processor 把按行文本转成任务数据,插件在 awake 里登记并保存 Promise,在 afterLoad 里等待结果。

import { ICoreState, IDataPluginDefine } from '@user/data-state';
import { ILoadTask, ILoadTaskProcessor, LoadDataType } from '@motajs/loader';

interface IQuestBook {
    title: string;
    entries: string[];
}

/** 把 UTF-8 文本转成任务数据 */
class QuestBookProcessor implements ILoadTaskProcessor<
    LoadDataType.Text,
    IQuestBook
> {
    process(
        text: string,
        _task: ILoadTask<LoadDataType.Text, IQuestBook>
    ): Promise<IQuestBook> {
        const [title, ...entries] = text.split('\n').filter(Boolean);
        return Promise.resolve({ title, entries });
    }
}

/** 在加载前登记,加载完成后再使用结果 */
export class QuestBookPlugin implements IDataPluginDefine {
    readonly id = 'quest-book';
    readonly dataDeps: readonly string[] = [];
    private book!: Promise<IQuestBook>;

    awake(state: ICoreState): void {
        this.book = state.loader.addCustomContent({
            identifier: 'quest-book',
            url: 'content/quest-book.txt',
            dataType: LoadDataType.Text,
            processor: new QuestBookProcessor()
        });
    }

    async initialize(): Promise<void> {}

    async afterLoad(): Promise<void> {
        const book = await this.book;
        // 在这里使用 book
    }
}

addCustomTask 是更低一层的逃生口:已经有一个构造好的 ILoadTask 实例时,直接把它交给加载器并等待结果。新增一种资源类型如果需要的不止这些,应反馈给引擎维护者,不要按现有任务拼一套旁路流程。

自定义 starter

换加载环境只需要实现 ILoadTaskStarter:start(request) 接收请求配置,返回 Promise<IResponseLike>。网页端返回的 Response 本身满足 IResponseLike,因此自定义 starter 通常只是改请求地址、请求方式或错误处理。

import {
    ILoadTaskRequest,
    ILoadTaskStarter,
    IResponseLike
} from '@motajs/loader';

/** 把所有请求转发到另一个资源来源 */
export class BaseURLLoadStarter implements ILoadTaskStarter {
    constructor(private readonly baseURL: string) {}

    start(request: ILoadTaskRequest): Promise<IResponseLike> {
        // 请求由任务给出,starter 只负责取响应
        return fetch(new URL(request.url, this.baseURL), {
            method: request.method,
            body: request.body,
            headers: request.headers
        });
    }
}

把 starter 实例传给数据端入口即可,例如 createCoreState(new BaseURLLoadStarter('https://example.com/'))。任务、处理器与阶段顺序都不需要改。

进度

加载由 state.load() 驱动。界面要显示进度时,读 loader.manager 上的计数即可,不需要接管加载:

成员 含义
getLoadedTasks() 已经加载完成的任务数
getAddedTasks() 已经加入的任务数
getTaskRatio() 已完成任务数与已加入任务数之比
getLoadedByte() 已加载的字节数
getByteRatio() 已加载字节与总量之比

这些计数只用于展示。需要等待加载结果时,应当读配置、读任务结果或等 loaded(),而不是等某个比例。渲染端可以复用同一个加载器:在 onCoreConfigLoaded 里追加贴图、音频、动画与字体等渲染侧配置,再从同一个 loader.manager 读取计数绘制进度条。

边界与注意

  • 配置 id 是键:同一个 id 在配置表中只保留一份,配置完成时若 id 已被占用,后来的任务会告警并被丢弃;需要两份配置时使用不同 id。
  • addExtraConfig 的时机:只能在加载开始前或 onCoreConfigLoaded 中调用;在 onExtraConfigLoaded 里追加已经错过本次加载。
  • addCustomContent 的时机与路径:只能在加载开始前、onCoreConfigLoaded 或 onExtraConfigLoaded 中调用;url 相对于 src,文件必须位于 src 下,加载完成后再调用会被忽略。
  • 解析失败是空对象:JSONC 标记出现多次或 JSON 非法时,加载器告警并把该配置存成 {},整次加载继续;getConfig 对未登记 id 返回 null,对解析失败返回 {}。
  • 自定义 starter 的返回契约:start 必须返回满足 IResponseLike 的对象,标准 Response 可以直接使用。

返回高级 III 导览。