refactor: 客户端加载

This commit is contained in:
AncTe committed 2026-03-20 16:08:43 +08:00
1 parent fdeee99d40
commit 4fc6db3e79
55 files changed
+1699 -1234

No files matched your search

+8 -1
View File
@@ -1,9 +1,16 @@
import { createMaterial } from './material';
import { loading } from '@user/data-base';
import { createMaterial, fallbackLoad } from './material';
import { materials } from './ins';
export function create() {
createMaterial();
loading.once('loaded', () => {
fallbackLoad(materials);
loading.emit('assetBuilt');
});
}
export * from './load';
export * from './material';
export * from './ins';
+39 -1
View File
@@ -1,4 +1,16 @@
import { BGMPlayer, MotaAudioContext, SoundPlayer } from '@motajs/audio';
import {
AudioType,
BGMPlayer,
MotaAudioContext,
OpusDecoder,
SoundPlayer,
VorbisDecoder
} from '@motajs/audio';
import { MotaAssetsLoader } from './load/loader';
import { AutotileProcessor, MaterialManager } from './material';
import { loadProgress } from '@user/data-base';
//#region 音频实例
/** 游戏全局音频上下文 */
export const audioContext = new MotaAudioContext();
@@ -6,3 +18,29 @@ export const audioContext = new MotaAudioContext();
export const soundPlayer = new SoundPlayer(audioContext);
/** 音乐播放器 */
export const bgmPlayer = new BGMPlayer(audioContext);
audioContext.registerDecoder(AudioType.Opus, () => new OpusDecoder());
audioContext.registerDecoder(AudioType.Ogg, () => new VorbisDecoder());
//#endregion
//#region 素材实例
/** 素材管理器 */
export const materials = new MaterialManager();
/** 自动元件处理器 */
export const autotile = new AutotileProcessor(materials);
//#endregion
//#region 加载实例
/** 全局加载实例 */
export const loader = new MotaAssetsLoader(
loadProgress,
audioContext,
soundPlayer,
materials
);
//#endregion
@@ -0,0 +1,37 @@
export const iconNames: string[] = [
'floor',
'lv',
'hpmax',
'hp',
'atk',
'def',
'mdef',
'money',
'exp',
'up',
'book',
'fly',
'toolbox',
'keyboard',
'shop',
'save',
'load',
'settings',
'play',
'pause',
'stop',
'speedDown',
'speedUp',
'rewind',
'equipbox',
'mana',
'skill',
'btn1',
'btn2',
'btn3',
'btn4',
'btn5',
'btn6',
'btn7',
'btn8'
];
@@ -0,0 +1 @@
export * from './processor';
@@ -0,0 +1,532 @@
import {
ILoadProgressTotal,
LoadDataType,
ILoadTask,
LoadTask,
ILoadTaskProcessor
} from '@motajs/loader';
import {
CompressedUsage,
CustomLoadFunc,
ICompressedMotaAssetsData,
ICompressedMotaAssetsLoadList,
IMotaAssetsLoader
} from './types';
import JSZip from 'jszip';
import {
LoadAudioProcessor,
LoadFontProcessor,
LoadImageProcessor,
LoadJSONProcessor,
LoadTextProcessor,
LoadZipProcessor
} from './processor';
import { IMotaAudioContext, ISoundPlayer } from '@motajs/audio';
import { loading } from '@user/data-base';
import { IMaterialManager } from '../material';
import { ITextureSplitter, Texture, TextureRowSplitter } from '@motajs/render';
import { iconNames } from './data';
interface LoadTaskStore<T extends LoadDataType = LoadDataType, R = any> {
/** 加载任务对象 */
readonly task: ILoadTask<T, R>;
/** 当 `onLoaded` 兑现后兑现的 `Promise` */
readonly loadPromise: Promise<R>;
/** 兑现 `loadPromise` */
readonly loadResolve: (data: R) => void;
/** 当加载任务完成时执行的函数 */
readonly onLoaded: CustomLoadFunc<R>;
}
export class MotaAssetsLoader implements IMotaAssetsLoader {
/** 当前是否正在进行加载 */
loading: boolean = false;
/** 当前加载工作是否已经完成 */
loaded: boolean = false;
readonly imageProcessor: ILoadTaskProcessor<LoadDataType.Blob, ImageBitmap>;
readonly audioProcessor: ILoadTaskProcessor<
LoadDataType.Uint8Array,
AudioBuffer | null
>;
readonly fontProcessor: ILoadTaskProcessor<
LoadDataType.ArrayBuffer,
FontFace
>;
readonly textProcessor: ILoadTaskProcessor<LoadDataType.Text, string>;
readonly jsonProcessor: ILoadTaskProcessor<LoadDataType.JSON, any>;
readonly zipProcessor: ILoadTaskProcessor<LoadDataType.ArrayBuffer, JSZip>;
/** 当前已添加的加载任务 */
private readonly tasks: Set<LoadTaskStore> = new Set();
/** 素材索引 */
private materialsCounter: number = 0;
/** 贴图行分割器,用于处理遗留 `icons.png` */
private readonly rowSplitter: ITextureSplitter<number>;
constructor(
readonly progress: ILoadProgressTotal,
private readonly ac: IMotaAudioContext,
private readonly sounds: ISoundPlayer<SoundIds>,
private readonly materials: IMaterialManager
) {
this.imageProcessor = new LoadImageProcessor();
this.audioProcessor = new LoadAudioProcessor(ac);
this.fontProcessor = new LoadFontProcessor();
this.textProcessor = new LoadTextProcessor();
this.jsonProcessor = new LoadJSONProcessor();
this.zipProcessor = new LoadZipProcessor();
this.rowSplitter = new TextureRowSplitter();
}
//#region 其他处理
private splitMaterialIcons(image: ImageBitmap) {
const tex = new Texture(image);
const splitted = [...this.rowSplitter.split(tex, 32)];
for (let i = 0; i < splitted.length; i++) {
const name = iconNames[i] ? `icon-${iconNames[i]}` : `icons-${i}`;
// todo: 早晚删除 icons.png
const index = this.materialsCounter++;
this.materials.imageStore.addTexture(index, splitted[i]);
this.materials.imageStore.alias(index, name);
}
}
//#region 加载后处理
/**
* 当字体加载完成后的操作
* @param font 字体名称
* @param fontFace 字体 `FontFace` 对象
*/
private fontLoaded(font: string, fontFace: FontFace) {
const suffix = font.lastIndexOf('.');
const family = font.slice(0, suffix);
fontFace.family = family;
document.fonts.add(fontFace);
return Promise.resolve();
}
/**
* 图片加载完成后的操作
* @param name 图片名称
* @param image 图片的 `ImageBitmap`
*/
private customImagesLoaded(name: ImageIds, image: ImageBitmap) {
core.material.images.images[name] = image;
this.materials.addImage(image, {
index: this.materialsCounter++,
alias: name
});
return Promise.resolve();
}
/**
* 音效加载完成后的操作
* @param name 音效名称
* @param buffer 音效解析完毕的 `AudioBuffer`
*/
private soundLoaded(name: SoundIds, buffer: AudioBuffer | null) {
if (buffer) {
this.sounds.add(name, buffer);
}
return Promise.resolve();
}
/**
* 当 tileset 加载完成后的操作
* @param name tileset 名称
* @param image 图片 `ImageBitmap`
*/
private tilesetLoaded(name: string, image: ImageBitmap) {
core.material.images.tilesets[name] = image;
// this.materials.addTileset(image, {
// index: this.materialsCounter++,
// alias: name
// });
return Promise.resolve();
}
/**
* 当自动元件加载完成后的操作
* @param autotiles 自动元件存储对象
* @param name 自动元件名称
* @param image 自动元件的 `ImageBitmap`
*/
private autotileLoaded(
autotiles: Partial<Record<AllIdsOf<'autotile'>, ImageBitmap>>,
name: AllIdsOf<'autotile'>,
image: ImageBitmap
) {
autotiles[name] = image;
loading.addAutotileLoaded();
loading.onAutotileLoaded(autotiles);
core.material.images.autotile[name] = image;
// const num = icon.autotile[name];
// this.materials.addAutotile(image, {
// id: name,
// num,
// cls: 'autotile'
// });
return Promise.resolve();
}
/**
* 当素材加载完成后的操作
* @param name 素材名称
* @param image 素材 `ImageBitmap`
*/
private materialLoaded(name: string, image: ImageBitmap) {
core.material.images[
name.slice(0, -4) as SelectKey<MaterialImages, ImageBitmap>
] = image;
if (name === 'icons.png') {
this.splitMaterialIcons(image);
}
return Promise.resolve();
}
/**
* 当动画加载完成后的操作
* @param animation 动画内容
*/
private animationLoaded(animation: string) {
const data = data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d;
const rows = animation.split('@@@~~~###~~~@@@');
rows.forEach((value, i) => {
const id = data.main.animates[i];
if (value.length === 0) {
throw new Error(`Cannot find animate: '${id}'`);
}
core.material.animates[id] = core.loader._loadAnimate(value);
});
return Promise.resolve();
}
//#endregion
//#region 加载流程
/**
* 开发时的加载流程
*/
private developingLoad() {
const data = data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d;
const icon = icons_4665ee12_3a1f_44a4_bea3_0fccba634dc1;
// font
data.main.fonts.forEach(font => {
const url = `project/fonts/${font}`;
const task = new LoadTask<LoadDataType.ArrayBuffer, FontFace>({
url,
identifier: `@system-font/${font}`,
dataType: LoadDataType.ArrayBuffer,
processor: this.fontProcessor,
progress: this.progress
});
this.addCustomLoadTask(task, data => this.fontLoaded(font, data));
});
// image
data.main.images.forEach(image => {
const url = `project/images/${image}`;
const task = new LoadTask<LoadDataType.Blob, ImageBitmap>({
url,
identifier: `@system-image/${image}`,
dataType: LoadDataType.Blob,
processor: this.imageProcessor,
progress: this.progress
});
this.addCustomLoadTask(task, data =>
this.customImagesLoaded(image, data)
);
});
// sound
data.main.sounds.forEach(sound => {
const url = `project/sounds/${sound}`;
const task = new LoadTask<
LoadDataType.Uint8Array,
AudioBuffer | null
>({
url,
identifier: `@system-sound/${sound}`,
dataType: LoadDataType.Uint8Array,
processor: this.audioProcessor,
progress: this.progress
});
this.addCustomLoadTask(task, data => this.soundLoaded(sound, data));
});
// tileset
data.main.tilesets.forEach(tileset => {
const url = `project/tilesets/${tileset}`;
const task = new LoadTask<LoadDataType.Blob, ImageBitmap>({
url,
identifier: `@system-tileset/${tileset}`,
dataType: LoadDataType.Blob,
processor: this.imageProcessor,
progress: this.progress
});
this.addCustomLoadTask(task, data =>
this.tilesetLoaded(tileset, data)
);
});
// autotile
const autotiles: Partial<Record<AllIdsOf<'autotile'>, ImageBitmap>> =
{};
Object.keys(icon.autotile).forEach(key => {
const url = `project/autotiles/${key}.png`;
const task = new LoadTask<LoadDataType.Blob, ImageBitmap>({
url,
identifier: `@system-autotile/${key}`,
dataType: LoadDataType.Blob,
processor: this.imageProcessor,
progress: this.progress
});
this.addCustomLoadTask(task, data =>
this.autotileLoaded(
autotiles,
key as AllIdsOf<'autotile'>,
data
)
);
});
// material
const materialImages = core.materials.slice() as SelectKey<
MaterialImages,
ImageBitmap
>[];
materialImages.push('keyboard');
materialImages
.map(v => `${v}.png`)
.forEach(materialName => {
const url = `project/materials/${materialName}`;
const task = new LoadTask<LoadDataType.Blob, ImageBitmap>({
url,
identifier: `@system-material/${materialName}`,
dataType: LoadDataType.Blob,
processor: this.imageProcessor,
progress: this.progress
});
this.addCustomLoadTask(task, data =>
this.materialLoaded(materialName, data)
);
});
// animate
const animatesUrl = `all/__all_animates__?v=${main.version}&id=${data.main.animates.join(',')}`;
const animateTask = new LoadTask<LoadDataType.Text, string>({
url: animatesUrl,
identifier: '@system-animates',
dataType: LoadDataType.Text,
processor: this.textProcessor,
progress: this.progress
});
this.addCustomLoadTask(animateTask, data => this.animationLoaded(data));
}
/**
* 获取 `JSZip` 读取方式
* @param type 加载类型
*/
private getZipOutputType(type: LoadDataType): JSZip.OutputType {
switch (type) {
case LoadDataType.Text:
case LoadDataType.JSON:
return 'string';
case LoadDataType.ArrayBuffer:
return 'arraybuffer';
case LoadDataType.Blob:
return 'blob';
case LoadDataType.Uint8Array:
return 'uint8array';
default:
return 'uint8array';
}
}
/**
* 根据应用方式获取其所在文件夹
* @param usage 压缩内容的应用方式
*/
private getZipFolderByUsage(usage: CompressedUsage): string {
switch (usage) {
case CompressedUsage.Image:
return 'image';
case CompressedUsage.Tileset:
return 'tileset';
case CompressedUsage.Autotile:
return 'autotile';
case CompressedUsage.Material:
return 'material';
case CompressedUsage.Font:
return 'font';
case CompressedUsage.Sound:
return 'sound';
case CompressedUsage.Animate:
return 'animate';
}
}
/**
* 处理压缩文件
* @param name 文件名称
* @param value 文件内容
* @param usage 文件的应用方式
*/
private async processZipFile(
name: string,
value: unknown,
usage: CompressedUsage
) {
switch (usage) {
case CompressedUsage.Image: {
const image = await createImageBitmap(value as Blob);
await this.customImagesLoaded(name as ImageIds, image);
break;
}
case CompressedUsage.Tileset: {
const image = await createImageBitmap(value as Blob);
await this.tilesetLoaded(name, image);
break;
}
case CompressedUsage.Material: {
const image = await createImageBitmap(value as Blob);
await this.materialLoaded(name, image);
break;
}
case CompressedUsage.Font: {
const fontFace = new FontFace(
name.slice(0, -4),
value as ArrayBuffer
);
await fontFace.load();
await this.fontLoaded(name, fontFace);
break;
}
case CompressedUsage.Sound: {
const buffer = await this.ac.decodeToAudioBuffer(
value as Uint8Array<ArrayBuffer>
);
await this.soundLoaded(name as SoundIds, buffer);
break;
}
case CompressedUsage.Animate: {
await this.animationLoaded(value as string);
break;
}
}
}
/**
* 处理单个压缩包
* @param list 当前压缩包中包含的内容
* @param zip 压缩包
*/
private async handleZip(list: ICompressedMotaAssetsData[], zip: JSZip) {
const autotiles: Partial<Record<AllIdsOf<'autotile'>, ImageBitmap>> =
{};
const materialImages = core.materials.slice() as SelectKey<
MaterialImages,
ImageBitmap
>[];
materialImages.push('keyboard');
const promises = list.map(async item => {
const { readAs, name, usage } = item;
const folder = this.getZipFolderByUsage(usage);
const file = zip.file(`${folder}/${name}`);
if (!file) return;
const value = await file.async(this.getZipOutputType(readAs));
if (usage === CompressedUsage.Autotile) {
const image = await createImageBitmap(value as Blob);
await this.autotileLoaded(
autotiles,
name.slice(0, -4) as AllIdsOf<'autotile'>,
image
);
}
await this.processZipFile(name, value, usage);
});
await Promise.all(promises);
}
/**
* 游戏中加载(压缩后)
*/
private async playingLoad() {
const loadListTask = new LoadTask<
LoadDataType.JSON,
ICompressedMotaAssetsLoadList
>({
url: `loadList.json`,
dataType: LoadDataType.JSON,
identifier: '@system-loadList',
processor: this.jsonProcessor,
progress: { onProgress() {} }
});
loadListTask.start();
const loadList = await loadListTask.loaded();
const zipTask = new LoadTask<LoadDataType.ArrayBuffer, JSZip>({
url: loadList.file,
identifier: `@system-zip/${loadList.file}`,
dataType: LoadDataType.ArrayBuffer,
processor: this.zipProcessor,
progress: this.progress
});
this.addCustomLoadTask(zipTask, zip => {
return this.handleZip(loadList.content, zip);
});
}
//#endregion
//#region 对外接口
initSystemLoadTask(): void {
if (import.meta.env.DEV) {
this.developingLoad();
} else {
this.playingLoad();
}
}
addCustomLoadTask<R>(
task: ILoadTask<LoadDataType, R>,
onLoaded: CustomLoadFunc<R>
): Promise<R> {
this.progress.addTask(task);
const { promise, resolve } = Promise.withResolvers<R>();
const store: LoadTaskStore<LoadDataType, R> = {
task,
onLoaded,
loadPromise: promise,
loadResolve: resolve
};
this.tasks.add(store);
return promise;
}
load(): Promise<any[]> {
const tasks = [...this.tasks].map(async task => {
task.task.start();
const data = await task.task.loaded();
await task.onLoaded(data);
task.loadResolve(data);
return data;
});
return Promise.all(tasks);
}
//#endregion
}
@@ -0,0 +1,66 @@
import { IMotaAudioContext } from '@motajs/audio';
import { ILoadTask, ILoadTaskProcessor, LoadDataType } from '@motajs/loader';
import JSZip from 'jszip';
export class LoadImageProcessor implements ILoadTaskProcessor<
LoadDataType.Blob,
ImageBitmap
> {
process(response: Blob): Promise<ImageBitmap> {
return createImageBitmap(response);
}
}
export class LoadAudioProcessor implements ILoadTaskProcessor<
LoadDataType.Uint8Array,
AudioBuffer | null
> {
constructor(private readonly ac: IMotaAudioContext) {}
process(response: Uint8Array<ArrayBuffer>): Promise<AudioBuffer | null> {
return this.ac.decodeToAudioBuffer(response);
}
}
export class LoadFontProcessor implements ILoadTaskProcessor<
LoadDataType.ArrayBuffer,
FontFace
> {
process(
response: ArrayBuffer,
task: ILoadTask<LoadDataType.ArrayBuffer, FontFace>
): Promise<FontFace> {
const font = new FontFace(task.identifier, response);
if (font.status === 'loaded') return Promise.resolve(font);
else return font.load();
}
}
export class LoadZipProcessor implements ILoadTaskProcessor<
LoadDataType.ArrayBuffer,
JSZip
> {
async process(response: ArrayBuffer): Promise<JSZip> {
const zip = new JSZip();
await zip.loadAsync(response);
return zip;
}
}
export class LoadTextProcessor implements ILoadTaskProcessor<
LoadDataType.Text,
string
> {
process(response: string): Promise<string> {
return Promise.resolve(response);
}
}
export class LoadJSONProcessor<T> implements ILoadTaskProcessor<
LoadDataType.JSON,
T
> {
process(response: any): Promise<T> {
return Promise.resolve(response);
}
}
@@ -0,0 +1,86 @@
import {
ILoadProgressTotal,
ILoadTask,
ILoadTaskProcessor,
LoadDataType
} from '@motajs/loader';
import JSZip from 'jszip';
export type CustomLoadFunc<R> = (data: R) => Promise<void>;
export const enum CompressedUsage {
// ---- 系统加载内容,不可更改
Font,
Image,
Sound,
Tileset,
Autotile,
Material,
Animate
}
export interface ICompressedMotaAssetsData {
/** 此内容的名称 */
readonly name: string;
/** 此内容应该由什么方式读取 */
readonly readAs: LoadDataType;
/** 此内容的应用方式 */
readonly usage: CompressedUsage;
}
export interface ICompressedMotaAssetsLoadList {
/** 压缩文件名称 */
readonly file: string;
/** 压缩包所包含的内容 */
readonly content: ICompressedMotaAssetsData[];
}
export interface IMotaAssetsLoader {
/** 加载进度对象 */
readonly progress: ILoadProgressTotal;
/** 当前是否正在加载 */
readonly loading: boolean;
/** 当前是否已经加载完毕 */
readonly loaded: boolean;
/** 图片处理器 */
readonly imageProcessor: ILoadTaskProcessor<LoadDataType.Blob, ImageBitmap>;
/** 音频处理器 */
readonly audioProcessor: ILoadTaskProcessor<
LoadDataType.Uint8Array,
AudioBuffer | null
>;
/** 字体处理器 */
readonly fontProcessor: ILoadTaskProcessor<
LoadDataType.ArrayBuffer,
FontFace
>;
/** 文字处理器 */
readonly textProcessor: ILoadTaskProcessor<LoadDataType.Text, string>;
/** JSON 处理器 */
readonly jsonProcessor: ILoadTaskProcessor<LoadDataType.JSON, any>;
/** `zip` 压缩包处理器 */
readonly zipProcessor: ILoadTaskProcessor<LoadDataType.ArrayBuffer, JSZip>;
/**
* 初始化系统加载任务
*/
initSystemLoadTask(): void;
/**
* 添加自定义加载任务
* @param task 自定义加载任务
* @param onLoad 当任务加载完成时执行
* @returns 一个 `Promise`,当添加的任务加载完毕,且 `onLoad` 返回的 `Promise` 兑现后兑现
*/
addCustomLoadTask<R>(
task: ILoadTask<LoadDataType, R>,
onLoad: CustomLoadFunc<R>
): Promise<R>;
/**
* 开始所有加载任务的加载工作
* @returns 一个 `Promise`,当所有加载任务加载完成后兑现
*/
load(): Promise<any[]>;
}
@@ -1,6 +1,9 @@
import { ITexture } from '@motajs/render';
import { materials } from './ins';
import { IBlockIdentifier, IIndexedIdentifier } from './types';
import {
IBlockIdentifier,
IIndexedIdentifier,
IMaterialManager
} from './types';
import { isNil } from 'lodash-es';
function extractClsBlocks<C extends Exclude<Cls, 'tileset'>>(
@@ -47,7 +50,7 @@ function addAutotile(set: Set<number>, map?: readonly (readonly number[])[]) {
/**
* 兼容旧版加载
*/
export function fallbackLoad() {
export function fallbackLoad(materials: IMaterialManager) {
// 基本素材
const icons = core.icons.icons;
const images = core.material.images;
@@ -102,12 +105,6 @@ export function fallbackLoad() {
materials.addTileset(img, identifier);
});
// Images
core.images.forEach((v, i) => {
const img = core.material.images.images[v];
materials.addImage(img, { index: i, alias: v });
});
// 地图上出现过的 tileset
const tilesetSet = new Set<number>();
const autotileSet = new Set<number>();
@@ -1,19 +1,12 @@
import { loading } from '@user/data-base';
import { fallbackLoad } from './fallback';
import { createAutotile } from './autotile';
export function createMaterial() {
createAutotile();
loading.once('loaded', () => {
fallbackLoad();
loading.emit('assetBuilt');
});
}
export * from './autotile';
export * from './builder';
export * from './fallback';
export * from './ins';
export * from './manager';
export * from './types';
export * from './utils';
@@ -1,5 +0,0 @@
import { AutotileProcessor } from './autotile';
import { MaterialManager } from './manager';
export const materials = new MaterialManager();
export const autotile = new AutotileProcessor(materials);
@@ -102,8 +102,7 @@ export interface IMaterialFramedData {
}
export interface IMaterialAsset
extends IDirtyTracker<boolean>,
IDirtyMarker<void> {
extends IDirtyTracker<boolean>, IDirtyMarker<void> {
/** 图集的贴图数据 */
readonly data: ITextureComposedData;
}
@@ -290,8 +289,7 @@ export interface IMaterialAliasGetter {
}
export interface IMaterialManager
extends IMaterialGetter,
IMaterialAliasGetter {
extends IMaterialGetter, IMaterialAliasGetter {
/** 贴图存储,把 terrains 等内容单独分开存储 */
readonly tileStore: ITextureStore;
/** tilesets 贴图存储,每个 tileset 是一个贴图对象 */
@@ -331,7 +329,6 @@ export interface IMaterialManager
addRowAnimate(
source: SizedCanvasImageSource,
map: ArrayLike<IBlockIdentifier>,
frames: number,
height: number
): Iterable<IMaterialData>;