16 KiB
加载系统
一次加载把游戏运行所需的外部数据取到内存:核心配置、地图与图块、道具与敌人,以及作者追加的资源。这些数据的来源、大小和用途各不相同,却要放进同一个进度条里等待。数据端因此把加载拆成三层:
- 单个资源:一个文件、一次请求、一份结果,是最小单位。
- 任务集合:把若干资源放在一起推进,累计任务数与字节数。
- 阶段编排:决定先加载哪一批、每批完成后允许追加什么,最后统一收尾。
三层依次向上依赖。本篇先给出整体模型,再解释模型里的每个概念,最后说明配置、钩子与自定义内容。
系统模型
| 对象 | 负责什么 | 什么时候会用到 |
|---|---|---|
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() 按固定顺序推进,前一段没有完成就不会进入下一段:
- 加载核心配置(
addCoreConfig登记的任务)。 - 触发
onCoreConfigLoaded,此时可以追加额外配置与自定义内容。 - 加载额外配置(
addExtraConfig登记的任务)。 - 触发
onExtraConfigLoaded,此时可以继续追加自定义内容。 - 加载自定义内容 / 自定义任务(
addCustomContent/addCustomTask登记的任务)。 - 全部任务完成,
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 导览。