import axios, { AxiosRequestConfig, ResponseType } from 'axios'; import { Disposable } from './disposable'; import { logger } from './logger'; import JSZip from 'jszip'; import { EventEmitter } from './eventEmitter'; type ProgressFn = (now: number, total: number) => void; interface ResourceType { text: string; buffer: ArrayBuffer; image: HTMLImageElement; material: HTMLImageElement; audio: HTMLAudioElement; json: any; zip: JSZip; } interface ResourceMap { text: TextResource; buffer: BufferResource; image: ImageResource; material: MaterialResource; audio: AudioResource; json: JSONResource; zip: ZipResource; } interface CompressedLoadListItem { type: keyof ResourceType; name: string; usage: string; } type CompressedLoadList = Record; const types: Record = { text: 'string', buffer: 'arraybuffer', image: 'blob', material: 'blob', audio: 'arraybuffer', json: 'string', zip: 'arraybuffer' }; const base = import.meta.env.DEV ? '/' : ''; function toURL(uri: string) { return import.meta.env.DEV ? uri : `${import.meta.env.BASE_URL}${uri}`; } export abstract class Resource extends Disposable { type = 'none'; uri: string = ''; resource?: T; loaded: boolean = false; /** * 创建一个资源 * @param uri 资源的URI,格式为 type/file * @param type 资源类型,不填为none,并会抛出警告 */ constructor(uri: string, type: string = 'none') { super(uri); this.type = type; this.uri = uri; if (this.type === 'none') { logger.warn(1, `Resource with type of 'none' is loaded.`); } } /** * 加载这个资源,需要被子类override */ abstract load(onProgress?: ProgressFn): Promise; /** * 解析资源URI,解析为一个URL,可以直接由请求获取 */ abstract resolveURI(): string; /** * 获取资源数据,当数据未加载完毕或未启用时返回null */ getData(): T | null { if (!this.activated || !this.loaded) return null; if (this.resource === null || this.resource === void 0) return null; return this.resource; } } export class ImageResource extends Resource { /** * 创建一个图片资源 * @param uri 图片资源的URI,格式为 image/file,例如 'image/project/images/hero.png' */ constructor(uri: string) { super(uri, 'image'); } load(onProgress?: ProgressFn): Promise { const img = new Image(); img.src = this.resolveURI(); this.resource = img; return new Promise(res => { img.addEventListener('load', () => { this.loaded = true; img.setAttribute('_width', img.width.toString()); img.setAttribute('_height', img.height.toString()); res(img); }); }); } resolveURI(): string { return toURL(`${base}${findURL(this.uri)}`); } } export class MaterialResource extends ImageResource { /** * 创建一个material资源 * @param uri 资源的URI,格式为 material/file,例如 'material/enemys.png' */ constructor(uri: string) { super(uri); this.type = 'material'; } override resolveURI(): string { return toURL(`${base}project/materials/${findURL(this.uri)}`); } } export class TextResource extends Resource { /** * 创建一个文字资源 * @param uri 文字资源的URI,格式为 text/file,例如 'text/myText.txt' * 这样的话会加载塔根目录下的 myText.txt 文件 */ constructor(uri: string) { super(uri, 'text'); } load(onProgress?: ProgressFn): Promise { return new Promise(res => { createAxiosLoader( this.resolveURI(), 'text', onProgress ).then(value => { this.resource = value.data; this.loaded = true; res(value.data); }); }); } resolveURI(): string { return toURL(`${base}${findURL(this.uri)}`); } } export class BufferResource extends Resource { /** * 创建一个二进制缓冲区资源 * @param uri 资源的URI,格式为 buffer/file,例如 'buffer/myBuffer.mp3' */ constructor(uri: string) { super(uri, 'buffer'); } load(onProgress?: ProgressFn): Promise { return new Promise(res => { createAxiosLoader( this.resolveURI(), 'arraybuffer', onProgress ).then(value => { this.resource = value.data; this.loaded = true; res(value.data); }); }); } resolveURI(): string { return toURL(`${base}${findURL(this.uri)}`); } } export class JSONResource extends Resource { /** * 创建一个JSON对象资源 * @param uri 资源的URI,格式为 json/file,例如 'buffer/myJSON.json' */ constructor(uri: string) { super(uri, 'json'); } load(onProgress?: ProgressFn): Promise { return new Promise(res => { createAxiosLoader(this.resolveURI(), 'json', onProgress).then( value => { this.resource = value.data; this.loaded = true; res(value.data); } ); }); } resolveURI(): string { return toURL(`${base}${findURL(this.uri)}`); } } export class AudioResource extends Resource { /** * 创建一个音乐资源 * @param uri 音乐资源的URI,格式为 audio/file,例如 'audio/bgm.mp3' * 注意这个资源类型为 bgm 等只在播放时才开始流式加载的音乐资源类型, * 对于需要一次性加载完毕的需要使用 BufferResource 进行加载, * 并可以通过 AudioPlayer 类进行解析播放 */ constructor(uri: string) { super(uri, 'audio'); } load(onProgress?: ProgressFn): Promise { const audio = new Audio(); audio.src = this.resolveURI(); this.resource = audio; return new Promise(res => { this.loaded = true; res(audio); }); } resolveURI(): string { return toURL(`${base}project/bgms/${findURL(this.uri)}`); } } export class ZipResource extends Resource { /** * 创建一个zip压缩资源 * @param uri 资源的URI,格式为 zip/file,例如 'zip/myZip.h5data' * 注意后缀名不要是zip,不然有的浏览器会触发下载,而不是加载 */ constructor(uri: string) { super(uri, 'zip'); this.type = 'zip'; } async load(onProgress?: ProgressFn): Promise { const data = await new Promise(res => { createAxiosLoader( this.resolveURI(), 'arraybuffer', onProgress ).then(value => { res(value.data); }); }); const unzipped = await JSZip.loadAsync(data); this.resource = unzipped; this.loaded = true; return unzipped; } resolveURI(): string { return toURL(`${base}${findURL(this.uri)}`); } } function createAxiosLoader( url: string, responseType: ResponseType, onProgress?: (now: number, total: number) => void ) { const config: AxiosRequestConfig = {}; config.responseType = responseType; if (onProgress) { config.onDownloadProgress = e => { onProgress(e.loaded, e.total ?? 0); }; } return axios.get(url, config); } function findURL(uri: string) { return uri.slice(uri.indexOf('/') + 1); } export const resourceTypeMap = { text: TextResource, buffer: BufferResource, image: ImageResource, material: MaterialResource, audio: AudioResource, json: JSONResource, zip: ZipResource }; interface LoadEvent { progress: ( type: keyof ResourceType, uri: string, now: number, total: number ) => void; load: (resource: ResourceMap[T]) => void | Promise; loadStart: (resource: ResourceMap[T]) => void; } type TaskProgressFn = ( loadedByte: number, totalByte: number, loadedTask: number, totalTask: number ) => void; export class LoadTask< T extends keyof ResourceType = keyof ResourceType > extends EventEmitter> { static totalByte: number = 0; static loadedByte: number = 0; static totalTask: number = 0; static loadedTask: number = 0; static errorTask: number = 0; /** 所有的资源,包括没有添加到加载任务里面的 */ static store: Map = new Map(); static taskList: Set = new Set(); static loadedTaskList: Set = new Set(); private static progress: TaskProgressFn; private static caledTask: Set = new Set(); resource: Resource; type: T; uri: string; private loadingStarted: boolean = false; loading: boolean = false; loaded: number = 0; /** * 新建一个加载任务 * @param type 任务类型 * @param uri 加载内容的URL */ constructor(type: T, uri: string) { super(); this.resource = new resourceTypeMap[type](uri); this.type = type; this.uri = uri; LoadTask.store.set(uri, this.resource); } /** * 执行加载过程,当加载完毕后,返回的Promise将会被resolve * @returns 加载的Promise */ async load(): Promise { if (this.loadingStarted) { logger.warn( 2, `Repeat load of resource '${this.resource.type}/${this.resource.uri}'` ); return new Promise(res => res()); } this.loadingStarted = true; let totalByte = 0; const load = this.resource .load((now, total) => { this.loading = true; this.emit('progress', this.type, this.uri, now, total); if (!LoadTask.caledTask.has(this.uri) && total !== 0) { LoadTask.totalByte += total; totalByte = total; LoadTask.caledTask.add(this.uri); } this.loaded = now; }) .catch(reason => { LoadTask.errorTask++; logger.error( 2, `Unexpected loading error in loading resource '${this.resource.type}/${this.resource.uri}'. Error info: ${reason}` ); }); this.emit('loadStart', this.resource); const value = await load; // @ts-ignore LoadTask.loadedTaskList.add(this); this.loaded = totalByte; LoadTask.loadedTask++; await Promise.all(this.emit('load', this.resource)); return await value; } /** * 新建一个加载任务,同时将任务加入加载列表 * @param type 任务类型 * @param uri 加载内容的URI */ static add( type: T, uri: string ): LoadTask { const task = new LoadTask(type, uri); // @ts-ignore this.taskList.add(task); return task; } /** * 将一个加载任务加入加载列表 * @param task 要加入列表的任务 */ static addTask(task: LoadTask) { this.taskList.add(task); } /** * 执行所有加载 */ static async load() { this.totalTask = this.taskList.size; const fn = () => { this.loadedByte = [...this.taskList].reduce((prev, curr) => { return prev + curr.loaded; }, 0); this.progress?.( this.loadedByte, this.totalByte, this.loadedTask, this.totalTask ); }; fn(); const interval = window.setInterval(fn, 100); const data = await Promise.all([...this.taskList].map(v => v.load())); window.clearInterval(interval); this.loadedByte = this.totalByte; fn(); this.progress?.( this.totalByte, this.totalByte, this.totalTask, this.totalTask ); return data; } /** * 设置当加载进度改变时执行的函数 */ static onProgress(progress: TaskProgressFn) { this.progress = progress; } /** * 重置加载设置 */ static reset() { this.loadedByte = 0; this.loadedTask = 0; this.totalByte = 0; this.totalTask = 0; this.errorTask = 0; this.caledTask.clear(); this.taskList.clear(); } } export function loadDefaultResource() { const data = data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d; const icon = icons_4665ee12_3a1f_44a4_bea3_0fccba634dc1; // bgm data.main.bgms.forEach(v => { const res = LoadTask.add('audio', `audio/${v}`); Mota.r(() => { res.once('loadStart', res => { Mota.require('var', 'bgm').add(`bgms.${v}`, res.resource!); }); }); }); // fonts data.main.fonts.forEach(v => { const res = LoadTask.add('buffer', `buffer/project/fonts/${v}.ttf`); Mota.r(() => { res.once('load', res => { document.fonts.add(new FontFace(v, res.resource!)); }); }); }); // image data.main.images.forEach(v => { const res = LoadTask.add('image', `image/project/images/${v}`); res.once('load', res => { core.material.images.images[v] = res.resource!; }); }); // sound data.main.sounds.forEach(v => { const res = LoadTask.add('buffer', `buffer/project/sounds/${v}`); Mota.r(() => { res.once('load', res => { Mota.require('var', 'sound').add(`sounds.${v}`, res.resource!); }); }); }); // tileset data.main.tilesets.forEach(v => { const res = LoadTask.add('image', `image/project/tilesets/${v}`); res.once('load', res => { core.material.images.tilesets[v] = res.resource!; }); }); // autotile const autotiles: Partial, HTMLImageElement>> = {}; Object.keys(icon.autotile).forEach(v => { const res = LoadTask.add('image', `image/project/autotiles/${v}.png`); res.once('load', res => { autotiles[v as AllIdsOf<'autotile'>] = res.resource; const loading = Mota.require('var', 'loading'); loading.addAutotileLoaded(); loading.onAutotileLoaded(autotiles); core.material.images.autotile[v as AllIdsOf<'autotile'>] = res.resource!; }); }); // materials const imgs = core.materials.slice() as SelectKey< MaterialImages, HTMLImageElement >[]; imgs.push('keyboard'); core.materials .map(v => `${v}.png`) .forEach(v => { const res = LoadTask.add('material', `material/${v}`); res.once('load', res => { // @ts-ignore core.material.images[ v.slice(0, -4) as SelectKey< MaterialImages, HTMLImageElement > ] = res.resource; }); }); const weathers: (keyof Weather)[] = ['fog', 'cloud', 'sun']; weathers.forEach(v => { const res = LoadTask.add('material', `material/${v}.png`); res.once('load', res => { // @ts-ignore core.animateFrame.weather[v] = res.resource; }); }); // animates { const res = LoadTask.add( 'text', `text/all/__all_animates__?v=${ main.version }&id=${data.main.animates.join(',')}` ); res.once('load', res => { const data = res.resource!.split('@@@~~~###~~~@@@'); data.forEach((v, i) => { const id = main.animates[i]; if (v === '') { throw new Error(`Cannot find animate: '${id}'`); } core.material.animates[id] = core.loader._loadAnimate(v); }); }); } } export async function loadCompressedResource() { const data = await axios.get(toURL('loadList.json'), { responseType: 'text' }); const list: CompressedLoadList = JSON.parse(data.data); const d = data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d; // 对于bgm,直接按照原来的方式加载即可 d.main.bgms.forEach(v => { const res = LoadTask.add('audio', `audio/${v}`); Mota.r(() => { res.once('loadStart', res => { Mota.require('var', 'bgm').add(`bgms.${v}`, res.resource!); }); }); }); // 对于区域内容,按照zip格式进行加载,然后解压处理 const autotiles: Partial, HTMLImageElement>> = {}; const materialImages = core.materials.slice() as SelectKey< MaterialImages, HTMLImageElement >[]; materialImages.push('keyboard'); const weathers: (keyof Weather)[] = ['fog', 'cloud', 'sun']; Object.entries(list).forEach(v => { const [uri, list] = v; const res = LoadTask.add('zip', `zip/${uri}`); res.once('load', resource => { const res = resource.resource; if (!res) return; return Promise.all( list.map(async v => { const { type, name, usage } = v; const asyncType = types[type]; const value = await res .file(`${type}/${name}`) ?.async(asyncType); if (!value) return; // 图片类型的资源 if (type === 'image') { const img = value as Blob; const image = new Image(); image.src = URL.createObjectURL(img); image.addEventListener('load', () => { image.setAttribute( '_width', image.width.toString() ); image.setAttribute( '_height', image.height.toString() ); }); // 图片 if (usage === 'image') { core.material.images.images[name as ImageIds] = image; } else if (usage === 'tileset') { // 额外素材 core.material.images.tilesets[name] = image; } else if (usage === 'autotile') { // 自动元件 autotiles[ name.slice(0, -4) as AllIdsOf<'autotile'> ] = image; const loading = Mota.require('var', 'loading'); loading.addAutotileLoaded(); loading.onAutotileLoaded(autotiles); core.material.images.autotile[ name.slice(0, -4) as AllIdsOf<'autotile'> ] = image; } } else if (type === 'material') { const img = value as Blob; const image = new Image(); image.src = URL.createObjectURL(img); image.addEventListener('load', () => { image.setAttribute( '_width', image.width.toString() ); image.setAttribute( '_height', image.height.toString() ); }); // material if (materialImages.some(v => name === v + '.png')) { // @ts-ignore core.material.images[ name.slice(0, -4) as SelectKey< MaterialImages, HTMLImageElement > ] = image; } else if (weathers.some(v => name === v + '.png')) { // @ts-ignore core.animateFrame.weather[v] = image; } } if (usage === 'font') { const font = value as ArrayBuffer; document.fonts.add( new FontFace(name.slice(0, -4), font) ); } else if (usage === 'sound') { const sound = value as ArrayBuffer; Mota.require('var', 'sound').add( `sounds.${name}`, sound ); } else if (usage === 'animate') { const ani = value as string; core.material.animates[ name.slice(0, -8) as AnimationIds ] = core.loader._loadAnimate(ani); } }) ); }); }); }