diff --git a/packages-user/client-base/src/index.ts b/packages-user/client-base/src/index.ts index 2f3ff66..4b9cf35 100644 --- a/packages-user/client-base/src/index.ts +++ b/packages-user/client-base/src/index.ts @@ -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'; diff --git a/packages-user/client-base/src/ins.ts b/packages-user/client-base/src/ins.ts index 5e036e2..40f84d5 100644 --- a/packages-user/client-base/src/ins.ts +++ b/packages-user/client-base/src/ins.ts @@ -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 diff --git a/packages-user/client-base/src/load/data.ts b/packages-user/client-base/src/load/data.ts new file mode 100644 index 0000000..f46e77c --- /dev/null +++ b/packages-user/client-base/src/load/data.ts @@ -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' +]; diff --git a/packages-user/client-base/src/load/index.ts b/packages-user/client-base/src/load/index.ts new file mode 100644 index 0000000..d7e04ee --- /dev/null +++ b/packages-user/client-base/src/load/index.ts @@ -0,0 +1 @@ +export * from './processor'; diff --git a/packages-user/client-base/src/load/loader.ts b/packages-user/client-base/src/load/loader.ts new file mode 100644 index 0000000..8514063 --- /dev/null +++ b/packages-user/client-base/src/load/loader.ts @@ -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 { + /** 加载任务对象 */ + readonly task: ILoadTask; + /** 当 `onLoaded` 兑现后兑现的 `Promise` */ + readonly loadPromise: Promise; + /** 兑现 `loadPromise` */ + readonly loadResolve: (data: R) => void; + /** 当加载任务完成时执行的函数 */ + readonly onLoaded: CustomLoadFunc; +} + +export class MotaAssetsLoader implements IMotaAssetsLoader { + /** 当前是否正在进行加载 */ + loading: boolean = false; + /** 当前加载工作是否已经完成 */ + loaded: boolean = false; + + readonly imageProcessor: ILoadTaskProcessor; + readonly audioProcessor: ILoadTaskProcessor< + LoadDataType.Uint8Array, + AudioBuffer | null + >; + readonly fontProcessor: ILoadTaskProcessor< + LoadDataType.ArrayBuffer, + FontFace + >; + readonly textProcessor: ILoadTaskProcessor; + readonly jsonProcessor: ILoadTaskProcessor; + readonly zipProcessor: ILoadTaskProcessor; + + /** 当前已添加的加载任务 */ + private readonly tasks: Set = new Set(); + + /** 素材索引 */ + private materialsCounter: number = 0; + /** 贴图行分割器,用于处理遗留 `icons.png` */ + private readonly rowSplitter: ITextureSplitter; + + constructor( + readonly progress: ILoadProgressTotal, + private readonly ac: IMotaAudioContext, + private readonly sounds: ISoundPlayer, + 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, 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 + ] = 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({ + 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({ + 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({ + 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, ImageBitmap>> = + {}; + Object.keys(icon.autotile).forEach(key => { + const url = `project/autotiles/${key}.png`; + const task = new LoadTask({ + 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({ + 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({ + 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 + ); + 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, 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({ + 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( + task: ILoadTask, + onLoaded: CustomLoadFunc + ): Promise { + this.progress.addTask(task); + const { promise, resolve } = Promise.withResolvers(); + const store: LoadTaskStore = { + task, + onLoaded, + loadPromise: promise, + loadResolve: resolve + }; + this.tasks.add(store); + return promise; + } + + load(): Promise { + 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 +} diff --git a/packages-user/client-base/src/load/processor.ts b/packages-user/client-base/src/load/processor.ts new file mode 100644 index 0000000..2234b0a --- /dev/null +++ b/packages-user/client-base/src/load/processor.ts @@ -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 { + return createImageBitmap(response); + } +} + +export class LoadAudioProcessor implements ILoadTaskProcessor< + LoadDataType.Uint8Array, + AudioBuffer | null +> { + constructor(private readonly ac: IMotaAudioContext) {} + + process(response: Uint8Array): Promise { + return this.ac.decodeToAudioBuffer(response); + } +} + +export class LoadFontProcessor implements ILoadTaskProcessor< + LoadDataType.ArrayBuffer, + FontFace +> { + process( + response: ArrayBuffer, + task: ILoadTask + ): Promise { + 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 { + const zip = new JSZip(); + await zip.loadAsync(response); + return zip; + } +} + +export class LoadTextProcessor implements ILoadTaskProcessor< + LoadDataType.Text, + string +> { + process(response: string): Promise { + return Promise.resolve(response); + } +} + +export class LoadJSONProcessor implements ILoadTaskProcessor< + LoadDataType.JSON, + T +> { + process(response: any): Promise { + return Promise.resolve(response); + } +} diff --git a/packages-user/client-base/src/load/types.ts b/packages-user/client-base/src/load/types.ts new file mode 100644 index 0000000..022a1a4 --- /dev/null +++ b/packages-user/client-base/src/load/types.ts @@ -0,0 +1,86 @@ +import { + ILoadProgressTotal, + ILoadTask, + ILoadTaskProcessor, + LoadDataType +} from '@motajs/loader'; +import JSZip from 'jszip'; + +export type CustomLoadFunc = (data: R) => Promise; + +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; + /** 音频处理器 */ + readonly audioProcessor: ILoadTaskProcessor< + LoadDataType.Uint8Array, + AudioBuffer | null + >; + /** 字体处理器 */ + readonly fontProcessor: ILoadTaskProcessor< + LoadDataType.ArrayBuffer, + FontFace + >; + /** 文字处理器 */ + readonly textProcessor: ILoadTaskProcessor; + /** JSON 处理器 */ + readonly jsonProcessor: ILoadTaskProcessor; + /** `zip` 压缩包处理器 */ + readonly zipProcessor: ILoadTaskProcessor; + + /** + * 初始化系统加载任务 + */ + initSystemLoadTask(): void; + + /** + * 添加自定义加载任务 + * @param task 自定义加载任务 + * @param onLoad 当任务加载完成时执行 + * @returns 一个 `Promise`,当添加的任务加载完毕,且 `onLoad` 返回的 `Promise` 兑现后兑现 + */ + addCustomLoadTask( + task: ILoadTask, + onLoad: CustomLoadFunc + ): Promise; + + /** + * 开始所有加载任务的加载工作 + * @returns 一个 `Promise`,当所有加载任务加载完成后兑现 + */ + load(): Promise; +} diff --git a/packages-user/client-base/src/material/fallback.ts b/packages-user/client-base/src/material/fallback.ts index df64e94..4b71813 100644 --- a/packages-user/client-base/src/material/fallback.ts +++ b/packages-user/client-base/src/material/fallback.ts @@ -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>( @@ -47,7 +50,7 @@ function addAutotile(set: Set, 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(); const autotileSet = new Set(); diff --git a/packages-user/client-base/src/material/index.ts b/packages-user/client-base/src/material/index.ts index 8f74adc..4fe0108 100644 --- a/packages-user/client-base/src/material/index.ts +++ b/packages-user/client-base/src/material/index.ts @@ -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'; diff --git a/packages-user/client-base/src/material/ins.ts b/packages-user/client-base/src/material/ins.ts deleted file mode 100644 index a4f3bc1..0000000 --- a/packages-user/client-base/src/material/ins.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { AutotileProcessor } from './autotile'; -import { MaterialManager } from './manager'; - -export const materials = new MaterialManager(); -export const autotile = new AutotileProcessor(materials); diff --git a/packages-user/client-base/src/material/types.ts b/packages-user/client-base/src/material/types.ts index 281e59f..ebe3404 100644 --- a/packages-user/client-base/src/material/types.ts +++ b/packages-user/client-base/src/material/types.ts @@ -102,8 +102,7 @@ export interface IMaterialFramedData { } export interface IMaterialAsset - extends IDirtyTracker, - IDirtyMarker { + extends IDirtyTracker, IDirtyMarker { /** 图集的贴图数据 */ 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, - frames: number, height: number ): Iterable; diff --git a/packages-user/client-modules/src/render/elements/cache.ts b/packages-user/client-modules/src/render/elements/cache.ts index 87037d5..8c57e2c 100644 --- a/packages-user/client-modules/src/render/elements/cache.ts +++ b/packages-user/client-modules/src/render/elements/cache.ts @@ -5,7 +5,7 @@ import { MotaOffscreenCanvas2D, SizedCanvasImageSource } from '@motajs/render'; // 得出结论,ImageBitmap和Canvas的绘制性能不如Image,于是直接画Image就行,所以缓存基本上就是存Image type ImageMapKeys = Exclude; -type ImageMap = Record; +type ImageMap = Record; const i = (img: ImageMapKeys) => { return core.material.images[img]; @@ -21,10 +21,10 @@ interface AutotileCache { type AutotileCaches = Record, AutotileCache>; interface TextureRequire { - tileset: Record; - material: Record; + tileset: Record; + material: Record; autotile: AutotileCaches; - images: Record; + images: Record; } interface RenderableDataBase { @@ -49,10 +49,10 @@ export interface AutotileRenderable extends RenderableDataBase { } class TextureCache { - tileset!: Record; - material: Record; + tileset!: Record; + material: Record; autotile!: AutotileCaches; - images!: Record; + images!: Record; idNumberMap!: IdToNumber; @@ -76,7 +76,7 @@ class TextureCache { characterTurn2: Dir2[] = ['leftup', 'rightup', 'rightdown', 'leftdown']; constructor() { - this.material = imageMap as Record; + this.material = imageMap as Record; } init() { diff --git a/packages-user/client-modules/src/render/index.tsx b/packages-user/client-modules/src/render/index.tsx index 2fc27a4..161c085 100644 --- a/packages-user/client-modules/src/render/index.tsx +++ b/packages-user/client-modules/src/render/index.tsx @@ -11,6 +11,7 @@ import { GameTitleUI } from './ui/title'; import { createWeather } from './weather'; import { createMainExtension } from './commonIns'; import { createApp } from './renderer'; +import { LoadSceneUI } from './ui/load'; export function createGameRenderer() { const App = defineComponent(_props => { @@ -23,6 +24,9 @@ export function createGameRenderer() { mainRenderer.hide(); createApp(App).mount(mainRenderer); + + sceneController.open(LoadSceneUI, {}); + mainRenderer.show(); } export function createRender() { @@ -31,11 +35,6 @@ export function createRender() { createAction(); createWeather(); - loading.once('loaded', () => { - sceneController.open(GameTitleUI, {}); - mainRenderer.show(); - }); - loading.once('assetBuilt', () => { createMainExtension(); }); diff --git a/packages-user/client-modules/src/render/shared.ts b/packages-user/client-modules/src/render/shared.ts index 9c0da05..6101e97 100644 --- a/packages-user/client-modules/src/render/shared.ts +++ b/packages-user/client-modules/src/render/shared.ts @@ -54,6 +54,8 @@ export const MOVING_TOLERANCE = 60; /** 开关门动画的动画时长 */ export const DOOR_ANIMATE_INTERVAL = 50; +//#endregion + //#region 状态栏 /** 状态栏像素宽度 */ @@ -69,6 +71,8 @@ export const STATUS_BAR_COUNT = ENABLE_RIGHT_STATUS_BAR ? 2 : 1; /** 状态栏宽度的一半 */ export const HALF_STATUS_WIDTH = STATUS_BAR_WIDTH / 2; +//#endregion + //#region 游戏画面 /** 游戏画面像素宽度,宽=地图宽度+状态栏宽度*状态栏数量 */ @@ -91,6 +95,8 @@ export const CENTER_LOC: ElementLocator = [ 0.5 ]; +//#endregion + //#region 通用配置 /** 弹框的宽度,使用在内置 UI 与组件中,包括确认框、选择框、等待框等 */ @@ -98,6 +104,31 @@ export const POP_BOX_WIDTH = MAP_WIDTH / 2; /** 默认字体 */ export const DEFAULT_FONT = new Font('Verdana', 16); +//#endregion + +//#region 加载界面 + +/** 加载界面的任务进度条半径 */ +export const LOAD_TASK_RADIUS = Math.min(MAIN_WIDTH, MAIN_HEIGHT) / 6; +/** 加载界面的字节进度条纵轴位置 */ +export const LOAD_BYTE_HEIGHT = MAIN_HEIGHT / 2 + MAIN_HEIGHT / 4; +/** 加载界面任务进度条的纵轴位置 */ +export const LOAD_TASK_CENTER_HEIGHT = MAIN_HEIGHT / 2 - MAIN_HEIGHT / 8; +/** 加载界面字节进度条的长度 */ +export const LOAD_BYTE_LENGTH = MAIN_WIDTH - MAIN_WIDTH / 12; +/** 加载界面任务进度条的粗细 */ +export const LOAD_TASK_LINE_WIDTH = 6; +/** 加载界面字节进度条的粗细 */ +export const LOAD_BYTE_LINE_WIDTH = 6; +/** 已加载部分进度条的颜色 */ +export const LOAD_LOADED_COLOR = '#57ff78'; +/** 未加载部分进度条的颜色 */ +export const LOAD_UNLOADED_COLOR = '#ccc'; +/** 加载界面的文字颜色 */ +export const LOAD_FONT_COLOR = '#fff'; + +//#endregion + //#region 存档界面 /** 存档缩略图尺寸 */ @@ -115,8 +146,13 @@ export const SAVE_DOWN_PAD = 30; /** 存档页码数,调高并不会影响性能,但是如果玩家存档太多的话会导致存档体积很大 */ export const SAVE_PAGES = 1000; +//#endregion + //#region 标题界面 +/** 标题图 */ +export const TITLE_BACKGROUND_IMAGE = 'bg.jpg'; + /** 标题文字中心横坐标 */ export const TITLE_X = HALF_WIDTH; /** 标题文字中心纵坐标 */ @@ -136,3 +172,5 @@ export const BUTTONS_HEIGHT = 200; export const BUTTONS_X = HALF_WIDTH; /** 标题界面按钮左上角纵坐标 */ export const BUTTONS_Y = MAIN_HEIGHT - BUTTONS_HEIGHT; + +//#endregion diff --git a/packages-user/client-modules/src/render/ui/load.tsx b/packages-user/client-modules/src/render/ui/load.tsx new file mode 100644 index 0000000..8d899ef --- /dev/null +++ b/packages-user/client-modules/src/render/ui/load.tsx @@ -0,0 +1,165 @@ +import { DefaultProps } from '@motajs/render-vue'; +import { + GameUI, + SetupComponentOptions, + UIComponentProps +} from '@motajs/system'; +import { defineComponent } from 'vue'; +import { + FULL_LOC, + LOAD_BYTE_HEIGHT, + LOAD_BYTE_LENGTH, + LOAD_BYTE_LINE_WIDTH, + LOAD_FONT_COLOR, + LOAD_LOADED_COLOR, + LOAD_TASK_CENTER_HEIGHT, + LOAD_TASK_LINE_WIDTH, + LOAD_TASK_RADIUS, + LOAD_UNLOADED_COLOR, + MAIN_WIDTH +} from '../shared'; +import { ElementLocator, Font, MotaOffscreenCanvas2D } from '@motajs/render'; +import { transitioned } from '../use'; +import { cosh, CurveMode, linear } from '@motajs/animate'; +import { loader } from '@user/client-base'; +import { clamp } from 'lodash-es'; +import { sleep } from '@motajs/common'; +import { loading } from '@user/data-base'; +import { GameTitleUI } from './title'; + +export interface ILoadProps extends UIComponentProps, DefaultProps {} + +const loadSceneProps = { + props: ['controller', 'instance'] +} satisfies SetupComponentOptions; + +export const LoadScene = defineComponent(props => { + const taskFont = new Font('Verdana', 24); + const byteFont = new Font('Verdana', 12); + + /** 当前加载进度 */ + const taskProgress = transitioned(0, 500, cosh(2, CurveMode.EaseOut))!; + const byteProgress = transitioned(0, 500, cosh(2, CurveMode.EaseOut))!; + const alpha = transitioned(1, 400, linear())!; + + // 两个进度条的位置 + const taskLoc: ElementLocator = [ + MAIN_WIDTH / 2, + LOAD_TASK_CENTER_HEIGHT, + LOAD_TASK_RADIUS * 2 + LOAD_TASK_LINE_WIDTH * 2, + LOAD_TASK_RADIUS * 2 + LOAD_TASK_LINE_WIDTH * 2, + 0.5, + 0.5 + ]; + const byteLoc: ElementLocator = [ + MAIN_WIDTH / 2, + LOAD_BYTE_HEIGHT, + LOAD_BYTE_LENGTH + LOAD_BYTE_LINE_WIDTH, + LOAD_BYTE_LINE_WIDTH * 2 + byteFont.size, + 0.5, + 0.5 + ]; + + const loadEnd = async () => { + loading.emit('loaded'); + alpha.set(0); + await sleep(400); + props.controller.closeAll(); + props.controller.open(GameTitleUI, {}); + }; + + const startLoad = async () => { + loader.initSystemLoadTask(); + loader.load().then(() => { + loadEnd(); + }); + for await (const _ of loader.progress) { + taskProgress.set(loader.progress.getLoadedTasks()); + byteProgress.set(loader.progress.getLoadedByte()); + } + }; + + // 开始加载 + startLoad(); + + /** 渲染加载任务进度 */ + const renderTaskList = (canvas: MotaOffscreenCanvas2D) => { + const ctx = canvas.ctx; + ctx.lineCap = 'round'; + ctx.lineWidth = LOAD_TASK_LINE_WIDTH; + ctx.font = taskFont.string(); + const loaded = loader.progress.getLoadedTasks(); + const total = loader.progress.getAddedTasks(); + // 这里使用渐变参数,因为要有动画效果 + const progress = clamp(taskProgress.value / total, 0, 1); + const cx = taskLoc[2]! / 2; + const cy = taskLoc[3]! / 2; + ctx.beginPath(); + ctx.arc(cx, cy, LOAD_TASK_RADIUS, 0, Math.PI * 2); + ctx.strokeStyle = LOAD_UNLOADED_COLOR; + ctx.stroke(); + ctx.beginPath(); + const end = progress * Math.PI * 2 - Math.PI / 2; + ctx.arc(cx, cy, LOAD_TASK_RADIUS, -Math.PI / 2, end); + ctx.strokeStyle = LOAD_LOADED_COLOR; + ctx.stroke(); + ctx.fillStyle = LOAD_FONT_COLOR; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(`${loaded} / ${total}`, cx, cy + 3); + }; + + /** 渲染加载字节进度 */ + const renderByteList = (canvas: MotaOffscreenCanvas2D) => { + const ctx = canvas.ctx; + ctx.lineCap = 'round'; + ctx.lineWidth = LOAD_BYTE_LINE_WIDTH; + ctx.font = byteFont.string(); + const total = loader.progress.getTotalByte(); + const loaded = loader.progress.getLoadedByte(); + // 这里使用渐变参数,因为要有动画效果 + const progress = clamp(byteProgress.value / total, 0, 1); + const sx = LOAD_BYTE_LINE_WIDTH; + const sy = byteFont.size + LOAD_BYTE_LINE_WIDTH; + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(sx + LOAD_BYTE_LENGTH, sy); + ctx.strokeStyle = LOAD_UNLOADED_COLOR; + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(sx + progress * LOAD_BYTE_LENGTH, sy); + ctx.strokeStyle = LOAD_LOADED_COLOR; + ctx.stroke(); + ctx.textAlign = 'right'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = LOAD_FONT_COLOR; + const loadedMB = (loaded / 2 ** 20).toFixed(2); + const totalMB = (total / 2 ** 20).toFixed(2); + const percent = loader.progress.getByteRatio() * 100; + ctx.fillText( + `${loadedMB}MB / ${totalMB}MB | ${percent.toFixed(2)}%`, + byteLoc[2]! - LOAD_BYTE_LINE_WIDTH, + byteLoc[3]! - LOAD_BYTE_LINE_WIDTH * 2 + ); + }; + + return () => ( + + + + + ); +}, loadSceneProps); + +export const LoadSceneUI = new GameUI('load-scene', LoadScene); diff --git a/packages-user/client-modules/src/render/ui/statusBar.tsx b/packages-user/client-modules/src/render/ui/statusBar.tsx index 4010f76..cc6c6bc 100644 --- a/packages-user/client-modules/src/render/ui/statusBar.tsx +++ b/packages-user/client-modules/src/render/ui/statusBar.tsx @@ -1,7 +1,7 @@ import { GameUI, SetupComponentOptions } from '@motajs/system'; import { computed, ComputedRef, defineComponent, shallowReactive } from 'vue'; import { TextContent } from '../components'; -import { ElementLocator, Font, SizedCanvasImageSource } from '@motajs/render'; +import { ElementLocator, Font, ITexture } from '@motajs/render'; import { MixedToolbar, ReplayingStatus } from './toolbar'; import { openViewMap } from './viewmap'; import { mainUIController } from './controller'; @@ -12,6 +12,7 @@ import { STATUS_BAR_WIDTH } from '../shared'; import { DefaultProps } from '@motajs/render-vue'; +import { materials } from '@user/client-base'; export interface ILeftHeroStatus { /** 楼层 id */ @@ -69,27 +70,27 @@ export interface IRightHeroStatus { interface StatusInfo { /** 图标 */ - icon: SizedCanvasImageSource; + readonly icon: ITexture | null; /** 属性值,经过格式化 */ - value: ComputedRef; + readonly value: ComputedRef; /** 字体 */ - font: Font; + readonly font: Font; /** 文字颜色 */ - color: CanvasStyle; + readonly color: CanvasStyle; } interface KeyLikeItem { /** 属性值,经过格式化 */ - value: ComputedRef; + readonly value: ComputedRef; /** 字体 */ - font: Font; + readonly font: Font; /** 文字颜色 */ - color: CanvasStyle; + readonly color: CanvasStyle; } interface KeyLikeInfo { /** 这一行包含的内容 */ - items: KeyLikeItem[]; + readonly items: KeyLikeItem[]; } interface StatusBarProps extends DefaultProps { @@ -115,15 +116,15 @@ export const LeftStatusBar = defineComponent>( /** 状态属性的开始纵坐标 */ const STATUS_Y = TITLE_HEIGHT + STATUS_PAD; - // 可以换成 core.material.images.images['xxx.png'] 来使用全塔属性注册的图片 - const hpIcon = core.statusBar.icons.hp; - const atkIcon = core.statusBar.icons.atk; - const defIcon = core.statusBar.icons.def; - const mdefIcon = core.statusBar.icons.mdef; - const moneyIcon = core.statusBar.icons.money; - const expIcon = core.statusBar.icons.exp; - const manaIcon = core.statusBar.icons.mana; - const lvIcon = core.statusBar.icons.lv; + // 可以换成 materials.getImageByAlias('xxx.png') 来使用全塔属性注册的图片 + const hpIcon = materials.getImageByAlias('icon-hp'); + const atkIcon = materials.getImageByAlias('icon-atk'); + const defIcon = materials.getImageByAlias('icon-def'); + const mdefIcon = materials.getImageByAlias('icon-mdef'); + const moneyIcon = materials.getImageByAlias('icon-money'); + const expIcon = materials.getImageByAlias('icon-exp'); + const manaIcon = materials.getImageByAlias('icon-mana'); + const lvIcon = materials.getImageByAlias('icon-lv'); const s = p.status; diff --git a/packages-user/client-modules/src/render/ui/title.tsx b/packages-user/client-modules/src/render/ui/title.tsx index d9f55bf..eeb6e04 100644 --- a/packages-user/client-modules/src/render/ui/title.tsx +++ b/packages-user/client-modules/src/render/ui/title.tsx @@ -14,6 +14,7 @@ import { HALF_WIDTH, MAIN_HEIGHT, MAIN_WIDTH, + TITLE_BACKGROUND_IMAGE, TITLE_FILL, TITLE_STROKE, TITLE_STROKE_WIDTH, @@ -34,6 +35,7 @@ import { MainSceneUI } from './main'; import { adjustCover } from '../utils'; import { cosh, CurveMode, linear } from '@motajs/animate'; import { sleep } from '@motajs/common'; +import { materials } from '@user/client-base'; const enum TitleButton { StartGame, @@ -62,12 +64,12 @@ const gameTitleProps = { } satisfies SetupComponentOptions; export const GameTitle = defineComponent(props => { - const bg = core.material.images.images['bg.jpg']; + const bg = materials.getImageByAlias(TITLE_BACKGROUND_IMAGE); //#region 计算背景图 const [width, height] = adjustCover( - bg.width, - bg.height, + bg?.width ?? MAIN_WIDTH, + bg?.height ?? MAIN_HEIGHT, MAIN_WIDTH, MAIN_HEIGHT ); diff --git a/packages-user/client-modules/src/render/ui/toolbar.tsx b/packages-user/client-modules/src/render/ui/toolbar.tsx index c2d3d89..cffe51c 100644 --- a/packages-user/client-modules/src/render/ui/toolbar.tsx +++ b/packages-user/client-modules/src/render/ui/toolbar.tsx @@ -25,6 +25,7 @@ import { MAIN_HEIGHT, FULL_LOC, POP_BOX_WIDTH, CENTER_LOC } from '../shared'; import { openReplay, openSettings } from './settings'; import { openViewMap } from './viewmap'; import { DefaultProps } from '@motajs/render-vue'; +import { materials } from '@user/client-base'; interface ToolbarProps extends DefaultProps { loc?: ElementLocator; @@ -73,15 +74,15 @@ export const PlayingToolbar = defineComponent< ToolbarEmits, keyof ToolbarEmits >((props, { emit }) => { - const bookIcon = core.statusBar.icons.book; - const flyIcon = core.statusBar.icons.fly; - const toolIcon = core.statusBar.icons.toolbox; - const equipIcon = core.statusBar.icons.equipbox; - const keyIcon = core.statusBar.icons.keyboard; - const shopIcon = core.statusBar.icons.shop; - const saveIcon = core.statusBar.icons.save; - const loadIcon = core.statusBar.icons.load; - const setIcon = core.statusBar.icons.settings; + const bookIcon = materials.getImageByAlias('icon-book'); + const flyIcon = materials.getImageByAlias('icon-fly'); + const toolIcon = materials.getImageByAlias('icon-toolbox'); + const equipIcon = materials.getImageByAlias('icon-equipbox'); + const keyIcon = materials.getImageByAlias('icon-keyboard'); + const shopIcon = materials.getImageByAlias('icon-shop'); + const saveIcon = materials.getImageByAlias('icon-save'); + const loadIcon = materials.getImageByAlias('icon-load'); + const setIcon = materials.getImageByAlias('icon-settings'); const iconFont = new Font('Verdana', 12); @@ -170,8 +171,8 @@ const replayingProps = { export const ReplayingToolbar = defineComponent(props => { const status = props.status; - const bookIcon = core.statusBar.icons.book; - const saveIcon = core.statusBar.icons.save; + const bookIcon = materials.getImageByAlias('icon-book'); + const saveIcon = materials.getImageByAlias('icon-save'); const font1 = Font.defaults({ size: 16 }); const font2 = new Font('Verdana', 12); diff --git a/packages-user/client-modules/src/render/weather/presets/sun.ts b/packages-user/client-modules/src/render/weather/presets/sun.ts index 5370e8f..2f8e070 100644 --- a/packages-user/client-modules/src/render/weather/presets/sun.ts +++ b/packages-user/client-modules/src/render/weather/presets/sun.ts @@ -4,7 +4,7 @@ import { clamp } from 'lodash-es'; export class SunWeather extends Weather { /** 阳光图片 */ - private image: HTMLImageElement | null = null; + private image: ImageBitmap | null = null; /** 阳光图片的不透明度 */ private alpha: number = 0; /** 阳光的最大不透明度 */ diff --git a/packages-user/data-base/src/game.ts b/packages-user/data-base/src/game.ts index d02ccd5..12187a0 100644 --- a/packages-user/data-base/src/game.ts +++ b/packages-user/data-base/src/game.ts @@ -49,7 +49,7 @@ class GameLoading extends EventEmitter { * @param autotiles 自动元件数组 */ onAutotileLoaded( - autotiles: Partial, HTMLImageElement>> + autotiles: Partial, ImageBitmap>> ) { if (this.autotileListened) return; this.autotileListened = true; diff --git a/packages-user/legacy-plugin-data/src/ui.ts b/packages-user/legacy-plugin-data/src/ui.ts index 78b5300..f53bd9e 100644 --- a/packages-user/legacy-plugin-data/src/ui.ts +++ b/packages-user/legacy-plugin-data/src/ui.ts @@ -3,8 +3,7 @@ export function initUI() { if (main.mode === 'editor') return; if (!main.replayChecking) { - const { mainUi, fixedUi, mainSetting } = - Mota.require('@motajs/legacy-ui'); + const { mainUi } = Mota.require('@motajs/legacy-ui'); ui.prototype.drawBook = function () { if (!core.isReplaying()) return mainUi.open('book'); @@ -25,11 +24,6 @@ export function initUI() { control.prototype.showStatusBar = function () { if (main.mode === 'editor') return; core.removeFlag('hideStatusBar'); - if (mainSetting.getValue('ui.tips')) { - if (!fixedUi.hasName('tips')) { - fixedUi.open('tips'); - } - } core.updateStatusBar(); }; @@ -39,8 +33,6 @@ export function initUI() { // 如果原本就是隐藏的,则先显示 if (!core.domStyle.showStatusBar) this.showStatusBar(); if (core.isReplaying()) showToolbox = true; - fixedUi.closeByName('tips'); - core.setFlag('hideStatusBar', true); core.setFlag('showToolbox', showToolbox || null); core.updateStatusBar(); diff --git a/packages/audio/src/sound.ts b/packages/audio/src/sound.ts index 07ad605..c3a84db 100644 --- a/packages/audio/src/sound.ts +++ b/packages/audio/src/sound.ts @@ -1,9 +1,11 @@ import { logger } from '@motajs/common'; -import { IAudioVolumeEffect, IMotaAudioContext } from './types'; +import { IAudioVolumeEffect, IMotaAudioContext, ISoundPlayer } from './types'; type LocationArray = [number, number, number]; -export class SoundPlayer { +export class SoundPlayer< + T extends string = SoundIds +> implements ISoundPlayer { /** 每个音效的唯一标识符 */ private num: number = 0; @@ -50,13 +52,17 @@ export class SoundPlayer { * @param id 音效名称 * @param data 音效的Uint8Array数据 */ - async add(id: T, data: Uint8Array) { - const buffer = await this.ac.decodeToAudioBuffer(data); - if (!buffer) { - logger.warn(51, id); - return; + async add(id: T, data: Uint8Array | AudioBuffer) { + if (data instanceof Uint8Array) { + const buffer = await this.ac.decodeToAudioBuffer(data); + if (!buffer) { + logger.warn(51, id); + return; + } + this.buffer.set(id, buffer); + } else { + this.buffer.set(id, data); } - this.buffer.set(id, buffer); } /** diff --git a/packages/audio/src/source.ts b/packages/audio/src/source.ts index f373606..95d2f1f 100644 --- a/packages/audio/src/source.ts +++ b/packages/audio/src/source.ts @@ -123,6 +123,12 @@ export class AudioStreamSource this.controller = controller; } + unpiped(controller: IStreamController): void { + if (this.controller === controller) { + this.controller = null; + } + } + async pump(data: Uint8Array | undefined, done: boolean): Promise { if (!data || this.errored) return; if (!this.headerRecieved) { diff --git a/packages/audio/src/types.ts b/packages/audio/src/types.ts index 9399445..8a09d21 100644 --- a/packages/audio/src/types.ts +++ b/packages/audio/src/types.ts @@ -611,7 +611,7 @@ export interface ISoundPlayer { * @param id 音效名称 * @param data 音效的Uint8Array数据 */ - add(id: T, data: Uint8Array): Promise; + add(id: T, data: Uint8Array | AudioBuffer): Promise; /** * 播放一个音效 diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index aaebf31..fc0290f 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -149,6 +149,7 @@ "92": "Followers can only be added when the last follower is not moving.", "93": "Followers can only be removed when the last follower is not moving.", "94": "Expecting an excitation binding when using '$1'", + "95": "Task adding is required before start loading.", "1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency." } } diff --git a/packages/legacy-common/src/disposable.ts b/packages/legacy-common/src/disposable.ts deleted file mode 100644 index 1b39829..0000000 --- a/packages/legacy-common/src/disposable.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { EventEmitter } from 'eventemitter3'; - -interface DisposableEvent { - active: [value: T]; - dispose: [value: T]; - destroy: []; -} - -export class Disposable extends EventEmitter> { - protected _data?: T; - set data(value: T | null) { - if (this.destroyed) { - throw new Error( - `Cannot set value of destroyed disposable variable.` - ); - } - if (value !== null) this._data = value; - } - get data(): T | null { - if (this.destroyed) { - throw new Error( - `Cannot get value of destroyed disposable variable.` - ); - } - if (!this.activated) { - return null; - } - return this._data!; - } - - protected activated: boolean = false; - protected destroyed: boolean = false; - - constructor(data: T) { - super(); - this._data = data; - } - - active() { - if (this.activated) return; - this.activated = true; - this.emit('active', this._data!); - } - - dispose() { - if (!this.activated) return; - this.activated = false; - this.emit('dispose', this._data!); - } - - destroy() { - if (this.destroyed) return; - this.destroyed = true; - this.emit('destroy'); - delete this._data; - } -} diff --git a/packages/legacy-common/src/index.ts b/packages/legacy-common/src/index.ts index 6bf076a..46984de 100644 --- a/packages/legacy-common/src/index.ts +++ b/packages/legacy-common/src/index.ts @@ -1,5 +1,3 @@ export * from './patch'; -export * from './disposable'; export * from './eventEmitter'; -export * from './resource'; export * from './utils'; diff --git a/packages/legacy-common/src/resource.ts b/packages/legacy-common/src/resource.ts deleted file mode 100644 index 803020e..0000000 --- a/packages/legacy-common/src/resource.ts +++ /dev/null @@ -1,712 +0,0 @@ -import axios, { AxiosRequestConfig, ResponseType } from 'axios'; -import { EventEmitter } from './eventEmitter'; -import { Disposable } from './disposable'; -import { logger } from '@motajs/common'; -import JSZip from 'jszip'; - -type ProgressFn = (now: number, total: number) => void; - -interface ResourceType { - text: string; - buffer: ArrayBuffer; - image: HTMLImageElement; - material: HTMLImageElement; - audio: HTMLAudioElement; - json: any; - zip: JSZip; - byte: Uint8Array; -} - -interface ResourceMap { - text: TextResource; - buffer: BufferResource; - image: ImageResource; - material: MaterialResource; - audio: AudioResource; - json: JSONResource; - zip: ZipResource; - byte: ByteResource; -} - -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', - byte: 'uint8array' -}; - -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); - } - } - - /** - * 加载这个资源,需要被子类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.loading = 'eager'; - 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 ByteResource extends Resource { - /** - * 创建一个二进制缓冲区资源 - * @param uri 资源的URI,格式为 byte/file,例如 'byte/myBuffer.mp3' - */ - constructor(uri: string) { - super(uri, 'buffer'); - } - - async load(_onProgress?: ProgressFn): Promise { - const response = await fetch(this.resolveURI()); - const data = await response.arrayBuffer(); - this.resource = new Uint8Array(data); - return this.resource; - } - - 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(); - audio.preload = 'none'; - 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, - byte: ByteResource -}; - -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, 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(() => { - LoadTask.errorTask++; - logger.error(2, this.resource.type, this.resource.uri); - }); - this.emit('loadStart', this.resource); - const value = await load; - 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); - 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('byte', `byte/project/sounds/${v}`); - Mota.r(() => { - res.once('load', res => { - const { soundPlayer } = Mota.require('@user/client-base'); - soundPlayer.add(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('@user/data-base'); - 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-expect-error 不能推导 - core.material.images[ - v.slice(0, -4) as SelectKey< - MaterialImages, - HTMLImageElement - > - ] = 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); - - // 对于区域内容,按照zip格式进行加载,然后解压处理 - const autotiles: Partial, HTMLImageElement>> = - {}; - const materialImages = core.materials.slice() as SelectKey< - MaterialImages, - HTMLImageElement - >[]; - materialImages.push('keyboard'); - - 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('@user/data-base'); - 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')) { - core.material.images[ - name.slice(0, -4) as SelectKey< - MaterialImages, - HTMLImageElement - > - ] = image; - } - } - - if (usage === 'font') { - const font = value as ArrayBuffer; - document.fonts.add( - new FontFace(name.slice(0, -4), font) - ); - } else if (usage === 'sound' && main.mode === 'play') { - const { soundPlayer } = - Mota.require('@user/client-base'); - soundPlayer.add(name as SoundIds, value as Uint8Array); - } else if (usage === 'animate') { - const ani = value as string; - core.material.animates[ - name.slice(0, -8) as AnimationIds - ] = core.loader._loadAnimate(ani); - } - }) - ); - }); - }); -} diff --git a/packages/legacy-ui/src/preset/ui.ts b/packages/legacy-ui/src/preset/ui.ts index 80e5733..565f0a6 100644 --- a/packages/legacy-ui/src/preset/ui.ts +++ b/packages/legacy-ui/src/preset/ui.ts @@ -4,7 +4,7 @@ import { isMobile } from '../use'; import { MotaSetting } from '../setting'; import { triggerFullscreen } from '../utils'; import settingsText from '../data/settings.json'; -import { fixedUi, mainUi } from './uiIns'; +import { mainUi } from './uiIns'; import { mainSetting } from './settingIns'; //#region legacy-ui @@ -13,8 +13,6 @@ export function createUI() { const { hook } = Mota.require('@user/data-base'); hook.once('mounted', () => { const ui = document.getElementById('ui-main')!; - const fixed = document.getElementById('ui-fixed')!; - const blur = mainSetting.getSetting('screen.blur'); mainUi.on('start', () => { @@ -34,12 +32,6 @@ export function createUI() { core.closePanel(); } }); - fixedUi.on('start', () => { - fixed.style.display = 'block'; - }); - fixedUi.on('end', () => { - fixed.style.display = 'none'; - }); }); } @@ -123,21 +115,11 @@ function handleAudioSetting( } } -function handleUiSetting(key: string, n: T, _o: T) { - if (key === 'danmaku') { - if (n) { - fixedUi.open('danmaku'); - } else { - fixedUi.closeByName('danmaku'); - } - } else if (key === 'tips') { - if (n && core.isPlaying()) { - fixedUi.open('tips'); - } else { - fixedUi.closeByName('tips'); - } - } -} +function handleUiSetting( + _key: string, + _n: T, + _o: T +) {} // ----- 游戏的所有设置项 mainSetting diff --git a/packages/legacy-ui/src/preset/uiIns.ts b/packages/legacy-ui/src/preset/uiIns.ts index 4c76d98..aac5eef 100644 --- a/packages/legacy-ui/src/preset/uiIns.ts +++ b/packages/legacy-ui/src/preset/uiIns.ts @@ -14,7 +14,3 @@ mainUi.register( new GameUi('virtualKey', VirtualKey) ); mainUi.showAll(); - -export const fixedUi = new UiController(true); -fixedUi.register(new GameUi('load', UI.Load)); -fixedUi.showAll(); diff --git a/packages/legacy-ui/src/ui/index.ts b/packages/legacy-ui/src/ui/index.ts index e40b40e..cc85b9e 100644 --- a/packages/legacy-ui/src/ui/index.ts +++ b/packages/legacy-ui/src/ui/index.ts @@ -6,4 +6,3 @@ export { default as Settings } from './settings.vue'; export { default as Shop } from './shop.vue'; export { default as Toolbox } from './toolbox.vue'; export { default as Hotkey } from './hotkey.vue'; -export { default as Load } from './load.vue'; diff --git a/packages/legacy-ui/src/ui/load.vue b/packages/legacy-ui/src/ui/load.vue deleted file mode 100644 index 07803d3..0000000 --- a/packages/legacy-ui/src/ui/load.vue +++ /dev/null @@ -1,106 +0,0 @@ - - - - - diff --git a/packages/loader/src/index.ts b/packages/loader/src/index.ts index 3b1a34e..2030c0c 100644 --- a/packages/loader/src/index.ts +++ b/packages/loader/src/index.ts @@ -1 +1,4 @@ +export * from './progress'; export * from './stream'; +export * from './task'; +export * from './types'; diff --git a/packages/loader/src/progress.ts b/packages/loader/src/progress.ts new file mode 100644 index 0000000..0b6ec80 --- /dev/null +++ b/packages/loader/src/progress.ts @@ -0,0 +1,91 @@ +import { clamp } from 'lodash-es'; +import { ILoadProgressTotal, ILoadTask, LoadDataType } from './types'; +import { logger } from '@motajs/common'; + +export class LoadProgressTotal< + T extends LoadDataType = LoadDataType, + R = any +> implements ILoadProgressTotal { + /** 当前已经附着的加载任务 */ + private readonly attached: Map, number> = new Map(); + /** 当前已经加载完毕的任务 */ + readonly loadedTasks: Set> = new Set(); + /** 当前已经添加的任务 */ + readonly addedTasks: Set> = new Set(); + + /** 总加载量 */ + private total: number = 0; + /** 当前已经加载的字节数 */ + private loaded: number = 0; + + /** 下一次触发 `onProgress` 时兑现 */ + private nextPromise: Promise; + /** 兑现当前的 `nextPromise` */ + private nextResolve: () => void; + + async *[Symbol.asyncIterator]() { + while (true) { + if (this.loadedTasks.size === this.addedTasks.size) { + return; + } + yield this.nextPromise; + } + } + + constructor() { + const { promise, resolve } = Promise.withResolvers(); + this.nextPromise = promise; + this.nextResolve = resolve; + } + + addTask(task: ILoadTask) { + this.addedTasks.add(task); + } + + onProgress(task: ILoadTask, loaded: number, total: number): void { + if (!this.addedTasks.has(task)) { + logger.warn(95); + return; + } + if (!this.attached.has(task)) { + this.total += total; + } + if (task.contentLoaded) { + this.loadedTasks.add(task); + } + const before = this.attached.getOrInsert(task, 0); + if (total !== 0) { + this.loaded += loaded - before; + } + this.attached.set(task, loaded); + this.nextResolve(); + const { promise, resolve } = Promise.withResolvers(); + this.nextPromise = promise; + this.nextResolve = resolve; + } + + getLoadedByte(): number { + return this.loaded; + } + + getTotalByte(): number { + return this.loaded; + } + + getLoadedTasks(): number { + return this.loadedTasks.size; + } + + getAddedTasks(): number { + return this.addedTasks.size; + } + + getTaskRatio(): number { + return this.loadedTasks.size / this.addedTasks.size; + } + + getByteRatio(): number { + if (this.total === 0) return 0; + return clamp(this.loaded / this.total, 0, 1); + } +} diff --git a/packages/loader/src/stream.ts b/packages/loader/src/stream.ts index 3a35f0a..cade148 100644 --- a/packages/loader/src/stream.ts +++ b/packages/loader/src/stream.ts @@ -1,76 +1,15 @@ import { logger } from '@motajs/common'; -import EventEmitter from 'eventemitter3'; +import { IStreamLoader, IStreamReader } from './types'; -export interface IStreamController { - readonly loading: boolean; - - /** - * 开始流传输 - */ - start(): Promise; - - /** - * 主动终止流传输 - * @param reason 终止原因 - */ - cancel(reason?: string): void; -} - -export interface IStreamReader { - /** - * 接受字节流流传输的数据 - * @param data 传入的字节流数据,只包含本分块的内容 - * @param done 是否传输完成 - */ - pump( - data: Uint8Array | undefined, - done: boolean, - response: Response - ): Promise; - - /** - * 当前对象被传递给加载流时执行的函数 - * @param controller 传输流控制对象 - */ - piped(controller: IStreamController): void; - - /** - * 开始流传输 - * @param stream 传输流对象 - * @param controller 传输流控制对象 - */ - start( - stream: ReadableStream, - controller: IStreamController, - response: Response - ): Promise; - - /** - * 结束流传输 - * @param done 是否传输完成,如果为 false 的话,说明可能是由于出现错误导致的终止 - * @param reason 如果没有传输完成,那么表示失败的原因 - */ - end(done: boolean, reason?: string): void; -} - -interface StreamLoaderEvent { - data: [data: Uint8Array | undefined, done: boolean]; -} - -export class StreamLoader - extends EventEmitter - implements IStreamController -{ +export class StreamLoader implements IStreamLoader { /** 传输目标 */ private target: Set = new Set(); /** 读取流对象 */ - private stream?: ReadableStream; + private stream: ReadableStream | null = null; loading: boolean = false; - constructor(public readonly url: string) { - super(); - } + constructor(public readonly url: string) {} /** * 将加载流传递给字节流读取对象 @@ -83,7 +22,14 @@ export class StreamLoader } this.target.add(reader); reader.piped(this); - return this; + } + + unpipe(reader: IStreamReader): void { + if (this.loading) { + logger.warn(46); + return; + } + this.target.delete(reader); } async start() { diff --git a/packages/loader/src/task.ts b/packages/loader/src/task.ts new file mode 100644 index 0000000..4e1eb3c --- /dev/null +++ b/packages/loader/src/task.ts @@ -0,0 +1,158 @@ +import { sumBy } from 'lodash-es'; +import { + ILoadDataTypeMap, + ILoadTask, + ILoadTaskInit, + ILoadTaskProcessor, + ILoadTaskProgress, + LoadDataType, + RequestMethod +} from './types'; + +/** 文字解码 */ +const loadTextDecoder = new TextDecoder(); + +export class LoadTask implements ILoadTask { + readonly dataType: T; + readonly identifier: string; + readonly url: string | URL; + readonly processor: ILoadTaskProcessor; + readonly progress: ILoadTaskProgress; + readonly method?: RequestMethod; + readonly body?: BodyInit; + readonly headers?: HeadersInit; + + contentLoaded: boolean = false; + loadedByte: number = 0; + totalByte: number = 0; + + /** 加载的 `Promise` */ + private readonly loadPromise: Promise; + /** 兑现加载对象 */ + private readonly loadResolve: (data: R) => void; + + /** 加载结果 */ + private loadedData: R | null = null; + + constructor(init: ILoadTaskInit) { + this.dataType = init.dataType; + this.identifier = init.identifier; + this.url = this.resolveURL(init.url); + this.processor = init.processor; + this.progress = init.progress; + this.method = init.method; + this.body = init.body; + this.headers = init.headers; + + const { promise, resolve } = Promise.withResolvers(); + this.loadPromise = promise; + this.loadResolve = resolve; + } + + private resolveURL(url: string | URL) { + if (typeof url === 'string') { + return `${import.meta.env.BASE_URL}${url}`; + } else { + return url; + } + } + + private processUnstreamableResponse( + response: Response + ): Promise { + switch (this.dataType) { + case LoadDataType.ArrayBuffer: + return response.arrayBuffer(); + case LoadDataType.Blob: + return response.blob(); + case LoadDataType.JSON: + return response.json(); + case LoadDataType.Text: + return response.text(); + case LoadDataType.Uint8Array: + return response.bytes(); + } + } + + private processStreamChunkResponse( + chunks: Uint8Array[] + ): ILoadDataTypeMap[T] { + if (this.dataType === LoadDataType.Blob) { + return new Blob(chunks); + } + const totalLength = sumBy(chunks, value => value.length); + const stacked: Uint8Array = new Uint8Array(totalLength); + let offset = 0; + for (let i = 0; i < chunks.length; i++) { + stacked.set(chunks[i], offset); + offset += chunks[i].length; + } + switch (this.dataType) { + case LoadDataType.ArrayBuffer: + return stacked.buffer; + case LoadDataType.Uint8Array: + return stacked; + } + const text = loadTextDecoder.decode(stacked); + switch (this.dataType) { + case LoadDataType.Text: + return text; + case LoadDataType.JSON: + return JSON.parse(text); + } + } + + private async processResponse(response: Response) { + const reader = response.body?.getReader(); + const contentLength = response.headers.get('Content-Length') ?? '0'; + const total = parseInt(contentLength, 10); + this.loadedByte = 0; + this.totalByte = total; + this.progress.onProgress(this, 0, total); + if (!reader) { + const data = await this.processUnstreamableResponse(response); + this.loadedByte = this.totalByte; + this.contentLoaded = true; + this.progress.onProgress(this, this.loadedByte, this.totalByte); + const processed = await this.processor.process(data, this); + this.loadedData = processed; + this.loadResolve(processed); + return; + } + let received = 0; + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (value) { + chunks.push(value); + received += value.byteLength; + } + if (done) this.contentLoaded = true; + this.loadedByte = received; + this.progress.onProgress(this, received, total); + if (done) break; + } + const data = this.processStreamChunkResponse(chunks); + const processed = await this.processor.process(data, this); + this.loadedData = processed; + this.loadResolve(processed); + } + + async start(): Promise { + const response = await fetch(this.url, { + method: this.method, + body: this.body, + headers: this.headers + }); + this.processResponse(response); + return; + } + + loaded(): Promise { + return this.loadPromise; + } + + getLoadedData(): R | null { + return this.loadedData; + } +} diff --git a/packages/loader/src/types.ts b/packages/loader/src/types.ts new file mode 100644 index 0000000..f477db5 --- /dev/null +++ b/packages/loader/src/types.ts @@ -0,0 +1,228 @@ +//#region 流传输 + +export interface IStreamController { + /** 当前是否正在加载 */ + readonly loading: boolean; + + /** + * 开始流传输 + */ + start(): Promise; + + /** + * 主动终止流传输 + * @param reason 终止原因 + */ + cancel(reason?: string): void; +} + +export interface IStreamReader { + /** + * 接受字节流流传输的数据 + * @param data 传入的字节流数据,只包含本分块的内容 + * @param done 是否传输完成 + */ + pump( + data: Uint8Array | undefined, + done: boolean, + response: Response + ): Promise; + + /** + * 当前对象被传递给加载流时执行的函数 + * @param controller 传输流控制对象 + */ + piped(controller: IStreamController): void; + + /** + * 当前对象取消指定加载流传输时执行的函数 + * @param controller 传输流控制对象 + */ + unpiped(controller: IStreamController): void; + + /** + * 开始流传输 + * @param stream 传输流对象 + * @param controller 传输流控制对象 + */ + start( + stream: ReadableStream, + controller: IStreamController, + response: Response + ): Promise; + + /** + * 结束流传输 + * @param done 是否传输完成,如果为 false 的话,说明可能是由于出现错误导致的终止 + * @param reason 如果没有传输完成,那么表示失败的原因 + */ + end(done: boolean, reason?: string): void; +} + +export interface IStreamLoader extends IStreamController { + /** + * 将加载流传递给字节流读取对象 + * @param reader 字节流读取对象 + */ + pipe(reader: IStreamReader): void; + + /** + * 取消一个字节流读取对象的绑定 + * @param reader 字节流读取对象 + */ + unpipe(reader: IStreamReader): void; +} + +//#endregion + +//#region 加载任务 + +export const enum LoadDataType { + ArrayBuffer, + Uint8Array, + Blob, + Text, + JSON +} + +export interface ILoadDataTypeMap { + [LoadDataType.ArrayBuffer]: ArrayBuffer; + [LoadDataType.Uint8Array]: Uint8Array; + [LoadDataType.Blob]: Blob; + [LoadDataType.Text]: string; + [LoadDataType.JSON]: any; +} + +export interface ILoadTaskProcessor { + /** + * 处理加载内容 + * @param response 处理前加载结果 + * @param task 加载任务对象 + */ + process(response: ILoadDataTypeMap[T], task: ILoadTask): Promise; +} + +export interface ILoadTaskProgress { + /** + * 更新加载进度 + * @param task 加载任务对象 + * @param loaded 已加载的字节数 + * @param total 文件总计字节数,如果此值为零说明无法读取到 `Content-Length` + */ + onProgress(task: ILoadTask, loaded: number, total: number): void; +} + +export const enum RequestMethod { + GET = 'GET', + POST = 'POST', + HEAD = 'HEAD', + PUT = 'PUT', + DELETE = 'DELETE', + CONNECT = 'CONNECT', + OPTIONS = 'OPTIONS', + TRACE = 'TRACE', + PATCH = 'PATCH' +} + +export interface ILoadTaskInit { + /** 请求响应格式 */ + readonly dataType: T; + /** 加载任务标识符 */ + readonly identifier: string; + /** 加载目标 URL */ + readonly url: string | URL; + /** 加载的处理对象,用于处理加载结果等 */ + readonly processor: ILoadTaskProcessor; + /** 加载进度对象,用于监控加载进度 */ + readonly progress: ILoadTaskProgress; + /** 请求模式 */ + readonly method?: RequestMethod; + /** 请求体 */ + readonly body?: BodyInit; + /** 请求头 */ + readonly headers?: HeadersInit; +} + +export interface ILoadTask extends ILoadTaskInit< + T, + R +> { + /** 当前是否加载完毕 */ + readonly contentLoaded: boolean; + /** 已经加载的字节数 */ + readonly loadedByte: number; + /** 该加载任务的总体字节数 */ + readonly totalByte: number; + + /** + * 开始此加载计划,返回一个 `Promise`,当得到服务器的响应后兑现 + */ + start(): Promise; + + /** + * 返回一个 `Promise`,当本计划加载完毕后兑现,兑现结果是加载结果 + */ + loaded(): Promise; + + /** + * 获取加载完成后的加载结果 + */ + getLoadedData(): R | null; +} + +//#endregion + +//#region 内置组件 + +export interface ILoadProgressTotal< + T extends LoadDataType = LoadDataType, + R = any +> extends ILoadTaskProgress { + /** 已经添加的加载任务对象 */ + readonly addedTasks: Set>; + /** 当前已经加载完毕的任务对象 */ + readonly loadedTasks: Set>; + + /** + * 迭代加载进度,当 `yield` 的值被兑现时,说明加载进度更新 + */ + [Symbol.asyncIterator](): AsyncGenerator; + + /** + * 向该进度监听器添加加载任务对象 + * @param task 加载任务对象 + */ + addTask(task: ILoadTask): void; + + /** + * 获取总体已加载的字节数 + */ + getLoadedByte(): number; + + /** + * 获取总体需要加载的字节数 + */ + getTotalByte(): number; + + /** + * 获取已经加载的字节数与总体需要加载的字节数之比 + */ + getByteRatio(): number; + + /** + * 获取已经加载完毕的加载任务数量 + */ + getLoadedTasks(): number; + + /** + * 获取此进度监听器已经添加的加载任务对象 + */ + getAddedTasks(): number; + + /** + * 获取已经加载完毕的任务数量与已添加的加载任务数量之比 + */ + getTaskRatio(): number; +} + +//#endregion diff --git a/packages/render-vue/src/props.ts b/packages/render-vue/src/props.ts index d52e4f1..27e0f9f 100644 --- a/packages/render-vue/src/props.ts +++ b/packages/render-vue/src/props.ts @@ -16,8 +16,10 @@ import { LineParams, QuadParams, RectRCircleParams, - RectREllipseParams + RectREllipseParams, + ITexture } from '@motajs/render'; +import { Ref } from 'vue'; export interface BaseProps { /** 元素的横坐标 */ @@ -81,6 +83,8 @@ export interface BaseProps { export interface CustomProps extends BaseProps { /** 自定义的渲染函数 */ render?: CustomRenderFunction; + /** 更新绑定,当数组中的任意一项更新时将会自动更新此元素的渲染 */ + bindings?: Ref[]; } export interface ContainerProps extends BaseProps {} @@ -111,7 +115,7 @@ export interface TextProps extends BaseProps { export interface ImageProps extends BaseProps { /** 图片对象 */ - image: CanvasImageSource; + image?: ITexture | null; } export interface CommentProps extends BaseProps { diff --git a/packages/render-vue/src/tag.ts b/packages/render-vue/src/tag.ts index 33258b4..7b0265a 100644 --- a/packages/render-vue/src/tag.ts +++ b/packages/render-vue/src/tag.ts @@ -9,13 +9,15 @@ import { Image, IRenderItem, IRenderTreeRoot, + ITexture, Line, Path, QuadraticCurve, Rect, RectR, Shader, - Text + Text, + Texture } from '@motajs/render'; import { IRenderTagInfo, IRenderTagManager, TagCreateFunction } from './types'; import { logger } from '@motajs/common'; @@ -24,13 +26,13 @@ export class RenderTagManager implements IRenderTagManager { /** 标签注册映射 */ private readonly tagRegistry: Map = new Map(); /** 空图片 */ - private readonly emptyImg: HTMLCanvasElement; + private readonly emptyImg: ITexture; constructor(readonly renderer: IRenderTreeRoot) { const emptyImage = document.createElement('canvas'); emptyImage.width = 1; emptyImage.height = 1; - this.emptyImg = emptyImage; + this.emptyImg = new Texture(emptyImage); this.resgiterIntrinsicTags(); } @@ -52,17 +54,7 @@ export class RenderTagManager implements IRenderTagManager { const { text = '', nocache = true, cache = false } = props; return this.renderer.createElement(Text, text, cache && !nocache); }); - this.registerTag('image', props => { - if (!props) { - return this.renderer.createElement(Image, this.emptyImg, false); - } - const { - image = this.emptyImg, - nocache = true, - cache = false - } = props; - return this.renderer.createElement(Image, image, cache && !nocache); - }); + this.registerTag('image', this.createStandardElement(false, Image)); this.registerTag('shader', this.createNoParamElement(Shader)); this.registerTag('comment', props => { if (!props) return this.renderer.createElement(Comment); diff --git a/packages/render/src/assets/store.ts b/packages/render/src/assets/store.ts index b9486f2..69bec93 100644 --- a/packages/render/src/assets/store.ts +++ b/packages/render/src/assets/store.ts @@ -2,9 +2,9 @@ import { isNil } from 'lodash-es'; import { ITexture, ITextureStore } from './types'; import { logger } from '@motajs/common'; -export class TextureStore - implements ITextureStore -{ +export class TextureStore< + T extends ITexture = ITexture +> implements ITextureStore { private readonly texMap: Map = new Map(); private readonly invMap: Map = new Map(); private readonly aliasMap: Map = new Map(); diff --git a/packages/render/src/core/misc.ts b/packages/render/src/core/misc.ts index 6c40b14..3c19fd4 100644 --- a/packages/render/src/core/misc.ts +++ b/packages/render/src/core/misc.ts @@ -2,6 +2,7 @@ import { RenderItem, Transform, MotaOffscreenCanvas2D } from '.'; import { CanvasStyle } from '../types'; import { Font } from '../style'; import { IRenderImage, IRenderText } from './types'; +import { ITexture } from '../assets'; /** 文字的安全填充,会填充在文字的上侧和下侧,防止削顶和削底 */ const SAFE_PAD = 1; @@ -147,31 +148,31 @@ export class Text extends RenderItem implements IRenderText { } export class Image extends RenderItem implements IRenderImage { - image: CanvasImageSource; + image: ITexture | null; - constructor(image: CanvasImageSource, enableCache: boolean = false) { + constructor(enableCache: boolean = false) { super(enableCache); - this.image = image; - if (image instanceof VideoFrame || image instanceof SVGElement) { - this.size(200, 200); - } else { - this.size(image.width, image.height); - } + this.image = null; } protected render( canvas: MotaOffscreenCanvas2D, _transform: Transform ): void { + if (!this.image) return; const ctx = canvas.ctx; - ctx.drawImage(this.image, 0, 0, this.width, this.height); + const { + source, + rect: { x, y, w, h } + } = this.image.render(); + ctx.drawImage(source, x, y, w, h, 0, 0, this.width, this.height); } /** * 设置图片资源 * @param image 图片资源 */ - setImage(image: CanvasImageSource) { + setImage(image: ITexture | null) { this.image = image; this.update(); } @@ -183,7 +184,8 @@ export class Image extends RenderItem implements IRenderImage { ): boolean { switch (key) { case 'image': - this.setImage(nextValue); + if (!nextValue) this.setImage(null); + else this.setImage(nextValue); return true; } return false; diff --git a/packages/render/src/core/types.ts b/packages/render/src/core/types.ts index 2e22654..31d9120 100644 --- a/packages/render/src/core/types.ts +++ b/packages/render/src/core/types.ts @@ -12,6 +12,7 @@ import { DefineComponent, DefineSetupFnComponent } from 'vue'; import { JSX } from 'vue/jsx-runtime'; import EventEmitter from 'eventemitter3'; import { SizedCanvasImageSource } from '../types'; +import { ITexture } from '../assets'; //#region 功能类型 @@ -572,13 +573,13 @@ export interface IRenderText extends IRenderItem { export interface IRenderImage extends IRenderItem { /** 当前元素的图片内容 */ - readonly image: CanvasImageSource; + readonly image: ITexture | null; /** * 设置图片资源 * @param image 图片资源 */ - setImage(image: CanvasImageSource): void; + setImage(image: ITexture): void; } //#endregion diff --git a/public/_server/table/data.comment.js b/public/_server/table/data.comment.js index ba2ff60..9d26d9c 100644 --- a/public/_server/table/data.comment.js +++ b/public/_server/table/data.comment.js @@ -122,8 +122,7 @@ var data_comment_c456ea59_6018_45ef_8bcc_211a24c627dc = { "_range": "editor.mode.checkUnique(thiseval)", "_directory": "./project/fonts/", "_transform": (function (one) { - if (one.endsWith(".ttf")) return one.substring(0, one.lastIndexOf('.')); - return null; + return one; }).toString(), "_docs": "使用字体", "_data": "在此存放所有可能使用的字体 \n 字体名不能使用中文,不能带空格或特殊字符" diff --git a/public/libs/control.js b/public/libs/control.js index 447b30d..d071a7c 100644 --- a/public/libs/control.js +++ b/public/libs/control.js @@ -2046,9 +2046,6 @@ control.prototype._doSL_load = function (id, callback) { core.saves.autosave.now, 1 )[0]; - if (!main.replayChecking) { - Mota.require('@motajs/legacy-ui').fixedUi.closeByName('start'); - } if (core.isPlaying() && !core.status.gameOver) { core.control.autosave(0); core.saves.autosave.now -= 1; @@ -2063,11 +2060,6 @@ control.prototype._doSL_load = function (id, callback) { id == 'autoSave' ? id : 'save' + id, null, function (data) { - if (!main.replayChecking && data) { - Mota.require('@motajs/legacy-ui').fixedUi.closeByName( - 'start' - ); - } if (id == 'autoSave' && data != null) { core.saves.autosave.data = data; if (!(core.saves.autosave.data instanceof Array)) { diff --git a/public/libs/core.js b/public/libs/core.js index 504a6e8..9270316 100644 --- a/public/libs/core.js +++ b/public/libs/core.js @@ -297,18 +297,6 @@ core.prototype.init = async function (coreData, callback) { core._afterLoadResources(callback); }); } - } else { - if (main.renderLoaded) - Mota.require('@motajs/legacy-ui').fixedUi.open('load', { - callback - }); - else { - Mota.require('@user/data-base').hook.once('renderLoaded', () => { - Mota.require('@motajs/legacy-ui').fixedUi.open('load', { - callback - }); - }); - } } }; diff --git a/public/libs/icons.js b/public/libs/icons.js index 2bf4d3f..0a39426 100644 --- a/public/libs/icons.js +++ b/public/libs/icons.js @@ -64,8 +64,8 @@ icons.prototype.getTilesetOffset = function (id) { for (var i in core.tilesets) { var imgName = core.tilesets[i]; var img = core.material.images.tilesets[imgName]; - var width = Math.floor(parseInt(img.getAttribute('_width')) / 32), - height = Math.floor(parseInt(img.getAttribute('_height')) / 32); + var width = Math.floor(img.width / 32), + height = Math.floor(img.height / 32); if (id >= startOffset && id < startOffset + width * height) { var x = (id - startOffset) % width, y = Math.floor((id - startOffset) / width); diff --git a/public/libs/loader.js b/public/libs/loader.js index d3c5910..43ed0fb 100644 --- a/public/libs/loader.js +++ b/public/libs/loader.js @@ -139,6 +139,7 @@ loader.prototype._loadMaterials_async = function (onprogress, onfinished) { }; loader.prototype._loadMaterials_afterLoad = function () { + if (main.mode === 'play') return; var images = core.splitImage(core.material.images['icons']); for (var key in core.statusBar.icons) { if (typeof core.statusBar.icons[key] == 'number') { @@ -602,11 +603,11 @@ loader.prototype.freeBgm = function (name) { name = core.getMappedName(name); if (!core.material.bgms[name]) return; // 从cachedBgms中删除 - core.musicStatus.cachedBgms = core.musicStatus.cachedBgms.filter(function ( - t - ) { - return t != name; - }); + core.musicStatus.cachedBgms = core.musicStatus.cachedBgms.filter( + function (t) { + return t != name; + } + ); // 清掉缓存 core.material.bgms[name].removeAttribute('src'); core.material.bgms[name].load(); diff --git a/public/main.js b/public/main.js index 26793f0..125a7ac 100644 --- a/public/main.js +++ b/public/main.js @@ -20,6 +20,7 @@ function main() { this.dom = { body: document.body, + // 这些是给编辑器留的 gameDraw: document.getElementById('game-draw'), gameCanvas: document.getElementsByClassName('gameCanvas'), inputDiv: document.getElementById('inputDiv'), @@ -64,6 +65,8 @@ function main() { 'icons' ]; + // 这些也是给编辑器留的 + this.canvas = {}; this.statusBar = { image: {}, icons: { @@ -105,8 +108,8 @@ function main() { btn8: 34 } }; + this.floors = {}; - this.canvas = {}; this.__VERSION__ = '2.10.0'; this.__VERSION_CODE__ = 610; diff --git a/script/build-game.ts b/script/build-game.ts index 5eae017..688fb95 100644 --- a/script/build-game.ts +++ b/script/build-game.ts @@ -9,8 +9,13 @@ import { transformAsync } from '@babel/core'; import archiver from 'archiver'; import { createWriteStream } from 'fs'; import { zip } from 'compressing'; -import { RequiredData, RequiredIconsData, ResourceType } from './types'; -import { splitResource, SplittedResource } from './build-resource'; +import { RequiredData, RequiredIconsData } from './types'; +import { + CompressedUsage, + LoadDataType, + splitResource, + SplittedResource +} from './build-resource'; import { formatSize } from './utils'; /** 打包调试 */ @@ -292,9 +297,9 @@ async function getAllChars(client: RollupOutput[]) { } interface CompressedLoadListItem { - type: ResourceType; - name: string; - usage: string; + readonly readAs: LoadDataType; + readonly name: string; + readonly usage: CompressedUsage; } type CompressedLoadList = Record; @@ -309,7 +314,7 @@ function generateResourceJSON(resources: SplittedResource[]) { const uri = `project/resource/${file.fileName}`; file.content.forEach(content => { const item: CompressedLoadListItem = { - type: content.type, + readAs: content.readAs, name: content.name, usage: content.usage }; @@ -468,7 +473,7 @@ async function buildGame() { await Promise.all( fonts.map(v => { const fontmin = new Fontmin(); - const src = resolve(tempDir, 'client/project/fonts', `${v}.ttf`); + const src = resolve(tempDir, 'client/project/fonts', v); const dest = resolve(tempDir, 'fonts'); const plugin = Fontmin.glyph({ text: [...chars].join('') diff --git a/script/build-resource.ts b/script/build-resource.ts index 572fbb5..903c65d 100644 --- a/script/build-resource.ts +++ b/script/build-resource.ts @@ -1,20 +1,34 @@ import JSZip from 'jszip'; -import { - RequiredData, - RequiredIconsData, - ResourceType, - ResourceUsage -} from './types'; +import { RequiredData, RequiredIconsData } from './types'; import { Stats } from 'fs'; import { readdir, readFile, stat } from 'fs/promises'; import { resolve } from 'path'; import { fileHash } from './utils'; +export const enum CompressedUsage { + // ---- 系统加载内容,不可更改 + Font, + Image, + Sound, + Tileset, + Autotile, + Material, + Animate +} + +export const enum LoadDataType { + ArrayBuffer, + Uint8Array, + Blob, + Text, + JSON +} + export interface ResourceInfo { - name: string; - type: ResourceType; - usage: ResourceUsage; - stats: Stats; + readonly name: string; + readonly readAs: LoadDataType; + readonly usage: CompressedUsage; + readonly stats: Stats; } export interface SplittedResource { @@ -26,35 +40,52 @@ export interface SplittedResource { } interface ResourceContent extends ResourceInfo { - content: string | Buffer | Uint8Array; - exceed: boolean; + readonly content: string | Buffer | Uint8Array; + readonly exceed: boolean; } interface ResourcePath { - name: string; - path: string; - usage: ResourceUsage; + readonly name: string; + readonly path: string; + readonly usage: CompressedUsage; } -function getTypeByUsage(usage: ResourceUsage): ResourceType { +function getTypeByUsage(usage: CompressedUsage): LoadDataType { switch (usage) { - case 'animate': - return 'text'; - case 'autotile': - case 'image': - case 'tileset': - return 'image'; - case 'sound': - return 'byte'; - case 'font': - return 'buffer'; - case 'material': - return 'material'; + case CompressedUsage.Animate: + return LoadDataType.Text; + case CompressedUsage.Autotile: + case CompressedUsage.Image: + case CompressedUsage.Tileset: + case CompressedUsage.Material: + return LoadDataType.Blob; + case CompressedUsage.Font: + case CompressedUsage.Sound: + return LoadDataType.ArrayBuffer; } } -function readFileOfType(path: string, type: ResourceType) { - if (type === 'text') { +function 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'; + } +} + +function readFileOfType(path: string, type: LoadDataType) { + if (type === LoadDataType.Text) { return readFile(path, 'utf-8'); } else { return readFile(path); @@ -64,7 +95,7 @@ function readFileOfType(path: string, type: ResourceType) { async function compressFiles(files: ResourceContent[]) { const zip = new JSZip(); files.forEach(v => { - const dir = `${v.type}/${v.name}`; + const dir = `${getZipFolderByUsage(v.usage)}/${v.name}`; zip.file(dir, v.content); }); const buffer = await zip.generateAsync({ @@ -107,37 +138,37 @@ export async function splitResource( ...animates.map(v => ({ name: `${v}.animate`, path: resolve(base, 'project/animates', `${v}.animate`), - usage: 'animate' + usage: CompressedUsage.Animate })), ...fonts.map(v => ({ - name: `${v}.ttf`, - path: resolve(fontsDir, `${v}.ttf`), - usage: 'font' + name: v, + path: resolve(fontsDir, v), + usage: CompressedUsage.Font })), ...images.map(v => ({ name: v, path: resolve(base, 'project/images', v), - usage: 'image' + usage: CompressedUsage.Image })), ...sounds.map(v => ({ name: v, path: resolve(base, 'project/sounds', v), - usage: 'sound' + usage: CompressedUsage.Sound })), ...tilesets.map(v => ({ name: v, path: resolve(base, 'project/tilesets', v), - usage: 'tileset' + usage: CompressedUsage.Tileset })), ...autotiles.map(v => ({ name: `${v}.png`, path: resolve(base, 'project/autotiles', `${v}.png`), - usage: 'autotile' + usage: CompressedUsage.Autotile })), ...materials.map(v => ({ name: v, path: resolve(base, 'project/materials', v), - usage: 'material' + usage: CompressedUsage.Material })) ]; @@ -154,7 +185,7 @@ export async function splitResource( const type = getTypeByUsage(usage); const content = await readFileOfType(path, type); const info: ResourceContent = { - type, + readAs: type, name, usage, stats, diff --git a/script/declare.ts b/script/declare.ts index b47b115..75e3fd7 100644 --- a/script/declare.ts +++ b/script/declare.ts @@ -73,11 +73,11 @@ import fs from 'fs/promises'; './src/types/source/data.d.ts', ` ${floorId} -${d.images.length > 0 ? imgs : 'type ImageIds = never\n'} -${d.animates.length > 0 ? anis : 'type AnimationIds = never\n'} -${d.sounds.length > 0 ? sounds : 'type SoundIds = never\n'} -${d.bgms.length > 0 ? bgms : 'type BgmIds = never\n'} -${d.fonts.length > 0 ? fonts : 'type FontIds = never\n'} +${d.images.length > 0 ? imgs : 'type ImageIds = string;\n'} +${d.animates.length > 0 ? anis : 'type AnimationIds = string;\n'} +${d.sounds.length > 0 ? sounds : 'type SoundIds = string;\n'} +${d.bgms.length > 0 ? bgms : 'type BgmIds = string;\n'} +${d.fonts.length > 0 ? fonts : 'type FontIds = string;\n'} ${names} `, 'utf-8' diff --git a/script/dev.ts b/script/dev.ts index 0b6249c..7f28dbb 100644 --- a/script/dev.ts +++ b/script/dev.ts @@ -590,11 +590,11 @@ async function doDeclaration(type: string, data: string) { 'src/types/source/data.d.ts', ` ${floorId} -${d.images.length > 0 ? imgs : 'type ImageIds = never\n'} -${d.animates.length > 0 ? anis : 'type AnimationIds = never\n'} -${d.sounds.length > 0 ? sounds : 'type SoundIds = never\n'} -${d.bgms.length > 0 ? bgms : 'type BgmIds = never\n'} -${d.fonts.length > 0 ? fonts : 'type FontIds = never\n'} +${d.images.length > 0 ? imgs : 'type ImageIds = string;\n'} +${d.animates.length > 0 ? anis : 'type AnimationIds = string;\n'} +${d.sounds.length > 0 ? sounds : 'type SoundIds = string;\n'} +${d.bgms.length > 0 ? bgms : 'type BgmIds = string;\n'} +${d.fonts.length > 0 ? fonts : 'type FontIds = string;\n'} ${names} `, 'utf-8' diff --git a/src/App.vue b/src/App.vue index de593dd..6c61888 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,21 +16,12 @@ -
- -