From bea2725d6aba2c4ef25540838ca0bc28a72dfa30 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Sat, 28 Feb 2026 22:56:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=86=20HumanBreak=20=E6=96=B0?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E7=A7=BB=E6=A4=8D=E5=85=A5=E8=AF=A5=E6=A0=B7?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/ui/ui.md | 2 +- package.json | 3 +- packages-user/client-base/package.json | 7 + packages-user/client-base/src/index.ts | 7 + .../client-base/src/material/autotile.ts | 454 +++++ .../client-base/src/material/builder.ts | 159 ++ .../client-base/src/material/fallback.ts | 140 ++ .../client-base/src/material/index.ts | 19 + packages-user/client-base/src/material/ins.ts | 5 + .../client-base/src/material/manager.ts | 602 ++++++ .../client-base/src/material/types.ts | 528 +++++ .../client-base/src/material/utils.ts | 47 + packages-user/client-modules/package.json | 1 + .../client-modules/src/action/hotkey.ts | 5 + .../client-modules/src/fallback/audio.ts | 3 +- .../client-modules/src/render/action.ts | 6 +- .../client-modules/src/render/commonIns.ts | 22 + .../src/render/components/textbox.tsx | 2 + .../src/render/components/textboxTyper.ts | 13 +- .../src/render/elements/cache.ts | 6 +- .../src/render/elements/hero.ts | 3 +- .../src/render/elements/index.ts | 29 + .../src/render/elements/misc.ts | 4 +- .../src/render/elements/props.ts | 11 +- .../client-modules/src/render/fx/image3d.ts | 8 +- .../client-modules/src/render/index.tsx | 13 +- .../client-modules/src/render/map/block.ts | 351 ++++ .../client-modules/src/render/map/constant.ts | 2 + .../client-modules/src/render/map/element.ts | 70 + .../src/render/map/extension/door.ts | 73 + .../src/render/map/extension/hero.ts | 518 +++++ .../src/render/map/extension/index.ts | 3 + .../src/render/map/extension/manager.ts | 60 + .../src/render/map/extension/text.ts | 63 + .../src/render/map/extension/types.ts | 246 +++ .../client-modules/src/render/map/index.ts | 11 + .../client-modules/src/render/map/moving.ts | 268 +++ .../client-modules/src/render/map/renderer.ts | 1783 +++++++++++++++++ .../src/render/map/shader/back.frag | 12 + .../src/render/map/shader/back.vert | 16 + .../src/render/map/shader/map.frag | 17 + .../src/render/map/shader/map.vert | 36 + .../client-modules/src/render/map/status.ts | 70 + .../client-modules/src/render/map/types.ts | 1126 +++++++++++ .../client-modules/src/render/map/vertex.ts | 1166 +++++++++++ .../client-modules/src/render/map/viewport.ts | 138 ++ .../client-modules/src/render/shared.ts | 16 + .../client-modules/src/render/ui/index.ts | 1 + .../client-modules/src/render/ui/save.tsx | 10 +- .../client-modules/src/render/ui/settings.tsx | 11 +- .../src/render/weather/presets/cloud.ts | 2 +- .../src/render/weather/presets/cloudLike.ts | 2 +- .../src/render/weather/presets/fog.ts | 2 +- packages-user/data-base/src/game.ts | 2 + packages-user/data-state/src/common/face.ts | 63 + packages-user/data-state/src/common/index.ts | 3 + packages-user/data-state/src/common/types.ts | 54 + packages-user/data-state/src/common/utils.ts | 182 ++ packages-user/data-state/src/core.ts | 33 + packages-user/data-state/src/enemy/damage.ts | 2 +- packages-user/data-state/src/enemy/special.ts | 2 +- packages-user/data-state/src/hero/index.ts | 2 + packages-user/data-state/src/hero/state.ts | 133 ++ packages-user/data-state/src/hero/types.ts | 204 ++ packages-user/data-state/src/index.ts | 75 +- packages-user/data-state/src/legacy/hero.ts | 115 ++ .../data-state/src/{state => legacy}/index.ts | 0 .../src/{state => legacy}/interface.ts | 0 .../data-state/src/{state => legacy}/item.ts | 0 .../data-state/src/{state => legacy}/move.ts | 0 .../data-state/src/{state => legacy}/utils.ts | 0 packages-user/data-state/src/map/index.ts | 3 + .../data-state/src/map/layerState.ts | 139 ++ packages-user/data-state/src/map/mapLayer.ts | 263 +++ packages-user/data-state/src/map/types.ts | 278 +++ packages-user/data-state/src/shared.ts | 2 + packages-user/data-state/src/state/hero.ts | 332 --- packages-user/data-state/src/types.ts | 32 + packages-user/entry-client/package.json | 4 +- packages-user/entry-client/src/create.ts | 7 +- packages-user/entry-data/src/index.ts | 8 + .../legacy-plugin-data/src/fallback.ts | 196 +- packages/client-base/src/glUtils.ts | 86 + packages/client-base/src/index.ts | 1 + packages/common/src/dirtyTracker.ts | 133 ++ packages/common/src/hook.ts | 86 + packages/common/src/index.ts | 3 + packages/common/src/logger.json | 48 + packages/common/src/logger.ts | 17 +- packages/common/src/types.ts | 117 ++ packages/render-assets/package.json | 6 + packages/render-assets/src/animater.ts | 134 ++ packages/render-assets/src/composer.ts | 451 +++++ packages/render-assets/src/index.ts | 7 + packages/render-assets/src/shader/pack.frag | 12 + packages/render-assets/src/shader/pack.vert | 12 + packages/render-assets/src/splitter.ts | 68 + packages/render-assets/src/store.ts | 107 + packages/render-assets/src/streamComposer.ts | 201 ++ packages/render-assets/src/texture.ts | 193 ++ packages/render-assets/src/types.ts | 218 ++ packages/render-core/package.json | 5 +- packages/render-core/src/gl2.ts | 4 +- packages/render-core/src/index.ts | 1 - packages/render-core/src/item.ts | 14 +- packages/render-core/src/transform.ts | 78 +- packages/render-core/src/types.ts | 6 - packages/render-elements/src/graphics.ts | 4 +- packages/render-elements/src/misc.ts | 3 +- packages/render-vue/src/props.ts | 4 +- packages/render/src/index.ts | 1 + pnpm-lock.yaml | 14 + public/_server/table/data.comment.js | 13 + public/project/data.js | 9 +- public/project/icons.js | 13 +- public/project/items.js | 55 + public/project/maps.js | 13 +- script/build-game.ts | 28 +- script/build-resource.ts | 8 +- script/dev.ts | 6 +- script/lines.ts | 15 +- src/styles.less | 3 +- src/types/declaration/data.d.ts | 5 + src/types/declaration/eventDec.d.ts | 2 +- src/types/source/cls.d.ts | 11 + src/types/source/items.d.ts | 11 + src/types/source/maps.d.ts | 22 + vite.config.ts | 3 +- 128 files changed, 11984 insertions(+), 573 deletions(-) create mode 100644 packages-user/client-base/package.json create mode 100644 packages-user/client-base/src/index.ts create mode 100644 packages-user/client-base/src/material/autotile.ts create mode 100644 packages-user/client-base/src/material/builder.ts create mode 100644 packages-user/client-base/src/material/fallback.ts create mode 100644 packages-user/client-base/src/material/index.ts create mode 100644 packages-user/client-base/src/material/ins.ts create mode 100644 packages-user/client-base/src/material/manager.ts create mode 100644 packages-user/client-base/src/material/types.ts create mode 100644 packages-user/client-base/src/material/utils.ts create mode 100644 packages-user/client-modules/src/render/commonIns.ts create mode 100644 packages-user/client-modules/src/render/map/block.ts create mode 100644 packages-user/client-modules/src/render/map/constant.ts create mode 100644 packages-user/client-modules/src/render/map/element.ts create mode 100644 packages-user/client-modules/src/render/map/extension/door.ts create mode 100644 packages-user/client-modules/src/render/map/extension/hero.ts create mode 100644 packages-user/client-modules/src/render/map/extension/index.ts create mode 100644 packages-user/client-modules/src/render/map/extension/manager.ts create mode 100644 packages-user/client-modules/src/render/map/extension/text.ts create mode 100644 packages-user/client-modules/src/render/map/extension/types.ts create mode 100644 packages-user/client-modules/src/render/map/index.ts create mode 100644 packages-user/client-modules/src/render/map/moving.ts create mode 100644 packages-user/client-modules/src/render/map/renderer.ts create mode 100644 packages-user/client-modules/src/render/map/shader/back.frag create mode 100644 packages-user/client-modules/src/render/map/shader/back.vert create mode 100644 packages-user/client-modules/src/render/map/shader/map.frag create mode 100644 packages-user/client-modules/src/render/map/shader/map.vert create mode 100644 packages-user/client-modules/src/render/map/status.ts create mode 100644 packages-user/client-modules/src/render/map/types.ts create mode 100644 packages-user/client-modules/src/render/map/vertex.ts create mode 100644 packages-user/client-modules/src/render/map/viewport.ts create mode 100644 packages-user/data-state/src/common/face.ts create mode 100644 packages-user/data-state/src/common/index.ts create mode 100644 packages-user/data-state/src/common/types.ts create mode 100644 packages-user/data-state/src/common/utils.ts create mode 100644 packages-user/data-state/src/core.ts create mode 100644 packages-user/data-state/src/hero/index.ts create mode 100644 packages-user/data-state/src/hero/state.ts create mode 100644 packages-user/data-state/src/hero/types.ts create mode 100644 packages-user/data-state/src/legacy/hero.ts rename packages-user/data-state/src/{state => legacy}/index.ts (100%) rename packages-user/data-state/src/{state => legacy}/interface.ts (100%) rename packages-user/data-state/src/{state => legacy}/item.ts (100%) rename packages-user/data-state/src/{state => legacy}/move.ts (100%) rename packages-user/data-state/src/{state => legacy}/utils.ts (100%) create mode 100644 packages-user/data-state/src/map/index.ts create mode 100644 packages-user/data-state/src/map/layerState.ts create mode 100644 packages-user/data-state/src/map/mapLayer.ts create mode 100644 packages-user/data-state/src/map/types.ts create mode 100644 packages-user/data-state/src/shared.ts delete mode 100644 packages-user/data-state/src/state/hero.ts create mode 100644 packages-user/data-state/src/types.ts create mode 100644 packages/client-base/src/glUtils.ts create mode 100644 packages/common/src/dirtyTracker.ts create mode 100644 packages/common/src/hook.ts create mode 100644 packages/common/src/types.ts create mode 100644 packages/render-assets/package.json create mode 100644 packages/render-assets/src/animater.ts create mode 100644 packages/render-assets/src/composer.ts create mode 100644 packages/render-assets/src/index.ts create mode 100644 packages/render-assets/src/shader/pack.frag create mode 100644 packages/render-assets/src/shader/pack.vert create mode 100644 packages/render-assets/src/splitter.ts create mode 100644 packages/render-assets/src/store.ts create mode 100644 packages/render-assets/src/streamComposer.ts create mode 100644 packages/render-assets/src/texture.ts create mode 100644 packages/render-assets/src/types.ts delete mode 100644 packages/render-core/src/types.ts diff --git a/docs/guide/ui/ui.md b/docs/guide/ui/ui.md index 4d53464..237b40f 100644 --- a/docs/guide/ui/ui.md +++ b/docs/guide/ui/ui.md @@ -633,7 +633,7 @@ export interface IWheelEvent extends IActionEvent { 1. 按下、抬起、点击**永远**保持为同一个 `identifier` 2. 移动过程中,使用最后一个按下的按键的 `identifier` 作为移动事件的 `identifier` -3. 如果移动过程中,最后一个按下的按键抬起,那么依然会维持**原先的** `identifer`,**不会**回退至上一个按下的按键 +3. 如果移动过程中,最后一个按下的按键抬起,那么依然会维持**原先的** `identifier`,**不会**回退至上一个按下的按键 除此之外,滚轮事件中的 `identifier` 永远为 -1。 diff --git a/package.json b/package.json index bc0b6ac..21836ac 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "type": "vue-tsc --noEmit", "lines": "tsx script/lines.ts packages packages-user", "build:packages": "vue-tsc --noEmit && tsx script/build-packages.ts", - "build:game": "tsx script/declare.ts vue-tsc --noEmit && tsx script/build-game.ts", + "build:game": "tsx script/declare.ts && vue-tsc --noEmit && tsx script/build-game.ts", "build:lib": "vue-tsc --noEmit && tsx script/build-lib.ts", "docs:dev": "concurrently -k -n SIDEBAR,VITEPRESS -c blue,green \"tsx docs/.vitepress/api.ts\" \"vitepress dev docs\"", "docs:build": "vitepress build docs", @@ -30,6 +30,7 @@ "jszip": "^3.10.1", "lodash-es": "^4.17.21", "lz-string": "^1.5.0", + "maxrects-packer": "^2.7.3", "mutate-animate": "^1.4.2", "ogg-opus-decoder": "^1.6.14", "opus-decoder": "^0.7.7", diff --git a/packages-user/client-base/package.json b/packages-user/client-base/package.json new file mode 100644 index 0000000..c215f6c --- /dev/null +++ b/packages-user/client-base/package.json @@ -0,0 +1,7 @@ +{ + "name": "@user/client-base", + "dependencies": { + "@motajs/render-assets": "workspace:*", + "@motajs/client-base": "workspace:*" + } +} diff --git a/packages-user/client-base/src/index.ts b/packages-user/client-base/src/index.ts new file mode 100644 index 0000000..ddf0bf0 --- /dev/null +++ b/packages-user/client-base/src/index.ts @@ -0,0 +1,7 @@ +import { createMaterial } from './material'; + +export function create() { + createMaterial(); +} + +export * from './material'; diff --git a/packages-user/client-base/src/material/autotile.ts b/packages-user/client-base/src/material/autotile.ts new file mode 100644 index 0000000..7d691f9 --- /dev/null +++ b/packages-user/client-base/src/material/autotile.ts @@ -0,0 +1,454 @@ +import { + IRect, + ITextureRenderable, + SizedCanvasImageSource +} from '@motajs/render-assets'; +import { + AutotileConnection, + AutotileType, + BlockCls, + IAutotileConnection, + IAutotileProcessor, + IMaterialFramedData, + IMaterialManager +} from './types'; +import { isNil } from 'lodash-es'; + +interface ConnectedAutotile { + readonly lt: Readonly; + readonly rt: Readonly; + readonly rb: Readonly; + readonly lb: Readonly; +} + +export interface IAutotileData { + /** 图像源 */ + readonly source: SizedCanvasImageSource; + /** 自动元件帧数 */ + readonly frames: number; +} + +/** 3x4 自动元件的连接映射,元组表示将对应大小的自动元件按照格子 1/4 大小切分后对应的索引位置 */ +const connectionMap3x4 = new Map(); +/** 2x3 自动元件的连接映射,元组表示将对应大小的自动元件按照格子 1/4 大小切分后对应的索引位置 */ +const connectionMap2x3 = new Map(); +/** 3x4 自动元件各方向连接的矩形映射 */ +const rectMap3x4 = new Map(); +/** 2x3 自动元件各方向连接的矩形映射 */ +const rectMap2x3 = new Map(); +/** 不重复连接映射,用于平铺自动元件,一共 48 种 */ +const distinctConnectionMap = new Map(); + +export class AutotileProcessor implements IAutotileProcessor { + /** 自动元件父子关系映射,子元件 -> 父元件 */ + readonly parentMap: Map = new Map(); + /** 自动元件父子关系映射,父元件 -> 子元件列表 */ + readonly childMap: Map> = new Map(); + + constructor(readonly manager: IMaterialManager) {} + + private ensureChildSet(num: number) { + const set = this.childMap.get(num); + if (set) return set; + const ensure = new Set(); + this.childMap.set(num, ensure); + return ensure; + } + + setConnection(autotile: number, parent: number): void { + this.parentMap.set(autotile, parent); + const child = this.ensureChildSet(parent); + child.add(autotile); + } + + private connectEdge(length: number, index: number, width: number): number { + // 最高位表示左上,低位依次顺时针旋转 + + // 如果地图大小只有 1 + if (length === 1) { + return 0b1111_1111; + } + // 如果地图高度只有 1 + if (length === width) { + if (index === 0) { + return 0b1110_1111; + } else if (index === length - 1) { + return 0b1111_1110; + } else { + return 0b1110_1110; + } + } + // 如果地图宽度只有 1 + if (width === 1) { + if (index === 0) { + return 0b1111_1011; + } else if (index === length - 1) { + return 0b1011_1111; + } else { + return 0b1011_1011; + } + } + + // 正常地图 + + const lastLine = length - width; + const x = index % width; + + // 四个角,左上,右上,右下,左下 + if (index === 0) { + return 0b1110_0011; + } else if (index === width - 1) { + return 0b1111_1000; + } else if (index === length - 1) { + return 0b0011_1110; + } else if (index === lastLine) { + return 0b1000_1111; + } + // 四条边,上,右,下,左 + else if (index < width) { + return 0b1110_0000; + } else if (x === width - 1) { + return 0b0011_1000; + } else if (index > lastLine) { + return 0b0000_1110; + } else if (x === 0) { + return 0b1000_0011; + } + // 不在边缘 + else { + return 0b0000_0000; + } + } + + connect( + array: Uint32Array, + index: number, + width: number + ): IAutotileConnection { + const block = array[index]; + if (block === 0) { + return { + connection: 0, + center: 0 + }; + } + let res: number = this.connectEdge(array.length, index, width); + const childList = this.childMap.get(block); + + // 最高位表示左上,低位依次顺时针旋转 + const a7 = array[index - width - 1] ?? 0; + const a6 = array[index - width] ?? 0; + const a5 = array[index - width + 1] ?? 0; + const a4 = array[index + 1] ?? 0; + const a3 = array[index + width + 1] ?? 0; + const a2 = array[index + width] ?? 0; + const a1 = array[index + width - 1] ?? 0; + const a0 = array[index - 1] ?? 0; + + // Benchmark https://www.measurethat.net/Benchmarks/Show/35271/0/convert-boolean-to-number + + if (!childList || childList.size === 0) { + // 不包含子元件,那么直接跟相同的连接 + res |= + +(a0 === block) | + (+(a1 === block) << 1) | + (+(a2 === block) << 2) | + (+(a3 === block) << 3) | + (+(a4 === block) << 4) | + (+(a5 === block) << 5) | + (+(a6 === block) << 6) | + (+(a7 === block) << 7); + } else { + res |= + +childList.has(a0) | + (+childList.has(a1) << 1) | + (+childList.has(a2) << 2) | + (+childList.has(a3) << 3) | + (+childList.has(a4) << 4) | + (+childList.has(a5) << 5) | + (+childList.has(a6) << 6) | + (+childList.has(a7) << 7); + } + + return { + connection: res, + center: block + }; + } + + updateConnectionFor( + connection: number, + center: number, + target: number, + direction: AutotileConnection + ): number { + const childList = this.childMap.get(center); + if (!childList || !childList.has(target)) { + return connection & ~direction; + } else { + return connection | direction; + } + } + + /** + * 检查贴图是否是一个自动元件 + * @param tile 贴图数据 + */ + private checkAutotile(tile: IMaterialFramedData) { + if (tile.cls !== BlockCls.Autotile) return false; + const { texture, frames } = tile; + if (texture.width !== 96 * frames) return false; + if (texture.height === 128 || texture.height === 144) return true; + else return false; + } + + render(autotile: number, connection: number): ITextureRenderable | null { + const tile = this.manager.getTile(autotile); + if (!tile) return null; + if (!this.checkAutotile(tile)) return null; + return this.renderWithoutCheck(tile, connection); + } + + renderWith( + tile: IMaterialFramedData, + connection: number + ): ITextureRenderable | null { + if (!this.checkAutotile(tile)) return null; + return this.renderWithoutCheck(tile, connection); + } + + renderWithoutCheck( + tile: IMaterialFramedData, + connection: number + ): ITextureRenderable | null { + const { texture } = tile; + const size = texture.height === 32 * 48 ? 32 : 48; + const index = distinctConnectionMap.get(connection); + if (isNil(index)) return null; + const { rect } = texture.render(); + return { + source: texture.source, + rect: { x: rect.x, y: rect.y + size * index, w: size, h: size } + }; + } + + *renderAnimated( + autotile: number, + connection: number + ): Generator { + const tile = this.manager.getTile(autotile); + if (!tile) return; + yield* this.renderAnimatedWith(tile, connection); + } + + *renderAnimatedWith( + tile: IMaterialFramedData, + connection: number + ): Generator { + if (!this.checkAutotile(tile)) return; + const { texture, frames } = tile; + const size = texture.height === 128 ? 32 : 48; + const index = distinctConnectionMap.get(connection); + if (isNil(index)) return; + for (let i = 0; i < frames; i++) { + yield { + source: texture.source, + rect: { x: i * size, y: size * index, w: size, h: size } + }; + } + } + + /** + * 将自动元件图片展平,平铺存储 48 种样式,此时可以只通过一次绘制来绘制出自动元件,不需要四次绘制 + * @param image 原始自动元件图片 + */ + static flatten(image: IAutotileData): SizedCanvasImageSource | null { + const { source, frames } = image; + if (source.width !== frames * 96) return null; + if (source.height !== 128 && source.height !== 144) return null; + const type = + source.height === 128 ? AutotileType.Big3x4 : AutotileType.Small2x3; + const size = type === AutotileType.Big3x4 ? 32 : 48; + const width = frames * size; + const height = 48 * size; + // 画到画布上 + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const half = size / 2; + const map = type === AutotileType.Big3x4 ? rectMap3x4 : rectMap2x3; + const used = new Set(); + // 遍历每个组合 + distinctConnectionMap.forEach((index, conn) => { + if (used.has(conn)) return; + used.add(conn); + const { lt, rt, rb, lb } = map.get(conn)!; + const y = index * size; + for (let i = 0; i < frames; i++) { + const x = i * size; + // prettier-ignore + ctx.drawImage(source, lt.x + i * 96, lt.y, lt.w, lt.h, x, y, half, half); + // prettier-ignore + ctx.drawImage(source, rt.x + i * 96, rt.y, rt.w, rt.h, x + half, y, half, half); + // prettier-ignore + ctx.drawImage(source, rb.x + i * 96, rb.y, rb.w, rb.h, x + half, y + half, half, half); + // prettier-ignore + ctx.drawImage(source, lb.x + i * 96, lb.y, lb.w, lb.h, x, y + half, half, half); + } + }); + + return canvas; + } +} + +/** + * 映射自动元件连接 + * @param target 输出映射对象 + * @param mode 自动元件类型,1 表示 3x4,2 表示 2x3 + */ +function mapAutotile( + target: Map, + mode: 1 | 2 +) { + const h = mode === 1 ? 2 : 1; // 横向偏移因子 + const v = mode === 1 ? 12 : 4; // 纵向偏移因子 + const luo = mode === 1 ? 12 : 8; // leftup origin + const ruo = mode === 1 ? 17 : 11; // rightup origin + const ldo = mode === 1 ? 42 : 20; // leftdown origin + const rdo = mode === 1 ? 47 : 23; // rightdown origin + const luc = mode === 1 ? 4 : 2; // leftup corner + const ruc = mode === 1 ? 5 : 3; // rightup corner + const rdc = mode === 1 ? 11 : 7; // rightdown corner + const ldc = mode === 1 ? 10 : 6; // leftdown corner + + for (let i = 0; i <= 0b1111_1111; i++) { + // 自动元件由四个更小的矩形组合而成 + // 初始状态下,四个矩形分别处在四个角的位置 + // 而且对应角落的矩形只可能出现在每个大区块的对应角落 + + let lu = luo; // leftup + let ru = ruo; // rightup + let ld = ldo; // leftdown + let rd = rdo; // rightdown + + // 先看四个方向,最后看斜角方向 + if (i & 0b0000_0001) { + // 左侧有连接,左侧两个矩形向右偏移两个因子 + lu += h * 2; + ld += h * 2; + // 如果右侧还有连接,那么右侧矩形和左侧矩形需要移动至中间 + // 但是由于后面还处理了先右侧再左侧的情况,因此需要先向右偏移一个因子 + // 结果就是先向右移动了一个因子,在后面又向左移动了两个因子,因此相当于向左移动了一个因子 + if (i & 0b0001_0000) { + ru += h; + rd += h; + } + } + if (i & 0b0000_0100) { + // 下侧有连接,下侧两个矩形向上偏移两个因子 + ld -= v * 2; + rd -= v * 2; + if (i & 0b0100_0000) { + lu -= v; + ru -= v; + } + } + if (i & 0b0001_0000) { + // 右侧有连接,右侧矩形向左移动两个因子 + ru -= h * 2; + rd -= h * 2; + if (i & 0b0000_0001) { + lu -= h; + ld -= h; + } + } + if (i & 0b0100_0000) { + // 上侧有链接,上侧矩形向下移动两个因子 + lu += v * 2; + ru += v * 2; + if (i & 0b0000_0100) { + ld += v; + rd += v; + } + } + // 斜角 + // 如果左上仅与上和左连接 + if ((i & 0b1100_0001) === 0b0100_0001) { + lu = luc; + } + // 如果右上仅与上和右连接 + if ((i & 0b0111_0000) === 0b0101_0000) { + ru = ruc; + } + // 如果右下仅与右和下连接 + if ((i & 0b0001_1100) === 0b0001_0100) { + rd = rdc; + } + // 如果左下仅与左和下连接 + if ((i & 0b0000_0111) === 0b0000_0101) { + ld = ldc; + } + target.set(i, [lu, ru, rd, ld]); + } +} + +export function createAutotile() { + mapAutotile(connectionMap3x4, 1); + mapAutotile(connectionMap2x3, 2); + + connectionMap3x4.forEach((data, connection) => { + const [ltd, rtd, rbd, lbd] = data; + const ltx = (ltd % 6) * 16; + const lty = Math.floor(ltd / 6) * 16; + const rtx = (rtd % 6) * 16; + const rty = Math.floor(rtd / 6) * 16; + const rbx = (rbd % 6) * 16; + const rby = Math.floor(rbd / 6) * 16; + const lbx = (lbd % 6) * 16; + const lby = Math.floor(lbd / 6) * 16; + rectMap3x4.set(connection, { + lt: { x: ltx, y: lty, w: 16, h: 16 }, + rt: { x: rtx, y: rty, w: 16, h: 16 }, + rb: { x: rbx, y: rby, w: 16, h: 16 }, + lb: { x: lbx, y: lby, w: 16, h: 16 } + }); + }); + connectionMap2x3.forEach((data, connection) => { + const [ltd, rtd, rbd, lbd] = data; + const ltx = (ltd % 4) * 24; + const lty = Math.floor(ltd / 4) * 24; + const rtx = (rtd % 4) * 24; + const rty = Math.floor(rtd / 4) * 24; + const rbx = (rbd % 4) * 24; + const rby = Math.floor(rbd / 4) * 24; + const lbx = (lbd % 4) * 24; + const lby = Math.floor(lbd / 4) * 24; + rectMap2x3.set(connection, { + lt: { x: ltx, y: lty, w: 24, h: 24 }, + rt: { x: rtx, y: rty, w: 24, h: 24 }, + rb: { x: rbx, y: rby, w: 24, h: 24 }, + lb: { x: lbx, y: lby, w: 24, h: 24 } + }); + }); + const usedRect: [number, number, number, number][] = []; + let flag = 0; + // 2x3 和 3x4 的自动元件连接方式一样,因此没必要映射两次 + connectionMap2x3.forEach((conn, num) => { + const index = usedRect.findIndex( + used => + used[0] === conn[0] && + used[1] === conn[1] && + used[2] === conn[2] && + used[3] === conn[3] + ); + if (index === -1) { + distinctConnectionMap.set(num, flag); + usedRect.push(conn.slice() as [number, number, number, number]); + flag++; + } else { + distinctConnectionMap.set(num, index); + } + }); +} diff --git a/packages-user/client-base/src/material/builder.ts b/packages-user/client-base/src/material/builder.ts new file mode 100644 index 0000000..29b4335 --- /dev/null +++ b/packages-user/client-base/src/material/builder.ts @@ -0,0 +1,159 @@ +import { + ITextureStore, + ITexture, + ITextureComposedData, + ITextureStreamComposer, + TextureMaxRectsStreamComposer, + SizedCanvasImageSource +} from '@motajs/render-assets'; +import { IAssetBuilder, IMaterialGetter, ITrackedAssetData } from './types'; +import { logger, PrivateListDirtyTracker } from '@motajs/common'; + +export class AssetBuilder implements IAssetBuilder { + readonly composer: ITextureStreamComposer = + new TextureMaxRectsStreamComposer(4096, 4096, 0); + + private output: ITextureStore | null = null; + private started: boolean = false; + + private readonly trackedData: TrackedAssetData; + + /** 当前的索引 */ + private index: number = -1; + + /** 贴图更新的 promise */ + private pending: Promise = Promise.resolve(); + + constructor(readonly materials: IMaterialGetter) { + this.trackedData = new TrackedAssetData(materials, this); + } + + pipe(store: ITextureStore): void { + if (this.started) { + logger.warn(76); + return; + } + this.output = store; + } + + addTexture(texture: ITexture): ITextureComposedData { + this.started = true; + const res = [...this.composer.add([texture])]; + const data = res[0]; + + if (this.output) { + if (data.index > this.index) { + this.output.addTexture(data.index, data.texture); + this.index = data.index; + } + } + + this.pending = this.pending.then(() => + this.trackedData.updateSource(data.index, data.texture.source) + ); + + return data; + } + + private async updateSourceList(source: Set) { + for (const data of source) { + await this.trackedData.updateSource( + data.index, + data.texture.source + ); + } + } + + addTextureList( + texture: Iterable + ): Iterable { + this.started = true; + const res = [...this.composer.add(texture)]; + const toUpdate = new Set(); + if (this.output) { + res.forEach(data => { + if (data.index > this.index) { + this.output!.addTexture(data.index, data.texture); + this.index = data.index; + toUpdate.add(data); + } else { + toUpdate.add(data); + } + }); + } + + this.pending = this.pending.then(() => this.updateSourceList(toUpdate)); + + return res; + } + + tracked(): ITrackedAssetData { + return this.trackedData; + } + + close(): void { + this.composer.close(); + } +} + +class TrackedAssetData + extends PrivateListDirtyTracker + implements ITrackedAssetData +{ + readonly sourceList: Map = new Map(); + readonly skipRef: Map = new Map(); + + private originSourceMap: Map = new Map(); + + private promises: Set> = new Set(); + + constructor( + readonly materials: IMaterialGetter, + readonly builder: AssetBuilder + ) { + super(0); + } + + markDirty(index: number) { + if (index >= this.length) { + this.updateLength(index + 1); + } + this.dirty(index); + } + + async updateSource(index: number, source: SizedCanvasImageSource) { + if (index >= this.length) { + this.updateLength(this.length + 1); + } + const origin = this.originSourceMap.get(index); + const prev = this.sourceList.get(index); + if (origin && origin !== source) { + this.skipRef.delete(origin); + } + if (prev) { + this.skipRef.delete(prev); + } + this.originSourceMap.set(index, source); + if (source instanceof ImageBitmap) { + if (this.skipRef.has(source)) return; + this.sourceList.set(index, source); + this.skipRef.set(source, index); + } else { + const promise = createImageBitmap(source); + this.promises.add(promise); + const bitmap = await promise; + this.promises.delete(promise); + this.sourceList.set(index, bitmap); + this.skipRef.set(bitmap, index); + // 要把源也加到映射中,因为这里的 bitmap 与外部源并不同引用 + this.skipRef.set(source, index); + } + this.dirty(index); + } + + async then(): Promise { + await Promise.all([...this.promises]); + } + + close(): void {} +} diff --git a/packages-user/client-base/src/material/fallback.ts b/packages-user/client-base/src/material/fallback.ts new file mode 100644 index 0000000..e6ceac6 --- /dev/null +++ b/packages-user/client-base/src/material/fallback.ts @@ -0,0 +1,140 @@ +import { ITexture } from '@motajs/render-assets'; +import { materials } from './ins'; +import { IBlockIdentifier, IIndexedIdentifier } from './types'; +import { isNil } from 'lodash-es'; + +function extractClsBlocks>( + cls: C, + map: Record, + icons: Record +): IBlockIdentifier[] { + const max = Math.max(...Object.values(icons)); + const arr = Array(max).fill(void 0); + for (const [key, value] of Object.entries(icons)) { + // 样板编辑器 bug 可能会导致多个 id 使用一个偏移,因此要判断下 + if (!(key in map) || !isNil(arr[value])) continue; + const id = key as AllIdsOf; + const num = map[id] as keyof NumberToId; + const identifier: IBlockIdentifier = { + id: id as string, + cls, + num + }; + arr[value] = identifier; + } + return arr; +} + +function addTileset(set: Set, map?: readonly (readonly number[])[]) { + if (!map) return; + map.forEach(line => { + line.forEach(v => { + if (v >= 10000) set.add(v); + }); + }); +} + +function addAutotile(set: Set, map?: readonly (readonly number[])[]) { + if (!map) return; + map.forEach(line => { + line.forEach(v => { + const id = core.maps.blocksInfo[v as keyof NumberToId]; + if (id?.cls === 'autotile') set.add(v); + }); + }); +} + +/** + * 兼容旧版加载 + */ +export function fallbackLoad() { + // 基本素材 + const icons = core.icons.icons; + const images = core.material.images; + const idNumMap: Record = {}; + + for (const [key, value] of Object.entries(core.maps.blocksInfo)) { + const num = Number(key); + idNumMap[value.id] = Number(num); + if (!isNil(value.animate)) { + materials.setDefaultFrame(num, value.animate - 1); + } + } + + const terrains = extractClsBlocks('terrains', idNumMap, icons.terrains); + const animates = extractClsBlocks('animates', idNumMap, icons.animates); + const items = extractClsBlocks('items', idNumMap, icons.items); + const enemys = extractClsBlocks('enemys', idNumMap, icons.enemys); + const npcs = extractClsBlocks('npcs', idNumMap, icons.npcs); + const enemy48 = extractClsBlocks('enemy48', idNumMap, icons.enemy48); + const npc48 = extractClsBlocks('npc48', idNumMap, icons.npc48); + + // Grid + materials.addGrid(images.terrains, terrains); + materials.addGrid(images.items, items); + + // Row Animates + materials.addRowAnimate(images.animates, animates, 32); + materials.addRowAnimate(images.enemys, enemys, 32); + materials.addRowAnimate(images.npcs, npcs, 32); + materials.addRowAnimate(images.enemy48, enemy48, 48); + materials.addRowAnimate(images.npc48, npc48, 48); + + // Autotile + for (const key of Object.keys(icons.autotile)) { + const id = key as AllIdsOf<'autotile'>; + const img = images.autotile[id]; + const identifier: IBlockIdentifier = { + id, + num: idNumMap[id], + cls: 'autotile' + }; + materials.addAutotile(img, identifier); + } + + // Tilesets + core.tilesets.forEach((v, i) => { + const img = images.tilesets[v]; + const identifier: IIndexedIdentifier = { + index: i, + alias: v + }; + 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(); + core.floorIds.forEach(v => { + const floor = core.floors[v]; + addTileset(tilesetSet, floor.bgmap); + addTileset(tilesetSet, floor.bg2map); + addTileset(tilesetSet, floor.map); + addTileset(tilesetSet, floor.fgmap); + addTileset(tilesetSet, floor.fg2map); + addAutotile(autotileSet, floor.bgmap); + addAutotile(autotileSet, floor.bg2map); + addAutotile(autotileSet, floor.map); + addAutotile(autotileSet, floor.fgmap); + addAutotile(autotileSet, floor.fg2map); + }); + + const heroTextures: ITexture[] = []; + + data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d.main.heroImages.forEach(v => { + const tex = materials.getImageByAlias(v); + if (tex) heroTextures.push(tex); + }); + + materials.buildAssets(); + + materials.cacheAutotileList(autotileSet); + materials.cacheTilesetList(tilesetSet); + materials.buildListToAsset(heroTextures); +} diff --git a/packages-user/client-base/src/material/index.ts b/packages-user/client-base/src/material/index.ts new file mode 100644 index 0000000..8f74adc --- /dev/null +++ b/packages-user/client-base/src/material/index.ts @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..a4f3bc1 --- /dev/null +++ b/packages-user/client-base/src/material/ins.ts @@ -0,0 +1,5 @@ +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/manager.ts b/packages-user/client-base/src/material/manager.ts new file mode 100644 index 0000000..eb75c3a --- /dev/null +++ b/packages-user/client-base/src/material/manager.ts @@ -0,0 +1,602 @@ +import { + ITexture, + ITextureComposedData, + ITextureRenderable, + ITextureSplitter, + ITextureStore, + SizedCanvasImageSource, + Texture, + TextureGridSplitter, + TextureRowSplitter, + TextureStore +} from '@motajs/render-assets'; +import { + IBlockIdentifier, + IMaterialData, + IMaterialManager, + IIndexedIdentifier, + IMaterialAssetData, + BlockCls, + IBigImageReturn, + IAssetBuilder, + IMaterialFramedData, + ITrackedAssetData +} from './types'; +import { logger } from '@motajs/common'; +import { getClsByString, getTextureFrame } from './utils'; +import { isNil } from 'lodash-es'; +import { AssetBuilder } from './builder'; +import { AutotileProcessor } from './autotile'; + +interface TilesetCache { + /** 是否已经在贴图库中存在 */ + readonly existed: boolean; + /** 贴图对象 */ + readonly texture: ITexture; +} + +export class MaterialManager implements IMaterialManager { + readonly tileStore: ITextureStore = new TextureStore(); + readonly tilesetStore: ITextureStore = new TextureStore(); + readonly imageStore: ITextureStore = new TextureStore(); + readonly assetStore: ITextureStore = new TextureStore(); + readonly bigImageStore: ITextureStore = new TextureStore(); + + /** 自动元件图像源映射 */ + readonly autotileSource: Map = new Map(); + + /** 图集信息存储 */ + readonly assetDataStore: Map = new Map(); + /** 贴图到图集索引的映射 */ + readonly assetMap: Map = new Map(); + /** 带有脏标记追踪的图集对象 */ + readonly trackedAsset: ITrackedAssetData; + + /** 大怪物数据 */ + readonly bigImageData: Map = new Map(); + /** tileset 中 `Math.floor(id / 10000) + 1` 映射到 tileset 对应索引的映射,用于处理图块超出 10000 的 tileset */ + readonly tilesetOffsetMap: Map = new Map(); + /** 图集打包器 */ + readonly assetBuilder: IAssetBuilder; + + /** 图块 id 到图块数字的映射 */ + readonly idNumMap: Map = new Map(); + /** 图块数字到图块 id 的映射 */ + readonly numIdMap: Map = new Map(); + /** 图块数字到图块类型的映射 */ + readonly clsMap: Map = new Map(); + /** 图块的默认帧数 */ + readonly defaultFrames: Map = new Map(); + + /** 网格切分器 */ + readonly gridSplitter: TextureGridSplitter = new TextureGridSplitter(); + /** 行切分器 */ + readonly rowSplitter: TextureRowSplitter = new TextureRowSplitter(); + + /** 大怪物贴图的标识符 */ + private bigImageId: number = 0; + /** 当前 tileset 索引 */ + private nowTilesetIndex: number = -1; + /** 当前 tileset 偏移 */ + private nowTilesetOffset: number = 0; + /** 是否已经构建过素材 */ + private built: boolean = false; + + constructor() { + this.assetBuilder = new AssetBuilder(this); + this.assetBuilder.pipe(this.assetStore); + this.trackedAsset = this.assetBuilder.tracked(); + } + + /** + * 添加由分割器和图块映射组成的图像源贴图 + * @param source 图像源 + * @param map 图块 id 与图块数字映射 + * @param store 要添加至的贴图存储对象 + * @param splitter 使用的分割器 + * @param splitterData 传递给分割器的数据 + * @param processTexture 对每个纹理进行处理 + */ + private addMappedSource( + source: SizedCanvasImageSource, + map: ArrayLike, + store: ITextureStore, + splitter: ITextureSplitter, + splitterData: T, + processTexture?: (tex: ITexture) => void + ): Iterable { + const tex = new Texture(source); + const textures = [...splitter.split(tex, splitterData)]; + if (textures.length !== map.length) { + logger.warn(75, textures.length.toString(), map.length.toString()); + } + const res: IMaterialData[] = textures.map((v, i) => { + if (!map[i]) { + return { + store, + texture: v, + identifier: -1, + alias: '@internal-unknown' + }; + } + const { id, num, cls } = map[i]; + store.addTexture(num, v); + store.alias(num, id); + this.clsMap.set(num, getClsByString(cls)); + processTexture?.(v); + const data: IMaterialData = { + store, + texture: v, + identifier: num, + alias: id + }; + return data; + }); + return res; + } + + addGrid( + source: SizedCanvasImageSource, + map: ArrayLike + ): Iterable { + return this.addMappedSource( + source, + map, + this.tileStore, + this.gridSplitter, + [32, 32] + ); + } + + addRowAnimate( + source: SizedCanvasImageSource, + map: ArrayLike, + height: number + ): Iterable { + return this.addMappedSource( + source, + map, + this.tileStore, + this.rowSplitter, + height + ); + } + + addAutotile( + source: SizedCanvasImageSource, + identifier: IBlockIdentifier + ): void { + this.autotileSource.set(identifier.num, source); + this.tileStore.alias(identifier.num, identifier.id); + this.clsMap.set(identifier.num, BlockCls.Autotile); + } + + addTileset( + source: SizedCanvasImageSource, + identifier: IIndexedIdentifier + ): IMaterialData | null { + const tex = new Texture(source); + this.tilesetStore.addTexture(identifier.index, tex); + this.tilesetStore.alias(identifier.index, identifier.alias); + const width = Math.floor(source.width / 32); + const height = Math.floor(source.height / 32); + const count = width * height; + const offset = Math.ceil(count / 10000); + if (identifier.index === 0) { + this.tilesetOffsetMap.set(0, 0); + this.nowTilesetIndex = 0; + this.nowTilesetOffset = offset; + } else { + if (identifier.index - 1 !== this.nowTilesetIndex) { + logger.warn(78); + return null; + } + // 一个 tileset 可能不止 10000 个图块,需要计算偏移 + const width = Math.floor(source.width / 32); + const height = Math.floor(source.height / 32); + const count = width * height; + const offset = Math.ceil(count / 10000); + const end = this.nowTilesetOffset + offset; + for (let i = this.nowTilesetOffset; i < end; i++) { + this.tilesetOffsetMap.set(i, identifier.index); + } + this.nowTilesetOffset = end; + this.nowTilesetIndex = identifier.index; + } + const data: IMaterialData = { + store: this.tilesetStore, + texture: tex, + identifier: identifier.index, + alias: identifier.alias + }; + return data; + } + + addImage( + source: SizedCanvasImageSource, + identifier: IIndexedIdentifier + ): IMaterialData { + const texture = new Texture(source); + this.imageStore.addTexture(identifier.index, texture); + this.imageStore.alias(identifier.index, identifier.alias); + const data: IMaterialData = { + store: this.imageStore, + texture, + identifier: identifier.index, + alias: identifier.alias + }; + return data; + } + + setDefaultFrame(identifier: number, defaultFrame: number): void { + this.defaultFrames.set(identifier, defaultFrame); + const bigImageData = this.bigImageData.get(identifier); + if (bigImageData) { + bigImageData.defaultFrame = defaultFrame; + } + } + + getDefaultFrame(identifier: number): number { + return this.defaultFrames.get(identifier) ?? -1; + } + + getTile(identifier: number): Readonly | null { + if (identifier < 10000) { + const cls = this.clsMap.get(identifier) ?? BlockCls.Unknown; + if ( + cls === BlockCls.Autotile && + this.autotileSource.has(identifier) + ) { + this.cacheAutotile(identifier); + } + const texture = this.tileStore.getTexture(identifier); + if (!texture) return null; + return { + texture, + cls, + offset: 32, + frames: getTextureFrame(cls, texture), + defaultFrame: this.defaultFrames.get(identifier) ?? -1 + }; + } else { + const texture = this.cacheTileset(identifier); + if (!texture) return null; + return { + texture, + cls: BlockCls.Tileset, + offset: 32, + frames: 1, + defaultFrame: -1 + }; + } + } + + getTileset(identifier: number): ITexture | null { + return this.tilesetStore.getTexture(identifier); + } + + getImage(identifier: number): ITexture | null { + return this.imageStore.getTexture(identifier); + } + + getTileByAlias(alias: string): Readonly | null { + if (/X\d{5,}/.test(alias)) { + return this.getTile(parseInt(alias.slice(1))); + } else { + const identifier = this.tileStore.identifierOf(alias); + if (isNil(identifier)) return null; + return this.getTile(identifier); + } + } + + getTilesetByAlias(alias: string): ITexture | null { + return this.tilesetStore.fromAlias(alias); + } + + getImageByAlias(alias: string): ITexture | null { + return this.imageStore.fromAlias(alias); + } + + private getTilesetOwnTexture(identifier: number): TilesetCache | null { + const texture = this.tileStore.getTexture(identifier); + if (texture) return { existed: true, texture }; + // 如果 tileset 不存在,那么执行缓存操作 + const offset = Math.floor(identifier / 10000); + const index = this.tilesetOffsetMap.get(offset - 1); + if (isNil(index)) return null; + // 获取对应的 tileset 贴图 + const tileset = this.tilesetStore.getTexture(index); + if (!tileset) return null; + // 计算图块位置 + const rest = identifier - offset * 10000; + const { width, height } = tileset; + const tileWidth = Math.floor(width / 32); + const tileHeight = Math.floor(height / 32); + // 如果图块位置超出了贴图范围 + if (rest > tileWidth * tileHeight) return null; + // 裁剪 tileset,生成贴图 + const x = rest % tileWidth; + const y = Math.floor(rest / tileWidth); + const newTexture = new Texture(tileset.source); + newTexture.clip(x * 32, y * 32, 32, 32); + return { existed: false, texture: newTexture }; + } + + /** + * 检查图集状态,如果已存在图集则标记为脏,否则新增图集 + * @param data 图集数据 + */ + private checkAssetDirty(data: ITextureComposedData) { + if (!this.built) return; + const asset = this.assetDataStore.get(data.index); + if (!asset) { + // 如果有新图集,需要添加 + const alias = `asset-${data.index}`; + this.assetStore.alias(data.index, alias); + this.assetDataStore.set(data.index, data); + } + } + + /** + * 将指定的贴图列表转换至指定的图集数据中 + * @param composedData 组合数据 + * @param textures 贴图列表 + */ + private cacheToAsset( + composedData: ITextureComposedData[], + textures: ITexture[] + ) { + textures.forEach(tex => { + const assetData = composedData.find(v => v.assetMap.has(tex)); + if (!assetData) { + logger.error(38); + return; + } + tex.toAsset(assetData); + }); + composedData.forEach(v => this.checkAssetDirty(v)); + } + + cacheTileset(identifier: number): ITexture | null { + const newTexture = this.getTilesetOwnTexture(identifier); + if (!newTexture) return null; + const { existed, texture } = newTexture; + if (existed) return texture; + // 缓存贴图 + this.tileStore.addTexture(identifier, texture); + this.idNumMap.set(`X${identifier}`, identifier); + this.numIdMap.set(identifier, `X${identifier}`); + const data = this.assetBuilder.addTexture(texture); + texture.toAsset(data); + this.checkAssetDirty(data); + return texture; + } + + cacheTilesetList( + identifierList: Iterable + ): Iterable { + const arr = [...identifierList]; + const toAdd: ITexture[] = []; + + arr.forEach(v => { + const newTexture = this.getTilesetOwnTexture(v); + if (!newTexture) return; + const { existed, texture } = newTexture; + if (existed) return; + toAdd.push(texture); + this.tileStore.addTexture(v, texture); + this.idNumMap.set(`X${v}`, v); + this.numIdMap.set(v, `X${v}`); + }); + + const data = this.assetBuilder.addTextureList(toAdd); + const res = [...data]; + this.cacheToAsset(res, toAdd); + + return toAdd; + } + + /** + * 获取自动元件展开后的图片,如果图片不存在,或是已经展开并存储至了 `tileStore`,那么返回 `null` + * @param identifier 自动元件标识符 + */ + private getFlattenedAutotile( + identifier: number + ): SizedCanvasImageSource | null { + const cls = this.clsMap.get(identifier); + if (cls !== BlockCls.Autotile) return null; + if (this.tileStore.getTexture(identifier)) return null; + const source = this.autotileSource.get(identifier); + if (!source) return null; + const frames = source.width === 96 ? 1 : 4; + const flattened = AutotileProcessor.flatten({ source, frames }); + if (!flattened) return null; + return flattened; + } + + cacheAutotile(identifier: number): ITexture | null { + const flattened = this.getFlattenedAutotile(identifier); + if (!flattened) return null; + const tex = new Texture(flattened); + this.tileStore.addTexture(identifier, tex); + const data = this.assetBuilder.addTexture(tex); + tex.toAsset(data); + this.autotileSource.delete(identifier); + this.checkAssetDirty(data); + return tex; + } + + cacheAutotileList( + identifierList: Iterable + ): Iterable { + const arr = [...identifierList]; + const toAdd: ITexture[] = []; + + arr.forEach(v => { + const flattened = this.getFlattenedAutotile(v); + if (!flattened) return; + const tex = new Texture(flattened); + this.tileStore.addTexture(v, tex); + toAdd.push(tex); + this.autotileSource.delete(v); + }); + + const data = this.assetBuilder.addTextureList(toAdd); + const res = [...data]; + this.cacheToAsset(res, toAdd); + + return toAdd; + } + + buildAssets(): Iterable { + if (this.built) { + logger.warn(79); + return []; + } + this.built = true; + return this.buildListToAsset(this.tileStore.values()); + } + + buildToAsset(texture: ITexture): IMaterialAssetData { + const data = this.assetBuilder.addTexture(texture); + const assetData: IMaterialAssetData = { + data: data, + identifier: data.index, + alias: `asset-${data.index}`, + store: this.assetStore + }; + this.checkAssetDirty(data); + texture.toAsset(data); + return assetData; + } + + buildListToAsset( + texture: Iterable + ): Iterable { + const data = this.assetBuilder.addTextureList(texture); + const arr = [...data]; + const res: IMaterialAssetData[] = []; + arr.forEach(v => { + const alias = `asset-${v.index}`; + if (!this.assetDataStore.has(v.index)) { + this.assetDataStore.set(v.index, v); + } + const data: IMaterialAssetData = { + data: v, + identifier: v.index, + alias, + store: this.assetStore + }; + for (const tex of v.assetMap.keys()) { + tex.toAsset(v); + } + res.push(data); + }); + arr.forEach(v => { + this.checkAssetDirty(v); + }); + return res; + } + + getAsset(identifier: number): ITextureComposedData | null { + return this.assetDataStore.get(identifier) ?? null; + } + + getAssetByAlias(alias: string): ITextureComposedData | null { + const id = this.assetStore.identifierOf(alias); + if (isNil(id)) return null; + return this.assetDataStore.get(id) ?? null; + } + + private getTextureOf(identifier: number, cls: BlockCls): ITexture | null { + if (cls === BlockCls.Unknown) return null; + if (cls !== BlockCls.Tileset) { + return this.tileStore.getTexture(identifier); + } + if (identifier < 10000) return null; + return this.cacheTileset(identifier); + } + + getRenderable(identifier: number): ITextureRenderable | null { + const cls = this.clsMap.get(identifier); + if (isNil(cls)) return null; + const texture = this.getTextureOf(identifier, cls); + if (!texture) return null; + return texture.render(); + } + + getRenderableByAlias(alias: string): ITextureRenderable | null { + const identifier = this.idNumMap.get(alias); + if (isNil(identifier)) return null; + return this.getRenderable(identifier); + } + + getBlockCls(identifier: number): BlockCls { + return this.clsMap.get(identifier) ?? BlockCls.Unknown; + } + + getBlockClsByAlias(alias: string): BlockCls { + const id = this.idNumMap.get(alias); + if (isNil(id)) return BlockCls.Unknown; + return this.clsMap.get(id) ?? BlockCls.Unknown; + } + + getIdentifierByAlias(alias: string): number | undefined { + return this.idNumMap.get(alias); + } + + getAliasByIdentifier(identifier: number): string | undefined { + return this.numIdMap.get(identifier); + } + + setBigImage( + identifier: number, + image: ITexture, + frames: number + ): IBigImageReturn { + const bigImageId = this.bigImageId++; + this.bigImageStore.addTexture(bigImageId, image); + const cls = this.clsMap.get(identifier) ?? BlockCls.Unknown; + const store: IMaterialFramedData = { + texture: image, + cls, + offset: image.width / 4, + frames, + defaultFrame: this.defaultFrames.get(identifier) ?? -1 + }; + this.bigImageData.set(identifier, store); + const data: IBigImageReturn = { + identifier: bigImageId, + store: this.bigImageStore + }; + return data; + } + + isBigImage(identifier: number): boolean { + return this.bigImageData.has(identifier); + } + + getBigImage(identifier: number): Readonly | null { + return this.bigImageData.get(identifier) ?? null; + } + + getBigImageByAlias(alias: string): Readonly | null { + const identifier = this.idNumMap.get(alias); + if (isNil(identifier)) return null; + return this.bigImageData.get(identifier) ?? null; + } + + getIfBigImage(identifier: number): Readonly | null { + const bigImage = this.bigImageData.get(identifier); + if (bigImage) return bigImage; + else return this.getTile(identifier); + } + + assetContainsTexture(texture: ITexture): boolean { + return this.trackedAsset.skipRef.has(texture.source); + } + + getTextureAsset(texture: ITexture): number | undefined { + return this.trackedAsset.skipRef.get(texture.source); + } +} diff --git a/packages-user/client-base/src/material/types.ts b/packages-user/client-base/src/material/types.ts new file mode 100644 index 0000000..ab02e46 --- /dev/null +++ b/packages-user/client-base/src/material/types.ts @@ -0,0 +1,528 @@ +import { IDirtyTracker, IDirtyMarker } from '@motajs/common'; +import { + ITexture, + ITextureComposedData, + ITextureRenderable, + ITextureStore, + SizedCanvasImageSource +} from '@motajs/render-assets'; + +export const enum BlockCls { + Unknown, + Terrains, + Animates, + Enemys, + Npcs, + Items, + Enemy48, + Npc48, + Tileset, + Autotile +} + +export const enum AutotileType { + Small2x3, + Big3x4 +} + +export const enum AutotileConnection { + LeftUp = 0b1000_0000, + Up = 0b0100_0000, + RightUp = 0b0010_0000, + Right = 0b0001_0000, + RightDown = 0b0000_1000, + Down = 0b0000_0100, + LeftDown = 0b0000_0010, + Left = 0b0000_0001 +} + +export interface IMaterialData { + /** 此素材的贴图对象存入了哪个贴图存储对象 */ + readonly store: ITextureStore; + /** 贴图对象 */ + readonly texture: ITexture; + /** 此素材的贴图对象的数字 id,一般对应到图块数字 */ + readonly identifier: number; + /** 此素材的贴图对象的字符串别名,一般对应到图块 id */ + readonly alias?: string; +} + +export interface IBlockIdentifier { + /** 图块 id */ + readonly id: string; + /** 图块数字 */ + readonly num: number; + /** 图块类型 */ + readonly cls: Cls; +} + +export interface IIndexedIdentifier { + /** 标识符索引 */ + readonly index: number; + /** 标识符别名 */ + readonly alias: string; +} + +export interface IMaterialAssetData { + /** 图集数据 */ + readonly data: ITextureComposedData; + /** 贴图的标识符 */ + readonly identifier: number; + /** 贴图的别名 */ + readonly alias: string; + /** 贴图所属的存储对象 */ + readonly store: ITextureStore; +} + +export interface IAutotileConnection { + /** 连接方式,最高位表示左上,低位依次顺时针旋转 */ + readonly connection: number; + /** 中心自动元件对应的图块数字 */ + readonly center: number; +} + +export interface IBigImageReturn { + /** 大怪物贴图在 store 中的标识符 */ + readonly identifier: number; + /** 存储大怪物贴图的存储对象 */ + readonly store: ITextureStore; +} + +export interface IMaterialFramedData { + /** 贴图对象 */ + texture: ITexture; + /** 图块类型 */ + cls: BlockCls; + /** 贴图总帧数 */ + frames: number; + /** 每帧的横向偏移量 */ + offset: number; + /** 默认帧数 */ + defaultFrame: number; +} + +export interface IMaterialAsset + extends IDirtyTracker, + IDirtyMarker { + /** 图集的贴图数据 */ + readonly data: ITextureComposedData; +} + +export interface IAutotileProcessor { + /** 该自动元件处理器使用的素材管理器 */ + readonly manager: IMaterialManager; + + /** + * 设置一个自动元件的特殊连接方式,设置后当前自动元件将会单方面与目标元件连接, + * 一个自动元件可以与多个自动元件有特殊连接 + * @param autotile 自动元件 + * @param target 当前自动元件将会连接至的自动元件 + */ + setConnection(autotile: number, target: number): void; + + /** + * 获取自动元件的连接情况 + * @param array 地图图块数组 + * @param index 自动元件图块所在的索引 + * @param width 地图每一行的宽度 + */ + connect( + array: Uint32Array, + index: number, + width: number + ): IAutotileConnection; + + /** + * 检查一个图块与指定方向的连接方式 + * @param connection 当前的连接 + * @param center 中心点的图块数字 + * @param target 连接点的图块数字 + * @param direction 连接点的方向 + * @returns 经过连接后的连接数字 + */ + updateConnectionFor( + connection: number, + center: number, + target: number, + direction: AutotileConnection + ): number; + + /** + * 根据图块数字,获取指定自动元件经过连接的可渲染对象 + * @param autotile 自动元件的图块数字 + * @param connection 连接方式,上方连接是第一位,顺时针旋转位次依次升高 + * @returns 连接方式的可渲染对象,可以通过偏移量依次获取其他帧 + */ + render(autotile: number, connection: number): ITextureRenderable | null; + + /** + * 根据图块贴图对象,获取指定自动元件经过连接的可渲染对象 + * @param tile 自动元件的图块贴图数据 + * @param connection 连接方式,上方连接是第一位,顺时针旋转位次依次升高 + * @returns 连接方式的可渲染对象,可以通过偏移量依次获取其他帧 + */ + renderWith( + tile: Readonly, + connection: number + ): ITextureRenderable | null; + + /** + * 根据图块贴图对象,获取指定自动元件经过连接的可渲染对象,但是会假设传入的图块就是自动元件,不做不必要的判断 + * @param tile 自动元件的图块贴图数据 + * @param connection 连接方式,上方连接是第一位,顺时针旋转位次依次升高 + * @returns 连接方式的可渲染对象,可以通过偏移量依次获取其他帧 + */ + renderWithoutCheck( + tile: Readonly, + connection: number + ): ITextureRenderable | null; + + /** + * 根据图块数字,获取指定自动元件经过链接的动态可渲染对象 + * @param autotile 自动元件的图块数字 + * @param connection 自动元件的连接方式 + * @returns 生成器,每一个输出代表每一帧的渲染对象,不同自动元件的帧数可能不同 + */ + renderAnimated( + autotile: number, + connection: number + ): Generator; + + /** + * 根据图块贴图对象,获取指定自动元件经过链接的动态可渲染对象 + * @param autotile 自动元件的图块数字 + * @param connection 自动元件的连接方式 + * @returns 生成器,每一个输出代表每一帧的渲染对象,不同自动元件的帧数可能不同 + */ + renderAnimatedWith( + tile: Readonly, + connection: number + ): Generator; +} + +export interface IMaterialGetter { + /** + * 根据图块数字获取图块,可以获取额外素材,会自动将未缓存的额外素材缓存 + * @param identifier 图块的图块数字 + */ + getTile(identifier: number): Readonly | null; + + /** + * 根据图块标识符获取图块类型 + * @param identifier 图块标识符,即图块数字 + */ + getBlockCls(identifier: number): BlockCls; + + /** + * 判断一个图块是否包含 `bigImage` 贴图,即是否是大怪物 + * @param identifier 图块标识符,即图块数字 + */ + isBigImage(identifier: number): boolean; + + /** + * 根据图块标识符获取一个图块的 `bigImage` 贴图 + * @param identifier 图块标识符,即图块数字 + */ + getBigImage(identifier: number): Readonly | null; + + /** + * 根据图块标识符,首先判断是否是 `bigImage` 贴图,如果是,则返回 `bigImage` 贴图, + * 否则返回普通贴图。如果图块不存在,则返回 `null` + * @param identifier 图块标识符,即图块数字 + */ + getIfBigImage(identifier: number): Readonly | null; + + /** + * 根据标识符获取图集信息 + * @param identifier 图集的标识符 + */ + getAsset(identifier: number): ITextureComposedData | null; + + /** + * 根据额外素材索引获取额外素材 + * @param identifier 额外素材的索引 + */ + getTileset(identifier: number): ITexture | null; + + /** + * 根据图片的索引获取图片 + * @param identifier 图片的索引 + */ + getImage(identifier: number): ITexture | null; +} + +export interface IMaterialAliasGetter { + /** + * 根据图块 id 获取图块,可以获取额外素材,会自动将未缓存的额外素材缓存 + * @param alias 图块 id + */ + getTileByAlias(alias: string): Readonly | null; + + /** + * 根据额外素材名称获取额外素材 + * @param alias 额外素材名称 + */ + getTilesetByAlias(alias: string): ITexture | null; + + /** + * 根据图片名称获取图片 + * @param alias 图片名称 + */ + getImageByAlias(alias: string): ITexture | null; + + /** + * 根据别名获取图集信息 + * @param alias 图集的别名 + */ + getAssetByAlias(alias: string): ITextureComposedData | null; + + /** + * 根据图块别名获取图块类型 + * @param alias 图块别名,即图块的 id + */ + getBlockClsByAlias(alias: string): BlockCls; + + /** + * 根据图块别名获取一个图块的 `bigImage` 贴图 + * @param alias 图块别名,即图块的 id + */ + getBigImageByAlias(alias: string): Readonly | null; +} + +export interface IMaterialManager + extends IMaterialGetter, + IMaterialAliasGetter { + /** 贴图存储,把 terrains 等内容单独分开存储 */ + readonly tileStore: ITextureStore; + /** tilesets 贴图存储,每个 tileset 是一个贴图对象 */ + readonly tilesetStore: ITextureStore; + /** 存储注册的图像的存储对象 */ + readonly imageStore: ITextureStore; + /** 图集存储,将常用贴图存入其中 */ + readonly assetStore: ITextureStore; + /** bigImage 存储,存储大怪物数据 */ + readonly bigImageStore: ITextureStore; + + /** 图集信息存储 */ + readonly assetDataStore: Iterable<[number, ITextureComposedData]>; + /** 带有脏标记追踪的图集信息 */ + readonly trackedAsset: ITrackedAssetData; + + /** 图块类型映射 */ + readonly clsMap: Map; + + /** + * 添加网格类型的贴图,包括 terrains 和 items 类型 + * @param source 图像源 + * @param map 贴图字符串 id 与图块数字映射,按照先从左到右,再从上到下的顺序映射 + */ + addGrid( + source: SizedCanvasImageSource, + map: ArrayLike + ): Iterable; + + /** + * 添加行动画的贴图,包括 animates enemys npcs enemy48 npc48 类型 + * @param source 图像源 + * @param map 贴图字符串 id 与图块数字映射,按从上到下的顺序映射 + * @param frames 每一行的帧数 + * @param height 每一行的高度 + */ + addRowAnimate( + source: SizedCanvasImageSource, + map: ArrayLike, + frames: number, + height: number + ): Iterable; + + /** + * 添加自动元件 + * @param source 图像源 + * @param identifier 自动元件的字符串 id 及图块数字 + * @returns 由于自动元件是懒加载的,因此不会返回任何东西 + */ + addAutotile( + source: SizedCanvasImageSource, + identifier: IBlockIdentifier + ): void; + + /** + * 添加一个 tileset 类型的素材 + * @param source 图像源 + * @param alias tileset 的标识符,包含其在 tilesets 列表中的索引和图片名称 + */ + addTileset( + source: SizedCanvasImageSource, + identifier: IIndexedIdentifier + ): IMaterialData | null; + + /** + * 添加一个图片 + * @param source 图像源 + * @param identifier 图片的标识符,包含其在 images 列表中的索引和图片名称 + */ + addImage( + source: SizedCanvasImageSource, + identifier: IIndexedIdentifier + ): IMaterialData; + + /** + * 设置指定图块默认显示第几帧 + * @param identifier 图块标识符,即图块数字 + * @param defaultFrame 图块的默认帧数 + */ + setDefaultFrame(identifier: number, defaultFrame: number): void; + + /** + * 获取图块的默认帧数,-1 表示正常动画,非负整数表示默认使用指定帧数,除非单独指定 + * @param identifier 图块标识符,即图块数字 + */ + getDefaultFrame(identifier: number): number; + + /** + * 缓存某个 tileset,当需要缓存多个时,请使用 {@link cacheTilesetList} 方法 + * @param identifier tileset 的标识符,即图块数字 + */ + cacheTileset(identifier: number): ITexture | null; + + /** + * 缓存一系列 tileset + * @param identifierList 标识符列表,即图块数字列表 + */ + cacheTilesetList( + identifierList: Iterable + ): Iterable; + + /** + * 缓存某个自动元件,当需要缓存多个时,请使用 {@link cacheAutotileList} 方法 + * @param identifier 自动元件标识符,即图块数字 + */ + cacheAutotile(identifier: number): ITexture | null; + + /** + * 缓存一系列自动元件 + * @param identifierList 自动元件标识符列表,即图块数字列表 + */ + cacheAutotileList( + identifierList: Iterable + ): Iterable; + + /** + * 把常用素材打包成为图集形式供后续使用 + */ + buildAssets(): Iterable; + + /** + * 将指定贴图打包进图集 + * @param texture 贴图对象 + */ + buildToAsset(texture: ITexture): IMaterialAssetData; + + /** + * 将一系列贴图打包进贴图对象 + * @param texture 贴图列表 + */ + buildListToAsset(texture: Iterable): Iterable; + + /** + * 根据图块标识符在图集中获取对应的可渲染对象 + * @param identifier 图块标识符,即图块数字 + */ + getRenderable(identifier: number): ITextureRenderable | null; + + /** + * 根据图块别名在图集中获取对应的可渲染对象 + * @param alias 图块的别名,即图块的 id + */ + getRenderableByAlias(alias: string): ITextureRenderable | null; + + /** + * 根据图块别名获取图块标识符,即图块数字 + * @param alias 图块别名,即图块的 id + */ + getIdentifierByAlias(alias: string): number | undefined; + + /** + * 根据图块标识符获取图块别名,即图块的 id + * @param identifier 图块标识符,即图块数字 + */ + getAliasByIdentifier(identifier: number): string | undefined; + + /** + * 设置一个图块的 `bigImage` 贴图,即大怪物贴图,但不止怪物能用 + * @param identifier 图块标识符,即图块数字 + * @param image `bigImage` 对应的贴图对象 + * @param frames `bigImage` 的帧数,即贴图有多少帧 + */ + setBigImage( + identifier: number, + image: ITexture, + frames: number + ): IBigImageReturn; + + /** + * 当前的所有图集中是否包含指定的贴图对象 + * @param texture 贴图对象 + */ + assetContainsTexture(texture: ITexture): boolean; + + /** + * 获取指定贴图对象所属的图集索引 + * @param texture 贴图对象 + */ + getTextureAsset(texture: ITexture): number | undefined; +} + +export interface IAssetBuilder { + /** + * 将图集打包器输出至指定贴图存储对象,只能输出到一个存储对象中,设置多个仅最后一个有效 + * @param store 贴图存储对象 + */ + pipe(store: ITextureStore): void; + + /** + * 添加贴图对象至打包器 + * @param texture 贴图对象 + * @returns 当前打包的贴图对象对应的组合数据 + */ + addTexture(texture: ITexture): ITextureComposedData; + + /** + * 添加一个贴图对象列表至打包器 + * @param texture 贴图对象列表 + * @returns 当前打包的贴图列表的组合数据,每一项代表一个图集,只包含使用到的图集,之前已经打包完成的将不会在列表内 + */ + addTextureList(texture: Iterable): Iterable; + + /** + * 获取可追踪贴图对象 + */ + tracked(): ITrackedAssetData; + + /** + * 结束此打包器 + */ + close(): void; +} + +export interface ITrackedAssetData extends IDirtyTracker> { + /** 图像源列表 */ + readonly sourceList: Map; + /** + * 贴图引用跳接,`ImageBitmap` 的传递性能远好于其他类型,而贴图图集为了能够动态增加内容会使用画布类型, + * 因此需要把贴图生成为额外的 `ImageBitmap`,并提供引用跳接映射。值代表在 `sourceList` 中的索引。 + */ + readonly skipRef: Map; + /** 贴图数据 */ + readonly materials: IMaterialGetter; + + /** + * 取消使用此图集,释放相关资源 + */ + close(): void; + + /** + * 等待所有打包操作结束 + */ + then(): Promise; +} diff --git a/packages-user/client-base/src/material/utils.ts b/packages-user/client-base/src/material/utils.ts new file mode 100644 index 0000000..e8bf237 --- /dev/null +++ b/packages-user/client-base/src/material/utils.ts @@ -0,0 +1,47 @@ +import { ITexture } from '@motajs/render-assets'; +import { BlockCls } from './types'; + +export function getClsByString(cls: Cls): BlockCls { + switch (cls) { + case 'terrains': + return BlockCls.Terrains; + case 'animates': + return BlockCls.Animates; + case 'autotile': + return BlockCls.Autotile; + case 'enemys': + return BlockCls.Enemys; + case 'items': + return BlockCls.Items; + case 'npcs': + return BlockCls.Npcs; + case 'npc48': + return BlockCls.Npc48; + case 'enemy48': + return BlockCls.Enemy48; + case 'tileset': + return BlockCls.Tileset; + default: + return BlockCls.Unknown; + } +} + +export function getTextureFrame(cls: BlockCls, texture: ITexture) { + switch (cls) { + case BlockCls.Animates: + case BlockCls.Enemy48: + case BlockCls.Npc48: + return 4; + case BlockCls.Autotile: + return texture.width === 384 ? 4 : 1; + case BlockCls.Enemys: + case BlockCls.Npcs: + return 2; + case BlockCls.Items: + case BlockCls.Terrains: + case BlockCls.Tileset: + return 1; + case BlockCls.Unknown: + return 0; + } +} diff --git a/packages-user/client-modules/package.json b/packages-user/client-modules/package.json index 3a9c7b5..ffd1f88 100644 --- a/packages-user/client-modules/package.json +++ b/packages-user/client-modules/package.json @@ -4,6 +4,7 @@ "@motajs/client-base": "workspace:*", "@motajs/common": "workspace:*", "@motajs/render": "workspace:*", + "@motajs/render-assets": "workspace:*", "@motajs/render-core": "workspace:*", "@motajs/legacy-common": "workspace:*", "@motajs/legacy-ui": "workspace:*", diff --git a/packages-user/client-modules/src/action/hotkey.ts b/packages-user/client-modules/src/action/hotkey.ts index 64498aa..04b124e 100644 --- a/packages-user/client-modules/src/action/hotkey.ts +++ b/packages-user/client-modules/src/action/hotkey.ts @@ -76,6 +76,11 @@ gameKey name: '快捷商店', defaults: KeyCode.KeyV }) + .register({ + id: 'statistics', + name: '数据统计', + defaults: KeyCode.KeyB + }) .register({ id: 'viewMap_1', name: '浏览地图_1', diff --git a/packages-user/client-modules/src/fallback/audio.ts b/packages-user/client-modules/src/fallback/audio.ts index b11a581..7a14a14 100644 --- a/packages-user/client-modules/src/fallback/audio.ts +++ b/packages-user/client-modules/src/fallback/audio.ts @@ -17,8 +17,7 @@ export function patchAudio() { }; patch.add('playBgm', function (bgm, startTime) { - const name = core.getMappedName(bgm) as BgmIds; - play(name, startTime); + play(bgm, startTime); }); patch.add('pauseBgm', function () { pause(); diff --git a/packages-user/client-modules/src/render/action.ts b/packages-user/client-modules/src/render/action.ts index 99a096a..fff3b3b 100644 --- a/packages-user/client-modules/src/render/action.ts +++ b/packages-user/client-modules/src/render/action.ts @@ -6,7 +6,8 @@ import { saveLoad, openSettings, openViewMap, - openReplay + openReplay, + openStatistics } from './ui'; import { ElementLocator } from '@motajs/render-core'; @@ -15,6 +16,9 @@ export function createAction() { .realize('save', () => { saveSave(mainUIController, FULL_LOC); }) + .realize('statistics', () => { + openStatistics(mainUIController); + }) .realize('load', () => { saveLoad(mainUIController, FULL_LOC); }) diff --git a/packages-user/client-modules/src/render/commonIns.ts b/packages-user/client-modules/src/render/commonIns.ts new file mode 100644 index 0000000..acfd1c2 --- /dev/null +++ b/packages-user/client-modules/src/render/commonIns.ts @@ -0,0 +1,22 @@ +import { state } from '@user/data-state'; +import { materials } from '@user/client-base'; +import { MapRenderer, MapExtensionManager } from './map'; + +/** 主地图渲染器,用于渲染游戏画面 */ +export const mainMapRenderer = new MapRenderer(materials, state.layer); +/** 主地图渲染器拓展 */ +export const mainMapExtension = new MapExtensionManager(mainMapRenderer); +/** 副地图渲染器,用于渲染缩略图、浏览地图等 */ +// export const expandMapRenderer = new MapRenderer(materials, state.layer); + +export async function createMainExtension() { + // 算是一种妥协吧,等之后加载系统重构之后应该会清晰很多 + await materials.trackedAsset.then(); + + mainMapRenderer.useAsset(materials.trackedAsset); + const layer = state.layer.getLayerByAlias('event'); + if (layer) { + mainMapExtension.addHero(state.hero, layer); + mainMapExtension.addDoor(layer); + } +} diff --git a/packages-user/client-modules/src/render/components/textbox.tsx b/packages-user/client-modules/src/render/components/textbox.tsx index 18703f0..5b327ad 100644 --- a/packages-user/client-modules/src/render/components/textbox.tsx +++ b/packages-user/client-modules/src/render/components/textbox.tsx @@ -33,6 +33,8 @@ import { import { SetupComponentOptions } from '@motajs/system-ui'; import { texture } from '../elements'; +// todo: TextContent 应该改成渲染元素? + //#region TextContent export interface TextContentProps diff --git a/packages-user/client-modules/src/render/components/textboxTyper.ts b/packages-user/client-modules/src/render/components/textboxTyper.ts index b74cde1..b43a5a9 100644 --- a/packages-user/client-modules/src/render/components/textboxTyper.ts +++ b/packages-user/client-modules/src/render/components/textboxTyper.ts @@ -817,7 +817,7 @@ export class TextContentParser { return pointer; } const time = parseInt(param); - this.addWaitRenderable(end, time); + this.addWaitRenderable(end + 1, time); return end; } @@ -830,19 +830,19 @@ export class TextContentParser { } if (/^\d+$/.test(param)) { const num = Number(param); - this.addIconRenderable(end, num as AllNumbers); + this.addIconRenderable(end + 1, num as AllNumbers); } else { if (/^X\d+$/.test(param)) { // 额外素材 const num = Number(param.slice(1)); - this.addIconRenderable(end, num as AllNumbers); + this.addIconRenderable(end + 1, num as AllNumbers); } else { const num = texture.idNumberMap[param as AllIds]; if (num === void 0) { logger.warn(59, param); return end; } - this.addIconRenderable(end, num); + this.addIconRenderable(end + 1, num); } } return end; @@ -960,6 +960,7 @@ export class TextContentParser { break; case 'n': // 在这里预先将换行处理为多个 node,会比在分行时再处理更方便 + pointer++; this.addTextNode(pointer + 1, true); break; } @@ -985,7 +986,9 @@ export class TextContentParser { this.resolved += char; } - this.addTextNode(text.length, false); + if (this.nodePointer < text.length) { + this.addTextNode(text.length, false); + } return this.splitLines(width); } diff --git a/packages-user/client-modules/src/render/elements/cache.ts b/packages-user/client-modules/src/render/elements/cache.ts index d8ab5d7..d0a51b4 100644 --- a/packages-user/client-modules/src/render/elements/cache.ts +++ b/packages-user/client-modules/src/render/elements/cache.ts @@ -1,8 +1,6 @@ import { logger } from '@motajs/common'; -import { - MotaOffscreenCanvas2D, - SizedCanvasImageSource -} from '@motajs/render-core'; +import { MotaOffscreenCanvas2D } from '@motajs/render-core'; +import { SizedCanvasImageSource } from '@motajs/render-assets'; // 经过测试(https://www.measurethat.net/Benchmarks/Show/30741/1/drawimage-img-vs-canvas-vs-bitmap-cropping-fix-loading) // 得出结论,ImageBitmap和Canvas的绘制性能不如Image,于是直接画Image就行,所以缓存基本上就是存Image diff --git a/packages-user/client-modules/src/render/elements/hero.ts b/packages-user/client-modules/src/render/elements/hero.ts index 28c2b4a..32c4fa2 100644 --- a/packages-user/client-modules/src/render/elements/hero.ts +++ b/packages-user/client-modules/src/render/elements/hero.ts @@ -1,4 +1,5 @@ -import { RenderAdapter, SizedCanvasImageSource } from '@motajs/render-core'; +import { RenderAdapter } from '@motajs/render-core'; +import { SizedCanvasImageSource } from '@motajs/render-assets'; import { logger } from '@motajs/common'; import { ILayerRenderExtends, Layer, LayerMovingRenderable } from './layer'; import EventEmitter from 'eventemitter3'; diff --git a/packages-user/client-modules/src/render/elements/index.ts b/packages-user/client-modules/src/render/elements/index.ts index ab23962..4fd0cf5 100644 --- a/packages-user/client-modules/src/render/elements/index.ts +++ b/packages-user/client-modules/src/render/elements/index.ts @@ -6,6 +6,10 @@ import { createViewport } from './viewport'; import { Icon, Winskin } from './misc'; import { Animate } from './animate'; import { createItemDetail } from './itemDetail'; +import { logger } from '@motajs/common'; +import { MapRender, MapRenderer } from '../map'; +import { state } from '@user/data-state'; +import { materials } from '@user/client-base'; export function createElements() { createCache(); @@ -66,6 +70,31 @@ export function createElements() { return new Animate(); }); tagMap.register('icon', standardElementNoCache(Icon)); + tagMap.register('map-render', (_0, _1, props) => { + if (!props) { + logger.error(42); + return new MapRender( + state.layer, + new MapRenderer(materials, state.layer) + ); + } + const { layerState, renderer } = props; + if (!layerState) { + logger.error(42, 'layerState'); + return new MapRender( + state.layer, + new MapRenderer(materials, state.layer) + ); + } + if (!renderer) { + logger.error(42, 'renderer'); + return new MapRender( + state.layer, + new MapRenderer(materials, state.layer) + ); + } + return new MapRender(layerState, renderer); + }); } export * from './animate'; diff --git a/packages-user/client-modules/src/render/elements/misc.ts b/packages-user/client-modules/src/render/elements/misc.ts index 811537d..d8ef9b6 100644 --- a/packages-user/client-modules/src/render/elements/misc.ts +++ b/packages-user/client-modules/src/render/elements/misc.ts @@ -4,9 +4,9 @@ import { RenderItem, RenderItemPosition, MotaOffscreenCanvas2D, - Transform, - SizedCanvasImageSource + Transform } from '@motajs/render-core'; +import { SizedCanvasImageSource } from '@motajs/render-assets'; import { isNil } from 'lodash-es'; import { RenderableData, AutotileRenderable, texture } from './cache'; import { IAnimateFrame, renderEmits } from './frame'; diff --git a/packages-user/client-modules/src/render/elements/props.ts b/packages-user/client-modules/src/render/elements/props.ts index 5bfe370..fced0d4 100644 --- a/packages-user/client-modules/src/render/elements/props.ts +++ b/packages-user/client-modules/src/render/elements/props.ts @@ -1,5 +1,6 @@ import { BaseProps, TagDefine } from '@motajs/render-vue'; -import { CanvasStyle, Transform } from '@motajs/render-core'; +import { ERenderItemEvent, Transform } from '@motajs/render-core'; +import { CanvasStyle } from '@motajs/render-assets'; import { ILayerGroupRenderExtends, FloorLayer, @@ -10,6 +11,8 @@ import { import { EAnimateEvent } from './animate'; import { EIconEvent, EWinskinEvent } from './misc'; import { IEnemyCollection } from '@motajs/types'; +import { ILayerState } from '@user/data-state'; +import { IMapRenderer } from '../map'; export interface AnimateProps extends BaseProps {} @@ -59,6 +62,11 @@ export interface LayerProps extends BaseProps { ex?: readonly ILayerRenderExtends[]; } +export interface MapRenderProps extends BaseProps { + layerState: ILayerState; + renderer: IMapRenderer; +} + declare module 'vue/jsx-runtime' { namespace JSX { export interface IntrinsicElements { @@ -67,6 +75,7 @@ declare module 'vue/jsx-runtime' { animation: TagDefine; icon: TagDefine; winskin: TagDefine; + 'map-render': TagDefine; } } } diff --git a/packages-user/client-modules/src/render/fx/image3d.ts b/packages-user/client-modules/src/render/fx/image3d.ts index 65a9caf..4c847c5 100644 --- a/packages-user/client-modules/src/render/fx/image3d.ts +++ b/packages-user/client-modules/src/render/fx/image3d.ts @@ -7,7 +7,7 @@ import { EffectBase } from './base'; export class Image3DEffect extends EffectBase - implements ITransformUpdatable + implements ITransformUpdatable { /** 图片的模型变换 */ private model: Transform3D = new Transform3D(); @@ -65,7 +65,7 @@ export class Image3DEffect * @param model 模型变换 */ setModel(model: Transform3D) { - this.model.bind(); + this.model.unbind(this); this.model = model; model.bind(this); } @@ -75,7 +75,7 @@ export class Image3DEffect * @param model 视角变换 */ setView(view: Transform3D) { - this.view.bind(); + this.view.unbind(this); this.view = view; view.bind(this); } @@ -85,7 +85,7 @@ export class Image3DEffect * @param model 投影变换 */ setProj(proj: Transform3D) { - this.proj.bind(); + this.proj.unbind(this); this.proj = proj; proj.bind(this); } diff --git a/packages-user/client-modules/src/render/index.tsx b/packages-user/client-modules/src/render/index.tsx index 5798087..e1163aa 100644 --- a/packages-user/client-modules/src/render/index.tsx +++ b/packages-user/client-modules/src/render/index.tsx @@ -9,21 +9,19 @@ import { createAction } from './action'; import { sceneController } from './scene'; import { GameTitleUI } from './ui/title'; import { createWeather } from './weather'; +import { createMainExtension } from './commonIns'; export function createGameRenderer() { const App = defineComponent(_props => { return () => ( - + {sceneController.render()} ); }); - mainRenderer.setAntiAliasing(false); mainRenderer.hide(); createApp(App).mount(mainRenderer); - - console.log(mainRenderer); } export function createRender() { @@ -32,11 +30,15 @@ export function createRender() { createAction(); createWeather(); - loading.on('loaded', () => { + loading.once('loaded', () => { sceneController.open(GameTitleUI, {}); mainRenderer.show(); }); + loading.once('assetBuilt', () => { + createMainExtension(); + }); + hook.on('restart', () => { sceneController.closeAll(); sceneController.open(GameTitleUI, {}); @@ -45,6 +47,7 @@ export function createRender() { Font.setDefaults(DEFAULT_FONT); } +export * from './commonIns'; export * from './components'; export * from './elements'; export * from './fx'; diff --git a/packages-user/client-modules/src/render/map/block.ts b/packages-user/client-modules/src/render/map/block.ts new file mode 100644 index 0000000..ab8800e --- /dev/null +++ b/packages-user/client-modules/src/render/map/block.ts @@ -0,0 +1,351 @@ +import { clamp } from 'lodash-es'; +import { + IBlockData, + IBlockIndex, + IBlockInfo, + IBlockSplitter, + IBlockSplitterConfig +} from './types'; + +export class BlockSplitter implements IBlockSplitter { + blockWidth: number = 0; + blockHeight: number = 0; + dataWidth: number = 0; + dataHeight: number = 0; + width: number = 1; + height: number = 1; + + /** 分块映射 */ + readonly blockMap: Map> = new Map(); + + /** 数据宽度配置 */ + private splitDataWidth: number = 0; + /** 数据高度配置 */ + private splitDataHeight: number = 0; + /** 单个分块的宽度配置 */ + private splitBlockWidth: number = 0; + /** 单个分块的高度配置 */ + private splitBlockHeight: number = 0; + + /** + * 检查坐标范围 + * @param x 分块横坐标 + * @param y 分块纵坐标 + */ + private checkLocRange(x: number, y: number) { + return x >= 0 && y >= 0 && x < this.width && y < this.height; + } + + getBlockByLoc(x: number, y: number): IBlockData | null { + if (!this.checkLocRange(x, y)) return null; + const index = y * this.width + x; + return this.blockMap.get(index) ?? null; + } + + getBlockByIndex(index: number): IBlockData | null { + return this.blockMap.get(index) ?? null; + } + + setBlockByLoc(data: T, x: number, y: number): IBlockData | null { + if (!this.checkLocRange(x, y)) return null; + const index = y * this.width + x; + const block = this.blockMap.get(index); + if (!block) return null; + block.data = data; + return block; + } + + setBlockByIndex(data: T, index: number): IBlockData | null { + const block = this.blockMap.get(index); + if (!block) return null; + block.data = data; + return block; + } + + *iterateBlockByLoc(x: number, y: number): Generator { + if (!this.checkLocRange(x, y)) return; + const index = y * this.width + x; + yield* this.iterateBlockByIndex(index); + } + + *iterateBlockByIndex(index: number): Generator { + const block = this.blockMap.get(index); + if (!block) return; + const startX = block.x * this.blockWidth; + const startY = block.y * this.blockHeight; + const endX = startX + block.width; + const endY = startY + block.height; + for (let ny = startY; ny < endY; ny++) { + for (let nx = startX; nx < endX; nx++) { + const index: IBlockIndex = { + x: nx, + y: ny, + dataX: nx * this.blockWidth, + dataY: ny * this.blockHeight, + index: ny * this.dataWidth + nx + }; + yield index; + } + } + } + + *iterateBlockByIndices( + indices: Iterable + ): Generator { + for (const index of indices) { + yield* this.iterateBlockByIndex(index); + } + } + + iterateBlocks(): Iterable> { + return this.blockMap.values(); + } + + *iterateBlocksOfDataArea( + x: number, + y: number, + width: number, + height: number + ): Generator> { + const r = this.width - 1; + const b = this.height - 1; + const rx = x + width; + const by = y + height; + const left = clamp(Math.floor(x / this.blockWidth), 0, r); + const top = clamp(Math.floor(y / this.blockHeight), 0, b); + const right = clamp(Math.floor(rx / this.blockWidth), 0, r); + const bottom = clamp(Math.floor(by / this.blockHeight), 0, b); + for (let ny = left; ny <= right; ny++) { + for (let nx = top; nx <= bottom; nx++) { + const index = ny * this.width + nx; + const block = this.blockMap.get(index); + if (!block) continue; + yield block; + } + } + } + + getIndexByLoc(x: number, y: number): number { + if (!this.checkLocRange(x, y)) return -1; + return y * this.width + x; + } + + getLocByIndex(index: number): Loc | null { + if (index >= this.width * this.height) return null; + return { + x: index % this.width, + y: Math.floor(index / this.width) + }; + } + + getIndicesByLocList(list: Iterable): Iterable { + const res: number[] = []; + for (const { x, y } of list) { + res.push(this.getIndexByLoc(x, y)); + } + return res; + } + + getLocListByIndices(list: Iterable): Iterable { + const res: (Loc | null)[] = []; + for (const index of list) { + res.push(this.getLocByIndex(index)); + } + return res; + } + + getBlockByDataLoc(x: number, y: number): IBlockData | null { + const bx = Math.floor(x / this.blockWidth); + const by = Math.floor(y / this.blockHeight); + if (!this.checkLocRange(bx, by)) return null; + const index = by * this.width + bx; + return this.blockMap.get(index) ?? null; + } + + getBlockByDataIndex(index: number): IBlockData | null { + const x = index % this.dataWidth; + const y = Math.floor(index / this.dataWidth); + return this.getBlockByDataLoc(x, y); + } + + getIndicesByDataLocList(list: Iterable): Set { + const res = new Set(); + for (const { x, y } of list) { + const bx = Math.floor(x / this.blockWidth); + const by = Math.floor(y / this.blockHeight); + if (!this.checkLocRange(bx, by)) continue; + res.add(bx + by * this.width); + } + return res; + } + + getIndicesByDataIndices(list: Iterable): Set { + const res = new Set(); + for (const index of list) { + const x = index % this.dataWidth; + const y = Math.floor(index / this.dataWidth); + const bx = Math.floor(x / this.blockWidth); + const by = Math.floor(y / this.blockHeight); + if (!this.checkLocRange(bx, by)) continue; + res.add(bx + by * this.width); + } + return res; + } + + getBlocksByDataLocList(list: Iterable): Set> { + const res = new Set>(); + for (const { x, y } of list) { + const bx = Math.floor(x / this.blockWidth); + const by = Math.floor(y / this.blockHeight); + if (!this.checkLocRange(bx, by)) continue; + const index = bx + by * this.width; + const data = this.blockMap.get(index); + if (data) res.add(data); + } + return res; + } + + getBlocksByDataIndices(list: Iterable): Set> { + const res = new Set>(); + for (const index of list) { + const x = index % this.dataWidth; + const y = Math.floor(index / this.dataWidth); + const bx = Math.floor(x / this.blockWidth); + const by = Math.floor(y / this.blockHeight); + if (!this.checkLocRange(bx, by)) continue; + const blockIndex = bx + by * this.width; + const data = this.blockMap.get(blockIndex); + if (data) res.add(data); + } + return res; + } + + configSplitter(config: IBlockSplitterConfig): void { + this.splitDataWidth = config.dataWidth; + this.splitDataHeight = config.dataHeight; + this.splitBlockWidth = config.blockWidth; + this.splitBlockHeight = config.blockHeight; + } + + private mapBlock( + x: number, + y: number, + realWidth: number, + width: number, + height: number, + fn: (block: IBlockInfo) => T + ) { + const index = y * realWidth + x; + const block: IBlockInfo = { + index, + x, + y, + dataX: x * this.blockWidth, + dataY: y * this.blockHeight, + width, + height + }; + const data = fn(block); + const blockData = new SplittedBlockData(this, block, data); + this.blockMap.set(index, blockData); + } + + splitBlocks(mapFn: (block: IBlockInfo) => T): void { + this.blockMap.clear(); + this.blockWidth = this.splitBlockWidth; + this.blockHeight = this.splitBlockHeight; + this.dataWidth = this.splitDataWidth; + this.dataHeight = this.splitDataHeight; + const restX = this.splitDataWidth % this.splitBlockWidth; + const restY = this.splitDataHeight % this.splitBlockHeight; + const width = Math.floor(this.splitDataWidth / this.splitBlockWidth); + const height = Math.floor(this.splitDataHeight / this.splitBlockHeight); + const hasXRest = restX > 0; + const hasYRest = restY > 0; + const realWidth = hasXRest ? width + 1 : width; + const bw = this.blockWidth; + const bh = this.blockHeight; + this.width = realWidth; + this.height = hasYRest ? height + 1 : height; + for (let ny = 0; ny < height; ny++) { + for (let nx = 0; nx < width; nx++) { + this.mapBlock(nx, ny, realWidth, bw, bh, mapFn); + } + } + if (hasXRest) { + for (let ny = 0; ny < height; ny++) { + this.mapBlock(width, ny, realWidth, restX, bh, mapFn); + } + } + if (hasYRest) { + for (let nx = 0; nx < width; nx++) { + this.mapBlock(nx, height, realWidth, bw, restY, mapFn); + } + } + if (hasXRest && hasYRest) { + this.mapBlock(width, height, realWidth, restX, restY, mapFn); + } + } +} + +class SplittedBlockData implements IBlockData { + width: number; + height: number; + x: number; + y: number; + dataX: number; + dataY: number; + index: number; + data: T; + + constructor( + readonly splitter: BlockSplitter, + info: IBlockInfo, + data: T + ) { + this.width = info.width; + this.height = info.height; + this.x = info.x; + this.y = info.y; + this.dataX = info.dataX; + this.dataY = info.dataY; + this.index = info.index; + this.data = data; + } + + left(): IBlockData | null { + return this.splitter.getBlockByLoc(this.x - 1, this.y); + } + + right(): IBlockData | null { + return this.splitter.getBlockByLoc(this.x + 1, this.y); + } + + up(): IBlockData | null { + return this.splitter.getBlockByLoc(this.x, this.y - 1); + } + + down(): IBlockData | null { + return this.splitter.getBlockByLoc(this.x, this.y + 1); + } + + leftUp(): IBlockData | null { + return this.splitter.getBlockByLoc(this.x - 1, this.y - 1); + } + + leftDown(): IBlockData | null { + return this.splitter.getBlockByLoc(this.x - 1, this.y + 1); + } + + rightUp(): IBlockData | null { + return this.splitter.getBlockByLoc(this.x + 1, this.y - 1); + } + + rightDown(): IBlockData | null { + return this.splitter.getBlockByLoc(this.x + 1, this.y + 1); + } + + next(): IBlockData | null { + return this.splitter.getBlockByIndex(this.index + 1); + } +} diff --git a/packages-user/client-modules/src/render/map/constant.ts b/packages-user/client-modules/src/render/map/constant.ts new file mode 100644 index 0000000..d29a817 --- /dev/null +++ b/packages-user/client-modules/src/render/map/constant.ts @@ -0,0 +1,2 @@ +/** 单个图块的实例化数据数量 */ +export const INSTANCED_COUNT = 4 + 4 + 4 + 4; diff --git a/packages-user/client-modules/src/render/map/element.ts b/packages-user/client-modules/src/render/map/element.ts new file mode 100644 index 0000000..2ce0093 --- /dev/null +++ b/packages-user/client-modules/src/render/map/element.ts @@ -0,0 +1,70 @@ +import { MotaOffscreenCanvas2D, RenderItem } from '@motajs/render-core'; +import { ILayerState } from '@user/data-state'; +import { IMapRenderer } from './types'; +import { ElementNamespace, ComponentInternalInstance } from 'vue'; +import { CELL_HEIGHT, CELL_WIDTH, MAP_HEIGHT, MAP_WIDTH } from '../shared'; + +export class MapRender extends RenderItem { + /** + * @param layerState 地图状态对象 + * @param renderer 地图渲染器对象 + */ + constructor( + readonly layerState: ILayerState, + readonly renderer: IMapRenderer + ) { + super('static', false, false); + + renderer.setLayerState(layerState); + renderer.setCellSize(CELL_WIDTH, CELL_HEIGHT); + renderer.setRenderSize(MAP_WIDTH, MAP_HEIGHT); + + this.delegateTicker(time => { + this.renderer.tick(time); + if (this.renderer.needUpdate()) { + this.update(); + } + }); + } + + private sizeGL(width: number, height: number) { + const ratio = this.highResolution ? devicePixelRatio : 1; + const scale = ratio * this.scale; + const w = width * scale; + const h = height * scale; + this.renderer.setCanvasSize(w, h); + this.renderer.setViewport(0, 0, w, h); + } + + onResize(scale: number): void { + super.onResize(scale); + this.sizeGL(this.width, this.height); + } + + size(width: number, height: number): void { + super.size(width, height); + this.sizeGL(width, height); + } + + protected render(canvas: MotaOffscreenCanvas2D): void { + this.renderer.clear(true, true); + const map = this.renderer.render(); + canvas.ctx.drawImage(map, 0, 0, canvas.width, canvas.height); + } + + patchProp( + key: string, + prevValue: any, + nextValue: any, + namespace?: ElementNamespace, + parentComponent?: ComponentInternalInstance | null + ): void { + switch (key) { + case 'layerState': { + this.renderer.setLayerState(nextValue); + break; + } + } + super.patchProp(key, prevValue, nextValue, namespace, parentComponent); + } +} diff --git a/packages-user/client-modules/src/render/map/extension/door.ts b/packages-user/client-modules/src/render/map/extension/door.ts new file mode 100644 index 0000000..17da5bb --- /dev/null +++ b/packages-user/client-modules/src/render/map/extension/door.ts @@ -0,0 +1,73 @@ +import { + IMapLayer, + IMapLayerHookController, + IMapLayerHooks +} from '@user/data-state'; +import { IMapDoorRenderer } from './types'; +import { IMapRenderer } from '../types'; +import { sleep } from 'mutate-animate'; +import { DOOR_ANIMATE_INTERVAL } from '../../shared'; + +export class MapDoorRenderer implements IMapDoorRenderer { + /** 钩子控制器 */ + readonly controller: IMapLayerHookController; + + /** 动画间隔 */ + private interval: number = DOOR_ANIMATE_INTERVAL; + + constructor( + readonly renderer: IMapRenderer, + readonly layer: IMapLayer + ) { + this.controller = layer.addHook(new MapDoorHook(this)); + this.controller.load(); + } + + setAnimateInterval(interval: number): void { + this.interval = interval; + } + + async openDoor(x: number, y: number): Promise { + const status = this.renderer.getBlockStatus(this.layer, x, y); + if (!status) return; + const array = this.layer.getMapRef().array; + const index = y * this.layer.width + x; + const num = array[index]; + const data = this.renderer.manager.getIfBigImage(num); + if (!data) return; + const frames = data.frames; + for (let i = 0; i < frames; i++) { + status.useSpecifiedFrame(i); + await sleep(this.interval); + } + } + + async closeDoor(num: number, x: number, y: number): Promise { + const data = this.renderer.manager.getIfBigImage(num); + if (!data) return; + const moving = this.renderer.addMovingBlock(this.layer, num, x, y); + const frames = data.frames; + + for (let i = frames - 1; i >= 0; i--) { + moving.useSpecifiedFrame(i); + await sleep(this.interval); + } + moving.destroy(); + } + + destroy(): void { + this.controller.unload(); + } +} + +class MapDoorHook implements Partial { + constructor(readonly renderer: MapDoorRenderer) {} + + onOpenDoor(x: number, y: number): Promise { + return this.renderer.openDoor(x, y); + } + + onCloseDoor(num: number, x: number, y: number): Promise { + return this.renderer.closeDoor(num, x, y); + } +} diff --git a/packages-user/client-modules/src/render/map/extension/hero.ts b/packages-user/client-modules/src/render/map/extension/hero.ts new file mode 100644 index 0000000..19b4a85 --- /dev/null +++ b/packages-user/client-modules/src/render/map/extension/hero.ts @@ -0,0 +1,518 @@ +import { + degradeFace, + FaceDirection, + getFaceMovement, + HeroAnimateDirection, + IHeroState, + IHeroStateHooks, + IMapLayer, + nextFaceDirection, + state +} from '@user/data-state'; +import { IMapRenderer, IMapRendererTicker, IMovingBlock } from '../types'; +import { isNil } from 'lodash-es'; +import { IHookController, logger } from '@motajs/common'; +import { BlockCls, IMaterialFramedData } from '@user/client-base'; +import { + ITexture, + ITextureSplitter, + TextureRowSplitter +} from '@motajs/render-assets'; +import { IMapHeroRenderer } from './types'; +import { TimingFn } from 'mutate-animate'; + +/** 默认的移动时长 */ +const DEFAULT_TIME = 100; + +interface HeroRenderEntity { + /** 移动图块对象 */ + readonly block: IMovingBlock; + /** 标识符,用于判定跟随者 */ + readonly identifier: string; + /** 目标横坐标,移动时有效 */ + targetX: number; + /** 目标纵坐标,移动时有效 */ + targetY: number; + /** 当前的移动朝向 */ + direction: FaceDirection; + /** 下一个跟随者的移动方向 */ + nextDirection: FaceDirection; + + /** 当前是否正在移动 */ + moving: boolean; + /** 当前是否正在动画,移动跟动画要分开,有的操作比如跳跃就是在移动中但是没动画 */ + animating: boolean; + /** 帧动画间隔 */ + animateInterval: number; + /** 当一次动画时刻 */ + lastAnimateTime: number; + /** 当前的动画帧数 */ + animateFrame: number; + /** 移动的 `Promise`,移动完成时兑现,如果停止,则一直是兑现状态 */ + promise: Promise; + /** 勇士移动的动画方向 */ + animateDirection: HeroAnimateDirection; +} + +export class MapHeroRenderer implements IMapHeroRenderer { + private static readonly splitter: ITextureSplitter = + new TextureRowSplitter(); + + /** 勇士钩子 */ + readonly controller: IHookController; + /** 勇士每个朝向的贴图对象 */ + readonly textureMap: Map = new Map(); + /** 勇士渲染实体,与 `entities[0]` 同引用 */ + readonly heroEntity: HeroRenderEntity; + + /** + * 渲染实体,索引 0 表示勇士,后续索引依次表示跟随的跟随者。 + * 整体是一个状态机,而且下一个跟随者只与上一个跟随者有关,下一个跟随者移动的方向就是上一个跟随者上一步移动后指向的方向。 + */ + readonly entities: HeroRenderEntity[] = []; + + /** 每帧执行的帧动画对象 */ + readonly ticker: IMapRendererTicker; + + constructor( + readonly renderer: IMapRenderer, + readonly layer: IMapLayer, + readonly hero: IHeroState + ) { + this.controller = hero.addHook(new MapHeroHook(this)); + this.controller.load(); + const moving = this.addHeroMoving(renderer, layer, hero); + const heroEntity: HeroRenderEntity = { + block: moving, + identifier: '', + targetX: hero.x, + targetY: hero.y, + direction: hero.direction, + nextDirection: FaceDirection.Unknown, + moving: false, + animating: false, + animateInterval: 0, + lastAnimateTime: 0, + animateFrame: 0, + promise: Promise.resolve(), + animateDirection: HeroAnimateDirection.Forward + }; + this.heroEntity = heroEntity; + this.entities.push(heroEntity); + this.ticker = renderer.requestTicker(time => this.tick(time)); + } + + /** + * 添加勇士对应的移动图块 + * @param renderer 渲染器 + * @param layer 图块所属图层 + * @param hero 勇士状态对象 + */ + private addHeroMoving( + renderer: IMapRenderer, + layer: IMapLayer, + hero: IHeroState + ) { + if (isNil(hero.image)) { + logger.warn(88); + return renderer.addMovingBlock(layer, 0, hero.x, hero.y); + } + const image = this.renderer.manager.getImageByAlias(hero.image); + if (!image) { + logger.warn(89, hero.image); + return renderer.addMovingBlock(layer, 0, hero.x, hero.y); + } + this.updateHeroTexture(image); + const tex = this.textureMap.get(degradeFace(hero.direction)); + if (!tex) { + return renderer.addMovingBlock(layer, 0, hero.x, hero.y); + } + const block = renderer.addMovingBlock(layer, tex, hero.x, hero.y); + block.useSpecifiedFrame(0); + return block; + } + + /** + * 更新勇士贴图 + * @param image 勇士使用的贴图,包含四个方向 + */ + private updateHeroTexture(image: ITexture) { + const textures = [ + ...image.split(MapHeroRenderer.splitter, image.height / 4) + ]; + if (textures.length !== 4) { + logger.warn(90, hero.image); + return; + } + const faceList = [ + FaceDirection.Down, + FaceDirection.Left, + FaceDirection.Right, + FaceDirection.Up + ]; + faceList.forEach((v, i) => { + const dirImage = textures[i]; + const data: IMaterialFramedData = { + offset: dirImage.width / 4, + texture: dirImage, + cls: BlockCls.Unknown, + frames: 4, + defaultFrame: 0 + }; + this.textureMap.set(v, data); + }); + } + + private tick(time: number) { + this.entities.forEach(v => { + if (v.animating) { + const dt = time - v.lastAnimateTime; + if (dt > v.animateInterval) { + if (v.animateDirection === HeroAnimateDirection.Forward) { + v.animateFrame++; + } else { + v.animateFrame--; + if (v.animateFrame < 0) { + // 小于 0,则加上帧数的整数倍,就写个 10000 倍吧 + v.animateFrame += v.block.texture.frames * 10000; + } + } + v.lastAnimateTime = time; + v.block.useSpecifiedFrame(v.animateFrame); + } + } else { + if (v.animateFrame !== 0) { + v.animateFrame = 0; + v.block.useSpecifiedFrame(0); + } + } + }); + } + + setImage(image: ITexture): void { + this.updateHeroTexture(image); + const tex = this.textureMap.get(degradeFace(this.hero.direction)); + if (!tex) return; + this.heroEntity.block.setTexture(tex); + } + + setAlpha(alpha: number): void { + this.heroEntity.block.setAlpha(alpha); + } + + setPosition(x: number, y: number): void { + this.entities.forEach(v => { + v.block.setPos(x, y); + v.nextDirection = FaceDirection.Unknown; + }); + } + + /** + * 移动指定渲染实体,不会影响其他渲染实体。多次调用时会按顺序依次移动 + * @param entity 渲染实体 + * @param direction 移动方向 + * @param time 移动时长 + */ + private moveEntity( + entity: HeroRenderEntity, + direction: FaceDirection, + time: number + ) { + const { x: dx, y: dy } = getFaceMovement(direction); + if (dx === 0 && dy === 0) return; + const block = entity.block; + const tx = block.x + dx; + const ty = block.y + dy; + const nextTile = state.roleFace.getFaceOf(block.tile, direction); + const nextTex = this.renderer.manager.getIfBigImage( + nextTile?.identifier ?? block.tile + ); + entity.animateInterval = time; + entity.promise = entity.promise.then(async () => { + entity.moving = true; + entity.animating = true; + entity.direction = direction; + if (nextTex) block.setTexture(nextTex); + await block.lineTo(tx, ty, time); + entity.nextDirection = entity.direction; + }); + } + + /** + * 生成跳跃曲线 + * @param dx 横向偏移量 + * @param dy 纵向偏移量 + */ + private generateJumpFn(dx: number, dy: number): TimingFn<2> { + const distance = Math.hypot(dx, dy); + const peak = 3 + distance; + + return (progress: number) => { + const x = dx * progress; + const y = progress * dy + (progress ** 2 - progress) * peak; + + return [x, y]; + }; + } + + /** + * 将指定渲染实体跳跃至目标点,多次调用时会按顺序依次执行,可以与 `moveEntity` 混用 + * @param entity 渲染实体 + * @param x 目标横坐标 + * @param y 目标纵坐标 + * @param time 跳跃时长 + */ + private jumpEntity( + entity: HeroRenderEntity, + x: number, + y: number, + time: number + ) { + const block = entity.block; + entity.promise = entity.promise.then(async () => { + const dx = block.x - x; + const dy = block.y - y; + const fn = this.generateJumpFn(dx, dy); + entity.moving = true; + entity.animating = false; + entity.animateFrame = 0; + await block.moveRelative(fn, time); + }); + } + + startMove(): void { + this.heroEntity.moving = true; + this.heroEntity.animating = true; + this.heroEntity.lastAnimateTime = this.ticker.timestamp; + } + + private endEntityMoving(entity: HeroRenderEntity) { + entity.moving = false; + entity.animating = false; + entity.animateFrame = 0; + entity.block.useSpecifiedFrame(0); + } + + async waitMoveEnd(): Promise { + await Promise.all(this.entities.map(v => v.promise)); + this.entities.forEach(v => this.endEntityMoving(v)); + } + + stopMove(): void { + this.entities.forEach(v => { + v.block.endMoving(); + this.endEntityMoving(v); + }); + } + + async move(direction: FaceDirection, time: number): Promise { + this.moveEntity(this.heroEntity, direction, time); + for (let i = 1; i < this.entities.length; i++) { + const last = this.entities[i - 1]; + this.moveEntity(this.entities[i], last.nextDirection, time); + } + await Promise.all(this.entities.map(v => v.promise)); + } + + async jumpTo( + x: number, + y: number, + time: number, + waitFollower: boolean + ): Promise { + // 首先要把所有的跟随者移动到勇士所在位置 + for (let i = 1; i < this.entities.length; i++) { + // 对于每一个跟随者,需要向前遍历每一个跟随者,然后朝向移动,这样就可以聚集在一起了 + const now = this.entities[i]; + for (let j = i - 1; j >= 0; j--) { + const last = this.entities[j]; + this.moveEntity(now, last.nextDirection, DEFAULT_TIME); + } + } + this.entities.forEach(v => { + this.jumpEntity(v, x, y, time); + }); + if (waitFollower) { + await Promise.all(this.entities.map(v => v.promise)); + } else { + return this.heroEntity.promise; + } + } + + addFollower(image: number, id: string): void { + const last = this.entities[this.entities.length - 1]; + if (last.moving) { + logger.warn(92); + return; + } + const nowFace = degradeFace(last.nextDirection, FaceDirection.Down); + const faced = state.roleFace.getFaceOf(image, nowFace); + const tex = this.renderer.manager.getIfBigImage(faced?.face ?? image); + if (!tex) { + logger.warn(91, image.toString()); + return; + } + const { x: dxn, y: dyn } = getFaceMovement(last.nextDirection); + const { x: dx, y: dy } = getFaceMovement(last.direction); + const x = last.block.x - dxn; + const y = last.block.y - dyn; + const moving = this.renderer.addMovingBlock(this.layer, tex, x, y); + const entity: HeroRenderEntity = { + block: moving, + identifier: id, + targetX: last.targetX - dx, + targetY: last.targetY - dy, + direction: nowFace, + nextDirection: FaceDirection.Unknown, + moving: false, + animating: false, + animateInterval: 0, + lastAnimateTime: 0, + animateFrame: 0, + promise: Promise.resolve(), + animateDirection: HeroAnimateDirection.Forward + }; + moving.useSpecifiedFrame(0); + this.entities.push(entity); + } + + async removeFollower(follower: string, animate: boolean): Promise { + const index = this.entities.findIndex(v => v.identifier === follower); + if (index === -1) return; + if (this.entities[index].moving) { + logger.warn(93); + return; + } + if (index === this.entities.length - 1) { + this.entities[index].block.destroy(); + this.entities.splice(index, 1); + return; + } + // 展示动画 + if (animate) { + for (let i = index + 1; i < this.entities.length; i++) { + const last = this.entities[i - 1]; + const moving = this.entities[i]; + this.moveEntity(moving, last.nextDirection, DEFAULT_TIME); + } + this.entities[index].block.destroy(); + this.entities.splice(index, 1); + await Promise.all(this.entities.map(v => v.promise)); + return; + } + // 不展示动画 + for (let i = index + 1; i < this.entities.length; i++) { + const last = this.entities[i - 1]; + const moving = this.entities[i]; + moving.block.setPos(last.block.x, last.block.y); + const nextFace = state.roleFace.getFaceOf( + moving.block.tile, + last.nextDirection + ); + if (!nextFace) continue; + const tile = this.renderer.manager.getIfBigImage( + nextFace.identifier + ); + if (!tile) continue; + moving.block.setTexture(tile); + moving.direction = last.nextDirection; + moving.nextDirection = moving.direction; + } + this.entities[index].block.destroy(); + this.entities.splice(index, 1); + } + + removeAllFollowers(): void { + for (let i = 1; i < this.entities.length; i++) { + this.entities[i].block.destroy(); + } + this.entities.length = 1; + } + + setFollowerAlpha(identifier: string, alpha: number): void { + const follower = this.entities.find(v => v.identifier === identifier); + if (!follower) return; + follower.block.setAlpha(alpha); + } + + setHeroAnimateDirection(direction: HeroAnimateDirection): void { + this.heroEntity.animateDirection = direction; + } + + turn(direction?: FaceDirection): void { + const next = isNil(direction) + ? nextFaceDirection(this.heroEntity.direction) + : direction; + const tex = this.textureMap.get(next); + if (tex) { + this.heroEntity.block.setTexture(tex); + this.heroEntity.direction = next; + } + } + + destroy() { + this.controller.unload(); + } +} + +class MapHeroHook implements Partial { + constructor(readonly hero: MapHeroRenderer) {} + + onSetImage(image: ImageIds): void { + const texture = this.hero.renderer.manager.getImageByAlias(image); + if (!texture) { + logger.warn(89, hero.image); + return; + } + this.hero.setImage(texture); + } + + onSetPosition(x: number, y: number): void { + this.hero.setPosition(x, y); + } + + onTurnHero(direction: FaceDirection): void { + this.hero.turn(direction); + } + + onStartMove(): void { + this.hero.startMove(); + } + + onMoveHero(direction: FaceDirection, time: number): Promise { + return this.hero.move(direction, time); + } + + onEndMove(): Promise { + return this.hero.waitMoveEnd(); + } + + onJumpHero( + x: number, + y: number, + time: number, + waitFollower: boolean + ): Promise { + return this.hero.jumpTo(x, y, time, waitFollower); + } + + onSetAlpha(alpha: number): void { + this.hero.setAlpha(alpha); + } + + onAddFollower(follower: number, identifier: string): void { + this.hero.addFollower(follower, identifier); + } + + onRemoveFollower(identifier: string, animate: boolean): void { + this.hero.removeFollower(identifier, animate); + } + + onRemoveAllFollowers(): void { + this.hero.removeAllFollowers(); + } + + onSetFollowerAlpha(identifier: string, alpha: number): void { + this.hero.setFollowerAlpha(identifier, alpha); + } +} diff --git a/packages-user/client-modules/src/render/map/extension/index.ts b/packages-user/client-modules/src/render/map/extension/index.ts new file mode 100644 index 0000000..59af7b0 --- /dev/null +++ b/packages-user/client-modules/src/render/map/extension/index.ts @@ -0,0 +1,3 @@ +export * from './hero'; +export * from './manager'; +export * from './types'; diff --git a/packages-user/client-modules/src/render/map/extension/manager.ts b/packages-user/client-modules/src/render/map/extension/manager.ts new file mode 100644 index 0000000..25f286e --- /dev/null +++ b/packages-user/client-modules/src/render/map/extension/manager.ts @@ -0,0 +1,60 @@ +import { IHeroState, IMapLayer } from '@user/data-state'; +import { + IMapDoorRenderer, + IMapExtensionManager, + IMapHeroRenderer +} from './types'; +import { IMapRenderer } from '../types'; +import { MapHeroRenderer } from './hero'; +import { logger } from '@motajs/common'; +import { MapDoorRenderer } from './door'; + +export class MapExtensionManager implements IMapExtensionManager { + /** 勇士状态至勇士渲染器的映射 */ + readonly heroMap: Map = new Map(); + /** 地图图层到门渲染器的映射 */ + readonly doorMap: Map = new Map(); + + constructor(readonly renderer: IMapRenderer) {} + + addHero(state: IHeroState, layer: IMapLayer): IMapHeroRenderer | null { + if (this.heroMap.has(state)) { + logger.error(45, 'hero renderer'); + return null; + } + const heroRenderer = new MapHeroRenderer(this.renderer, layer, state); + this.heroMap.set(state, heroRenderer); + return heroRenderer; + } + + removeHero(state: IHeroState): void { + const renderer = this.heroMap.get(state); + if (!renderer) return; + renderer.destroy(); + this.heroMap.delete(state); + } + + addDoor(layer: IMapLayer): IMapDoorRenderer | null { + if (this.doorMap.has(layer)) { + logger.error(45, 'door renderer'); + return null; + } + const doorRenderer = new MapDoorRenderer(this.renderer, layer); + this.doorMap.set(layer, doorRenderer); + return doorRenderer; + } + + removeDoor(layer: IMapLayer): void { + const renderer = this.doorMap.get(layer); + if (!renderer) return; + renderer.destroy(); + this.doorMap.delete(layer); + } + + destroy(): void { + this.heroMap.forEach(v => void v.destroy()); + this.doorMap.forEach(v => void v.destroy()); + this.heroMap.clear(); + this.doorMap.clear(); + } +} diff --git a/packages-user/client-modules/src/render/map/extension/text.ts b/packages-user/client-modules/src/render/map/extension/text.ts new file mode 100644 index 0000000..a73d5aa --- /dev/null +++ b/packages-user/client-modules/src/render/map/extension/text.ts @@ -0,0 +1,63 @@ +import { IMapRenderer } from '../types'; +import { IMapTextArea, IMapTextRenderable, IOnMapTextRenderer } from './types'; + +export class OnMapTextRenderer implements IOnMapTextRenderer { + /** 画布元素 */ + readonly canvas: HTMLCanvasElement; + /** 画布 Canvas2D 上下文 */ + readonly ctx: CanvasRenderingContext2D; + + /** 图块索引到图块文本对象的映射 */ + readonly areaMap: Map = new Map(); + private dirty: boolean = false; + + constructor(readonly renderer: IMapRenderer) { + this.canvas = document.createElement('canvas'); + this.ctx = this.canvas.getContext('2d')!; + } + + render(): HTMLCanvasElement { + return this.canvas; + } + + requireBlockArea(x: number, y: number): Readonly { + const index = y * this.renderer.mapWidth + x; + const exist = this.areaMap.get(index); + if (exist) return exist; + const area = new MapTextArea(this, x, y); + this.areaMap.set(index, area); + return area; + } + + needUpdate(): boolean { + return this.dirty; + } + + clear(): void {} + + destroy(): void {} +} + +class MapTextArea implements IMapTextArea { + index: number; + + constructor( + readonly renderer: OnMapTextRenderer, + public mapX: number, + public mapY: number + ) { + this.index = mapY * renderer.renderer.mapWidth + mapX; + } + + addTextRenderable(renderable: IMapTextRenderable): void { + throw new Error('Method not implemented.'); + } + + removeTextRenderable(renderable: IMapTextRenderable): void { + throw new Error('Method not implemented.'); + } + + clear(): void { + throw new Error('Method not implemented.'); + } +} diff --git a/packages-user/client-modules/src/render/map/extension/types.ts b/packages-user/client-modules/src/render/map/extension/types.ts new file mode 100644 index 0000000..4617b77 --- /dev/null +++ b/packages-user/client-modules/src/render/map/extension/types.ts @@ -0,0 +1,246 @@ +import { ITexture } from '@motajs/render-assets'; +import { + FaceDirection, + HeroAnimateDirection, + IHeroState, + IMapLayer +} from '@user/data-state'; +import { Font } from '@motajs/render-style'; + +export interface IMapExtensionManager { + /** + * 添加勇士渲染拓展 + * @param state 勇士状态 + * @param layer 勇士所在图层 + */ + addHero(state: IHeroState, layer: IMapLayer): IMapHeroRenderer | null; + + /** + * 移除勇士渲染拓展 + * @param state 勇士状态 + */ + removeHero(state: IHeroState): void; + + /** + * 添加开门动画拓展 + */ + addDoor(layer: IMapLayer): IMapDoorRenderer | null; + + /** + * 移除开门动画拓展 + */ + removeDoor(layer: IMapLayer): void; + + /** + * 摧毁这个拓展管理对象,释放相关资源 + */ + destroy(): void; +} + +export interface IMapHeroRenderer { + /** + * 设置勇士图片 + * @param image 勇士使用的图片 + */ + setImage(image: ITexture): void; + + /** + * 添加跟随者 + * @param image 跟随者图块数字 + * @param id 跟随者的 id,用于删除操作 + */ + addFollower(image: number, id: string): void; + + /** + * 取消跟随者 + * @param follower 跟随者的 id + * @param animate 填 `true` 的话,如果删除了中间的跟随者,后续跟随者会使用移动动画移动到下一格,否则瞬移至下一格 + */ + removeFollower(follower: string, animate: boolean): Promise; + + /** + * 移除所有跟随者 + */ + removeAllFollowers(): void; + + /** + * 设置勇士位置 + */ + setPosition(x: number, y: number): void; + + /** + * 开始移动,在移动前需要调用此方法切换勇士状态 + */ + startMove(): void; + + /** + * 等待勇士移动停止后,将移动状态切换为停止 + * @param waitFollower 是否也等待跟随者移动结束 + */ + waitMoveEnd(waitFollower: boolean): Promise; + + /** + * 立刻停止移动,勇士和跟随者瞬移到目标点 + */ + stopMove(): void; + + /** + * 勇士朝某个方向移动 + * @param direction 移动方向 + */ + move(direction: FaceDirection, time: number): Promise; + + /** + * 跳跃勇士至目标点 + * @param x 目标点横坐标 + * @param y 目标点纵坐标 + * @param time 跳跃时长 + * @param waitFollower 是否等待跟随者也跳跃完毕 + */ + jumpTo( + x: number, + y: number, + time: number, + waitFollower: boolean + ): Promise; + + /** + * 设置勇士不透明度 + * @param alpha 不透明度 + */ + setAlpha(alpha: number): void; + + /** + * 设置跟随者的不透明度 + * @param identifier 跟随者标识符 + * @param alpha 跟随者不透明度 + */ + setFollowerAlpha(identifier: string, alpha: number): void; + + /** + * 设置勇士移动的动画播放方向,一般后退会使用反向播放的动画,前进使用正向播放的动画 + * @param direction 动画方向 + */ + setHeroAnimateDirection(direction: HeroAnimateDirection): void; + + /** + * 设置勇士朝向 + * @param direction 勇士朝向,不填表示顺时针旋转 + */ + turn(direction?: FaceDirection): void; + + /** + * 摧毁这个勇士渲染拓展,释放相关资源 + */ + destroy(): void; +} + +export interface IMapDoorRenderer { + /** + * 开启指定位置的门,播放开门动画 + * @param x 门横坐标 + * @param y 门纵坐标 + */ + openDoor(x: number, y: number): Promise; + + /** + * 在指定位置执行关门动画 + * @param num 门图块数字 + * @param x 门横坐标 + * @param y 门纵坐标 + */ + closeDoor(num: number, x: number, y: number): Promise; + + /** + * 设置开关门动画两帧之间的间隔 + * @param interval 开门动画间隔 + */ + setAnimateInterval(interval: number): void; + + /** + * 摧毁这个门动画拓展,释放相关资源 + */ + destroy(): void; +} + +export interface IMapTextRenderable { + /** 文本内容 */ + readonly text: string; + /** 文本字体 */ + readonly font: Font; + /** 文本填充样式 */ + readonly fillStyle: CanvasStyle; + /** 文本描边样式 */ + readonly strokeStyle: CanvasStyle; + /** 文本横坐标,注意 {@link IMapTextArea.addTextRenderable} 的相对关系 */ + readonly px: number; + /** 文本纵坐标,注意 {@link IMapTextArea.addTextRenderable} 的相对关系 */ + readonly py: number; + /** 文本横向对齐方式 */ + readonly textAlign: CanvasTextAlign; + /** 文本纵向对齐方式 */ + readonly textBaseline: CanvasTextBaseline; +} + +export interface IMapTextRequested { + /** + * 申请更新指定的图块 + * @param blocks 需要更新数据的图块列表 + */ + requestBlocks(blocks: IMapTextArea[]): void; +} + +export interface IMapTextArea { + /** 图块在地图上的索引 */ + index: number; + /** 图块横坐标 */ + mapX: number; + /** 图块纵坐标 */ + mapY: number; + + /** + * 添加文字可渲染对象。可渲染对象的坐标相对于图块,而非地图。 + * @param renderable 可渲染对象 + */ + addTextRenderable(renderable: IMapTextRenderable): void; + + /** + * 移除指定的文字可渲染对象 + * @param renderable 可渲染对象 + */ + removeTextRenderable(renderable: IMapTextRenderable): void; + + /** + * 清除本图块的所有文字可渲染对象 + */ + clear(): void; +} + +export interface IOnMapTextRenderer { + /** + * 渲染地图文字,返回的画布就是文字画到的画布 + */ + render(): HTMLCanvasElement; + + /** + * 申请指定图块坐标的文字管理对象 + * @param x 图块横坐标 + * @param y 图块纵坐标 + */ + requireBlockArea(x: number, y: number): Readonly; + + /** + * 判断是否需要更新 + */ + needUpdate(): boolean; + + /** + * 清空所有文字内容 + */ + clear(): void; + + /** + * 摧毁这个文字渲染对象,释放相关资源 + */ + destroy(): void; +} diff --git a/packages-user/client-modules/src/render/map/index.ts b/packages-user/client-modules/src/render/map/index.ts new file mode 100644 index 0000000..a19d767 --- /dev/null +++ b/packages-user/client-modules/src/render/map/index.ts @@ -0,0 +1,11 @@ +export * from './extension'; + +export * from './block'; +export * from './constant'; +export * from './element'; +export * from './moving'; +export * from './renderer'; +export * from './status'; +export * from './types'; +export * from './vertex'; +export * from './viewport'; diff --git a/packages-user/client-modules/src/render/map/moving.ts b/packages-user/client-modules/src/render/map/moving.ts new file mode 100644 index 0000000..8c89b9b --- /dev/null +++ b/packages-user/client-modules/src/render/map/moving.ts @@ -0,0 +1,268 @@ +import { linear, TimingFn } from 'mutate-animate'; +import { IMapRenderer, IMapVertexGenerator, IMovingBlock } from './types'; +import { IMaterialFramedData, IMaterialManager } from '@user/client-base'; +import { logger } from '@motajs/common'; +import { IMapLayer } from '@user/data-state'; +import { DynamicBlockStatus } from './status'; + +export interface IMovingRenderer { + /** 素材管理器 */ + readonly manager: IMaterialManager; + /** 顶点数组生成器 */ + readonly vertex: IMapVertexGenerator; + + /** + * 获得当前时间戳 + */ + getTimestamp(): number; + + /** + * 删除指定的移动图块对象 + * @param block 移动图块对象 + */ + deleteMoving(block: IMovingBlock): void; +} + +export class MovingBlock extends DynamicBlockStatus implements IMovingBlock { + readonly tile: number; + readonly renderer: IMovingRenderer; + readonly layer: IMapLayer; + + texture: IMaterialFramedData; + index: number; + x: number = 0; + y: number = 0; + + /** 当前动画开始的时刻 */ + private startTime: number = 0; + + /** 是否是直线动画 */ + private line: boolean = false; + /** 是否是相对模式 */ + private relative: boolean = false; + /** 目标横坐标 */ + private targetX: number = 0; + /** 目标纵坐标 */ + private targetY: number = 0; + /** 直线移动的横坐标增量 */ + private dx: number = 0; + /** 直线移动的纵坐标增量 */ + private dy: number = 0; + /** 动画时长 */ + private time: number = 0; + /** 速率曲线 */ + private timing: TimingFn = () => 0; + /** 移动轨迹曲线 */ + private curve: TimingFn<2> = () => [0, 0]; + + /** 动画开始时横坐标 */ + private startX: number = 0; + /** 动画开始时纵坐标 */ + private startY: number = 0; + /** 当前动画是否已经结束 */ + private end: boolean = true; + + /** 是否通过 `setPos` 设置了位置 */ + private posUpdated: boolean = false; + + /** 兑现函数 */ + private promiseFunc: () => void = () => {}; + + constructor( + renderer: IMovingRenderer & IMapRenderer, + index: number, + layer: IMapLayer, + block: number | IMaterialFramedData + ) { + super(layer, renderer.vertex, index); + this.renderer = renderer; + this.index = index; + this.layer = layer; + if (typeof block === 'number') { + this.texture = renderer.manager.getTile(block)!; + this.tile = block; + } else { + if (!renderer.manager.assetContainsTexture(block.texture)) { + logger.error(34); + } + if (renderer.getOffsetIndex(block.offset) === -1) { + logger.error(41); + } + this.texture = block; + this.tile = -1; + } + } + + setPos(x: number, y: number): void { + if (!this.end) return; + this.x = x; + this.y = y; + this.posUpdated = true; + } + + setTexture(texture: IMaterialFramedData): void { + if (texture === this.texture) return; + this.texture = texture; + this.renderer.vertex.updateMoving(this, true); + } + + lineTo( + x: number, + y: number, + time: number, + timing?: TimingFn + ): Promise { + if (!this.end) return Promise.resolve(this); + this.startX = this.x; + this.startY = this.y; + this.targetX = x; + this.targetY = y; + this.dx = x - this.x; + this.dy = y - this.y; + this.time = time; + this.relative = false; + this.startTime = this.renderer.getTimestamp(); + if (time === 0) { + this.x = x; + this.y = y; + this.end = true; + return Promise.resolve(this); + } + this.end = false; + this.timing = timing ?? linear(); + this.line = true; + return new Promise(res => { + this.promiseFunc = () => res(this); + }); + } + + moveAs(curve: TimingFn<2>, time: number, timing?: TimingFn): Promise { + if (!this.end) return Promise.resolve(this); + this.time = time; + this.line = false; + this.relative = false; + this.startX = this.x; + this.startY = this.y; + this.startTime = this.renderer.getTimestamp(); + if (time === 0) { + const [tx, ty] = curve(1); + this.x = tx; + this.y = ty; + this.end = true; + return Promise.resolve(this); + } + this.end = false; + this.timing = timing ?? linear(); + this.curve = curve; + return new Promise(res => { + this.promiseFunc = () => res(this); + }); + } + + moveRelative( + curve: TimingFn<2>, + time: number, + timing?: TimingFn + ): Promise { + if (!this.end) return Promise.resolve(this); + this.time = time; + this.line = false; + this.relative = true; + this.startX = this.x; + this.startY = this.y; + this.startTime = this.renderer.getTimestamp(); + if (time === 0) { + const [tx, ty] = curve(1); + this.x = tx + this.startX; + this.y = ty + this.startY; + this.end = true; + return Promise.resolve(this); + } + this.end = false; + this.timing = timing ?? linear(); + this.curve = curve; + return new Promise(res => { + this.promiseFunc = () => res(this); + }); + } + + stepMoving(timestamp: number): boolean { + if (this.end) { + if (this.posUpdated) { + this.posUpdated = false; + return true; + } + return false; + } + const dt = timestamp - this.startTime; + if (this.line) { + if (dt > this.time) { + this.x = this.targetX; + this.y = this.targetY; + this.end = true; + this.promiseFunc(); + return true; + } else { + const timeProgress = dt / this.time; + const progress = this.timing(timeProgress); + this.x = this.startX + progress * this.dx; + this.y = this.startY + progress * this.dy; + } + } else { + if (dt > this.time) { + const [tx, ty] = this.curve(1); + if (this.relative) { + this.x = tx + this.startX; + this.y = ty + this.startY; + } else { + this.x = tx; + this.y = ty; + } + this.end = true; + this.promiseFunc(); + return true; + } else { + const timeProgress = dt / this.time; + const progress = this.timing(timeProgress); + const [tx, ty] = this.curve(progress); + if (this.relative) { + this.x = tx + this.startX; + this.y = ty + this.startY; + } else { + this.x = tx; + this.y = ty; + } + } + } + return true; + } + + endMoving(): void { + this.end = true; + if (this.line) { + this.x = this.targetX; + this.y = this.targetY; + } else { + const [x, y] = this.curve(1); + if (this.relative) { + this.x = x + this.startX; + this.y = y + this.startY; + } else { + this.x = x; + this.y = y; + } + } + this.promiseFunc(); + this.posUpdated = true; + } + + useDefaultFrame(): void { + if (!this.renderer.manager.getTile(this.tile)) return; + const defaultFrame = this.renderer.manager.getDefaultFrame(this.tile); + this.useSpecifiedFrame(defaultFrame); + } + + destroy(): void { + this.renderer.deleteMoving(this); + } +} diff --git a/packages-user/client-modules/src/render/map/renderer.ts b/packages-user/client-modules/src/render/map/renderer.ts new file mode 100644 index 0000000..70468e4 --- /dev/null +++ b/packages-user/client-modules/src/render/map/renderer.ts @@ -0,0 +1,1783 @@ +import { + ITextureAnimater, + ITextureRenderable, + SizedCanvasImageSource, + TextureColumnAnimater +} from '@motajs/render-assets'; +import { + AutotileProcessor, + BlockCls, + IAutotileProcessor, + IMaterialFramedData, + IMaterialManager, + ITrackedAssetData +} from '@user/client-base'; +import { + IBlockStatus, + IContextData, + IMapBackgroundConfig, + IMapRenderConfig, + IMapRenderer, + IMapRendererPostEffect, + IMapRendererTicker, + IMapVertexGenerator, + IMapViewportController, + IMovingBlock, + MapBackgroundRepeat, + MapTileAlign, + MapTileBehavior, + MapTileSizeTestMode +} from './types'; +import { ILayerState, ILayerStateHooks, IMapLayer } from '@user/data-state'; +import { IHookController, logger } from '@motajs/common'; +import { compileProgramWith } from '@motajs/client-base'; +import { isNil, maxBy } from 'lodash-es'; +import { IMapDataGetter, MapVertexGenerator } from './vertex'; +import mapVert from './shader/map.vert?raw'; +import mapFrag from './shader/map.frag?raw'; +import backVert from './shader/back.vert?raw'; +import backFrag from './shader/back.frag?raw'; +import { IMovingRenderer, MovingBlock } from './moving'; +import { + CELL_HEIGHT, + CELL_WIDTH, + DYNAMIC_RESERVE, + MOVING_TOLERANCE +} from '../shared'; +import { Transform } from '@motajs/render-core'; +import { MapViewport } from './viewport'; +import { INSTANCED_COUNT } from './constant'; +import { StaticBlockStatus } from './status'; + +const enum BackgroundType { + Static, + Dynamic, + Tile +} + +export class MapRenderer + implements IMapRenderer, IMovingRenderer, IMapDataGetter +{ + //#region 实例属性 + + /** 自动元件处理器 */ + readonly autotile: IAutotileProcessor; + /** 顶点数组生成器 */ + readonly vertex: IMapVertexGenerator; + /** 地图渲染的视角控制 */ + readonly viewport: IMapViewportController; + + mapWidth: number = 0; + mapHeight: number = 0; + layerCount: number = 0; + renderWidth: number = 0; + renderHeight: number = 0; + cellWidth: number = CELL_WIDTH; + cellHeight: number = CELL_HEIGHT; + assetWidth: number = 4096; + assetHeight: number = 4096; + + layerState: ILayerState; + /** 地图状态钩子控制器 */ + private layerStateHook: IHookController; + + /** 排序后的图层 */ + private sortedLayers: IMapLayer[] = []; + /** 图层到排序索引的映射 */ + private layerIndexMap: Map = new Map(); + + /** 使用的图集数据 */ + private assetData: ITrackedAssetData | null = null; + + /** 背景图类型 */ + private backgroundType: BackgroundType = BackgroundType.Tile; + /** 静态背景 */ + private staticBack: ITextureRenderable | null = null; + /** 动态背景 */ + private dynamicBack: ITextureRenderable[] | null = null; + /** 图块背景 */ + private tileBack: number = 0; + /** 动态背景每帧持续时长 */ + private backFrameSpeed: number = 300; + /** 当前背景图帧数 */ + private backgroundFrame: number = 0; + /** 是否需要更新背景图帧数 */ + private needUpdateBackgroundFrame: boolean = true; + /** 背景图总帧数 */ + private backgroundFrameCount: number = 1; + /** 背景图上一帧的时刻 */ + private backLastFrame: number = 0; + /** 背景图横向平铺方式 */ + private backRepeatModeX: MapBackgroundRepeat = MapBackgroundRepeat.Repeat; + /** 背景图纵向平铺方式 */ + private backRepeatModeY: MapBackgroundRepeat = MapBackgroundRepeat.Repeat; + /** 背景图是否使用图片大小作为渲染大小 */ + private backUseImageSize: boolean = true; + /** 背景图的渲染宽度 */ + private backRenderWidth: number = 0; + /** 背景图的渲染高度 */ + private backRenderHeight: number = 0; + /** 背景图是否需要更新 */ + private backgroundDirty: boolean = false; + /** 背景图是否正在更新 */ + private backgroundPending: boolean = false; + /** 背景顶点数组 */ + private backgroundVertex: Float32Array = new Float32Array(4 * 4); + + /** 图块缩小行为,即当图块比格子大时,应该如何渲染 */ + tileMinifyBehavior: MapTileBehavior = MapTileBehavior.KeepSize; + /** 图块放大行为,即当图块比格子小时,应该如何渲染 */ + tileMagnifyBehavior: MapTileBehavior = MapTileBehavior.FitToSize; + /** 图块水平对齐,仅当图块行为为 `KeepSize` 时有效 */ + tileAlignX: MapTileAlign = MapTileAlign.Center; + /** 图块竖直对齐,仅当图块行为为 `KeepSize` 时有效 */ + tileAlignY: MapTileAlign = MapTileAlign.End; + /** 图块大小与网格大小的判断方式,如果图块大于网格,则执行缩小行为,否则执行放大行为 */ + tileTestMode: MapTileSizeTestMode = MapTileSizeTestMode.WidthOrHeight; + + /** 偏移池 */ + private offsetPool: number[]; + /** 归一化过后的偏移池 */ + private normalizedOffsetPool: number[]; + /** 是否应该更新偏移池 uniform */ + private needUpdateOffsetPool: boolean = true; + + /** 所有正在移动的图块 */ + private movingBlock: Set = new Set(); + /** 移动图块对象索引池 */ + private movingIndexPool: number[] = []; + /** 移动图块对象数量 */ + private movingCount: number = DYNAMIC_RESERVE; + /** 移动索引映射 */ + private movingIndexMap: Map = new Map(); + /** 移动容忍度,如果正在移动的图块数量长期小于预留数量的一半,那么将减少移动数组长度 */ + private lastExpandTime = 0; + + /** 时间戳 */ + private timestamp: number = 0; + /** 上一帧动画的时刻 */ + private lastFrameTime: number = 0; + /** 当前帧数 */ + private frameCounter: number = 0; + /** 是否需要更新当前帧数 */ + private needUpdateFrameCounter: boolean = true; + /** 帧动画速率 */ + private frameSpeed: number = 300; + /** 帧动画列表 */ + private tickers: Set = new Set(); + + /** 画布元素 */ + readonly canvas: HTMLCanvasElement; + /** 画布 WebGL2 上下文 */ + readonly gl: WebGL2RenderingContext; + /** 画布上下文数据 */ + private contextData: IContextData; + + /** 效果对象优先级映射 */ + private effectPriority: Map = new Map(); + /** 渲染器效果对象列表,使用数组是因为要有顺序 */ + private postEffects: IMapRendererPostEffect[] = []; + + /** 地图变换矩阵 */ + transform: Transform; + /** 是否需要更新变换矩阵 */ + private needUpdateTransform: boolean = true; + /** 是否需要重新渲染 */ + private updateRequired: boolean = true; + + /** 图块动画器 */ + private readonly tileAnimater: ITextureAnimater; + + /** `gl.viewport` 横坐标 */ + private viewportX: number = 0; + /** `gl.viewport` 纵坐标 */ + private viewportY: number = 0; + /** `gl.viewport` 宽度 */ + private viewportWidth: number = 0; + /** `gl.viewport` 高度 */ + private viewportHeight: number = 0; + + //#endregion + + //#region 初始化 + + /** + * 创建地图渲染器 + * @param manager 素材管理器 + * @param gl 画布 WebGL2 上下文 + * @param transform 视角变换矩阵 + */ + constructor( + readonly manager: IMaterialManager, + layerState: ILayerState + ) { + this.movingIndexPool.push( + ...Array.from({ length: this.movingCount }, (_, i) => i).reverse() + ); + this.canvas = document.createElement('canvas'); + this.gl = this.canvas.getContext('webgl2')!; + this.transform = new Transform(); + this.layerState = layerState; + this.layerStateHook = layerState.addHook( + new RendererLayerStateHook(this) + ); + this.layerStateHook.load(); + // 上下文初始化要依赖于 offsetPool,因此提前调用 + const offsetPool = this.getOffsetPool(); + this.offsetPool = offsetPool; + const data = this.initContext()!; + this.normalizedOffsetPool = offsetPool.map( + v => v / data.tileTextureWidth + ); + this.contextData = data; + this.vertex = new MapVertexGenerator(this, data); + this.autotile = new AutotileProcessor(manager); + this.tick = this.tick.bind(this); + this.viewport = new MapViewport(this); + this.tileAnimater = new TextureColumnAnimater(); + this.initVertexPointer(this.gl, data); + this.setViewport(0, 0, this.canvas.width, this.canvas.height); + } + + /** + * 初始化顶点 pointer + * @param gl 画布 WebGL2 上下文 + * @param data 上下文数据 + */ + private initVertexPointer(gl: WebGL2RenderingContext, data: IContextData) { + // 顶点数组初始化 + const { + backVAO, + tileVAO, + vertexBuffer, + instancedBuffer, + backgroundVertexBuffer, + vertexAttribLocation: vaLocation, + insTilePosAttribLocation: tilePos, + insTexCoordAttribLocation: texCoord, + insTileDataAttribLocation: tileData, + insTexDataAttribLocation: texData, + backVertexAttribLocation: bvaLocation, + backTexCoordAttribLocation: btcaLocation + } = data; + // 背景初始化 + gl.bindVertexArray(backVAO); + gl.bindBuffer(gl.ARRAY_BUFFER, backgroundVertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, 16 * 4, gl.DYNAMIC_DRAW); + gl.vertexAttribPointer(bvaLocation, 2, gl.FLOAT, false, 4 * 4, 0); + gl.vertexAttribPointer(btcaLocation, 2, gl.FLOAT, false, 4 * 4, 2 * 4); + gl.enableVertexAttribArray(bvaLocation); + gl.enableVertexAttribArray(btcaLocation); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + gl.bindVertexArray(null); + + // 顶点数组 + gl.bindVertexArray(tileVAO); + gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + // prettier-ignore + new Float32Array([ + // 左下,右下,左上,右上,前两个是顶点坐标,后两个是纹理坐标 + // 因为我们已经在数据处理阶段将数据归一化到了 [-1, 1] 的范围,因此顶点坐标应该是 [0, 1] 的范围 + // 同时又因为我们以左上角为原点,纵坐标与 WebGL2 相反,因此纵坐标需要取反 + 0, 0, 0, 0, + 1, 0, 1, 0, + 0, -1, 0, 1, + 1, -1, 1, 1 + ]), + gl.STATIC_DRAW + ); + gl.vertexAttribPointer(vaLocation, 4, gl.FLOAT, false, 0, 0); + gl.vertexAttribDivisor(vaLocation, 0); + gl.enableVertexAttribArray(vaLocation); + gl.bindBuffer(gl.ARRAY_BUFFER, instancedBuffer); + const stride = INSTANCED_COUNT * 4; + gl.vertexAttribPointer(tilePos, 4, gl.FLOAT, false, stride, 0); + gl.vertexAttribPointer(texCoord, 4, gl.FLOAT, false, stride, 4 * 4); + gl.vertexAttribPointer(tileData, 4, gl.FLOAT, false, stride, 8 * 4); + gl.vertexAttribPointer(texData, 4, gl.FLOAT, false, stride, 12 * 4); + gl.vertexAttribDivisor(tilePos, 1); + gl.vertexAttribDivisor(texCoord, 1); + gl.vertexAttribDivisor(tileData, 1); + gl.vertexAttribDivisor(texData, 1); + gl.enableVertexAttribArray(tilePos); + gl.enableVertexAttribArray(texCoord); + gl.enableVertexAttribArray(tileData); + gl.enableVertexAttribArray(texData); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + gl.bindVertexArray(null); + } + + private sortPostEffect() { + this.postEffects.sort((a, b) => { + const pa = this.effectPriority.get(a) ?? 0; + const pb = this.effectPriority.get(b) ?? 0; + return pb - pa; + }); + } + + addPostEffect(effect: IMapRendererPostEffect, priority: number): void { + this.postEffects.push(effect); + this.effectPriority.set(effect, priority); + this.sortPostEffect(); + this.updateRequired = true; + } + + removePostEffect(effect: IMapRendererPostEffect): void { + const index = this.postEffects.indexOf(effect); + if (index === -1) return; + this.postEffects.splice(index); + this.effectPriority.delete(effect); + this.sortPostEffect(); + this.updateRequired = true; + } + + setPostEffectPriority( + effect: IMapRendererPostEffect, + priority: number + ): void { + if (!this.effectPriority.has(effect)) return; + this.effectPriority.set(effect, priority); + this.sortPostEffect(); + this.updateRequired = true; + } + + //#endregion + + //#region 状态控制 + + setTransform(transform: Transform): void { + this.transform.unbind(this); + this.transform = transform; + transform.bind(this); + this.viewport.bindTransform(transform); + this.needUpdateTransform = true; + } + + setCanvasSize(width: number, height: number): void { + this.canvas.width = width; + this.canvas.height = height; + // 更新 FBO 的纹理尺寸信息 + const gl = this.gl; + const { pingTexture2D, pongTexture2D } = this.contextData; + gl.bindTexture(gl.TEXTURE_2D, pingTexture2D); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + width, + height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null + ); + gl.bindTexture(gl.TEXTURE_2D, pongTexture2D); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + width, + height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null + ); + gl.bindTexture(gl.TEXTURE_2D, null); + this.updateRequired = true; + } + + setViewport(x: number, y: number, width: number, height: number): void { + this.viewportX = x; + this.viewportY = y; + this.viewportWidth = width; + this.viewportHeight = height; + this.updateRequired = true; + } + + clear(color: boolean, depth: boolean): void { + let bit = 0; + if (color) bit |= this.gl.COLOR_BUFFER_BIT; + if (depth) bit |= this.gl.DEPTH_BUFFER_BIT; + if (bit > 0) this.gl.clear(bit); + } + + //#endregion + + //#region 图层处理 + + /** + * 图层排序 + */ + private sortLayer() { + this.sortedLayers = [...this.layerState.layerList].sort((a, b) => { + return a.zIndex - b.zIndex; + }); + this.sortedLayers.forEach((v, i) => this.layerIndexMap.set(v, i)); + } + + updateLayerList() { + this.sortLayer(); + this.resizeLayer(); + this.layerCount = this.layerState.layerList.size; + this.vertex.updateLayerArray(); + } + + setLayerState(layerState: ILayerState): void { + if (layerState === this.layerState) return; + this.layerStateHook.unload(); + this.layerState = layerState; + this.layerStateHook = layerState.addHook( + new RendererLayerStateHook(this) + ); + this.layerStateHook.load(); + this.sortLayer(); + this.resizeLayer(); + this.layerCount = layerState.layerList.size; + this.vertex.updateLayerArray(); + this.vertex.resizeMap(); + } + + getLayer(identifier: string): IMapLayer | null { + return this.layerState.getLayerByAlias(identifier) ?? null; + } + + hasLayer(layer: IMapLayer): boolean { + return this.layerState.hasLayer(layer); + } + + getSortedLayer(): IMapLayer[] { + return this.sortedLayers.slice(); + } + + getLayerIndex(layer: IMapLayer): number { + return this.layerIndexMap.get(layer) ?? -1; + } + + /** + * 重新适应新的图层大小 + */ + resizeLayer() { + const maxWidth = maxBy(this.sortedLayers, v => v.width)?.width ?? 0; + const maxHeight = maxBy(this.sortedLayers, v => v.height)?.height ?? 0; + if (this.mapWidth === maxWidth && this.mapHeight === maxHeight) { + return; + } + this.mapWidth = maxWidth; + this.mapHeight = maxHeight; + this.updateBackgroundVertex( + this.gl, + this.contextData, + this.contextData.backgroundWidth, + this.contextData.backgroundHeight + ); + this.vertex.resizeMap(); + } + + //#endregion + + //#region 背景处理 + + setStaticBackground(renderable: ITextureRenderable): void { + this.backgroundType = BackgroundType.Static; + this.staticBack = renderable; + this.dynamicBack = null; + this.tileBack = 0; + this.backLastFrame = this.timestamp; + this.backgroundFrameCount = 1; + this.backgroundDirty = true; + this.checkBackground(this.gl, this.contextData); + } + + setDynamicBackground(renderable: Iterable): void { + const array = [...renderable]; + this.backgroundType = BackgroundType.Dynamic; + this.dynamicBack = array; + this.staticBack = null; + this.tileBack = 0; + this.backLastFrame = this.timestamp; + this.backgroundFrameCount = array.length; + this.backgroundDirty = true; + this.checkBackground(this.gl, this.contextData); + } + + setTileBackground(tile: number): void { + this.backgroundType = BackgroundType.Tile; + this.tileBack = tile; + this.staticBack = null; + this.dynamicBack = null; + this.backLastFrame = this.timestamp; + this.backgroundDirty = true; + this.checkBackground(this.gl, this.contextData); + } + + configBackground(config: Partial): void { + if (!isNil(config.renderWidth)) { + this.updateRequired = true; + this.backRenderWidth = config.renderWidth; + } + if (!isNil(config.renderHeight)) { + this.updateRequired = true; + this.backRenderHeight = config.renderHeight; + } + if (!isNil(config.repeatX)) { + this.updateRequired = true; + this.backRepeatModeX = config.repeatX; + } + if (!isNil(config.repeatY)) { + this.updateRequired = true; + this.backRepeatModeY = config.repeatY; + } + if (!isNil(config.useImageSize)) { + this.updateRequired = true; + this.backUseImageSize = config.useImageSize; + } + if (!isNil(config.frameSpeed)) { + this.backFrameSpeed = config.frameSpeed; + } + this.updateBackgroundVertex( + this.gl, + this.contextData, + this.contextData.backgroundWidth, + this.contextData.backgroundHeight + ); + } + + getBackgroundConfig(): Readonly { + return { + renderWidth: this.backRenderWidth, + renderHeight: this.backRenderHeight, + repeatX: this.backRepeatModeX, + repeatY: this.backRepeatModeY, + useImageSize: this.backUseImageSize, + frameSpeed: this.backFrameSpeed + }; + } + + //#endregion + + //#region 渲染设置 + + useAsset(asset: ITrackedAssetData): void { + if (this.assetData === asset) return; + this.assetData = asset; + this.sortedLayers.forEach(v => { + this.updateLayerArea(v, 0, 0, v.width, v.height); + }); + } + + setRenderSize(width: number, height: number): void { + if (width === this.renderWidth && height === this.renderHeight) return; + this.renderWidth = width; + this.renderHeight = height; + this.sortedLayers.forEach(v => { + this.vertex.updateArea(v, 0, 0, this.mapWidth, this.mapHeight); + }); + } + + setCellSize(width: number, height: number): void { + if (width === this.cellWidth && height === this.cellHeight) return; + this.cellWidth = width; + this.cellHeight = height; + this.sortedLayers.forEach(v => { + this.vertex.updateArea(v, 0, 0, this.mapWidth, this.mapHeight); + }); + } + + configRendering(config: Partial): void { + if (!isNil(config.minBehavior)) { + this.tileMinifyBehavior = config.minBehavior; + this.updateRequired = true; + } + if (!isNil(config.magBehavior)) { + this.tileMagnifyBehavior = config.magBehavior; + this.updateRequired = true; + } + if (!isNil(config.tileAlignX)) { + this.tileAlignX = config.tileAlignX; + this.updateRequired = true; + } + if (!isNil(config.tileAlignY)) { + this.tileAlignY = config.tileAlignY; + this.updateRequired = true; + } + if (!isNil(config.tileTestMode)) { + this.tileTestMode = config.tileTestMode; + this.updateRequired = true; + } + if (!isNil(config.frameSpeed)) { + this.frameSpeed = config.frameSpeed; + } + } + + getRenderingConfig(): Readonly { + return { + minBehavior: this.tileMinifyBehavior, + magBehavior: this.tileMagnifyBehavior, + tileAlignX: this.tileAlignX, + tileAlignY: this.tileAlignY, + tileTestMode: this.tileTestMode, + frameSpeed: this.frameSpeed + }; + } + + private getOffsetPool(): number[] { + const pool = new Set([32]); + // 其他的都是 bigImage 了,直接遍历获取 + for (const identifier of this.manager.bigImageStore.keys()) { + const data = this.manager.getBigImage(identifier); + if (!data) continue; + const offset = data.texture.width / data.frames; + pool.add(offset); + } + // 还有勇士图片 + for (const tex of this.manager.imageStore.values()) { + if (!this.manager.assetContainsTexture(tex)) continue; + const { w } = tex.render().rect; + pool.add(w / 4); + } + // 其他判断 + if (pool.size > 64 && import.meta.env.DEV) { + logger.warn(82); + } + if (pool.size > this.gl.MAX_VERTEX_UNIFORM_VECTORS) { + logger.error(39, this.gl.MAX_VERTEX_UNIFORM_VECTORS.toString()); + } + return [...pool]; + } + + getAssetSourceIndex(source: SizedCanvasImageSource): number { + if (!this.assetData) return -1; + return this.assetData.skipRef.get(source) ?? -1; + } + + getOffsetIndex(offset: number): number { + return this.offsetPool.indexOf(offset); + } + + //#endregion + + //#region 画布上下文 + + private initContext(): IContextData | null { + const gl = this.gl; + const vs = mapVert.replace('$1', this.offsetPool.length.toString()); + const tileProgram = compileProgramWith(gl, vs, mapFrag); + const backProgram = compileProgramWith(gl, backVert, backFrag); + if (!tileProgram || !backProgram) { + logger.error(28); + return null; + } + + const { program: tp } = tileProgram; + const { program: bp } = backProgram; + + const poolLocation = gl.getUniformLocation( + tp, + // 数组要写 [0] + 'u_offsetPool[0]' + ); + const frameLocation = gl.getUniformLocation(tp, 'u_nowFrame'); + const tileSampler = gl.getUniformLocation(tp, 'u_sampler'); + const backSampler = gl.getUniformLocation(bp, 'u_sampler'); + const tileTrans = gl.getUniformLocation(tp, 'u_transform'); + const backTrans = gl.getUniformLocation(bp, 'u_transform'); + const backFrame = gl.getUniformLocation(bp, 'u_nowFrame'); + if ( + !poolLocation || + !frameLocation || + !tileSampler || + !backSampler || + !tileTrans || + !backTrans || + !backFrame + ) { + logger.error(29); + return null; + } + + const vertexAttrib = gl.getAttribLocation(tp, 'a_position'); + const insTilePosAttrib = gl.getAttribLocation(tp, 'a_tilePos'); + const insTexCoordAttib = gl.getAttribLocation(tp, 'a_texCoord'); + const insTileDataAttrib = gl.getAttribLocation(tp, 'a_tileData'); + const insTexDataAttib = gl.getAttribLocation(tp, 'a_texData'); + const backVertex = gl.getAttribLocation(bp, 'a_position'); + const backTexCoord = gl.getAttribLocation(bp, 'a_texCoord'); + + const vertexBuffer = gl.createBuffer(); + const instancedBuffer = gl.createBuffer(); + const backVertexBuffer = gl.createBuffer(); + + const tileTexture = gl.createTexture(); + const backgroundTexture = gl.createTexture(); + + const tileVAO = gl.createVertexArray(); + const backVAO = gl.createVertexArray(); + + // Post effect + const pingFramebuffer = gl.createFramebuffer(); + const pongFramebuffer = gl.createFramebuffer(); + const pingTexture2D = gl.createTexture(); + const pongTexture2D = gl.createTexture(); + + // 初始化 Post effect FBO 和 Texture + gl.bindTexture(gl.TEXTURE_2D, pongTexture2D); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + this.canvas.width, + this.canvas.height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, pingFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + pongTexture2D, + 0 + ); + gl.bindTexture(gl.TEXTURE_2D, pingTexture2D); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + this.canvas.width, + this.canvas.height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, pongFramebuffer); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + pingTexture2D, + 0 + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.bindTexture(gl.TEXTURE_2D, null); + + // 初始化图块纹理 + gl.bindTexture(gl.TEXTURE_2D_ARRAY, tileTexture); + gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, 4096, 4096, 1); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, null); + + // 配置清空选项 + gl.clearColor(0, 0, 0, 1); + gl.clearDepth(1); + + // 其他配置 + gl.disable(gl.CULL_FACE); + gl.enable(gl.DEPTH_TEST); + gl.enable(gl.BLEND); + gl.depthFunc(gl.LESS); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + const data: IContextData = { + tileProgram: tileProgram.program, + tileVertShader: tileProgram.vertexShader, + tileFragShader: tileProgram.fragmentShader, + backProgram: backProgram.program, + backVertShader: backProgram.vertexShader, + backFragShader: backProgram.fragmentShader, + vertexBuffer, + instancedBuffer, + backgroundVertexBuffer: backVertexBuffer, + offsetPoolLocation: poolLocation, + nowFrameLocation: frameLocation, + tileSamplerLocation: tileSampler, + backSamplerLocation: backSampler, + tileTransformLocation: tileTrans, + backTransformLocation: backTrans, + backNowFrameLocation: backFrame, + vertexAttribLocation: vertexAttrib, + insTilePosAttribLocation: insTilePosAttrib, + insTexCoordAttribLocation: insTexCoordAttib, + insTileDataAttribLocation: insTileDataAttrib, + insTexDataAttribLocation: insTexDataAttib, + backVertexAttribLocation: backVertex, + backTexCoordAttribLocation: backTexCoord, + tileVAO, + backVAO, + tileTexture, + backgroundTexture, + + pingFramebuffer, + pongFramebuffer, + pingTexture2D, + pongTexture2D, + + tileTextureWidth: 4096, + tileTextureHeight: 4096, + tileTextureDepth: 1, + backgroundWidth: 0, + backgroundHeight: 0, + backgroundDepth: 0, + tileTextureMark: Symbol(), + vertexMark: Symbol() + }; + + return data; + } + + destroy(): void { + const gl = this.gl; + const data = this.contextData; + if (!data) return; + gl.deleteBuffer(data.vertexBuffer); + gl.deleteBuffer(data.instancedBuffer); + gl.deleteBuffer(data.backgroundVertexBuffer); + gl.deleteProgram(data.tileProgram); + gl.deleteProgram(data.backProgram); + gl.deleteShader(data.tileVertShader); + gl.deleteShader(data.tileFragShader); + gl.deleteShader(data.backVertShader); + gl.deleteShader(data.backFragShader); + gl.deleteTexture(data.tileTexture); + gl.deleteTexture(data.backgroundTexture); + gl.deleteVertexArray(data.tileVAO); + gl.deleteVertexArray(data.backVAO); + } + + //#endregion + + //#region 渲染 + + /** + * 检查指定画布的纹理数组尺寸,需要预先绑定 gl.TEXTURE_2D_ARRAY 纹理 + * @param gl WebGL2 上下文 + * @param data 画布上下文数据 + * @param source 图形源列表 + * @returns 最终贴图尺寸是否改变 + */ + private checkTextureArraySize( + gl: WebGL2RenderingContext, + data: IContextData, + source: ImageBitmap[] + ): boolean { + const maxWidth = maxBy(source, v => v.width)?.width ?? 0; + const maxHeight = maxBy(source, v => v.height)?.height ?? 0; + const count = source.length; + if ( + maxWidth !== data.tileTextureWidth || + maxHeight !== data.tileTextureHeight || + count !== data.tileTextureDepth + ) { + gl.texStorage3D( + gl.TEXTURE_2D_ARRAY, + 1, + gl.RGBA8, + maxWidth, + maxHeight, + count + ); + data.tileTextureWidth = maxWidth; + data.tileTextureHeight = maxHeight; + data.tileTextureDepth = count; + this.assetWidth = maxWidth; + this.assetHeight = maxHeight; + this.normalizedOffsetPool = this.offsetPool.map(v => v / maxWidth); + this.needUpdateOffsetPool = true; + return true; + } else { + return false; + } + } + + /** + * 检查指定画布上下文的纹理是否需要更新 + * @param gl WebGL2 上下文 + * @param data 画布上下文数据 + */ + private checkTexture(gl: WebGL2RenderingContext, data: IContextData) { + if (!this.assetData) return; + const tile = data.tileTexture; + const source = this.assetData.sourceList; + const sourceArray = [...source.values()]; + if (!this.assetData.hasMark(data.tileTextureMark)) { + // 如果没有标记,那么直接全部重新传递 + this.assetData.unmark(data.tileTextureMark); + data.tileTextureMark = this.assetData.mark(); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, tile); + this.checkTextureArraySize(gl, data, sourceArray); + source.forEach((v, i) => { + gl.texSubImage3D( + gl.TEXTURE_2D_ARRAY, + 0, + 0, + 0, + i, + v.width, + v.height, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + v + ); + }); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT); + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_MAG_FILTER, + gl.NEAREST + ); + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_MIN_FILTER, + gl.NEAREST + ); + } else { + const dirty = this.assetData.dirtySince(data.tileTextureMark); + if (dirty.size === 0) return; + this.assetData.unmark(data.tileTextureMark); + data.tileTextureMark = this.assetData.mark(); + logger.warn(87); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, tile); + const sizeChanged = this.checkTextureArraySize( + gl, + data, + sourceArray + ); + if (sizeChanged) { + // 尺寸变化,需要全部重新传递 + source.forEach((v, i) => { + gl.texSubImage3D( + gl.TEXTURE_2D_ARRAY, + 0, + 0, + 0, + i, + v.width, + v.height, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + v + ); + }); + } else { + // 否则只需要传递标记为脏的图像 + dirty.forEach(v => { + const img = source.get(v)!; + gl.texSubImage3D( + gl.TEXTURE_2D_ARRAY, + 0, + 0, + 0, + v, + img.width, + img.height, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + img + ); + }); + } + } + } + + /** + * 检查图块顶点数组是否需要更新 + * @param gl WebGL2 上下文 + * @param data 上下文数据 + */ + private checkTileVertexArray( + gl: WebGL2RenderingContext, + data: IContextData + ) { + if (!this.assetData) return; + this.vertex.checkRebuild(); + const hasDirty = this.vertex.hasMark(data.vertexMark); + if (hasDirty) { + const dirty = this.vertex.dirtySince(data.vertexMark); + if (!dirty) return; + } + this.vertex.unmark(data.vertexMark); + data.vertexMark = this.vertex.mark(); + const array = this.vertex.getVertexArray(); + const { instancedBuffer } = data; + // 更新实例化缓冲区 + gl.bindBuffer(gl.ARRAY_BUFFER, instancedBuffer); + gl.bufferData(gl.ARRAY_BUFFER, array.tileInstanced, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + } + + /** + * 传递静态背景图 + * @param gl 画布上下文 + * @param data 上下文数据 + * @param source 图像源 + */ + private texStaticBackground( + gl: WebGL2RenderingContext, + data: IContextData, + source: ImageBitmap + ) { + gl.bindTexture(gl.TEXTURE_2D_ARRAY, data.backgroundTexture); + const { width: w, height: h } = source; + if ( + w !== data.backgroundWidth || + h !== data.backgroundHeight || + data.backgroundDepth !== 1 + ) { + gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, w, h, 1); + data.backgroundWidth = w; + data.backgroundHeight = h; + data.backgroundDepth = 1; + } + gl.texSubImage3D( + gl.TEXTURE_2D_ARRAY, + 0, + 0, + 0, + 0, + w, + h, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + source + ); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, null); + } + + /** + * 传递动态背景图 + * @param gl 画布上下文 + * @param data 上下文数据 + * @param width 图像源宽度 + * @param height 图像源高度 + * @param source 图像源列表,要求图像源尺寸一致 + */ + private texDynamicBackground( + gl: WebGL2RenderingContext, + data: IContextData, + width: number, + height: number, + source: ImageBitmap[] + ) { + gl.bindTexture(gl.TEXTURE_2D_ARRAY, data.backgroundTexture); + const w = width; + const h = height; + const depth = source.length; + if ( + w !== data.backgroundWidth || + h !== data.backgroundHeight || + data.backgroundDepth !== depth + ) { + gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, w, h, depth); + data.backgroundWidth = w; + data.backgroundHeight = h; + data.backgroundDepth = depth; + } + source.forEach((v, i) => { + gl.texSubImage3D( + gl.TEXTURE_2D_ARRAY, + 0, + 0, + 0, + i, + v.width, + v.height, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + v + ); + }); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, null); + } + + /** + * 更新背景图的顶点数组。如果使用了图片尺寸作为渲染尺寸,则使用 `width` `height` 参数, + * 否则使用 `this.backRenderWidth` 和 `this.backRenderHeight` + * @param gl WebGL2 上下文 + * @param data 上下文数据 + * @param width 图片宽度 + * @param height 图片高度 + */ + private updateBackgroundVertex( + gl: WebGL2RenderingContext, + data: IContextData, + width: number, + height: number + ) { + const w = this.backUseImageSize ? width : this.backRenderWidth; + const h = this.backUseImageSize ? height : this.backRenderHeight; + if (w === 0 || h === 0) return; + const mapRenderWidth = this.mapWidth * this.cellWidth; + const mapRenderHeight = this.mapHeight * this.cellHeight; + const vx = mapRenderWidth / w; + const vy = mapRenderHeight / h; + const arr = this.backgroundVertex; + const left = -1; + const right = (mapRenderWidth / this.renderWidth) * 2 - 1; + const top = -1; + const bottom = (mapRenderHeight / this.renderHeight) * 2 - 1; + // 左下角 + arr[0] = left; + arr[1] = bottom; + arr[2] = 0; + arr[3] = vy; + // 右下角 + arr[4] = right; + arr[5] = bottom; + arr[6] = vx; + arr[7] = vy; + // 左上角 + arr[8] = left; + arr[9] = top; + arr[10] = 0; + arr[11] = 0; + // 右上角 + arr[12] = right; + arr[13] = top; + arr[14] = vx; + arr[15] = 0; + gl.bindBuffer(gl.ARRAY_BUFFER, data.backgroundVertexBuffer); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, arr); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + } + + /** + * 使用静态图片作为背景 + * @param gl WebGL2 上下文 + * @param data 上下文数据 + * @param renderable 可渲染对象 + */ + private async useStaticBackground( + gl: WebGL2RenderingContext, + data: IContextData, + renderable: ITextureRenderable + ) { + const { rect, source } = renderable; + const { x, y, w, h } = rect; + if ( + source.width === w && + source.height === h && + source instanceof ImageBitmap + ) { + // 如果图块的纹理直接就是整个图像源,那么直接传递,就不需要再创建位图了 + this.texStaticBackground(gl, data, source); + } else { + // 否则需要单独创建位图 + const image = await createImageBitmap(source, x, y, w, h); + this.texStaticBackground(gl, data, image); + } + // 更新顶点数组 + this.updateBackgroundVertex(gl, data, w, h); + this.backgroundFrameCount = 1; + } + + /** + * 使用动态图片作为背景 + * @param gl WebGL2 上下文 + * @param data 上下文数据 + * @param renderable 可渲染对象列表 + */ + private async useDynamicBackground( + gl: WebGL2RenderingContext, + data: IContextData, + renderable: ITextureRenderable[] + ) { + if (renderable.length === 0) { + // 纹理不包含动画可渲染数据 + logger.error(36); + return; + } + const { w, h } = renderable[0].rect; + if (renderable.some(v => v.rect.w !== w || v.rect.h !== h)) { + // 如果纹理每帧尺寸不一致 + logger.error(37); + return; + } + const images = await Promise.all( + renderable.map(v => { + const { x, y, w, h } = v.rect; + return createImageBitmap(v.source, x, y, w, h); + }) + ); + this.texDynamicBackground(gl, data, w, h, images); + this.updateBackgroundVertex(gl, data, w, h); + } + + /** + * 使用图块作为背景,当绑定过 VAO 与纹理后再调用此方法 + * @param gl 画布上下文 + * @param data 上下文数据 + * @param tile 使用的背景图块 + */ + private useTileBackground( + gl: WebGL2RenderingContext, + data: IContextData, + tile: number + ): Promise { + // 图块背景 + const tex = this.manager.getIfBigImage(tile); + if (!tex) { + // 图块不存在 + logger.error(35); + return Promise.resolve(); + } + this.backgroundFrameCount = tex.frames; + if (tex.frames === 1) { + // 对于一帧图块,只需要传递一个纹理 + if (tex.cls === BlockCls.Autotile) { + const renderable = this.autotile.renderWithoutCheck( + tex, + 0b1111_1111 + )!; + return this.useStaticBackground(gl, data, renderable); + } else { + return this.useStaticBackground(gl, data, tex.texture.render()); + } + } else { + // 多帧图块 + if (tex.cls === BlockCls.Autotile) { + const gen = this.autotile.renderAnimatedWith(tex, 0b1111_1111); + return this.useDynamicBackground(gl, data, [...gen]); + } else { + const gen = this.tileAnimater.once(tex.texture, tex.frames); + return this.useDynamicBackground(gl, data, [...gen]); + } + } + } + + private async checkBackground( + gl: WebGL2RenderingContext, + data: IContextData + ) { + if (!this.backgroundDirty || this.backgroundPending) return; + this.backgroundPending = true; + const { backgroundTexture } = data; + // 根据背景类型使用不同贴图 + switch (this.backgroundType) { + case BackgroundType.Tile: { + await this.useTileBackground(gl, data, this.tileBack); + break; + } + case BackgroundType.Static: { + if (!this.staticBack) return; + await this.useStaticBackground(gl, data, this.staticBack); + break; + } + case BackgroundType.Dynamic: { + if (!this.dynamicBack) return; + await this.useDynamicBackground(gl, data, this.dynamicBack); + break; + } + } + gl.bindTexture(gl.TEXTURE_2D_ARRAY, backgroundTexture); + // 重复模式 + switch (this.backRepeatModeX) { + case MapBackgroundRepeat.Repeat: { + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_WRAP_S, + gl.REPEAT + ); + break; + } + case MapBackgroundRepeat.RepeatMirror: { + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_WRAP_S, + gl.MIRRORED_REPEAT + ); + break; + } + case MapBackgroundRepeat.ClampToEdge: { + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_WRAP_S, + gl.CLAMP_TO_EDGE + ); + break; + } + } + switch (this.backRepeatModeY) { + case MapBackgroundRepeat.Repeat: { + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_WRAP_T, + gl.REPEAT + ); + break; + } + case MapBackgroundRepeat.RepeatMirror: { + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_WRAP_T, + gl.MIRRORED_REPEAT + ); + break; + } + case MapBackgroundRepeat.ClampToEdge: { + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_WRAP_T, + gl.CLAMP_TO_EDGE + ); + break; + } + } + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_MAG_FILTER, + gl.NEAREST + ); + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_MIN_FILTER, + gl.NEAREST + ); + this.backgroundPending = false; + gl.bindTexture(gl.TEXTURE_2D_ARRAY, null); + this.updateRequired = true; + } + + render(): HTMLCanvasElement { + const gl = this.gl; + const data = this.contextData; + if (!this.assetData) { + logger.error(31); + return this.canvas; + } + + const { + backVAO, + backProgram, + backNowFrameLocation, + backTransformLocation, + backgroundTexture, + tileVAO, + tileProgram, + tileTexture, + instancedBuffer, + offsetPoolLocation, + nowFrameLocation, + tileTransformLocation, + insTilePosAttribLocation: tilePos, + insTexCoordAttribLocation: texCoord, + insTileDataAttribLocation: tileData, + insTexDataAttribLocation: texData, + pingFramebuffer, + pongFramebuffer, + pingTexture2D, + pongTexture2D + } = data; + + // 图层检查 + this.vertex.checkRebuild(); + + // 数据检查 + this.checkTexture(gl, data); + this.checkTileVertexArray(gl, data); + + const area = this.viewport.getRenderArea(); + area.blockList.forEach(v => { + if (v.data.dirty) { + this.vertex.updateBlockCache(v); + } + if (v.data.renderDirty) { + v.data.render(); + } + }); + this.vertex.renderDynamic(); + + if (area.dirty.length > 0) { + // 如果需要更新顶点数组... + const array = this.vertex.getVertexArray(); + gl.bindBuffer(gl.ARRAY_BUFFER, instancedBuffer); + area.dirty.forEach(v => { + gl.bufferSubData( + gl.ARRAY_BUFFER, + // float32 需要 * 4 + v.startIndex * INSTANCED_COUNT * 4, + array.tileInstanced, + v.startIndex * INSTANCED_COUNT, + v.count * INSTANCED_COUNT + ); + }); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + } + + gl.viewport( + this.viewportX, + this.viewportY, + this.viewportWidth, + this.viewportHeight + ); + + const postEffects = this.postEffects.filter(v => v.enabled); + + if (postEffects.length > 1) { + gl.bindFramebuffer(gl.FRAMEBUFFER, pingFramebuffer); + } else { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + // 背景 + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.useProgram(backProgram); + if (this.needUpdateBackgroundFrame) { + gl.uniform1f(backNowFrameLocation, this.backgroundFrame); + } + if (this.needUpdateTransform) { + gl.uniformMatrix3fv( + backTransformLocation, + false, + this.transform.mat + ); + } + gl.bindTexture(gl.TEXTURE_2D_ARRAY, backgroundTexture); + gl.bindVertexArray(backVAO); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + gl.bindVertexArray(null); + + // 图块 + gl.useProgram(tileProgram); + if (this.needUpdateOffsetPool) { + gl.uniform1fv(offsetPoolLocation, this.normalizedOffsetPool); + } + if (this.needUpdateFrameCounter) { + gl.uniform1f(nowFrameLocation, this.frameCounter); + } + if (this.needUpdateTransform) { + gl.uniformMatrix3fv( + tileTransformLocation, + false, + this.transform.mat + ); + } + gl.bindTexture(gl.TEXTURE_2D_ARRAY, tileTexture); + gl.bindVertexArray(tileVAO); + + // 由于 WebGL2 没有 glDrawArraysInstancedBaseInstance,只能每次渲染的时候临时修改 VBO 读取方式 + const stride = INSTANCED_COUNT * 4; + gl.bindBuffer(gl.ARRAY_BUFFER, instancedBuffer); + area.render.forEach(v => { + const s = v.startIndex * INSTANCED_COUNT; + const o1 = s + 0; + const o2 = o1 + 4 * 4; + const o3 = o2 + 4 * 4; + const o4 = o3 + 4 * 4; + gl.vertexAttribPointer(tilePos, 4, gl.FLOAT, false, stride, o1); + gl.vertexAttribPointer(texCoord, 4, gl.FLOAT, false, stride, o2); + gl.vertexAttribPointer(tileData, 4, gl.FLOAT, false, stride, o3); + gl.vertexAttribPointer(texData, 4, gl.FLOAT, false, stride, o4); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, v.count); + }); + gl.bindVertexArray(null); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, null); + + // Post effects + let inputTextrue = pongTexture2D; + let outputFBO: WebGLFramebuffer | null = pingFramebuffer; + + postEffects.forEach((v, i, a) => { + v.render(gl, inputTextrue, outputFBO, data); + if (inputTextrue === pongTexture2D) { + inputTextrue = pingTexture2D; + } else { + inputTextrue = pongTexture2D; + } + if (i === a.length - 2) { + outputFBO = null; + } else { + if (outputFBO === pingFramebuffer) { + outputFBO = pongFramebuffer; + } else { + outputFBO = pingFramebuffer; + } + } + }); + + // 清空更新状态标识 + this.updateRequired = false; + this.needUpdateFrameCounter = false; + this.needUpdateBackgroundFrame = false; + this.needUpdateTransform = false; + this.needUpdateOffsetPool = false; + this.vertex.renderDynamic(); + + return this.canvas; + } + + //#endregion + + //#region 地图处理 + + /** + * 更新指定图层的指定区域 + * @param layer 更新的图层 + * @param x 左上角横坐标 + * @param y 左上角纵坐标 + * @param w 区域宽度 + * @param h 区域高度 + */ + updateLayerArea( + layer: IMapLayer, + x: number, + y: number, + w: number, + h: number + ) { + this.vertex.updateArea(layer, x, y, w, h); + this.updateRequired = true; + } + + /** + * 更新指定图层的指定图块 + * @param layer 更新的图层 + * @param block 更新为的图块 + * @param x 图块横坐标 + * @param y 图块纵坐标 + */ + updateLayerBlock(layer: IMapLayer, block: number, x: number, y: number) { + this.vertex.updateBlock(layer, block, x, y); + this.updateRequired = true; + } + + getBlockStatus( + layer: IMapLayer, + x: number, + y: number + ): IBlockStatus | null { + if (x < 0 || y < 0 || x > this.mapWidth || y > this.mapHeight) { + return null; + } + return new StaticBlockStatus(layer, this.vertex, x, y); + } + + //#endregion + + //#region 移动图块处理 + + /** + * 扩大移动数组 + */ + private expandMoving() { + const start = this.movingCount; + this.movingCount *= 2; + this.vertex.expandMoving(this.movingCount); + this.movingIndexPool.push( + // 翻转数组是因为这样的话内容会优先取到低索引的内容,更容易优化 + ...Array.from({ length: start }, (_, i) => i + start).reverse() + ); + } + + /** + * 减小移动数组 + */ + private reduceMoving() { + const half = Math.round(this.movingCount / 2); + if (half < DYNAMIC_RESERVE) return; + for (const moving of this.movingBlock) { + if (moving.index >= half) return; + } + this.vertex.reduceMoving(half); + } + + /** + * 申请移动索引 + * @returns 移动索引 + */ + private requireMovingIndex(): number { + if (this.movingIndexPool.length === 0) { + this.expandMoving(); + } + const half = Math.max( + Math.round(this.movingCount / 2), + DYNAMIC_RESERVE + ); + if (this.movingIndexPool.length < half) { + this.lastExpandTime = this.timestamp; + } + return this.movingIndexPool.pop()!; + } + + /** + * 退回移动索引 + * @param index 退回的索引 + */ + private returnMovingIndex(index: number) { + this.movingIndexPool.push(index); + } + + addMovingBlock( + layer: IMapLayer, + block: number | IMaterialFramedData, + x: number, + y: number + ): IMovingBlock { + const index = this.requireMovingIndex(); + const moving = new MovingBlock(this, index, layer, block); + moving.setPos(x, y); + this.movingBlock.add(moving); + this.movingIndexMap.set(index, moving); + this.vertex.updateMoving(moving, true); + return moving; + } + + getMovingBlock(): Set { + return this.movingBlock; + } + + getMovingBlockByIndex(index: number): IMovingBlock | null { + return this.movingIndexMap.get(index) ?? null; + } + + deleteMoving(block: IMovingBlock): void { + this.returnMovingIndex(block.index); + this.movingBlock.delete(block); + this.movingIndexMap.delete(block.index); + this.vertex.deleteMoving(block); + } + + hasMoving(moving: IMovingBlock): boolean { + return this.movingBlock.has(moving); + } + + //#endregion + + //#region 其他方法 + + getTimestamp(): number { + return this.timestamp; + } + + tick(timestamp: number) { + this.timestamp = timestamp; + + // 移动数组 + const expandDT = timestamp - this.lastExpandTime; + if (expandDT > MOVING_TOLERANCE * 1000) { + this.reduceMoving(); + this.lastExpandTime = timestamp; + } + + // 背景 + const backgroundDT = timestamp - this.backLastFrame; + if (backgroundDT > this.backFrameSpeed) { + this.backgroundFrame++; + this.backgroundFrame %= this.backgroundFrameCount; + this.backLastFrame = timestamp; + this.needUpdateBackgroundFrame = true; + } + + // 地图帧动画 + const frameDT = timestamp - this.lastFrameTime; + if (frameDT > this.frameSpeed) { + this.lastFrameTime = timestamp; + this.frameCounter++; + this.needUpdateFrameCounter = true; + } + + this.tickers.forEach(v => void v.fn(timestamp)); + + // 图块移动 + if (this.movingBlock.size > 0) { + const toUpdate: IMovingBlock[] = []; + this.movingBlock.forEach(v => { + const move = v.stepMoving(timestamp); + if (move) toUpdate.push(v); + }); + this.vertex.updateMovingList(toUpdate, false); + } + } + + requestTicker(fn: (timestamp: number) => void): IMapRendererTicker { + const ticker = new MapRendererTicker(this, fn, this.timestamp); + this.tickers.add(ticker); + return ticker; + } + + removeTicker(ticker: MapRendererTicker): void { + this.tickers.delete(ticker); + } + + updateTransform(): void { + this.needUpdateTransform = true; + } + + requestUpdate(): void { + this.updateRequired = true; + } + + needUpdate(): boolean { + return ( + this.updateRequired || + this.needUpdateFrameCounter || + this.needUpdateBackgroundFrame || + this.needUpdateTransform || + this.vertex.dynamicRenderDirty || + this.needUpdateOffsetPool + ); + } + + //#endregion +} + +class RendererLayerStateHook implements Partial { + constructor(readonly renderer: MapRenderer) {} + + onChangeBackground(tile: number): void { + this.renderer.setTileBackground(tile); + } + + onResizeLayer(): void { + this.renderer.resizeLayer(); + } + + onUpdateLayer(): void { + this.renderer.updateLayerList(); + } + + onUpdateLayerArea( + layer: IMapLayer, + x: number, + y: number, + width: number, + height: number + ): void { + this.renderer.updateLayerArea(layer, x, y, width, height); + } + + onUpdateLayerBlock( + layer: IMapLayer, + block: number, + x: number, + y: number + ): void { + this.renderer.updateLayerBlock(layer, block, x, y); + } +} + +class MapRendererTicker implements IMapRendererTicker { + constructor( + readonly renderer: MapRenderer, + readonly fn: (timestamp: number) => void, + public timestamp: number + ) {} + + tick(timestamp: number) { + this.timestamp = timestamp; + this.fn(timestamp); + } + + remove(): void { + this.renderer.removeTicker(this); + } +} diff --git a/packages-user/client-modules/src/render/map/shader/back.frag b/packages-user/client-modules/src/render/map/shader/back.frag new file mode 100644 index 0000000..6ae77b6 --- /dev/null +++ b/packages-user/client-modules/src/render/map/shader/back.frag @@ -0,0 +1,12 @@ +#version 300 es +precision highp float; +precision highp sampler2DArray; + +in vec3 v_texCoord; +out vec4 outColor; + +uniform sampler2DArray u_sampler; + +void main() { + outColor = texture(u_sampler, v_texCoord); +} diff --git a/packages-user/client-modules/src/render/map/shader/back.vert b/packages-user/client-modules/src/render/map/shader/back.vert new file mode 100644 index 0000000..c94ec7e --- /dev/null +++ b/packages-user/client-modules/src/render/map/shader/back.vert @@ -0,0 +1,16 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 a_position; +layout(location = 1) in vec2 a_texCoord; + +out vec3 v_texCoord; + +uniform float u_nowFrame; +uniform mat3 u_transform; + +void main() { + vec3 transformed = u_transform * vec3(a_position, 1.0); + v_texCoord = vec3(a_texCoord, u_nowFrame); + gl_Position = vec4(transformed.xy, 0.95, 1.0); +} diff --git a/packages-user/client-modules/src/render/map/shader/map.frag b/packages-user/client-modules/src/render/map/shader/map.frag new file mode 100644 index 0000000..360bf3e --- /dev/null +++ b/packages-user/client-modules/src/render/map/shader/map.frag @@ -0,0 +1,17 @@ +#version 300 es +precision highp float; +precision highp sampler2DArray; + +in vec4 v_texCoord; + +out vec4 outColor; + +uniform sampler2DArray u_sampler; + +void main() { + vec4 texColor = texture(u_sampler, v_texCoord.xyz); + float alpha = texColor.a * v_texCoord.a; + // todo: 透明像素应该如何解决?? + if (alpha < 0.1) discard; + outColor = vec4(texColor.rgb, alpha); +} diff --git a/packages-user/client-modules/src/render/map/shader/map.vert b/packages-user/client-modules/src/render/map/shader/map.vert new file mode 100644 index 0000000..a13554f --- /dev/null +++ b/packages-user/client-modules/src/render/map/shader/map.vert @@ -0,0 +1,36 @@ +#version 300 es +precision highp float; +precision mediump int; + +// 顶点坐标 +layout(location = 0) in vec4 a_position; +// 实例化数据 +// 图块坐标 +layout(location = 1) in vec4 a_tilePos; +// 贴图坐标 +layout(location = 2) in vec4 a_texCoord; +// x: 纵深,y: 不透明度 +layout(location = 3) in vec4 a_tileData; +// x: 当前帧数,负数表示使用 u_nowFrame,y: 最大帧数,z: 偏移池索引,w: 纹理数组索引 +layout(location = 4) in vec4 a_texData; + +// x,y,z: 纹理坐标,w: 不透明度 +out vec4 v_texCoord; + +uniform float u_offsetPool[$1]; +uniform float u_nowFrame; +uniform mat3 u_transform; + +void main() { + // 坐标 + vec2 pos = a_position.xy * a_tilePos.zw + a_tilePos.xy; + vec2 texCoord = a_position.zw * a_texCoord.zw + a_texCoord.xy; + // 偏移量 + float offset = mod(a_texData.x < 0.0 ? u_nowFrame : a_texData.x, a_texData.y); + int offsetIndex = int(a_texData.z); + // 贴图偏移 + texCoord.x += u_offsetPool[offsetIndex] * offset; + v_texCoord = vec4(texCoord.xy, a_texData.w, a_tileData.y); + vec3 transformed = u_transform * vec3(pos.xy, 1.0); + gl_Position = vec4(transformed.xy, a_tileData.x, 1.0); +} diff --git a/packages-user/client-modules/src/render/map/status.ts b/packages-user/client-modules/src/render/map/status.ts new file mode 100644 index 0000000..922b14b --- /dev/null +++ b/packages-user/client-modules/src/render/map/status.ts @@ -0,0 +1,70 @@ +import { IMapLayer } from '@user/data-state'; +import { IBlockStatus, IMapVertexStatus } from './types'; + +export class StaticBlockStatus implements IBlockStatus { + /** + * @param layer 图层对象 + * @param vertex 顶点数组生成器对象 + * @param x 图块横坐标 + * @param y 图块纵坐标 + */ + constructor( + readonly layer: IMapLayer, + readonly vertex: IMapVertexStatus, + readonly x: number, + readonly y: number + ) {} + + setAlpha(alpha: number): void { + this.vertex.setStaticAlpha(this.layer, alpha, this.x, this.y); + } + + getAlpha(): number { + return this.vertex.getStaticAlpha(this.layer, this.x, this.y); + } + + useGlobalFrame(): void { + this.vertex.setStaticFrame(this.layer, this.x, this.y, -1); + } + + useSpecifiedFrame(frame: number): void { + this.vertex.setStaticFrame(this.layer, this.x, this.y, frame); + } + + getFrame(): number { + return this.vertex.getStaticFrame(this.layer, this.x, this.y); + } +} + +export class DynamicBlockStatus implements IBlockStatus { + /** + * @param layer 图层对象 + * @param vertex 顶点数组生成器对象 + * @param index 图块索引 + */ + constructor( + readonly layer: IMapLayer, + readonly vertex: IMapVertexStatus, + readonly index: number + ) {} + + setAlpha(alpha: number): void { + this.vertex.setDynamicAlpha(this.index, alpha); + } + + getAlpha(): number { + return this.vertex.getDynamicAlpha(this.index); + } + + useGlobalFrame(): void { + this.vertex.setDynamicFrame(this.index, -1); + } + + useSpecifiedFrame(frame: number): void { + this.vertex.setDynamicFrame(this.index, frame); + } + + getFrame(): number { + return this.vertex.getDynamicFrame(this.index); + } +} diff --git a/packages-user/client-modules/src/render/map/types.ts b/packages-user/client-modules/src/render/map/types.ts new file mode 100644 index 0000000..6571ff2 --- /dev/null +++ b/packages-user/client-modules/src/render/map/types.ts @@ -0,0 +1,1126 @@ +import { IDirtyMark, IDirtyTracker } from '@motajs/common'; +import { + ITextureRenderable, + SizedCanvasImageSource +} from '@motajs/render-assets'; +import { Transform } from '@motajs/render-core'; +import { + IAutotileProcessor, + IMaterialFramedData, + IMaterialManager, + ITrackedAssetData +} from '@user/client-base'; +import { ILayerState, IMapLayer } from '@user/data-state'; +import { TimingFn } from 'mutate-animate'; + +export const enum MapBackgroundRepeat { + /** 直接重复 */ + Repeat, + /** 重复,但镜像 */ + RepeatMirror, + /** 超出范围的使用边缘像素 */ + ClampToEdge +} + +export const enum MapTileBehavior { + /** 适应到格子尺寸,宽高会被设置为格子尺寸,如果比例不对会被拉伸 */ + FitToSize, + /** 保持图块尺寸,具体渲染位置会受到对齐属性的影响 */ + KeepSize +} + +export const enum MapTileSizeTestMode { + /** 只要宽度或高度有一个大于格子,那么就视为超出格子大小,会执行图块缩小行为 */ + WidthOrHeight, + /** 当宽度和高度都大于格子宽度和高度时,才视为超出格子大小,执行图块缩小行为 */ + WidthAndHeight +} + +export const enum MapTileAlign { + /** 对于水平对齐,表示与格子左侧对齐;对于竖直对齐,表示与格子上侧对齐 */ + Start, + /** 对于水平对齐,表示与格子左右居中对齐;对于竖直对齐,表示与格子上下居中对齐 */ + Center, + /** 对于水平对齐,表示与格子右侧对齐;对于竖直对齐,表示与格子下侧对齐 */ + End +} + +export interface IMapBackgroundConfig { + /** 是否使用图片大小作为背景图渲染大小,如果是 `false`,则使用 `renderWidth` `renderHeight` 作为渲染大小 */ + readonly useImageSize: boolean; + /** 背景图渲染宽度,即要画多宽,单位像素 */ + readonly renderWidth: number; + /** 背景图渲染高度,即要画多高,单位像素 */ + readonly renderHeight: number; + /** 背景图在水平方向上的重复方式 */ + readonly repeatX: MapBackgroundRepeat; + /** 背景图在竖直方向上的重复方式 */ + readonly repeatY: MapBackgroundRepeat; + /** 动态背景图每帧的时长 */ + readonly frameSpeed: number; +} + +export interface IMapRenderConfig { + /** 当图块比格子大时,图块应该如何渲染 */ + readonly minBehavior: MapTileBehavior; + /** 当图块比格子小时,图块应该如何渲染 */ + readonly magBehavior: MapTileBehavior; + /** 当图块与格子大小不匹配时,图块水平对齐方式 */ + readonly tileAlignX: MapTileAlign; + /** 当图块与格子大小不匹配时,图块竖直对齐方式 */ + readonly tileAlignY: MapTileAlign; + /** 图块与网格判断大小的方式 */ + readonly tileTestMode: MapTileSizeTestMode; + /** 帧动画时长 */ + readonly frameSpeed: number; +} + +export interface IContextData { + /** 图块程序 */ + readonly tileProgram: WebGLProgram; + /** 背景程序 */ + readonly backProgram: WebGLProgram; + /** 图块顶点着色器 */ + readonly tileVertShader: WebGLShader; + /** 图块片段着色器 */ + readonly tileFragShader: WebGLShader; + /** 背景顶点着色器 */ + readonly backVertShader: WebGLShader; + /** 背景片段着色器 */ + readonly backFragShader: WebGLShader; + /** 偏移池 uniform 地址 */ + readonly offsetPoolLocation: WebGLUniformLocation; + /** 当前帧 uniform 地址 */ + readonly nowFrameLocation: WebGLUniformLocation; + /** 图块采样器 uniform 地址 */ + readonly tileSamplerLocation: WebGLUniformLocation; + /** 图块变换矩阵 uniform 地址 */ + readonly tileTransformLocation: WebGLUniformLocation; + /** 背景采样器 uniform 地址 */ + readonly backSamplerLocation: WebGLUniformLocation; + /** 背景变换矩阵 uniform 地址 */ + readonly backTransformLocation: WebGLUniformLocation; + /** 背景图当前帧数 uniform 地址 */ + readonly backNowFrameLocation: WebGLUniformLocation; + /** 顶点数组输入 */ + readonly vertexAttribLocation: number; + /** 图块坐标输入 */ + readonly insTilePosAttribLocation: number; + /** 图块纹理坐标输入 */ + readonly insTexCoordAttribLocation: number; + /** 图块数据输入 */ + readonly insTileDataAttribLocation: number; + /** 图块当前帧数输入 */ + readonly insTexDataAttribLocation: number; + /** 背景顶点数组输入 */ + readonly backVertexAttribLocation: number; + /** 背景纹理数组输入 */ + readonly backTexCoordAttribLocation: number; + /** 顶点数组 */ + readonly vertexBuffer: WebGLBuffer; + /** 实例化数据数组 */ + readonly instancedBuffer: WebGLBuffer; + /** 背景顶点数组 */ + readonly backgroundVertexBuffer: WebGLBuffer; + /** 图块纹理对象 */ + readonly tileTexture: WebGLTexture; + /** 背景纹理对象 */ + readonly backgroundTexture: WebGLTexture; + /** 图块程序的 VAO */ + readonly tileVAO: WebGLVertexArrayObject; + /** 背景程序的 VAO */ + readonly backVAO: WebGLVertexArrayObject; + + /** 第一个 framebuffer */ + readonly pingFramebuffer: WebGLFramebuffer; + /** 第二个 framebuffer */ + readonly pongFramebuffer: WebGLFramebuffer; + /** 第一个 texture2D */ + readonly pingTexture2D: WebGLTexture; + /** 第二个 texture2D */ + readonly pongTexture2D: WebGLTexture; + + /** 当前画布的图块纹理宽度 */ + tileTextureWidth: number; + /** 当前画布的图块纹理高度 */ + tileTextureHeight: number; + /** 当前画布的图块纹理深度 */ + tileTextureDepth: number; + /** 当前画布的背景纹理宽度 */ + backgroundWidth: number; + /** 当前画布的背景纹理高度 */ + backgroundHeight: number; + /** 当前画布的背景纹理深度 */ + backgroundDepth: number; + + /** 图块纹理的脏标记 */ + tileTextureMark: IDirtyMark; + /** 顶点数组的脏标记 */ + vertexMark: IDirtyMark; +} + +export interface IBlockStatus { + /** 图块所属图层 */ + readonly layer: IMapLayer; + + /** + * 设置图块的不透明度 + * @param alpha 图块不透明度 + */ + setAlpha(alpha: number): void; + + /** + * 获取图块的不透明度 + */ + getAlpha(): number; + + /** + * 使用全局帧动画 + */ + useGlobalFrame(): void; + + /** + * 使用指定的动画帧数,传入第几帧图块就画第几帧,超过最大帧数会自动取模 + * @param frame 第几帧 + */ + useSpecifiedFrame(frame: number): void; + + /** + * 获取动画帧数,-1 表示使用全局帧动画,非负整数表示图块是第几帧 + */ + getFrame(): number; +} + +export interface IMovingBlock extends IBlockStatus { + /** 移动图块的索引 */ + readonly index: number; + /** 图块数字 */ + readonly tile: number; + /** 图块横坐标 */ + readonly x: number; + /** 图块纵坐标 */ + readonly y: number; + /** 图块使用的纹理 */ + readonly texture: IMaterialFramedData; + + /** + * 直接设置图块的位置,动画中设置无效 + * @param x 目标横坐标 + * @param y 目标纵坐标 + */ + setPos(x: number, y: number): void; + + /** + * 设置此移动图块使用的贴图,最好预先打包至图集中,否则动态重建图集会很耗时间 + * @param texture 贴图对象 + */ + setTexture(texture: IMaterialFramedData): void; + + /** + * 沿直线移动到目标点 + * @param x 目标横坐标,可以填小数 + * @param y 目标纵坐标,可以填小数 + * @param timing 移动的速率曲线,默认为匀速移动 + */ + lineTo( + x: number, + y: number, + time: number, + timing?: TimingFn + ): Promise; + + /** + * 按照指定曲线移动,使用绝对模式,即 `curve` 输出值就是图块当前位置 + * @param curve 移动曲线,接收完成度作为参数,输出图块应该在的位置 + * @param timing 移动的速率曲线,默认为匀速移动 + */ + moveAs(curve: TimingFn<2>, time: number, timing?: TimingFn): Promise; + + /** + * 按照指定曲线移动,使用相对模式,即 `curve` 输出值与原始坐标相加得到当前位置 + * @param curve 移动曲线,接收完成度作为参数,输出图块应该在的位置 + * @param timing 移动的速率曲线,默认为匀速移动 + */ + moveRelative( + curve: TimingFn<2>, + time: number, + timing?: TimingFn + ): Promise; + + /** + * 进行一步动画移动效果 + * @param timestamp 当前时间戳 + * @returns 图块是否发生了移动 + */ + stepMoving(timestamp: number): boolean; + + /** + * 立刻停止移动 + */ + endMoving(): void; + + /** + * 使用图块默认帧数(如果图块存在的话) + */ + useDefaultFrame(): void; + + /** + * 摧毁这个移动图块对象,之后不会再显示到画面上 + */ + destroy(): void; +} + +export interface IMapRendererTicker { + /** 当前的时间戳 */ + readonly timestamp: number; + + /** + * 移除这个帧函数 + */ + remove(): void; +} + +export interface IMapRendererPostEffect { + /** 当前后处理对象是否启用 */ + readonly enabled: boolean; + + /** + * 启用此后处理对象 + */ + enable(): void; + + /** + * 禁用此后处理对象 + */ + disable(): void; + + /** + * 初始化渲染器效果对象,一般是编译着色器、准备数据缓冲区等 + * @param gl WebGL2 画布上下文 + * @param data 地图渲染的上下文数据 + */ + init(gl: WebGL2RenderingContext, data: IContextData): void; + + /** + * 渲染效果对象,将内容渲染到输出 FBO 上,不建议使用 `gl.viewport` 切换渲染区域,因为在调用此方法时已经处理完毕了。 + * 需要自行绑定输出 FBO 和输入纹理、缓冲区清空等内容。 + * @param gl WebGL2 画布上下文 + * @param input 输入的 Texture2D + * @param output 输出 FBO,内容要画到这个 FBO 上,如果是 `null` 的话说明本次绘制会直接推送到画布 + * @param data 地图渲染的上下文数据 + */ + render( + gl: WebGL2RenderingContext, + input: WebGLTexture, + output: WebGLFramebuffer | null, + data: IContextData + ): void; +} + +export interface IMapRenderer { + /** 地图渲染器使用的资源管理器 */ + readonly manager: IMaterialManager; + /** 画布渲染上下文 */ + readonly gl: WebGL2RenderingContext; + + /** 自动元件处理对象 */ + readonly autotile: IAutotileProcessor; + /** 地图变换矩阵 */ + readonly transform: Transform; + /** 地图渲染的视角控制 */ + readonly viewport: IMapViewportController; + /** 顶点数组生成器 */ + readonly vertex: IMapVertexGenerator; + /** 使用的地图状态对象 */ + readonly layerState: ILayerState; + + /** 地图宽度 */ + readonly mapWidth: number; + /** 地图高度 */ + readonly mapHeight: number; + /** 图层数量 */ + readonly layerCount: number; + /** 渲染宽度 */ + readonly renderWidth: number; + /** 渲染高度 */ + readonly renderHeight: number; + /** 每个格子的宽度 */ + readonly cellWidth: number; + /** 每个格子的高度 */ + readonly cellHeight: number; + /** 图集宽度 */ + readonly assetWidth: number; + /** 图集高度 */ + readonly assetHeight: number; + + /** + * 使用指定图集对象 + * @param asset 要使用的缓存对象 + */ + useAsset(asset: ITrackedAssetData): void; + + /** + * 摧毁此地图渲染器,表示当前渲染器不会再被使用到 + */ + destroy(): void; + + /** + * 添加一个地图渲染器效果对象,一般是对地图渲染的后处理,可以是一些特效和自定义绘制等 + * @param effect 地图渲染器效果对象 + * @param priority 效果对象优先级 + */ + addPostEffect(effect: IMapRendererPostEffect, priority: number): void; + + /** + * 移除指定的效果对象 + * @param effect 地图渲染器效果对象 + */ + removePostEffect(effect: IMapRendererPostEffect): void; + + /** + * 设置效果对象的优先级 + * @param effect 地图渲染器效果对象 + * @param priority 效果对象优先级 + */ + setPostEffectPriority( + effect: IMapRendererPostEffect, + priority: number + ): void; + + /** + * 渲染地图 + */ + render(): HTMLCanvasElement; + + /** + * 设置地图的变换矩阵 + * @param transform 变换矩阵 + */ + setTransform(transform: Transform): void; + + /** + * 设置画布尺寸 + * @param width 画布宽度 + * @param height 画布高度 + */ + setCanvasSize(width: number, height: number): void; + + /** + * 设置渲染区域,等于 `gl.viewport` + * @param x 左上角横坐标 + * @param y 左上角纵坐标 + * @param width 区域宽度 + * @param height 区域高度 + */ + setViewport(x: number, y: number, width: number, height: number): void; + + /** + * 清空画布缓冲区 + * @param color 是否清空颜色缓冲区 + * @param depth 是否清空深度缓冲区 + */ + clear(color: boolean, depth: boolean): void; + + /** + * 设置渲染器使用的地图状态 + * @param layerState 地图状态 + */ + setLayerState(layerState: ILayerState): void; + + /** + * 根据标识符获取图层 + * @param identifier 图层标识符 + */ + getLayer(identifier: string): IMapLayer | null; + + /** + * 判断当前渲染器是否包含指定图层对象 + * @param layer 图层对象 + */ + hasLayer(layer: IMapLayer): boolean; + + /** + * 获取排序后的图层列表,是内部引用的副本,不是对内部的直接引用,不具有实时性 + */ + getSortedLayer(): IMapLayer[]; + + /** + * 获取指定图层排序后的索引位置 + * @param layer 要获取的图层 + */ + getLayerIndex(layer: IMapLayer): number; + + /** + * 根据图集的图像源获取其索引 + * @param source 图像源 + */ + getAssetSourceIndex(source: SizedCanvasImageSource): number; + + /** + * 获取指定偏移值在偏移池中的索引 + * @param offset 原始偏移值,非归一化偏移值 + */ + getOffsetIndex(offset: number): number; + + /** + * 使用静态图片作为地图背景图 + * @param renderable 可渲染对象 + */ + setStaticBackground(renderable: ITextureRenderable): void; + + /** + * 使用普通动画图片作为地图背景图 + * @param renderable 可渲染对象列表,不能是无限循环动画(`ITexture.cycled`),需要是普通动画(`ITexture.dynamic`) + */ + setDynamicBackground(renderable: Iterable): void; + + /** + * 使用图块作为地图背景图,图块可以包含动画 + * @param tile 图块数字 + */ + setTileBackground(tile: number): void; + + /** + * 配置背景图片的渲染方式,仅对静态与动态背景图有效,对图块背景图无效 + * @param config 背景图片配置 + */ + configBackground(config: Partial): void; + + /** + * 配置渲染器的渲染设置 + * @param config 渲染设置 + */ + configRendering(config: Partial): void; + + /** + * 设置渲染的宽高,单位像素 + * @param width 渲染的像素宽度 + * @param height 渲染的像素高度 + */ + setRenderSize(width: number, height: number): void; + + /** + * 获取背景渲染设置。并不是内部存储的引用,不会实时更新。 + */ + getBackgroundConfig(): IMapBackgroundConfig; + + /** + * 获取地图渲染设置。并不是内部存储的引用,不会实时更新。 + */ + getRenderingConfig(): IMapRenderConfig; + + /** + * 设置每个格子的宽高 + * @param width 每个格子的宽度 + * @param height 每个格子的高度 + */ + setCellSize(width: number, height: number): void; + + /** + * 添加一个移动图块 + * @param layer 图块所属的图层 + * @param block 图块数字或图块的素材对象,要求可渲染对象的图像源必须出现在图集中 + * @param x 图块初始横坐标,可以填小数 + * @param y 图块初始纵坐标,可以填小数 + * @returns 移动图块对象,可以用于控制图块移动 + */ + addMovingBlock( + layer: IMapLayer, + block: number | IMaterialFramedData, + x: number, + y: number + ): Readonly; + + /** + * 获取所有的移动图块 + */ + getMovingBlock(): Set>; + + /** + * 根据索引获取移动图块对象 + * @param index 移动图块索引 + */ + getMovingBlockByIndex(index: number): Readonly | null; + + /** + * 进行一帧更新 + * @param timestamp 时间戳 + */ + tick(timestamp: number): void; + + /** + * 获取指定图层的指定图块的状态信息,可以设置与获取图块状态。多次调用的返回值不同引用。 + * @param layer 图层对象 + * @param x 图块横坐标 + * @param y 图块纵坐标 + */ + getBlockStatus(layer: IMapLayer, x: number, y: number): IBlockStatus | null; + + /** + * 当前地图状态是否发生改变,需要更新 + */ + needUpdate(): boolean; + + /** + * 添加一个每帧执行的函数 + * @param fn 每帧执行的函数 + */ + requestTicker(fn: (timestamp: number) => void): IMapRendererTicker; +} + +export interface IMapVertexArray { + /** + * 地图渲染实例化数组,结构是一个三维张量 `[B, L, T]`,其中 `B` 代表分块,`L` 代表图层,`T` 代表图块,并按照结构顺序平铺存储。 + * + * 语义解释就是,最内层存储图块,再外面一层存储图层,最外层存储分块。这样的话可以一次性将一个分块的所有图层渲染完毕。 + * + * 依次存储 a_tilePos, a_texCoord, a_tileData, a_texData + */ + readonly tileInstanced: Float32Array; + + /** 动态内容的起始索引,以实例为单位 */ + readonly dynamicStart: number; + /** 动态内容的数量,以实例为单位 */ + readonly dynamicCount: number; +} + +export interface IBlockIndex { + /** 横坐标 */ + readonly x: number; + /** 纵坐标 */ + readonly y: number; + /** 分块左上角数据的横坐标 */ + readonly dataX: number; + /** 分块左上角数据的纵坐标 */ + readonly dataY: number; + /** 索引,等于 横坐标+纵坐标*宽度 */ + readonly index: number; +} + +export interface IBlockInfo extends IBlockIndex { + /** 分块宽度 */ + readonly width: number; + /** 分块高度 */ + readonly height: number; +} + +export interface IBlockData extends IBlockInfo { + /** 这个分块的数据 */ + data: T; + + /** + * 获取这个分块左方的分块 + */ + left(): IBlockData | null; + + /** + * 获取这个分块右方的分块 + */ + right(): IBlockData | null; + + /** + * 获取这个分块上方的分块 + */ + up(): IBlockData | null; + + /** + * 获取这个分块下方的分块 + */ + down(): IBlockData | null; + + /** + * 获取这个分块左上方的分块 + */ + leftUp(): IBlockData | null; + + /** + * 获取这个分块左下方的分块 + */ + leftDown(): IBlockData | null; + + /** + * 获取这个分块右上方的分块 + */ + rightUp(): IBlockData | null; + + /** + * 获取这个分块右下方的分块 + */ + rightDown(): IBlockData | null; + + /** + * 获取下一个索引的分块 + */ + next(): IBlockData | null; +} + +export interface IBlockSplitterConfig { + /** 分块宽度 */ + readonly blockWidth: number; + /** 分块高度 */ + readonly blockHeight: number; + /** 数据宽度 */ + readonly dataWidth: number; + /** 数据高度 */ + readonly dataHeight: number; +} + +export interface IBlockSplitter extends IBlockSplitterConfig { + /** 宽度,即横向有多少分块 */ + readonly width: number; + /** 高度,即纵向有多少分块 */ + readonly height: number; + + /** + * 根据坐标获取分块内容 + * @param x 横坐标 + * @param y 纵坐标 + */ + getBlockByLoc(x: number, y: number): IBlockData | null; + + /** + * 根据分块索引获取分块内容 + * @param index 分块索引 + */ + getBlockByIndex(index: number): IBlockData | null; + + /** + * 根据分块坐标设置分块内容 + * @param data 分块数据 + * @param x 分块横坐标 + * @param y 分块纵坐标 + * @returns 分块内容 + */ + setBlockByLoc(data: T, x: number, y: number): IBlockData | null; + + /** + * 根据分块索引设置分块内容 + * @param data 分块数据 + * @param index 分块索引 + * @returns 分块内容 + */ + setBlockByIndex(data: T, index: number): IBlockData | null; + + /** + * 根据分块坐标,遍历指定分块中的所有坐标(分块 -> 元素) + * @param x 分块横坐标 + * @param y 分块纵坐标 + */ + iterateBlockByLoc(x: number, y: number): Generator; + + /** + * 根据分块索引,遍历指定分块中的所有坐标(分块 -> 元素) + * @param index 分块索引 + */ + iterateBlockByIndex(index: number): Generator; + + /** + * 根据分块索引列表,依次遍历每个分块的所有坐标(分块 -> 元素) + * @param indices 分块索引列表 + */ + iterateBlockByIndices( + indices: Iterable + ): Generator; + + /** + * 遍历所有的分块(分块 -> 分块) + */ + iterateBlocks(): Iterable>; + + /** + * 传入指定的数据区域,对其所涉及的分块依次遍历(元素 -> 分块) + * @param x 数据左上角横坐标 + * @param y 数据左上角纵坐标 + * @param width 数据宽度 + * @param height 数据高度 + */ + iterateBlocksOfDataArea( + x: number, + y: number, + width: number, + height: number + ): Generator>; + + /** + * 根据分块坐标获取分块索引,如果坐标超出范围,返回 -1(分块 -> 分块) + * @param x 分块横坐标 + * @param y 分块纵坐标 + */ + getIndexByLoc(x: number, y: number): number; + + /** + * 根据分块索引获取分块坐标,如果索引超出范围,返回 `null`(分块 -> 分块) + * @param index 分块索引 + */ + getLocByIndex(index: number): Loc | null; + + /** + * 根据坐标列表,获取对应的分块索引,并组成一个列表(分块 -> 分块) + * @param list 坐标列表 + */ + getIndicesByLocList(list: Iterable): Iterable; + + /** + * 根据索引列表,获取对应的分块坐标,并组成一个列表(分块 -> 分块) + * @param list 索引列表 + */ + getLocListByIndices(list: Iterable): Iterable; + + /** + * 根据数据坐标获取其对应分块的信息(元素 -> 分块) + * @param x 数据横坐标 + * @param y 数据纵坐标 + */ + getBlockByDataLoc(x: number, y: number): IBlockData | null; + + /** + * 根据数据索引获取其对应分块的信息(元素 -> 分块) + * @param index 数据索引 + */ + getBlockByDataIndex(index: number): IBlockData | null; + + /** + * 根据数据的坐标列表,获取数据所在的分块索引,并组成一个集合(元素 -> 分块) + * @param list 数据坐标列表 + */ + getIndicesByDataLocList(list: Iterable): Set; + + /** + * 根据数据的索引列表,获取数据所在的分块索引,并组成一个集合(元素 -> 分块) + * @param list 数据索引列表 + */ + getIndicesByDataIndices(list: Iterable): Set; + + /** + * 根据数据的坐标列表,获取数据所在的分块内容,并组成一个集合(元素 -> 分块) + * @param list 数据坐标列表 + */ + getBlocksByDataLocList(list: Iterable): Set>; + + /** + * 根据数据的索引列表,获取数据所在的分块内容,并组成一个集合(元素 -> 分块) + * @param list 数据索引列表 + */ + getBlocksByDataIndices(list: Iterable): Set>; + + /** + * 配置此分块切分器,此行为不会清空分块数据,只有在执行下一次分块时才会生效 + * @param config 切分器配置 + */ + configSplitter(config: IBlockSplitterConfig): void; + + /** + * 执行分块操作,对每个分块执行函数,获取分块数据 + * @param mapFn 对每个分块执行的函数 + */ + splitBlocks(mapFn: (block: IBlockInfo) => T): void; +} + +export interface IMapVertexData { + /** 这个分块的实例化数据数组 */ + readonly instancedArray: Float32Array; +} + +export interface IIndexedMapVertexData extends IMapVertexData { + /** 这个分块的实例化数据的起始索引 */ + readonly instancedStart: number; +} + +export interface ILayerDirtyData { + /** 是否需要更新顶点数组 */ + dirty: boolean; + /** 脏区域左边缘 */ + dirtyLeft: number; + /** 脏区域上边缘 */ + dirtyTop: number; + /** 脏区域右边缘 */ + dirtyRight: number; + /** 脏区域下边缘 */ + dirtyBottom: number; +} + +export interface IMapVertexBlock extends IMapVertexData { + /** 当前区域是否需要更新 */ + readonly dirty: boolean; + /** 渲染是否需要更新 */ + readonly renderDirty: boolean; + /** 起始索引,即第一个元素索引,以实例为单位 */ + readonly startIndex: number; + /** 终止索引,即最后一个元素索引+1,以实例为单位 */ + readonly endIndex: number; + /** 元素数量,即终止索引-起始索引,以实例为单位 */ + readonly count: number; + + /** + * 取消渲染的脏标记 + */ + render(): void; + + /** + * 取消数据脏标记 + */ + updated(): void; + + /** + * 标记指定区域为脏,需要更新 + * @param layer 图层对象 + * @param left 标记区域左边缘,相对于分块,即分块左上角为 `0,0`,包含 + * @param top 标记区域上边缘,相对于分块,即分块左上角为 `0,0`,包含 + * @param right 标记区域右边缘,相对于分块,即分块左上角为 `0,0`,不包含 + * @param bottom 标记区域下边缘,相对于分块,即分块左上角为 `0,0`,不包含 + */ + markDirty( + layer: IMapLayer, + left: number, + top: number, + right: number, + bottom: number + ): void; + + /** + * 获取图层需要更新的区域 + * @param layer 图层 + */ + getDirtyArea(layer: IMapLayer): Readonly | null; + + /** + * 获取指定图层的实例化数据数组,是对内部存储的直接引用 + * @param layer 图层对象 + */ + getLayerInstanced(layer: IMapLayer): Float32Array | null; + + /** + * 获取指定图层的所有顶点数组数据,是对内部存储的直接引用 + * @param layer 图层对象 + */ + getLayerData(layer: IMapLayer): IMapVertexData | null; +} + +export interface IMapBlockUpdateObject { + /** 要更新为的图块数字 */ + readonly block: number; + /** 图块横坐标 */ + readonly x: number; + /** 图块纵坐标 */ + readonly y: number; +} + +export interface IMapVertexStatus { + /** + * 设置图块的不透明度 + * @param layer 图块所属图层 + * @param x 图块横坐标 + * @param y 图块纵坐标 + * @param alpha 目标不透明度 + */ + setStaticAlpha(layer: IMapLayer, x: number, y: number, alpha: number): void; + + /** + * 设置图块显示第几帧 + * @param layer 图块所属图层 + * @param x 图块横坐标 + * @param y 图块纵坐标 + * @param frame 图块的帧数,-1 表示使用全局帧数,非负整数表示画第几帧,超出最大帧数会自动取余 + */ + setStaticFrame(layer: IMapLayer, x: number, y: number, frame: number): void; + + /** + * 设置移动图块的不透明度 + * @param index 移动图块索引 + * @param alpha 目标不透明度 + */ + setDynamicAlpha(index: number, alpha: number): void; + + /** + * 设置移动图块显示第几帧 + * @param index 移动图块索引 + * @param frame 图块的帧数,-1 表示使用全局帧数,非负整数表示画第几帧,超出最大帧数会自动取余 + */ + setDynamicFrame(index: number, frame: number): void; + + /** + * 获取指定位置图块的不透明度,如果图块不在地图内则返回 0 + * @param layer 图块所属图层 + * @param x 图块横坐标 + * @param y 图块纵坐标 + */ + getStaticAlpha(layer: IMapLayer, x: number, y: number): number; + + /** + * 获取指定位置图块的帧数,-1 表示使用全局帧数,非负整数表示当前第几帧,不会超出最大帧数,如果图块不在地图内则返回 -1 + * @param layer 图块所属图层 + * @param x 图块横坐标 + * @param y 图块纵坐标 + */ + getStaticFrame(layer: IMapLayer, x: number, y: number): number; + + /** + * 获取移动图块的不透明度 + * @param index 移动图块索引 + */ + getDynamicAlpha(index: number): number; + + /** + * 获取移动图块的当前帧数,-1 表示使用全局帧数,非负整数表示当前第几帧 + * @param index 移动图块索引 + */ + getDynamicFrame(index: number): number; +} + +/** + * 脏标记表示顶点数组的长度是否发生变化 + */ +export interface IMapVertexGenerator + extends IDirtyTracker, + IMapVertexStatus { + /** 地图渲染器 */ + readonly renderer: IMapRenderer; + /** 地图分块 */ + readonly block: IBlockSplitter; + /** 动态部分是否需要更新渲染缓冲区 */ + readonly dynamicRenderDirty: boolean; + + /** 动态内容起始索引,以实例为单位 */ + readonly dynamicStart: number; + /** 动态内容数量,以实例为单位 */ + readonly dynamicCount: number; + + /** + * 取消动态内容渲染的脏标记 + */ + renderDynamic(): void; + + /** + * 设置分块大小。一般设置为与画面大小一致,这样在多数情况下性能最优。 + * 不建议主动调用此方法,因为此方法会重建顶点数组,对性能影响较大。 + * @param width 分块宽度 + * @param height 分块高度 + */ + setBlockSize(width: number, height: number): void; + + /** + * 重设地图尺寸 + */ + resizeMap(): void; + + /** + * 扩大移动图块数组尺寸 + * @param targetSize 目标大小 + */ + expandMoving(targetSize: number): void; + + /** + * 缩小移动图块数组尺寸 + * @param targetSize 目标大小 + */ + reduceMoving(targetSize: number): void; + + /** + * 更新图层数组 + */ + updateLayerArray(): void; + + /** + * 检查是否需要重建数组 + */ + checkRebuild(): void; + + /** + * 获取顶点数组,是对内部存储的直接引用,但是内部存储在重新分配内存时引用会丢失 + */ + getVertexArray(): IMapVertexArray; + + /** + * 更新指定区域内的所有图块 + * @param layer 更新的图层 + * @param x 更新区域左上角横坐标 + * @param y 更新区域左上角纵坐标 + * @param w 更新区域宽度 + * @param h 更新区域高度 + */ + updateArea( + layer: IMapLayer, + x: number, + y: number, + w: number, + h: number + ): void; + + /** + * 更新指定图块 + * @param layer 更新的图层 + * @param block 设置为的图块 + * @param x 图块横坐标 + * @param y 图块纵坐标 + */ + updateBlock(layer: IMapLayer, block: number, x: number, y: number): void; + + /** + * 更新一系列图块,适用于散点图块,如果需要更新某个区域,请换用 {@link updateArea} + * @param layer 更新的图层 + * @param blocks 要更新的图块列表 + */ + updateBlockList(layer: IMapLayer, blocks: IMapBlockUpdateObject[]): void; + + /** + * 更新指定分块的数据,专门用于懒更新 + * @param block 要更新的分块 + */ + updateBlockCache(block: Readonly>): void; + + /** + * 更新指定的移动图块对象,可以用于新增移动图块 + * @param moving 要更新的移动图块对象 + * @param updateTexture 是否更新贴图信息 + */ + updateMoving(moving: IMovingBlock, updateTexture: boolean): void; + + /** + * 更新一系列移动图块,可以用于新增移动图块 + * @param moving 移动图块列表 + * @param updateTexture 是否更新贴图信息 + */ + updateMovingList(moving: IMovingBlock[], updateTexture: boolean): void; + + /** + * 移除指定的移动图块 + * @param moving 移动图块对象 + */ + deleteMoving(moving: IMovingBlock): void; +} + +export interface IMapRenderArea { + /** 顶点起始索引,从哪个顶点开始处理 */ + startIndex: number; + /** 顶点终止索引,处理到哪个顶点 */ + endIndex: number; + /** 顶点数量,即终止索引减去起始索引 */ + count: number; +} + +export interface IMapRenderData { + /** 需要渲染的区域 */ + readonly render: IMapRenderArea[]; + /** 需要更新顶点数组的区域 */ + readonly dirty: IMapRenderArea[]; + /** 需要更新的分块列表 */ + readonly blockList: IBlockData[]; +} + +export interface IMapViewportController { + /** 变换矩阵 */ + readonly transform: Transform; + + /** + * 获取渲染区域,即哪些分块在画面内 + */ + getRenderArea(): IMapRenderData; + + /** + * 绑定变换矩阵 + * @param transform 变换矩阵 + */ + bindTransform(transform: Transform): void; +} + +export interface IMapCamera {} diff --git a/packages-user/client-modules/src/render/map/vertex.ts b/packages-user/client-modules/src/render/map/vertex.ts new file mode 100644 index 0000000..0a93d8d --- /dev/null +++ b/packages-user/client-modules/src/render/map/vertex.ts @@ -0,0 +1,1166 @@ +import { IMapLayer } from '@user/data-state'; +import { + IBlockData, + IBlockSplitter, + IContextData, + IIndexedMapVertexData, + ILayerDirtyData, + IMapBlockUpdateObject, + IMapRenderer, + IMapVertexArray, + IMapVertexBlock, + IMapVertexData, + IMapVertexGenerator, + IMovingBlock, + MapTileAlign, + MapTileBehavior, + MapTileSizeTestMode +} from './types'; +import { logger, PrivateBooleanDirtyTracker } from '@motajs/common'; +import { DYNAMIC_RESERVE, MAP_BLOCK_HEIGHT, MAP_BLOCK_WIDTH } from '../shared'; +import { BlockSplitter } from './block'; +import { clamp, isNil } from 'lodash-es'; +import { BlockCls, IMaterialFramedData } from '@user/client-base'; +import { IRect } from '@motajs/render-assets'; +import { INSTANCED_COUNT } from './constant'; + +export interface IMapDataGetter { + /** 图块缩小行为,即图块比格子大时应该如何处理 */ + readonly tileMinifyBehavior: MapTileBehavior; + /** 图块放大行为,即图块比格子小时应该如何处理 */ + readonly tileMagnifyBehavior: MapTileBehavior; + /** 图块水平对齐,仅当图块行为为 `KeepSize` 时有效 */ + readonly tileAlignX: MapTileAlign; + /** 图块竖直对齐,仅当图块行为为 `KeepSize` 时有效 */ + readonly tileAlignY: MapTileAlign; + /** 图块大小与格子大小判断方式 */ + readonly tileTestMode: MapTileSizeTestMode; + + /** + * 渲染器是否包含指定的移动图块对象 + * @param moving 移动图块对象 + */ + hasMoving(moving: IMovingBlock): boolean; + + /** + * 申请更新渲染 + */ + requestUpdate(): void; +} + +interface BlockMapPos { + /** 图块的图块数字 */ + readonly num: number; + /** 地图中的横坐标 */ + readonly mapX: number; + /** 地图中的纵坐标 */ + readonly mapY: number; +} + +interface IndexedBlockMapPos extends BlockMapPos { + /** 图块所在的图层 */ + readonly layer: IMapLayer; + /** 图块在分块中的索引 */ + readonly blockIndex: number; +} + +interface BlockIndex extends IndexedBlockMapPos { + /** 图块在分块中的横坐标 */ + readonly blockX: number; + /** 图块在分块中的纵坐标 */ + readonly blockY: number; + /** 地图中的索引 */ + readonly mapIndex: number; +} + +interface VertexArrayOfBlock { + /** 图块在数组中的起始索引 */ + readonly index: number; + /** 分块顶点数组 */ + readonly array: Float32Array; + /** 分块数据 */ + readonly block: IBlockData; +} + +const enum VertexUpdate { + /** 更新顶点位置信息 */ + Position = 0b001, + /** 更新贴图信息 */ + Texture = 0b010, + /** 是否更新默认帧数 */ + Frame = 0b100, + /** 除帧数外全部更新 */ + NoFrame = 0b011, + /** 全部更新 */ + All = 0b111 +} + +/** + * 构建地图顶点数组,当且仅当数组长度发生变化时才会标记为脏,需要完全重新分配内存。 + */ +export class MapVertexGenerator + extends PrivateBooleanDirtyTracker + implements IMapVertexGenerator +{ + //#region 属性声明 + + dynamicRenderDirty: boolean = true; + + dynamicStart: number = 0; + dynamicCount: number = DYNAMIC_RESERVE; + + /** 空顶点数组,因为空顶点很常用,所以直接定义一个全局常量 */ + private static readonly EMPTY_VETREX: Float32Array = new Float32Array( + // prettier-ignore + [ + 0, 0, 0, 0, // 顶点坐标 + 0, 0, 0, 0, // 纹理坐标 + 0, 1, 0, 0, // a_tileData,不透明度需要设为 1 + -1, 0, 0, 0, // a_texData,当前帧数需要设为 -1 + ] + ); + + readonly block: IBlockSplitter; + + /** 偏移数组 */ + private instancedArray: Float32Array = new Float32Array(); + /** 动态内容偏移数组 */ + private dynamicInstancedArray: Float32Array = new Float32Array(); + + /** 分块宽度 */ + private blockWidth: number = MAP_BLOCK_WIDTH; + /** 分块高度 */ + private blockHeight: number = MAP_BLOCK_HEIGHT; + + /** 地图宽度 */ + private mapWidth: number = 0; + /** 地图高度 */ + private mapHeight: number = 0; + + /** 是否需要重建数组 */ + private needRebuild: boolean = false; + + /** 更新图块性能检查防抖起始时刻 */ + private updateCallDebounceTime: number = 0; + /** 更新图块性能检查的调用次数 */ + private updateCallDebounceCount: number = 0; + + constructor( + readonly renderer: IMapRenderer & IMapDataGetter, + readonly data: IContextData + ) { + super(); + this.resizeMap(); + this.block = new BlockSplitter(); + } + + //#endregion + + //#region 分块操作 + + private mallocVertexArray() { + // 顶点数组尺寸等于 地图大小 * 每个图块的顶点数量 * 每个顶点的数据量 + const area = this.renderer.mapWidth * this.renderer.mapHeight; + const staticCount = area * this.renderer.layerCount; + const count = staticCount + this.dynamicCount; + const offsetSize = count * INSTANCED_COUNT; + this.instancedArray = new Float32Array(offsetSize); + this.dynamicStart = staticCount; + this.dynamicInstancedArray = this.instancedArray.subarray( + staticCount * INSTANCED_COUNT, + count * INSTANCED_COUNT + ); + // 不透明度默认是 1,帧数默认是 -1 + for (let i = 0; i < count; i++) { + const start = i * INSTANCED_COUNT; + this.instancedArray[start + 9] = 1; + this.instancedArray[start + 12] = -1; + } + } + + private splitBlock() { + this.block.configSplitter({ + dataWidth: this.mapWidth, + dataHeight: this.mapHeight, + blockWidth: this.blockWidth, + blockHeight: this.blockHeight + }); + const blockCount = this.blockWidth * this.blockHeight; + const lineCount = this.mapWidth * this.blockHeight; + const lastCount = (this.mapHeight % this.blockHeight) * this.blockWidth; + const bh = Math.floor(this.mapHeight / this.blockHeight); + const lastStart = bh * lineCount; + this.block.splitBlocks(block => { + // 最后一行的算法与其他行不同 + const startIndex = + block.height < this.blockHeight + ? lastStart + lastCount * block.x + : lineCount * block.y + blockCount * block.x; + const count = block.width * block.height; + + const origin: IMapVertexData = { + instancedArray: this.instancedArray + }; + const data = new MapVertexBlock( + this.renderer, + origin, + startIndex, + count, + block.width, + block.height + ); + return data; + }); + } + + setBlockSize(width: number, height: number): void { + this.blockWidth = width; + this.blockHeight = height; + this.mallocVertexArray(); + this.splitBlock(); + } + + resizeMap(): void { + if ( + this.mapWidth !== this.renderer.mapWidth || + this.mapHeight !== this.renderer.mapHeight + ) { + this.needRebuild = true; + this.mapWidth = this.renderer.mapWidth; + this.mapHeight = this.renderer.mapHeight; + } + } + + expandMoving(targetSize: number): void { + const beforeOffset = this.instancedArray; + this.dynamicCount = targetSize; + this.mallocVertexArray(); + this.instancedArray.set(beforeOffset); + const array: IMapVertexData = { + instancedArray: this.instancedArray + }; + // 重建一下对应分块就行了,不需要重新分块 + for (const block of this.block.iterateBlocks()) { + block.data.rebuild(array); + } + } + + reduceMoving(targetSize: number): void { + const beforeOffsetLength = this.instancedArray.length; + const deltaLength = this.dynamicCount - targetSize; + this.dynamicCount = targetSize; + this.instancedArray = this.instancedArray.subarray( + 0, + beforeOffsetLength - deltaLength * INSTANCED_COUNT + ); + this.dynamicInstancedArray = this.dynamicInstancedArray.subarray( + 0, + targetSize * INSTANCED_COUNT + ); + // 这个不需要重新分配内存,依然共用同一个 ArrayBuffer,因此不需要重新分块 + } + + updateLayerArray(): void { + this.needRebuild = true; + } + + checkRebuild() { + if (!this.needRebuild) return; + this.needRebuild = false; + this.mallocVertexArray(); + this.splitBlock(); + this.dirty(); + } + + //#endregion + + //#region 顶点数组更新 + + /** + * 获取图块经过对齐与缩放后的位置 + * @param pos 图块位置信息 + * @param width 图块的贴图宽度 + * @param height 图块的贴图高度 + */ + private getTilePosition( + pos: BlockMapPos, + width: number, + height: number + ): Readonly { + const { + renderWidth, + renderHeight, + cellWidth, + cellHeight, + tileMinifyBehavior, + tileMagnifyBehavior, + tileAlignX, + tileAlignY, + tileTestMode + } = this.renderer; + const larger = + tileTestMode === MapTileSizeTestMode.WidthOrHeight + ? width > cellWidth || height > cellHeight + : width > cellWidth && height > cellHeight; + // 放大行为多数是适应到格子大小,因此把尺寸相等也归为放大行为,性能表现会更好 + const mode = larger ? tileMinifyBehavior : tileMagnifyBehavior; + const cwu = cellWidth / renderWidth; // normalized cell width in range [0, 1] + const chu = cellHeight / renderHeight; // normalized cell width in range [0, 1] + const cw = cwu * 2; // normalized cell width in range [-1, 1] + const ch = chu * 2; // normalized cell height in range [-1, 1] + const cl = pos.mapX * cw - 1; // cell left + const ct = 1 - pos.mapY * ch; // cell top + if (mode === MapTileBehavior.FitToSize) { + // 适应到格子大小 + return { + x: cl, + y: ct, + w: cw, + h: ch + }; + } else { + // 维持大小,需要判断对齐 + // 下面这些计算是经过推导后的最简表达式,因此和语义可能不同 + // twu, thu, cwu, chu 的准确含义应该是“归一化尺寸的一半”,这样就好理解了 + + const twu = width / renderWidth; // normalized texture width in range [0, 1] + const thu = height / renderHeight; // normalized texture width in range [0, 1] + const tw = twu * 2; // normalized texture width in range [-1, 1] + const th = thu * 2; // normalized texture height in range [-1, 1] + let left = 0; + let top = 0; + switch (tileAlignX) { + case MapTileAlign.Start: { + // 左对齐 + left = cl; + break; + } + case MapTileAlign.Center: { + // 左右居中对齐 + left = cl - cwu + twu; + break; + } + case MapTileAlign.End: { + // 右对齐 + left = cl - cw + tw; + break; + } + } + switch (tileAlignY) { + case MapTileAlign.Start: { + // 上对齐 + top = ct; + break; + } + case MapTileAlign.Center: { + // 上下居中对齐 + top = ct - chu + thu; + break; + } + case MapTileAlign.End: { + // 下对齐 + top = ct - ch + th; + } + } + return { x: left, y: top, w: tw, h: th }; + } + } + + /** + * 更新指定图块的顶点数组信息 + * @param vertex 顶点数组对象 + * @param rect 可渲染对象的矩形区域 + * @param index 图块索引对象 + * @param assetIndex 贴图所在的图集索引 + * @param offsetIndex 贴图偏移值所在偏移池的索引 + * @param frames 贴图总帧数 + * @param update 顶点坐标更新方式 + */ + private updateTileVertex( + vertex: IMapVertexData, + rect: Readonly, + index: IndexedBlockMapPos, + assetIndex: number, + offsetIndex: number, + frames: number, + update: VertexUpdate, + dynamic: boolean + ) { + const { instancedArray } = vertex; + // 顶点数组 + const { layerCount, assetWidth, assetHeight } = this.renderer; + const { x, y, w: width, h: height } = rect; + const startIndex = index.blockIndex * INSTANCED_COUNT; + if (update & VertexUpdate.Position) { + // 如果需要更新顶点坐标 + const layerIndex = this.renderer.getLayerIndex(index.layer); + // 避免 z 坐标是 1 的时候被裁剪,因此范围选择 [-0.9, 0.9] + const layerStart = (layerIndex / layerCount) * 1.8 - 0.9; + const perBlockZ = 1 / this.mapHeight / layerCount; + const blockZ = index.mapY * perBlockZ; + const zIndex = -layerStart - blockZ - (dynamic ? perBlockZ : 0); + const { x, y, w, h } = this.getTilePosition(index, width, height); + // 图块位置 + instancedArray[startIndex] = x; + instancedArray[startIndex + 1] = y; + instancedArray[startIndex + 2] = w; + instancedArray[startIndex + 3] = h; + // 图块纵深 + instancedArray[startIndex + 8] = zIndex; + } + if (update & VertexUpdate.Texture) { + const texX = x / assetWidth; + const texY = y / assetHeight; + const texWidth = width / assetWidth; + const texHeight = height / assetHeight; + // 纹理坐标 + instancedArray[startIndex + 4] = texX; + instancedArray[startIndex + 5] = texY; + instancedArray[startIndex + 6] = texWidth; + instancedArray[startIndex + 7] = texHeight; + // 帧数、偏移、纹理索引 + instancedArray[startIndex + 13] = frames; + instancedArray[startIndex + 14] = offsetIndex; + instancedArray[startIndex + 15] = assetIndex; + } + if (update & VertexUpdate.Frame) { + const defaultFrame = this.renderer.manager.getDefaultFrame( + index.num + ); + instancedArray[startIndex + 12] = defaultFrame; + } + } + + /** + * 更新指定点的自动元件,不会检查中心点是不是自动元件 + * @param mapArray 地图数组 + * @param vertex 顶点数组对象 + * @param index 中心图块索引 + * @param tile 图块的素材对象 + * @param update 顶点数组更新方式 + */ + private updateAutotile( + mapArray: Uint32Array, + vertex: IMapVertexData, + index: BlockIndex, + tile: IMaterialFramedData, + update: VertexUpdate, + dynamic: boolean + ) { + const autotile = this.renderer.autotile; + const { connection, center } = autotile.connect( + mapArray, + index.mapIndex, + this.mapWidth + ); + // 使用不带检查的版本可以减少分支数量,提升性能 + const renderable = autotile.renderWithoutCheck(tile, connection); + if (!renderable) return; + const assetIndex = this.renderer.getAssetSourceIndex(renderable.source); + const offsetIndex = this.renderer.getOffsetIndex(tile.offset); + if (assetIndex === -1 || offsetIndex === -1) { + logger.error(40, center.toString()); + return; + } + this.updateTileVertex( + vertex, + renderable.rect, + index, + assetIndex, + offsetIndex, + tile.frames, + update, + dynamic + ); + } + + /** + * 处理一个自动元件周围一圈的自动元件连接 + * @param mapArray 地图图块数组 + * @param vertex 顶点数组对象 + * @param index 原始索引 + * @param dx 横坐标偏移 + * @param dy 纵坐标偏移 + */ + private checkAutotileConnectionAround( + layer: IMapLayer, + mapArray: Uint32Array, + index: BlockIndex, + dx: number, + dy: number + ) { + const mx = index.mapX + dx; + const my = index.mapY + dy; + const block = this.block.getBlockByDataLoc(mx, my); + if (!block) return; + const vertex = block.data.getLayerData(layer); + if (!vertex) return; + const bx = mx - block.dataX; + const by = my - block.dataY; + const mapIndex = my * this.mapWidth + mx; + const num = mapArray[mapIndex]; + const newIndex: BlockIndex = { + layer, + num, + mapX: mx, + mapY: my, + mapIndex, + blockX: bx, + blockY: by, + blockIndex: by * block.width + bx + }; + const tile = this.renderer.manager.getTile(mapArray[newIndex.mapIndex]); + if (!tile || tile.cls !== BlockCls.Autotile) return; + this.updateAutotile( + mapArray, + vertex, + newIndex, + tile, + // 周围一圈的自动元件应该只更新贴图,不需要更新位置和默认帧数 + VertexUpdate.Texture, + false + ); + block.data.markRenderDirty(); + } + + /** + * 更新指定的顶点数组 + * @param mapArray 地图图块数组,用于自动元件判定 + * @param vertex 顶点数组对象 + * @param index 图块索引对象 + * @param num 图块数字 + */ + private updateVertexArray( + mapArray: Uint32Array, + vertex: IMapVertexData, + index: BlockIndex, + num: number, + dynamic: boolean + ) { + // 此处仅更新当前图块,不更新周围一圈的自动元件 + // 周围一圈的自动元件需要在更新某个图块或者某个区域时处理,不在这里处理 + const tile = this.renderer.manager.getIfBigImage(num); + + if (!tile) { + // 不存在可渲染对象,认为是空图块 + const { instancedArray } = vertex; + const instancedStart = index.blockIndex * INSTANCED_COUNT; + // 只把坐标改成 0 就可以了,其他的保留 + instancedArray[instancedStart] = 0; + instancedArray[instancedStart + 1] = 0; + instancedArray[instancedStart + 2] = 0; + instancedArray[instancedStart + 3] = 0; + return; + } + + // todo: 这样的话,如果更新了指定分块,那么本来设置的帧数也会重置为默认帧数,如何修改? + if (tile.cls === BlockCls.Autotile) { + // 如果图块是自动元件 + this.updateAutotile( + mapArray, + vertex, + index, + tile, + // 图块变了,所以全部要更新 + VertexUpdate.All, + dynamic + ); + } else { + // 正常图块 + const renderable = tile.texture.render(); + // 宽度要除以帧数,因为我们假设所有素材都是横向平铺的 + const rect: IRect = { + x: renderable.rect.x, + y: renderable.rect.y, + w: renderable.rect.w / tile.frames, + h: renderable.rect.h + }; + const assetIndex = this.renderer.getAssetSourceIndex( + renderable.source + ); + const offsetIndex = this.renderer.getOffsetIndex(tile.offset); + if (assetIndex === -1 || offsetIndex === -1) { + logger.error(40, num.toString()); + return; + } + this.updateTileVertex( + vertex, + rect, + index, + assetIndex, + offsetIndex, + tile.frames, + // 图块变了,所以全部要更新 + VertexUpdate.All, + dynamic + ); + } + } + + /** + * 更新指定图块,但是不包含调用性能检查 + * @param layer 更新的图层 + * @param block 设置为的图块 + * @param x 图块横坐标 + * @param y 图块纵坐标 + */ + private updateBlockVertex( + layer: IMapLayer, + num: number, + x: number, + y: number + ) { + const block = this.block.getBlockByDataLoc(x, y); + if (!block) return; + const vertex = block.data.getLayerData(layer); + const data = layer.getMapRef(); + if (!vertex) return; + const { array } = data; + const dx = x - block.dataX; + const dy = y - block.dataY; + const dIndex = dy * block.width + dx; + const index: BlockIndex = { + layer, + num, + mapX: x, + mapY: y, + mapIndex: y * this.mapWidth + x, + blockX: block.x, + blockY: block.y, + blockIndex: dIndex + }; + + // 需要检查周围一圈的自动元件 + this.checkAutotileConnectionAround(layer, array, index, -1, -1); + this.checkAutotileConnectionAround(layer, array, index, 0, -1); + this.checkAutotileConnectionAround(layer, array, index, 1, -1); + this.checkAutotileConnectionAround(layer, array, index, 1, 0); + this.checkAutotileConnectionAround(layer, array, index, 1, 1); + this.checkAutotileConnectionAround(layer, array, index, 0, 1); + this.checkAutotileConnectionAround(layer, array, index, -1, 1); + this.checkAutotileConnectionAround(layer, array, index, -1, 0); + // 再更新当前图块 + this.updateVertexArray(array, vertex, index, num, false); + block.data.markRenderDirty(); + } + + //#endregion + + //#region 更新接口 + + /** + * 性能监测,如果频繁调用 `updateArea` `updateBlock` `updateBlockList` 则抛出警告 + */ + private checkUpdateCallPerformance(method: string) { + const now = performance.now(); + if (now - this.updateCallDebounceTime <= 10) { + this.updateCallDebounceCount++; + } else { + this.updateCallDebounceCount = 0; + this.updateCallDebounceTime = now; + } + if (this.updateCallDebounceCount >= 50) { + logger.warn(83, method); + this.updateCallDebounceCount = 0; + this.updateCallDebounceTime = now; + } + } + + updateArea( + layer: IMapLayer, + x: number, + y: number, + w: number, + h: number + ): void { + if (!this.renderer.hasLayer(layer)) return; + this.checkRebuild(); + // 这里多一圈是因为要更新这一圈的自动元件 + const ax = x - 1; + const ay = y - 1; + const areaRight = x + w + 1; + const areaBottom = y + h + 1; + const blocks = this.block.iterateBlocksOfDataArea(ax, ay, w + 2, h + 2); + + for (const block of blocks) { + const left = ax - block.dataX; + const top = ay - block.dataY; + const right = Math.min(areaRight - block.dataX, left + block.width); + const bottom = Math.min( + areaBottom - block.dataY, + top + block.height + ); + block.data.markDirty(layer, left, top, right, bottom); + block.data.markRenderDirty(); + } + } + + updateBlock(layer: IMapLayer, num: number, x: number, y: number): void { + if (import.meta.env.DEV) { + this.checkUpdateCallPerformance('updateBlock'); + } + this.checkRebuild(); + this.updateBlockVertex(layer, num, x, y); + } + + updateBlockList(layer: IMapLayer, blocks: IMapBlockUpdateObject[]): void { + if (!this.renderer.hasLayer(layer)) return; + if (import.meta.env.DEV) { + this.checkUpdateCallPerformance('updateBlockList'); + } + this.checkRebuild(); + + if (blocks.length <= 50) { + blocks.forEach(({ block: num, x, y }) => { + this.updateBlockVertex(layer, num, x, y); + }); + return; + } + + // 对于超出50个的更新操作使用懒更新 + blocks.forEach(v => { + const block = this.block.getBlockByDataLoc(v.x, v.y); + if (!block) return; + const bx = v.x - block.dataX; + const by = v.y - block.dataY; + block.data.markDirty(layer, bx - 1, by - 1, bx + 2, by + 2); + block.data.markRenderDirty(); + const left = bx === 0; + const top = by === 0; + const right = bx === block.width - 1; + const bottom = by === block.height - 1; + // 需要更一圈的自动元件 + if (left) { + // 左侧的分块需要更新 + const nextBlock = block.left(); + if (nextBlock) { + const { width: w, data } = nextBlock; + data.markDirty(layer, w - 1, by - 1, w, by + 1); + data.markRenderDirty(); + } + if (top) { + // 左上侧的分块需要更新 + const nextBlock = block.leftUp(); + if (nextBlock) { + const { width: w, height: h, data } = nextBlock; + data.markDirty(layer, w - 1, h - 1, w, h); + data.markRenderDirty(); + } + } + if (bottom) { + // 左下侧的分块需要更新 + const nextBlock = block.leftDown(); + if (nextBlock) { + const { width: w, data } = nextBlock; + data.markDirty(layer, w - 1, 0, w, 1); + data.markRenderDirty(); + } + } + } + if (top) { + // 上侧的分块需要更新 + const nextBlock = block.up(); + if (nextBlock) { + const { height: h, data } = nextBlock; + data.markDirty(layer, bx - 1, h - 1, bx + 1, h); + data.markRenderDirty(); + } + } + if (right) { + // 右侧的分块需要更新 + const nextBlock = block.right(); + if (nextBlock) { + const { data } = nextBlock; + data.markDirty(layer, 0, by - 1, 1, by + 1); + data.markRenderDirty(); + } + if (top) { + // 右上侧的分块需要更新 + const nextBlock = block.rightUp(); + if (nextBlock) { + const { height: h, data } = nextBlock; + data.markDirty(layer, 0, h - 1, 1, h); + data.markRenderDirty(); + } + } + if (bottom) { + // 右下侧的分块需要更新 + const nextBlock = block.rightDown(); + if (nextBlock) { + const { data } = nextBlock; + data.markDirty(layer, 0, 0, 1, 1); + data.markRenderDirty(); + } + } + } + if (bottom) { + // 下侧的分块需要更新 + const nextBlock = block.down(); + if (nextBlock) { + const { data } = nextBlock; + data.markDirty(layer, bx - 1, 0, bx + 1, 1); + data.markRenderDirty(); + } + } + }); + } + + updateBlockCache(block: Readonly>): void { + if (!block.data.dirty) return; + const layers = this.renderer.getSortedLayer(); + layers.forEach(layer => { + const dirty = block.data.getDirtyArea(layer); + if (!dirty || !dirty.dirty) return; + block.data.updated(); + const vertex = block.data.getLayerData(layer); + const mapData = layer.getMapRef(); + if (!vertex) return; + const { array } = mapData; + const { dirtyLeft, dirtyTop, dirtyRight, dirtyBottom } = dirty; + for (let nx = dirtyLeft; nx < dirtyRight; nx++) { + for (let ny = dirtyTop; ny < dirtyBottom; ny++) { + const mapX = nx + block.dataX; + const mapY = ny + block.dataY; + const mapIndex = mapY * this.mapWidth + mapX; + const num = array[mapIndex]; + const index: BlockIndex = { + layer, + num, + blockX: nx, + blockY: ny, + blockIndex: ny * block.width + nx, + mapX, + mapY, + mapIndex + }; + this.updateVertexArray(array, vertex, index, num, false); + } + } + }); + } + + //#endregion + + //#region 动态图块 + + updateMoving(block: IMovingBlock, updateTexture: boolean): void { + if (!this.renderer.hasMoving(block)) return; + const { cls, frames, offset, texture } = block.texture; + const vertex: IMapVertexData = { + instancedArray: this.dynamicInstancedArray + }; + const index: IndexedBlockMapPos = { + layer: block.layer, + num: block.tile, + mapX: block.x, + mapY: block.y, + blockIndex: block.index + }; + const assetIndex = this.renderer.getAssetSourceIndex(texture.source); + const offsetIndex = this.renderer.getOffsetIndex(offset); + if (assetIndex === -1 || offsetIndex === -1) { + logger.error(40, block.tile.toString()); + return; + } + const update = updateTexture + ? VertexUpdate.NoFrame + : VertexUpdate.Position; + if (cls === BlockCls.Autotile) { + // 自动元件使用全部不连接 + const renderable = this.renderer.autotile.renderWithoutCheck( + block.texture, + 0b0000_0000 + ); + if (!renderable) return; + + this.updateTileVertex( + vertex, + renderable.rect, + index, + assetIndex, + offset, + frames, + update, + true + ); + } else { + // 正常图块 + const renderable = texture.render(); + // 宽度要除以帧数,因为我们假设所有素材都是横向平铺的 + const rect: IRect = { + x: renderable.rect.x, + y: renderable.rect.y, + w: renderable.rect.w / frames, + h: renderable.rect.h + }; + this.updateTileVertex( + vertex, + rect, + index, + assetIndex, + offset, + frames, + update, + true + ); + } + + this.dynamicRenderDirty = true; + } + + updateMovingList(moving: IMovingBlock[], updateTexture: boolean): void { + moving.forEach(v => { + this.updateMoving(v, updateTexture); + }); + } + + deleteMoving(moving: IMovingBlock): void { + const instancedStart = moving.index * INSTANCED_COUNT; + // 这个需要全部清空了,因为可能会复用 + this.dynamicInstancedArray.set( + MapVertexGenerator.EMPTY_VETREX, + instancedStart + ); + this.dynamicRenderDirty = true; + } + + //#endregion + + //#region 图块状态 + + /** + * 获取指定图层指定坐标的图块对应的分块信息 + * @param layer 图层对象 + * @param x 图块横坐标 + * @param y 图块纵坐标 + */ + private getIndexInBlock( + layer: IMapLayer, + x: number, + y: number + ): VertexArrayOfBlock | null { + const block = this.block.getBlockByDataLoc(x, y); + if (!block) return null; + const data = block?.data.getLayerInstanced(layer); + if (!data) return null; + const dx = x - block.x; + const dy = y - block.y; + const dIndex = dy * block.width + dx; + return { array: data, index: dIndex, block }; + } + + setStaticAlpha( + layer: IMapLayer, + x: number, + y: number, + alpha: number + ): void { + const index = this.getIndexInBlock(layer, x, y); + if (!index) return; + index.array[index.index * INSTANCED_COUNT + 9] = alpha; + index.block.data.markRenderDirty(); + } + + setStaticFrame( + layer: IMapLayer, + x: number, + y: number, + frame: number + ): void { + const index = this.getIndexInBlock(layer, x, y); + if (!index) return; + index.array[index.index * INSTANCED_COUNT + 12] = frame; + index.block.data.markRenderDirty(); + } + + getStaticAlpha(layer: IMapLayer, x: number, y: number): number { + const index = this.getIndexInBlock(layer, x, y); + if (!index) return 0; + return index.array[index.index * INSTANCED_COUNT + 9]; + } + + getStaticFrame(layer: IMapLayer, x: number, y: number): number { + const index = this.getIndexInBlock(layer, x, y); + if (!index) return -1; + return index.array[index.index * INSTANCED_COUNT + 12]; + } + + setDynamicAlpha(index: number, alpha: number): void { + this.dynamicInstancedArray[index * INSTANCED_COUNT + 9] = alpha; + this.dynamicRenderDirty = true; + } + + setDynamicFrame(index: number, frame: number): void { + this.dynamicInstancedArray[index * INSTANCED_COUNT + 12] = frame; + this.dynamicRenderDirty = true; + } + + getDynamicAlpha(index: number): number { + if (index > this.dynamicCount) return 0; + return this.dynamicInstancedArray[index * INSTANCED_COUNT + 9]; + } + + getDynamicFrame(index: number): number { + if (index > this.dynamicCount) return -1; + return this.dynamicInstancedArray[index * INSTANCED_COUNT + 12]; + } + + //#endregion + + //#region 其他接口 + + renderDynamic(): void { + this.dynamicRenderDirty = false; + } + + getVertexArray(): IMapVertexArray { + this.checkRebuild(); + return { + dynamicStart: this.dynamicStart, + dynamicCount: this.dynamicCount, + tileInstanced: this.instancedArray + }; + } + + //#endregion +} + +//#region 分块对象 + +class MapVertexBlock implements IMapVertexBlock { + instancedArray!: Float32Array; + + dirty: boolean = true; + renderDirty: boolean = true; + + private readonly layerDirty: Map = new Map(); + + readonly startIndex: number; + readonly endIndex: number; + readonly count: number; + readonly layerCount: number; + + readonly instancedStart: number; + + private readonly indexMap: Map = new Map(); + private readonly instancedMap: Map = new Map(); + + /** + * 创建分块的顶点数组对象,此对象不能动态扩展,如果地图变化,需要全部重建 + * @param renderer 渲染器对象 + * @param originArray 原始顶点数组 + * @param startIndex 起始网格索引 + * @param count 单个图层的图块数量 + * @param blockWidth 分块宽度 + * @param blockHeight 分块高度 + */ + constructor( + readonly renderer: IMapRenderer & IMapDataGetter, + originArray: IMapVertexData, + startIndex: number, + count: number, + private readonly blockWidth: number, + private readonly blockHeight: number + ) { + const layerCount = renderer.layerCount; + this.startIndex = startIndex * layerCount; + this.endIndex = (startIndex + count) * layerCount; + this.count = count; + const offsetStart = startIndex * layerCount * INSTANCED_COUNT; + this.instancedStart = offsetStart; + this.layerCount = layerCount; + this.rebuild(originArray); + } + + render(): void { + this.renderDirty = false; + } + + updated(): void { + this.dirty = false; + } + + /** + * 标记为需要更新渲染缓冲区 + */ + markRenderDirty() { + this.renderDirty = true; + this.renderer.requestUpdate(); + } + + markDirty( + layer: IMapLayer, + left: number, + top: number, + right: number, + bottom: number + ): void { + const data = this.layerDirty.get(layer); + if (!data) return; + const dl = clamp(left, 0, this.blockWidth); + const dt = clamp(top, 0, this.blockHeight); + const dr = clamp(right, left, this.blockWidth); + const db = clamp(bottom, top, this.blockHeight); + if (!data.dirty) { + data.dirtyLeft = dl; + data.dirtyTop = dt; + data.dirtyRight = dr; + data.dirtyBottom = db; + } else { + data.dirtyLeft = Math.min(dl, data.dirtyLeft); + data.dirtyTop = Math.min(dt, data.dirtyTop); + data.dirtyRight = Math.max(dr, data.dirtyRight); + data.dirtyBottom = Math.max(db, data.dirtyBottom); + } + this.dirty = true; + this.renderer.requestUpdate(); + } + + getDirtyArea(layer: IMapLayer): Readonly | null { + return this.layerDirty.get(layer) ?? null; + } + + rebuild(originArray: IMapVertexData) { + const offsetStart = this.instancedStart; + const count = this.count; + this.instancedArray = originArray.instancedArray.subarray( + offsetStart, + offsetStart + count * INSTANCED_COUNT * this.layerCount + ); + + this.renderer.getSortedLayer().forEach((v, i) => { + const os = i * count * INSTANCED_COUNT; + const oa = this.instancedArray.subarray( + os, + os + count * INSTANCED_COUNT + ); + this.instancedMap.set(v, oa); + this.indexMap.set(v, i); + this.layerDirty.set(v, { + dirty: true, + dirtyLeft: 0, + dirtyTop: 0, + dirtyRight: this.blockWidth, + dirtyBottom: this.blockHeight + }); + }); + this.dirty = true; + } + + getLayerInstanced(layer: IMapLayer): Float32Array | null { + return this.instancedMap.get(layer) ?? null; + } + + getLayerData(layer: IMapLayer): IIndexedMapVertexData | null { + const offset = this.instancedMap.get(layer); + const index = this.indexMap.get(layer); + if (!offset || isNil(index)) return null; + return { + instancedArray: offset, + instancedStart: + this.instancedStart + index * this.count * INSTANCED_COUNT + }; + } +} + +//#endregion diff --git a/packages-user/client-modules/src/render/map/viewport.ts b/packages-user/client-modules/src/render/map/viewport.ts new file mode 100644 index 0000000..e5d7012 --- /dev/null +++ b/packages-user/client-modules/src/render/map/viewport.ts @@ -0,0 +1,138 @@ +import { Transform } from '@motajs/render-core'; +import { + IBlockData, + IMapRenderArea, + IMapRenderData, + IMapRenderer, + IMapVertexBlock, + IMapVertexGenerator, + IMapViewportController +} from './types'; +import { clamp } from 'lodash-es'; + +export class MapViewport implements IMapViewportController { + transform: Transform = new Transform(); + /** 顶点生成器 */ + readonly vertex: IMapVertexGenerator; + + constructor(readonly renderer: IMapRenderer) { + this.vertex = renderer.vertex; + } + + private pushBlock( + list: IMapRenderArea[], + start: IBlockData, + end: IBlockData + ) { + const startIndex = start.data.startIndex; + const endIndex = end.data.endIndex; + list.push({ + startIndex, + endIndex, + count: endIndex - startIndex + }); + } + + private checkDynamic( + list: IMapRenderArea[], + dynamicStart: number, + dynamicCount: number + ) { + const last = list[list.length - 1]; + if (!last || last.endIndex < dynamicStart) { + list.push({ + startIndex: dynamicStart, + endIndex: dynamicStart + dynamicCount, + count: dynamicCount + }); + } else { + last.endIndex = dynamicStart + dynamicCount; + last.count += dynamicCount; + } + } + + getRenderArea(): IMapRenderData { + const { cellWidth, cellHeight, renderWidth, renderHeight } = + this.renderer; + const { blockWidth, blockHeight, width, height } = this.vertex.block; + // 其实只需要算左上角和右下角就行了 + const [left, top] = this.transform.untransformed(-1, -1); + const [right, bottom] = this.transform.untransformed(1, 1); + const cl = (left * renderWidth) / cellWidth; + const ct = (top * renderHeight) / cellHeight; + const cr = (right * renderWidth) / cellWidth; + const cb = (bottom * renderHeight) / cellHeight; + const blockLeft = clamp(Math.floor(cl / blockWidth), 0, width - 1); + const blockRight = clamp(Math.floor(cr / blockWidth), 0, width - 1); + const blockTop = clamp(Math.floor(ct / blockHeight), 0, height - 1); + const blockBottom = clamp(Math.floor(cb / blockHeight), 0, height - 1); + + const renderArea: IMapRenderArea[] = []; + const updateArea: IMapRenderArea[] = []; + const blockList: IBlockData[] = []; + + // 内层横向外层纵向的话,索引在换行之前都是连续的,方便整合 + for (let ny = blockTop; ny <= blockBottom; ny++) { + for (let nx = blockLeft; nx <= blockRight; nx++) { + const block = this.vertex.block.getBlockByLoc(nx, ny)!; + blockList.push(block); + } + } + + if (blockList.length > 0) { + if (blockList.length === 1) { + const block = blockList[0]; + if (block.data.renderDirty) { + this.pushBlock(updateArea, block, block); + } + this.pushBlock(renderArea, block, block); + } else { + // 更新区域 + let updateStart: IBlockData = blockList[0]; + let updateEnd: IBlockData = blockList[0]; + let renderStart: IBlockData = blockList[0]; + let renderEnd: IBlockData = blockList[0]; + for (let i = 1; i < blockList.length; i++) { + const block = blockList[i]; + const { renderDirty } = block.data; + // 连续则合并 + // 渲染区域 + if (block.index === renderEnd.index + 1) { + renderEnd = block; + } else { + this.pushBlock(renderArea, renderStart, renderEnd); + renderStart = block; + renderEnd = block; + } + // 缓冲区更新区域 + if (renderDirty && block.index === updateEnd.index + 1) { + updateEnd = block; + } else { + this.pushBlock(updateArea, updateStart, updateEnd); + updateStart = block; + updateEnd = block; + } + } + this.pushBlock(updateArea, updateStart, updateEnd); + this.pushBlock(renderArea, renderStart, renderEnd); + } + } + + const dynamicStart = this.vertex.dynamicStart; + const dynamicCount = this.vertex.dynamicCount; + this.checkDynamic(renderArea, dynamicStart, dynamicCount); + if (this.vertex.dynamicRenderDirty) { + this.checkDynamic(updateArea, dynamicStart, dynamicCount); + } + + return { + render: renderArea, + dirty: updateArea, + blockList: blockList + }; + } + + bindTransform(transform: Transform): void { + this.transform = transform; + } +} diff --git a/packages-user/client-modules/src/render/shared.ts b/packages-user/client-modules/src/render/shared.ts index acff2da..a39b3e5 100644 --- a/packages-user/client-modules/src/render/shared.ts +++ b/packages-user/client-modules/src/render/shared.ts @@ -5,6 +5,10 @@ import { Font } from '@motajs/render-style'; //#region 地图 +/** 每个格子的默认宽度,现阶段用处不大 */ +export const CELL_WIDTH = 32; +/** 每个格子的默认高度,现阶段用处不大 */ +export const CELL_HEIGHT = 32; /** 每个格子的宽高 */ export const CELL_SIZE = 32; /** 地图格子宽度,此处仅影响画面,不影响游戏内逻辑,游戏内逻辑地图大小请在 core.js 中修改 */ @@ -19,6 +23,18 @@ export const MAP_HEIGHT = CELL_SIZE * MAP_BLOCK_HEIGHT; export const HALF_MAP_WIDTH = MAP_WIDTH / 2; /** 地图高度的一半 */ export const HALF_MAP_HEIGHT = MAP_HEIGHT / 2; +/** + * 动态内容预留,不明白含义的话不要动。地图上所有正在移动的图块称为动态内容,这些内容的数量无法预测,因此需要预留数组大小, + * 如果不够再临时扩充。如果你的塔中有大量的移动操作,可以适当提高此值,避免频繁的内存扩充行为,可以一定程度上提高性能表现。 + */ +export const DYNAMIC_RESERVE = 16; +/** + * 移动图块容忍度,不明白含义的话不要动。如果移动图块的数量长期小于当前预留数量,那么将会降低预留数量,提升性能表现。 + * 调整此值可以调整频率,值越大,越不容易因为数量小于预留数量而减小预留。 + */ +export const MOVING_TOLERANCE = 60; +/** 开关门动画的动画时长 */ +export const DOOR_ANIMATE_INTERVAL = 50; //#region 状态栏 diff --git a/packages-user/client-modules/src/render/ui/index.ts b/packages-user/client-modules/src/render/ui/index.ts index 81c0c24..67db8c2 100644 --- a/packages-user/client-modules/src/render/ui/index.ts +++ b/packages-user/client-modules/src/render/ui/index.ts @@ -8,6 +8,7 @@ export * from './controller'; export * from './main'; export * from './save'; export * from './settings'; +export * from './statistics'; export * from './statusBar'; export * from './toolbar'; export * from './viewmap'; diff --git a/packages-user/client-modules/src/render/ui/save.tsx b/packages-user/client-modules/src/render/ui/save.tsx index 66ac79e..11b9e36 100644 --- a/packages-user/client-modules/src/render/ui/save.tsx +++ b/packages-user/client-modules/src/render/ui/save.tsx @@ -241,10 +241,10 @@ export const Save = defineComponent( }; onMounted(() => { - const startIndex = getPosIndex(core.saves.saveIndex - 1); - selected.value = startIndex; + const startIndex = getPosIndex(core.saves.saveIndex); + selected.value = startIndex - 1; pageRef.value?.changePage( - Math.floor((core.saves.saveIndex - 1) / (grid.value.count - 1)) + Math.floor(core.saves.saveIndex / (grid.value.count - 1)) ); updateDataList(now.value); }); @@ -380,10 +380,10 @@ export const Save = defineComponent( '@save_right', () => { const count = grid.value.count; - if (selected.value < count - 1) { + if (selected.value < count) { selected.value++; } else { - selected.value = 1; + selected.value = 0; pageRef.value?.movePage(1); } }, diff --git a/packages-user/client-modules/src/render/ui/settings.tsx b/packages-user/client-modules/src/render/ui/settings.tsx index 3fe3c2d..3acf24a 100644 --- a/packages-user/client-modules/src/render/ui/settings.tsx +++ b/packages-user/client-modules/src/render/ui/settings.tsx @@ -20,6 +20,7 @@ import { generateKeyboardEvent } from '@motajs/system-action'; import { getVitualKeyOnce } from '@motajs/legacy-ui'; import { getAllSavesData, getSaveData, syncFromServer } from '../utils'; import { getInput } from '../components'; +import { openStatistics } from './statistics'; import { saveWithExist } from './save'; import { compressToBase64 } from 'lz-string'; import { ViewMapUI } from './viewmap'; @@ -126,7 +127,7 @@ export const MainSettings = defineComponent(props => { choices={choices} width={POP_BOX_WIDTH} onChoose={choose} - maxHeight={MAIN_HEIGHT - 32} + maxHeight={MAIN_HEIGHT - 64} interval={8} scope={scope} /> @@ -245,7 +246,6 @@ export const ReplaySettings = defineComponent(props => { onChoose={choose} interval={8} scope={scope} - maxHeight={MAIN_HEIGHT - 32} /> ); }, mainSettingsProps); @@ -275,12 +275,7 @@ export const GameInfo = defineComponent(props => { const choose = async (key: ChoiceKey) => { switch (key) { case GameInfoChoice.Statistics: { - getConfirm( - props.controller, - '数据统计尚未完工', - CENTER_LOC, - POP_BOX_WIDTH - ); + openStatistics(props.controller); break; } case GameInfoChoice.Project: { diff --git a/packages-user/client-modules/src/render/weather/presets/cloud.ts b/packages-user/client-modules/src/render/weather/presets/cloud.ts index 6f7004c..b76b3cc 100644 --- a/packages-user/client-modules/src/render/weather/presets/cloud.ts +++ b/packages-user/client-modules/src/render/weather/presets/cloud.ts @@ -1,5 +1,5 @@ import { CloudLike } from './cloudLike'; -import { SizedCanvasImageSource } from '@motajs/render-core'; +import { SizedCanvasImageSource } from '@motajs/render-assets'; export class CloudWeather extends CloudLike { getImage(): SizedCanvasImageSource | null { diff --git a/packages-user/client-modules/src/render/weather/presets/cloudLike.ts b/packages-user/client-modules/src/render/weather/presets/cloudLike.ts index fcde800..237819b 100644 --- a/packages-user/client-modules/src/render/weather/presets/cloudLike.ts +++ b/packages-user/client-modules/src/render/weather/presets/cloudLike.ts @@ -1,6 +1,6 @@ import { MotaOffscreenCanvas2D, Sprite } from '@motajs/render-core'; import { Weather } from '../weather'; -import { SizedCanvasImageSource } from '@motajs/render-core'; +import { SizedCanvasImageSource } from '@motajs/render-assets'; export abstract class CloudLike extends Weather { /** 不透明度 */ diff --git a/packages-user/client-modules/src/render/weather/presets/fog.ts b/packages-user/client-modules/src/render/weather/presets/fog.ts index 0ad39fb..30692f8 100644 --- a/packages-user/client-modules/src/render/weather/presets/fog.ts +++ b/packages-user/client-modules/src/render/weather/presets/fog.ts @@ -1,6 +1,6 @@ import { MotaOffscreenCanvas2D } from '@motajs/render-core'; import { CloudLike } from './cloudLike'; -import { SizedCanvasImageSource } from '@motajs/render-core'; +import { SizedCanvasImageSource } from '@motajs/render-assets'; export class FogWeather extends CloudLike { /** 雾天气的图像比较小,因此将四个进行合并 */ diff --git a/packages-user/data-base/src/game.ts b/packages-user/data-base/src/game.ts index 51f0f50..d02ccd5 100644 --- a/packages-user/data-base/src/game.ts +++ b/packages-user/data-base/src/game.ts @@ -11,6 +11,8 @@ interface GameLoadEvent { coreInit: []; /** 当所有启动必要资源加载完毕后触发 */ loaded: []; + /** 当资源构建完毕后触发,后续需要用新的加载系统替代 */ + assetBuilt: []; /** 当客户端(渲染端)和数据端都挂载完毕后触发 */ registered: []; /** 当数据端挂载完毕后触发 */ diff --git a/packages-user/data-state/src/common/face.ts b/packages-user/data-state/src/common/face.ts new file mode 100644 index 0000000..5318fcf --- /dev/null +++ b/packages-user/data-state/src/common/face.ts @@ -0,0 +1,63 @@ +import { logger } from '@motajs/common'; +import { IFaceData, IRoleFaceBinder } from './types'; +import { isNil } from 'lodash-es'; +import { FaceDirection } from '.'; + +interface FaceInfo { + /** 此图块的朝向 */ + readonly face: FaceDirection; + /** 此图块对应的映射 */ + readonly map: Map; +} + +export class RoleFaceBinder implements IRoleFaceBinder { + /** 每个图块对应的朝向信息 */ + private faceMap: Map = new Map(); + /** 主要朝向映射 */ + private mainMap: Map = new Map(); + + malloc(identifier: number, main: FaceDirection): void { + this.mainMap.set(identifier, main); + const map = new Map(); + map.set(main, identifier); + const info: FaceInfo = { face: main, map }; + this.faceMap.set(identifier, info); + } + + bind(identifier: number, main: number, face: FaceDirection): void { + const mainFace = this.mainMap.get(main); + if (isNil(mainFace)) { + logger.error(43, main.toString()); + return; + } + if (mainFace === face) { + logger.error(44, main.toString()); + return; + } + const { map } = this.faceMap.get(main)!; + map.set(face, identifier); + const info: FaceInfo = { face, map }; + this.faceMap.set(identifier, info); + this.mainMap.set(identifier, mainFace); + } + + getFaceOf(identifier: number, face: FaceDirection): IFaceData | null { + const info = this.faceMap.get(identifier); + if (!info) return null; + const target = info.map.get(face); + if (isNil(target)) return null; + const data: IFaceData = { identifier: target, face }; + return data; + } + + getFaceDirection(identifier: number): FaceDirection | undefined { + return this.faceMap.get(identifier)?.face; + } + + getMainFace(identifier: number): IFaceData | null { + const face = this.mainMap.get(identifier); + if (isNil(face)) return null; + const data: IFaceData = { identifier, face }; + return data; + } +} diff --git a/packages-user/data-state/src/common/index.ts b/packages-user/data-state/src/common/index.ts new file mode 100644 index 0000000..5fcbbcb --- /dev/null +++ b/packages-user/data-state/src/common/index.ts @@ -0,0 +1,3 @@ +export * from './face'; +export * from './types'; +export * from './utils'; diff --git a/packages-user/data-state/src/common/types.ts b/packages-user/data-state/src/common/types.ts new file mode 100644 index 0000000..ef726bb --- /dev/null +++ b/packages-user/data-state/src/common/types.ts @@ -0,0 +1,54 @@ +export const enum FaceDirection { + Unknown, + Left, + Up, + Right, + Down, + LeftUp, + RightUp, + LeftDown, + RightDown +} + +export interface IFaceData { + /** 图块数字 */ + readonly identifier: number; + /** 图块朝向 */ + readonly face: FaceDirection; +} + +export interface IRoleFaceBinder { + /** + * 给指定的图块分配朝向绑定 + * @param identifier 图块数字 + * @param main 主图块朝向,一般是朝下 + */ + malloc(identifier: number, main: FaceDirection): void; + + /** + * 将一个图块与另一个图块绑定朝向,需要注意要先调用 {@link malloc} 分配朝向信息 + * @param identifier 当前图块数字 + * @param main 主图块数字,即当前图块与目标图块属于主图块的另一个朝向 + * @param face 当前图块的朝向方向 + */ + bind(identifier: number, main: number, face: FaceDirection): void; + + /** + * 获取一个图块指定朝向的图块数字 + * @param identifier 图块数字,可以是任意朝向的图块数字 + * @param face 要获取的朝向 + */ + getFaceOf(identifier: number, face: FaceDirection): IFaceData | null; + + /** + * 获取指定图块数字是哪个朝向 + * @param identifier 图块数字 + */ + getFaceDirection(identifier: number): FaceDirection | undefined; + + /** + * 获取指定图块数字绑定至的主朝向 + * @param identifier 图块数字,可以是任意朝向的图块数字 + */ + getMainFace(identifier: number): IFaceData | null; +} diff --git a/packages-user/data-state/src/common/utils.ts b/packages-user/data-state/src/common/utils.ts new file mode 100644 index 0000000..9502841 --- /dev/null +++ b/packages-user/data-state/src/common/utils.ts @@ -0,0 +1,182 @@ +import { FaceDirection } from './types'; + +/** + * 获取指定朝向的坐标偏移量 + * @param dir 朝向 + */ +export function getFaceMovement(dir: FaceDirection): Loc { + switch (dir) { + case FaceDirection.Left: + return { x: -1, y: 0 }; + case FaceDirection.Right: + return { x: 1, y: 0 }; + case FaceDirection.Up: + return { x: 0, y: -1 }; + case FaceDirection.Down: + return { x: 0, y: 1 }; + case FaceDirection.LeftUp: + return { x: -1, y: -1 }; + case FaceDirection.RightUp: + return { x: 1, y: -1 }; + case FaceDirection.LeftDown: + return { x: -1, y: 1 }; + case FaceDirection.RightDown: + return { x: 1, y: 1 }; + case FaceDirection.Unknown: + return { x: 0, y: 0 }; + } +} + +/** + * 将八方向朝向降级为四方向朝向 + * @param dir 朝向 + * @param unknown 如果朝向是 `FaceDirection.Unknown`,那么会返回什么,默认还是未知 + */ +export function degradeFace( + dir: FaceDirection, + unknown: FaceDirection = FaceDirection.Unknown +): FaceDirection { + switch (dir) { + case FaceDirection.LeftUp: + return FaceDirection.Left; + case FaceDirection.LeftDown: + return FaceDirection.Left; + case FaceDirection.RightUp: + return FaceDirection.Right; + case FaceDirection.RightDown: + return FaceDirection.Right; + case FaceDirection.Unknown: + return unknown; + } + return dir; +} + +/** + * 获取指定朝向旋转后的朝向 + * @param dir 当前朝向 + * @param anticlockwise 是否逆时针旋转,默认顺时针 + * @param face8 是否使用八朝向。为 `false` 时,旋转为九十度旋转,即 上->右->下->左,左上->右上->右下->左下。 + * 为 `true` 时,旋转为四十五度旋转,即 上->右上->右->右下->下->左下->左->左上。逆时针反过来旋转。 + */ +export function nextFaceDirection( + dir: FaceDirection, + anticlockwise: boolean = false, + face8: boolean = false +): FaceDirection { + if (face8) { + if (anticlockwise) { + switch (dir) { + case FaceDirection.Left: + return FaceDirection.LeftDown; + case FaceDirection.LeftDown: + return FaceDirection.Down; + case FaceDirection.Down: + return FaceDirection.RightDown; + case FaceDirection.RightDown: + return FaceDirection.Right; + case FaceDirection.Right: + return FaceDirection.RightUp; + case FaceDirection.RightUp: + return FaceDirection.Up; + case FaceDirection.Up: + return FaceDirection.LeftUp; + case FaceDirection.LeftUp: + return FaceDirection.Left; + case FaceDirection.Unknown: + return FaceDirection.Unknown; + } + } else { + switch (dir) { + case FaceDirection.Left: + return FaceDirection.LeftUp; + case FaceDirection.LeftUp: + return FaceDirection.Up; + case FaceDirection.Up: + return FaceDirection.RightUp; + case FaceDirection.RightUp: + return FaceDirection.Right; + case FaceDirection.Right: + return FaceDirection.RightDown; + case FaceDirection.RightDown: + return FaceDirection.Down; + case FaceDirection.Down: + return FaceDirection.LeftDown; + case FaceDirection.LeftDown: + return FaceDirection.Left; + case FaceDirection.Unknown: + return FaceDirection.Unknown; + } + } + } else { + if (anticlockwise) { + switch (dir) { + case FaceDirection.Left: + return FaceDirection.Down; + case FaceDirection.Down: + return FaceDirection.Right; + case FaceDirection.Right: + return FaceDirection.Up; + case FaceDirection.Up: + return FaceDirection.Left; + case FaceDirection.LeftUp: + return FaceDirection.LeftDown; + case FaceDirection.LeftDown: + return FaceDirection.RightDown; + case FaceDirection.RightDown: + return FaceDirection.RightUp; + case FaceDirection.RightUp: + return FaceDirection.LeftUp; + case FaceDirection.Unknown: + return FaceDirection.Unknown; + } + } else { + switch (dir) { + case FaceDirection.Left: + return FaceDirection.Up; + case FaceDirection.Up: + return FaceDirection.Right; + case FaceDirection.Right: + return FaceDirection.Down; + case FaceDirection.Down: + return FaceDirection.Left; + case FaceDirection.LeftUp: + return FaceDirection.RightUp; + case FaceDirection.RightUp: + return FaceDirection.RightDown; + case FaceDirection.RightDown: + return FaceDirection.LeftDown; + case FaceDirection.LeftDown: + return FaceDirection.LeftUp; + case FaceDirection.Unknown: + return FaceDirection.Unknown; + } + } + } +} + +/** + * 根据朝向字符串获取朝向枚举值 + * @param dir 朝向字符串 + */ +export function fromDirectionString(dir: Dir2): FaceDirection { + switch (dir) { + case 'left': + return FaceDirection.Left; + case 'right': + return FaceDirection.Right; + case 'up': + return FaceDirection.Up; + case 'down': + return FaceDirection.Down; + case 'leftup': + return FaceDirection.LeftUp; + case 'rightup': + return FaceDirection.RightUp; + case 'leftdown': + return FaceDirection.LeftDown; + case 'rightdown': + return FaceDirection.RightDown; + default: + return FaceDirection.Unknown; + } +} diff --git a/packages-user/data-state/src/core.ts b/packages-user/data-state/src/core.ts new file mode 100644 index 0000000..34f1f8d --- /dev/null +++ b/packages-user/data-state/src/core.ts @@ -0,0 +1,33 @@ +import { ICoreState, IStateSaveData } from './types'; +import { IHeroState, HeroState } from './hero'; +import { ILayerState, LayerState } from './map'; +import { IRoleFaceBinder, RoleFaceBinder } from './common'; + +export class CoreState implements ICoreState { + readonly layer: ILayerState; + readonly hero: IHeroState; + readonly roleFace: IRoleFaceBinder; + readonly idNumberMap: Map; + readonly numberIdMap: Map; + + constructor() { + this.layer = new LayerState(); + this.hero = new HeroState(); + this.roleFace = new RoleFaceBinder(); + this.idNumberMap = new Map(); + this.numberIdMap = new Map(); + } + + saveState(): IStateSaveData { + return structuredClone({ + followers: this.hero.followers + }); + } + + loadState(data: IStateSaveData): void { + this.hero.removeAllFollowers(); + data?.followers.forEach(v => { + this.hero.addFollower(v.num, v.identifier); + }); + } +} diff --git a/packages-user/data-state/src/enemy/damage.ts b/packages-user/data-state/src/enemy/damage.ts index 3a737e3..46d5774 100644 --- a/packages-user/data-state/src/enemy/damage.ts +++ b/packages-user/data-state/src/enemy/damage.ts @@ -1,4 +1,4 @@ -import { getHeroStatusOf, getHeroStatusOn } from '../state/hero'; +import { getHeroStatusOf, getHeroStatusOn } from '../legacy/hero'; import { Range, ensureArray, has, manhattan } from '@user/data-utils'; import EventEmitter from 'eventemitter3'; import { hook } from '@user/data-base'; diff --git a/packages-user/data-state/src/enemy/special.ts b/packages-user/data-state/src/enemy/special.ts index 7055412..9ddf428 100644 --- a/packages-user/data-state/src/enemy/special.ts +++ b/packages-user/data-state/src/enemy/special.ts @@ -1,5 +1,5 @@ import { EnemyInfo } from '@motajs/types'; -import { getHeroStatusOn } from '../state/hero'; +import { getHeroStatusOn } from '../legacy/hero'; export interface SpecialDeclaration { code: number; diff --git a/packages-user/data-state/src/hero/index.ts b/packages-user/data-state/src/hero/index.ts new file mode 100644 index 0000000..0de1d2f --- /dev/null +++ b/packages-user/data-state/src/hero/index.ts @@ -0,0 +1,2 @@ +export * from './state'; +export * from './types'; diff --git a/packages-user/data-state/src/hero/state.ts b/packages-user/data-state/src/hero/state.ts new file mode 100644 index 0000000..113eebd --- /dev/null +++ b/packages-user/data-state/src/hero/state.ts @@ -0,0 +1,133 @@ +import { Hookable, HookController, IHookController } from '@motajs/common'; +import { IHeroFollower, IHeroState, IHeroStateHooks } from './types'; +import { FaceDirection, getFaceMovement, nextFaceDirection } from '../common'; +import { isNil } from 'lodash-es'; +import { DEFAULT_HERO_IMAGE } from '../shared'; + +export class HeroState extends Hookable implements IHeroState { + x: number = 0; + y: number = 0; + direction: FaceDirection = FaceDirection.Down; + image: ImageIds = DEFAULT_HERO_IMAGE; + + /** 当前勇士是否正在移动 */ + moving: boolean = false; + alpha: number = 1; + + readonly followers: IHeroFollower[] = []; + + protected createController( + hook: Partial + ): IHookController { + return new HookController(this, hook); + } + + setPosition(x: number, y: number): void { + this.x = x; + this.y = y; + this.forEachHook(hook => { + hook.onSetPosition?.(x, y); + }); + } + + turn(direction?: FaceDirection): void { + const next = isNil(direction) + ? nextFaceDirection(this.direction) + : direction; + this.direction = next; + this.forEachHook(hook => { + hook.onTurnHero?.(next); + }); + } + + startMove(): void { + this.moving = true; + this.forEachHook(hook => { + hook.onStartMove?.(); + }); + } + + async move(dir: FaceDirection, time: number = 100): Promise { + await Promise.all( + this.forEachHook(hook => { + return hook.onMoveHero?.(dir, time); + }) + ); + const { x, y } = getFaceMovement(dir); + this.x += x; + this.y += y; + } + + async endMove(): Promise { + if (!this.moving) return; + await Promise.all( + this.forEachHook(hook => { + return hook.onEndMove?.(); + }) + ); + this.moving = false; + } + + async jumpHero( + x: number, + y: number, + time: number = 500, + waitFollower: boolean = false + ): Promise { + await Promise.all( + this.forEachHook(hook => { + return hook.onJumpHero?.(x, y, time, waitFollower); + }) + ); + this.x = x; + this.y = y; + } + + setImage(image: ImageIds): void { + this.image = image; + this.forEachHook(hook => { + hook.onSetImage?.(image); + }); + } + + setAlpha(alpha: number): void { + this.alpha = alpha; + this.forEachHook(hook => { + hook.onSetAlpha?.(alpha); + }); + } + + setFollowerAlpha(identifier: string, alpha: number): void { + const follower = this.followers.find(v => v.identifier === identifier); + if (!follower) return; + follower.alpha = alpha; + this.forEachHook(hook => { + hook.onSetFollowerAlpha?.(identifier, alpha); + }); + } + + addFollower(follower: number, identifier: string): void { + this.followers.push({ num: follower, identifier, alpha: 1 }); + this.forEachHook(hook => { + hook.onAddFollower?.(follower, identifier); + }); + } + + removeFollower(identifier: string, animate: boolean = false): void { + const index = this.followers.findIndex( + v => v.identifier === identifier + ); + if (index === -1) return; + this.followers.splice(index, 1); + this.forEachHook(hook => { + hook.onRemoveFollower?.(identifier, animate); + }); + } + + removeAllFollowers(): void { + this.followers.length = 0; + this.forEachHook(hook => { + hook.onRemoveAllFollowers?.(); + }); + } +} diff --git a/packages-user/data-state/src/hero/types.ts b/packages-user/data-state/src/hero/types.ts new file mode 100644 index 0000000..197bf7d --- /dev/null +++ b/packages-user/data-state/src/hero/types.ts @@ -0,0 +1,204 @@ +import { IHookBase, IHookable } from '@motajs/common'; +import { FaceDirection } from '../common/types'; + +export const enum HeroAnimateDirection { + /** 正向播放动画 */ + Forward, + /** 反向播放动画 */ + Backward +} + +export interface IHeroFollower { + /** 跟随者的图块数字 */ + readonly num: number; + /** 跟随者的标识符 */ + readonly identifier: string; + /** 跟随者的不透明度 */ + alpha: number; +} + +export interface IHeroStateHooks extends IHookBase { + /** + * 当设置勇士的坐标时触发 + * @param controller 钩子控制器 + * @param x 勇士横坐标 + * @param y 勇士纵坐标 + */ + onSetPosition(x: number, y: number): void; + + /** + * 当设置勇士朝向时触发 + * @param direction 勇士朝向 + */ + onTurnHero(direction: FaceDirection): void; + + /** + * 当勇士开始移动时触发 + */ + onStartMove(): void; + + /** + * 当移动勇士时触发 + * @param controller 钩子控制器 + * @param direction 移动方向 + * @param time 移动动画时长 + */ + onMoveHero(direction: FaceDirection, time: number): Promise; + + /** + * 当停止移动时触发 + */ + onEndMove(): Promise; + + /** + * 当勇士跳跃时触发 + * @param x 目标点横坐标 + * @param y 目标点纵坐标 + * @param time 跳跃动画时长 + * @param waitFollower 是否等待跟随者跳跃完毕 + */ + onJumpHero( + x: number, + y: number, + time: number, + waitFollower: boolean + ): Promise; + + /** + * 当设置勇士图片时触发 + * @param image 勇士图片 id + */ + onSetImage(image: ImageIds): void; + + /** + * 当设置勇士不透明度时执行 + * @param alpha 不透明度 + */ + onSetAlpha(alpha: number): void; + + /** + * 添加跟随者时触发 + * @param follower 跟随者的图块数字 + * @param identifier 跟随者的标识符 + */ + onAddFollower(follower: number, identifier: string): void; + + /** + * 当移除跟随者时触发 + * @param identifier 跟随者的标识符 + * @param animate 填 `true` 的话,如果删除了中间的跟随者,后续跟随者会使用移动动画移动到下一格,否则瞬移至下一格 + */ + onRemoveFollower(identifier: string, animate: boolean): void; + + /** + * 当移除所有跟随者时触发 + */ + onRemoveAllFollowers(): void; + + /** + * 设置跟随者的不透明度 + * @param identifier 跟随者标识符 + * @param alpha 跟随者不透明度 + */ + onSetFollowerAlpha(identifier: string, alpha: number): void; +} + +export interface IHeroState extends IHookable { + /** 勇士横坐标 */ + readonly x: number; + /** 勇士纵坐标 */ + readonly y: number; + /** 勇士朝向 */ + readonly direction: FaceDirection; + /** 勇士图片 */ + readonly image?: ImageIds; + /** 跟随者列表 */ + readonly followers: readonly IHeroFollower[]; + /** 勇士当前的不透明度 */ + readonly alpha: number; + + /** + * 设置勇士位置 + * @param x 横坐标 + * @param y 纵坐标 + */ + setPosition(x: number, y: number): void; + + /** + * 设置勇士朝向 + * @param direction 勇士朝向,不填表示顺时针旋转 + */ + turn(direction?: FaceDirection): void; + + /** + * 开始勇士移动,在移动前必须先调用此方法将勇士切换为移动状态 + */ + startMove(): void; + + /** + * 移动勇士。能否移动的逻辑暂时不在这里,目前作为过渡作用,仅服务于渲染 + * @param dir 移动方向 + * @param time 移动动画时长,默认 100ms + * @returns 移动的 `Promise`,当相关的移动动画结束后兑现 + */ + move(dir: FaceDirection, time?: number): Promise; + + /** + * 结束勇士移动 + * @returns 当移动动画结束后兑现的 `Promise` + */ + endMove(): Promise; + + /** + * 跳跃勇士至目标点 + * @param x 目标点横坐标 + * @param y 目标点纵坐标 + * @param time 跳跃动画时长,默认 500ms + * @param waitFollower 是否等待跟随者跳跃完毕,默认不等待 + * @returns 跳跃的 `Promise`,当相关的移动动画结束后兑现 + */ + jumpHero( + x: number, + y: number, + time?: number, + waitFollower?: boolean + ): Promise; + + /** + * 设置勇士图片 + * @param image 图片 id + */ + setImage(image: ImageIds): void; + + /** + * 设置勇士的不透明度 + * @param alpha 不透明度 + */ + setAlpha(alpha: number): void; + + /** + * 添加一个跟随者 + * @param follower 跟随者的图块数字 + * @param identifier 跟随者的标识符,可以用来移除 + */ + addFollower(follower: number, identifier: string): void; + + /** + * 移除指定的跟随者 + * @param identifier 跟随者的标识符 + * @param animate 填 `true` 的话,如果删除了中间的跟随者,后续跟随者会使用移动动画移动到下一格,否则瞬移至下一格 + */ + removeFollower(identifier: string, animate?: boolean): void; + + /** + * 移除所有跟随者 + */ + removeAllFollowers(): void; + + /** + * 设置指定跟随者的不透明度 + * @param identifier 跟随者标识符 + * @param alpha 跟随者不透明度 + */ + setFollowerAlpha(identifier: string, alpha: number): void; +} diff --git a/packages-user/data-state/src/index.ts b/packages-user/data-state/src/index.ts index 9b6d413..23b609c 100644 --- a/packages-user/data-state/src/index.ts +++ b/packages-user/data-state/src/index.ts @@ -1,2 +1,75 @@ +import { loading } from '@user/data-base'; +import { CoreState } from './core'; +import { isNil } from 'lodash-es'; +import { FaceDirection } from './common'; + +function createCoreState() { + //#region 地图部分 + + const width = core._WIDTH_; + const height = core._HEIGHT_; + const bg = state.layer.addLayer(width, height); + const bg2 = state.layer.addLayer(width, height); + const event = state.layer.addLayer(width, height); + const fg = state.layer.addLayer(width, height); + const fg2 = state.layer.addLayer(width, height); + state.layer.setLayerAlias(bg, 'bg'); + state.layer.setLayerAlias(bg2, 'bg2'); + state.layer.setLayerAlias(event, 'event'); + state.layer.setLayerAlias(fg, 'fg'); + state.layer.setLayerAlias(fg2, 'fg2'); + + //#endregion + + //#region 图块部分 + + const data = Object.entries(core.maps.blocksInfo); + for (const [key, block] of data) { + const num = Number(key); + state.idNumberMap.set(block.id, num); + state.numberIdMap.set(num, block.id); + } + + for (const [key, block] of data) { + if (!block.faceIds) continue; + const { down, up, left, right } = block.faceIds; + const downNum = state.idNumberMap.get(down); + if (downNum !== Number(key)) continue; + const upNum = state.idNumberMap.get(up); + const leftNum = state.idNumberMap.get(left); + const rightNum = state.idNumberMap.get(right); + state.roleFace.malloc(downNum, FaceDirection.Down); + if (!isNil(upNum)) { + state.roleFace.bind(upNum, downNum, FaceDirection.Up); + } + if (!isNil(leftNum)) { + state.roleFace.bind(leftNum, downNum, FaceDirection.Left); + } + if (!isNil(rightNum)) { + state.roleFace.bind(rightNum, downNum, FaceDirection.Right); + } + } + + //#endregion +} + +export function create() { + loading.once('loaded', () => { + // 加载后初始化全局状态 + createCoreState(); + }); +} + +/** + * 数据端核心状态,目前处于过渡阶段,仅服务于渲染,不负责任何逻辑计算,会在后续把核心逻辑逐渐移动至此对象。 + * 此对象是数据端状态,本身不负责任何渲染操作,仅会向渲染端发送数据让渲染端渲染,不要把渲染操作直接放到此对象上, + * 否则可能导致录像验证失败。 + */ +export const state = new CoreState(); + +export * from './common'; +export * from './core'; export * from './enemy'; -export * from './state'; +export * from './hero'; +export * from './map'; +export * from './legacy'; diff --git a/packages-user/data-state/src/legacy/hero.ts b/packages-user/data-state/src/legacy/hero.ts new file mode 100644 index 0000000..b238993 --- /dev/null +++ b/packages-user/data-state/src/legacy/hero.ts @@ -0,0 +1,115 @@ +/** + * 获取勇士在某一点的属性 + * @param name 要获取的勇士属性 + * @param floorId 勇士所在楼层 + */ +export function getHeroStatusOn(name: 'all', floorId?: FloorIds): HeroStatus; +export function getHeroStatusOn( + name: (keyof HeroStatus)[], + floorId?: FloorIds +): Partial; +export function getHeroStatusOn( + name: K, + floorId?: FloorIds +): HeroStatus[K]; +export function getHeroStatusOn( + name: keyof HeroStatus | 'all' | (keyof HeroStatus)[], + floorId?: FloorIds +) { + // @ts-expect-error 暂时无法推导 + return getHeroStatusOf(core.status.hero, name, floorId); +} + +/** + * 获取一定状态下的勇士在某一点的属性 + * @param status 勇士的状态 + * @param name 要获取的勇士属性 + * @param floorId 勇士所在楼层 + */ +export function getHeroStatusOf( + status: Partial, + name: 'all', + floorId?: FloorIds +): HeroStatus; +export function getHeroStatusOf( + status: Partial, + name: (keyof HeroStatus)[], + floorId?: FloorIds +): Partial; +export function getHeroStatusOf( + status: Partial, + name: K, + floorId?: FloorIds +): HeroStatus[K]; +export function getHeroStatusOf( + status: DeepPartial, + name: keyof HeroStatus | 'all' | (keyof HeroStatus)[], + floorId?: FloorIds +) { + return getRealStatus(status, name, floorId); +} + +function getRealStatus( + status: DeepPartial, + name: keyof HeroStatus | 'all' | (keyof HeroStatus)[], + floorId: FloorIds = core.status.floorId +): any { + if (name instanceof Array) { + const res: any = {}; + name.forEach(v => { + res[v] = getRealStatus(status, v, floorId); + }); + return res; + } + + if (name === 'all') { + const res: any = {}; + for (const [key, value] of Object.entries(core.status.hero)) { + if (typeof value === 'number') { + res[key] = getRealStatus( + status, + key as keyof HeroStatus, + floorId + ); + } else { + res[key] = value; + } + } + + return res; + } + + let s = (status[name] ?? core.status.hero[name]) as number; + if (s === null || s === void 0) { + throw new ReferenceError( + `Wrong hero status property name is delivered: ${name}` + ); + } + + if (typeof s !== 'number') return s; + + // buff + s *= core.status.hero.buff[name] ?? 1; + s = Math.floor(s); + + // 衰弱效果 + if ((name === 'atk' || name === 'def') && flags.weak) { + const weak = core.values.weakValue; + if (weak < 1) { + // 百分比衰弱 + s *= 1 - weak; + } else { + s -= weak; + } + } + + return s; +} + +// 下面的内容暂时无用 + +export interface IHeroStatusDefault { + atk: number; + def: number; + hp: number; +} diff --git a/packages-user/data-state/src/state/index.ts b/packages-user/data-state/src/legacy/index.ts similarity index 100% rename from packages-user/data-state/src/state/index.ts rename to packages-user/data-state/src/legacy/index.ts diff --git a/packages-user/data-state/src/state/interface.ts b/packages-user/data-state/src/legacy/interface.ts similarity index 100% rename from packages-user/data-state/src/state/interface.ts rename to packages-user/data-state/src/legacy/interface.ts diff --git a/packages-user/data-state/src/state/item.ts b/packages-user/data-state/src/legacy/item.ts similarity index 100% rename from packages-user/data-state/src/state/item.ts rename to packages-user/data-state/src/legacy/item.ts diff --git a/packages-user/data-state/src/state/move.ts b/packages-user/data-state/src/legacy/move.ts similarity index 100% rename from packages-user/data-state/src/state/move.ts rename to packages-user/data-state/src/legacy/move.ts diff --git a/packages-user/data-state/src/state/utils.ts b/packages-user/data-state/src/legacy/utils.ts similarity index 100% rename from packages-user/data-state/src/state/utils.ts rename to packages-user/data-state/src/legacy/utils.ts diff --git a/packages-user/data-state/src/map/index.ts b/packages-user/data-state/src/map/index.ts new file mode 100644 index 0000000..1f9d7d5 --- /dev/null +++ b/packages-user/data-state/src/map/index.ts @@ -0,0 +1,3 @@ +export * from './layerState'; +export * from './mapLayer'; +export * from './types'; diff --git a/packages-user/data-state/src/map/layerState.ts b/packages-user/data-state/src/map/layerState.ts new file mode 100644 index 0000000..d6c8d24 --- /dev/null +++ b/packages-user/data-state/src/map/layerState.ts @@ -0,0 +1,139 @@ +import { + Hookable, + HookController, + IHookController, + logger +} from '@motajs/common'; +import { + ILayerState, + ILayerStateHooks, + IMapLayer, + IMapLayerHookController, + IMapLayerHooks +} from './types'; +import { MapLayer } from './mapLayer'; + +export class LayerState + extends Hookable + implements ILayerState +{ + readonly layerList: Set = new Set(); + /** 图层到图层别名映射 */ + readonly layerAliasMap: WeakMap = new WeakMap(); + /** 图层别名到图层的映射 */ + readonly aliasLayerMap: Map = new Map(); + + /** 背景图块 */ + private backgroundTile: number = 0; + + /** 图层钩子映射 */ + private layerHookMap: Map = new Map(); + + addLayer(width: number, height: number): IMapLayer { + const array = new Uint32Array(width * height); + const layer = new MapLayer(array, width, height); + this.layerList.add(layer); + this.forEachHook(hook => { + hook.onUpdateLayer?.(this.layerList); + }); + const controller = layer.addHook(new StateMapLayerHook(this, layer)); + this.layerHookMap.set(layer, controller); + controller.load(); + return layer; + } + + removeLayer(layer: IMapLayer): void { + this.layerList.delete(layer); + const alias = this.layerAliasMap.get(layer); + if (alias) { + const symbol = Symbol.for(alias); + this.aliasLayerMap.delete(symbol); + this.layerAliasMap.delete(layer); + } + this.forEachHook(hook => { + hook.onUpdateLayer?.(this.layerList); + }); + const controller = this.layerHookMap.get(layer); + if (!controller) return; + controller.unload(); + this.layerHookMap.delete(layer); + } + + hasLayer(layer: IMapLayer): boolean { + return this.layerList.has(layer); + } + + setLayerAlias(layer: IMapLayer, alias: string): void { + const symbol = Symbol.for(alias); + if (this.aliasLayerMap.has(symbol)) { + logger.warn(84, alias); + return; + } + this.layerAliasMap.set(layer, alias); + this.aliasLayerMap.set(symbol, layer); + } + + getLayerByAlias(alias: string): IMapLayer | null { + const symbol = Symbol.for(alias); + return this.aliasLayerMap.get(symbol) ?? null; + } + + getLayerAlias(layer: IMapLayer): string | undefined { + return this.layerAliasMap.get(layer); + } + + resizeLayer( + layer: IMapLayer, + width: number, + height: number, + keepBlock: boolean = false + ): void { + if (keepBlock) { + layer.resize(width, height); + } else { + layer.resize2(width, height); + } + } + + setBackground(tile: number): void { + this.backgroundTile = tile; + this.forEachHook(hook => { + hook.onChangeBackground?.(tile); + }); + } + + getBackground(): number { + return this.backgroundTile; + } + + protected createController( + hook: Partial + ): IHookController { + return new HookController(this, hook); + } +} + +class StateMapLayerHook implements Partial { + constructor( + readonly state: LayerState, + readonly layer: IMapLayer + ) {} + + onUpdateArea(x: number, y: number, width: number, height: number): void { + this.state.forEachHook(hook => { + hook.onUpdateLayerArea?.(this.layer, x, y, width, height); + }); + } + + onUpdateBlock(block: number, x: number, y: number): void { + this.state.forEachHook(hook => { + hook.onUpdateLayerBlock?.(this.layer, block, x, y); + }); + } + + onResize(width: number, height: number): void { + this.state.forEachHook(hook => { + hook.onResizeLayer?.(this.layer, width, height); + }); + } +} diff --git a/packages-user/data-state/src/map/mapLayer.ts b/packages-user/data-state/src/map/mapLayer.ts new file mode 100644 index 0000000..a1a7879 --- /dev/null +++ b/packages-user/data-state/src/map/mapLayer.ts @@ -0,0 +1,263 @@ +import { isNil } from 'lodash-es'; +import { + IMapLayer, + IMapLayerData, + IMapLayerHookController, + IMapLayerHooks +} from './types'; +import { Hookable, HookController, logger } from '@motajs/common'; + +// todo: 提供 core.setBlock 等方法的替代方法,同时添加 setBlockList,以及前景背景的接口 + +export class MapLayer + extends Hookable + implements IMapLayer +{ + width: number; + height: number; + empty: boolean = true; + zIndex: number = 0; + + /** 地图图块数组 */ + private mapArray: Uint32Array; + /** 地图数据引用 */ + private mapData: IMapLayerData; + + constructor(array: Uint32Array, width: number, height: number) { + super(); + this.width = width; + this.height = height; + const area = width * height; + this.mapArray = new Uint32Array(area); + // 超出的裁剪,不足的补零 + this.mapArray.set(array); + this.mapData = { + expired: false, + array: this.mapArray + }; + } + + resize(width: number, height: number): void { + if (this.width === width && this.height === height) { + return; + } + this.mapData.expired = true; + const before = this.mapArray; + const beforeWidth = this.width; + const beforeHeight = this.height; + const beforeArea = beforeWidth * beforeHeight; + this.width = width; + this.height = height; + const area = width * height; + const newArray = new Uint32Array(area); + this.mapArray = newArray; + // 将原来的地图数组赋值给现在的 + if (beforeArea > area) { + // 如果地图变小了,那么直接设置,不需要补零 + for (let ny = 0; ny < height; ny++) { + const begin = ny * beforeWidth; + newArray.set(before.subarray(begin, begin + width), ny * width); + } + } else { + // 如果地图变大了,那么需要补零。因为新数组本来就是用 0 填充的,实际上只要赋值就可以了 + for (let ny = 0; ny < beforeHeight; ny++) { + const begin = ny * beforeWidth; + newArray.set( + before.subarray(begin, begin + beforeWidth), + ny * width + ); + } + } + this.mapData = { + expired: false, + array: this.mapArray + }; + this.forEachHook(hook => { + hook.onResize?.(width, height); + }); + } + + resize2(width: number, height: number): void { + if (this.width === width && this.height === height) { + this.mapArray.fill(0); + return; + } + this.mapData.expired = true; + this.width = width; + this.height = height; + this.mapArray = new Uint32Array(width * height); + this.mapData = { + expired: false, + array: this.mapArray + }; + this.empty = true; + this.forEachHook(hook => { + hook.onResize?.(width, height); + }); + } + + setBlock(block: number, x: number, y: number): void { + const index = y * this.width + x; + if (block === this.mapArray[index]) return; + this.mapArray[index] = block; + this.forEachHook(hook => { + hook.onUpdateBlock?.(block, x, y); + }); + if (block !== 0) { + this.empty = false; + } + } + + getBlock(x: number, y: number): number { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) { + // 不在地图内,返回 -1 + return -1; + } + return this.mapArray[y * this.width + x]; + } + + putMapData(array: Uint32Array, x: number, y: number, width: number): void { + if (array.length % width !== 0) { + logger.warn(8); + } + const height = Math.ceil(array.length / width); + if (width === this.width && height === this.height) { + this.mapArray.set(array); + this.forEachHook(hook => { + hook.onUpdateArea?.(x, y, width, height); + }); + return; + } + const w = this.width; + const r = x + width; + const b = y + height; + if (x < 0 || y < 0 || r > w || b > this.height) { + logger.warn(9); + } + const nl = Math.max(x, 0); + const nt = Math.max(y, 0); + const nr = Math.min(r, w); + const nb = Math.min(b, this.height); + const nw = nr - nl; + const nh = nb - nt; + let empty = true; + for (let ny = 0; ny < nh; ny++) { + const start = ny * nw; + const offset = (ny + nt) * w + nl; + const sub = array.subarray(start, start + nw); + if (empty && sub.some(v => v !== 0)) { + // 空地图判断 + empty = false; + } + this.mapArray.set(array.subarray(start, start + nw), offset); + } + this.forEachHook(hook => { + hook.onUpdateArea?.(x, y, width, height); + }); + this.empty &&= empty; + } + + getMapData(): Uint32Array; + getMapData( + x: number, + y: number, + width: number, + height: number + ): Uint32Array; + getMapData( + x?: number, + y?: number, + width?: number, + height?: number + ): Uint32Array { + if (isNil(x)) { + return new Uint32Array(this.mapArray); + } + if (isNil(y) || isNil(width) || isNil(height)) { + logger.warn(80); + return new Uint32Array(); + } + const w = this.width; + const h = this.height; + const r = x + width; + const b = y + height; + if (x < 0 || y < 0 || r > w || b > h) { + logger.warn(81); + } + const res = new Uint32Array(width * height); + const arr = this.mapArray; + const nr = Math.min(r, w); + const nb = Math.min(b, h); + for (let nx = x; nx < nr; nx++) { + for (let ny = y; ny < nb; ny++) { + const origin = ny * w + nx; + const target = (ny - y) * width + (nx - x); + res[target] = arr[origin]; + } + } + return res; + } + + /** + * 获取地图数据的内部存储直接引用 + */ + getMapRef(): IMapLayerData { + return this.mapData; + } + + protected createController( + hook: Partial + ): IMapLayerHookController { + return new MapLayerHookController(this, hook); + } + + setZIndex(zIndex: number): void { + this.zIndex = zIndex; + } + + async openDoor(x: number, y: number): Promise { + const index = y * this.width + x; + const num = this.mapArray[index]; + if (num === 0) return; + await Promise.all( + this.forEachHook(hook => { + return hook.onOpenDoor?.(x, y); + }) + ); + this.setBlock(0, x, y); + } + + async closeDoor(num: number, x: number, y: number): Promise { + const index = y * this.width + x; + const nowNum = this.mapArray[index]; + if (nowNum !== 0) { + logger.error(46, x.toString(), y.toString()); + return; + } + await Promise.all( + this.forEachHook(hook => { + return hook.onCloseDoor?.(num, x, y); + }) + ); + this.setBlock(num, x, y); + } +} + +class MapLayerHookController + extends HookController + implements IMapLayerHookController +{ + hookable: MapLayer; + + constructor( + readonly layer: MapLayer, + hook: Partial + ) { + super(layer, hook); + this.hookable = layer; + } + + getMapData(): Readonly { + return this.layer.getMapRef(); + } +} diff --git a/packages-user/data-state/src/map/types.ts b/packages-user/data-state/src/map/types.ts new file mode 100644 index 0000000..a702edb --- /dev/null +++ b/packages-user/data-state/src/map/types.ts @@ -0,0 +1,278 @@ +import { IHookable, IHookBase, IHookController } from '@motajs/common'; + +export interface IMapLayerData { + /** 当前引用是否过期,当地图图层内部的地图数组引用更新时,此项会变为 `true` */ + expired: boolean; + /** 地图图块数组,是对内部存储的直接引用 */ + array: Uint32Array; +} + +export interface IMapLayerHooks extends IHookBase { + /** + * 当地图大小发生变化时执行,如果调用了地图的 `resize` 方法,但是地图大小没变,则不会触发 + * @param width 地图宽度 + * @param height 地图高度 + */ + onResize(width: number, height: number): void; + + /** + * 当更新某个区域的图块时执行 + * @param x 更新区域左上角横坐标 + * @param y 更新区域左上角纵坐标 + * @param width 更新区域宽度 + * @param height 更新区域高度 + */ + onUpdateArea(x: number, y: number, width: number, height: number): void; + + /** + * 当更新某个点的图块时执行,如果设置的图块与原先一样,则不会触发此方法 + * @param block 更新为的图块数字 + * @param x 更新点横坐标 + * @param y 更新点纵坐标 + */ + onUpdateBlock(block: number, x: number, y: number): void; + + /** + * 当开门时触发,返回一个 `Promise`,当相关动画执行完毕后兑现 + * @param x 门横坐标 + * @param y 门纵坐标 + */ + onOpenDoor(x: number, y: number): Promise; + + /** + * 当关门时触发,返回一个 `Promise`,当相关动画执行完毕后兑现 + * @param num 门的图块数字 + * @param x 门横坐标 + * @param y 门纵坐标 + */ + onCloseDoor(num: number, x: number, y: number): Promise; +} + +export interface IMapLayerHookController + extends IHookController { + /** 拓展所属的图层对象 */ + readonly layer: IMapLayer; + + /** + * 获取地图数据,是对内部存储的直接引用 + */ + getMapData(): Readonly; +} + +export interface IMapLayer + extends IHookable { + /** 地图宽度 */ + readonly width: number; + /** 地图高度 */ + readonly height: number; + /** + * 地图是否全部空白,此值具有充分性,但不具有必要性, + * 即如果其为 `true`,则地图一定空白,但是如果其为 `false`,那么地图也有可能空白 + */ + readonly empty: boolean; + /** 图层纵深 */ + readonly zIndex: number; + + /** + * 调整地图尺寸,维持原有图块。如果尺寸变大,那么会补零,如果尺寸变小,那么会将当前数组裁剪 + * @param width 地图宽度 + * @param height 地图高度 + */ + resize(width: number, height: number): void; + + /** + * 调整地图尺寸,但是将地图全部重置为零,不保留原地图数据 + * @param width 地图宽度 + * @param height 地图高度 + */ + resize2(width: number, height: number): void; + + /** + * 设置某一点的图块 + * @param block 图块数字 + * @param x 图块横坐标 + * @param y 图块纵坐标 + */ + setBlock(block: number, x: number, y: number): void; + + /** + * 获取指定点的图块 + * @param x 图块横坐标 + * @param y 图块纵坐标 + * @returns 指定点的图块,如果没有图块,返回 0,如果不在地图上,返回 -1 + */ + getBlock(x: number, y: number): number; + + /** + * 设置地图图块 + * @param array 地图图块数组 + * @param x 数组第一项代表的横坐标 + * @param y 数组第一项代表的纵坐标 + * @param width 传入数组所表示的矩形范围的宽度 + */ + putMapData(array: Uint32Array, x: number, y: number, width: number): void; + + /** + * 获取整个地图的地图数组,是对内部地图数组的拷贝,并不能通过修改它来直接修改地图内容 + */ + getMapData(): Uint32Array; + /** + * 获取地图指定区域的地图数组,是对内部地图数组的拷贝,并不能通过修改它来直接修改地图内容 + * @param x 左上角横坐标 + * @param y 左上角纵坐标 + * @param width 获取区域的宽度 + * @param height 获取区域的高度 + */ + getMapData( + x: number, + y: number, + width: number, + height: number + ): Uint32Array; + + /** + * 获取整个地图的地图数组,是对内部数组的直接引用 + */ + getMapRef(): IMapLayerData; + + /** + * 设置地图纵深,会影响渲染的遮挡顺序 + * @param zIndex 纵深 + */ + setZIndex(zIndex: number): void; + + /** + * 开启指定位置的门 + * @param x 门横坐标 + * @param y 门纵坐标 + */ + openDoor(x: number, y: number): Promise; + + /** + * 在指定位置关门,门的图块数字由参数指定 + * @param num 门图块数字 + * @param x 门横坐标 + * @param y 门纵坐标 + */ + closeDoor(num: number, x: number, y: number): Promise; +} + +export interface ILayerStateHooks extends IHookBase { + /** + * 当设置背景图块时执行,如果设置的背景图块与原先一样,则不会执行 + * @param tile 背景图块 + */ + onChangeBackground(tile: number): void; + + /** + * 当地图列表发生变化时执行 + * @param layerList 地图图层列表 + */ + onUpdateLayer(layerList: Set): void; + + /** + * 当地图状态对象的某个图层发生区域更新时执行 + * @param layer 触发更新的地图图层对象 + * @param x 更新区域左上角横坐标 + * @param y 更新区域左上角纵坐标 + * @param width 更新区域宽度 + * @param height 更新区域高度 + */ + onUpdateLayerArea( + layer: IMapLayer, + x: number, + y: number, + width: number, + height: number + ): void; + + /** + * 当地图状态对象的某个图层设置图块时执行,如果设置的图块与原先一样则不会触发 + * @param layer 触发更新的地图图层对象 + * @param block 设置为的图块 + * @param x 图块横坐标 + * @param y 图块纵坐标 + */ + onUpdateLayerBlock( + layer: IMapLayer, + block: number, + x: number, + y: number + ): void; + + /** + * 当地图状态对象的某个图层大小发生变化时执行 + * @param layer 触发更新的地图图层对象 + * @param width 地图的新宽度 + * @param height 地图的新高度 + */ + onResizeLayer(layer: IMapLayer, width: number, height: number): void; +} + +export interface ILayerState extends IHookable { + /** 地图列表 */ + readonly layerList: Set; + + /** + * 添加图层 + * @param width 地图宽度 + * @param height 地图高度 + */ + addLayer(width: number, height: number): IMapLayer; + + /** + * 移除指定图层 + * @param layer 图层对象 + */ + removeLayer(layer: IMapLayer): void; + + /** + * 当前地图状态对象是否包含指定图层对象 + * @param layer 图层对象 + */ + hasLayer(layer: IMapLayer): boolean; + + /** + * 设置图层别名 + * @param layer 图层对象 + * @param alias 图层别名 + */ + setLayerAlias(layer: IMapLayer, alias: string): void; + + /** + * 根据图层别名获取图层对象 + * @param alias 图层别名 + */ + getLayerByAlias(alias: string): IMapLayer | null; + + /** + * 获取图层对象的别名 + * @param layer 图层对象 + */ + getLayerAlias(layer: IMapLayer): string | undefined; + + /** + * 重新设置图层的大小 + * @param layer 图层对象 + * @param width 新的图层宽度 + * @param height 新的图层高度 + * @param keepBlock 是否保留原有图块,默认不保留 + */ + resizeLayer( + layer: IMapLayer, + width: number, + height: number, + keepBlock?: boolean + ): void; + + /** + * 设置背景图块 + * @param tile 背景图块数字 + */ + setBackground(tile: number): void; + + /** + * 获取背景图块数字,如果没有设置过,则返回 0 + */ + getBackground(): number; +} diff --git a/packages-user/data-state/src/shared.ts b/packages-user/data-state/src/shared.ts new file mode 100644 index 0000000..81e114e --- /dev/null +++ b/packages-user/data-state/src/shared.ts @@ -0,0 +1,2 @@ +/** 默认的勇士图片 */ +export const DEFAULT_HERO_IMAGE: ImageIds = 'hero.png'; diff --git a/packages-user/data-state/src/state/hero.ts b/packages-user/data-state/src/state/hero.ts deleted file mode 100644 index ef13895..0000000 --- a/packages-user/data-state/src/state/hero.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { logger } from '@motajs/common'; -import { EventEmitter } from 'eventemitter3'; -import { cloneDeep } from 'lodash-es'; - -/** - * 获取勇士在某一点的属性 - * @param name 要获取的勇士属性 - * @param floorId 勇士所在楼层 - */ -export function getHeroStatusOn(name: 'all', floorId?: FloorIds): HeroStatus; -export function getHeroStatusOn( - name: (keyof HeroStatus)[], - floorId?: FloorIds -): Partial; -export function getHeroStatusOn( - name: K, - floorId?: FloorIds -): HeroStatus[K]; -export function getHeroStatusOn( - name: keyof HeroStatus | 'all' | (keyof HeroStatus)[], - floorId?: FloorIds -) { - // @ts-expect-error 暂时无法推导 - return getHeroStatusOf(core.status.hero, name, floorId); -} - -/** - * 获取一定状态下的勇士在某一点的属性 - * @param status 勇士的状态 - * @param name 要获取的勇士属性 - * @param floorId 勇士所在楼层 - */ -export function getHeroStatusOf( - status: Partial, - name: 'all', - floorId?: FloorIds -): HeroStatus; -export function getHeroStatusOf( - status: Partial, - name: (keyof HeroStatus)[], - floorId?: FloorIds -): Partial; -export function getHeroStatusOf( - status: Partial, - name: K, - floorId?: FloorIds -): HeroStatus[K]; -export function getHeroStatusOf( - status: DeepPartial, - name: keyof HeroStatus | 'all' | (keyof HeroStatus)[], - floorId?: FloorIds -) { - return getRealStatus(status, name, floorId); -} - -function getRealStatus( - status: DeepPartial, - name: keyof HeroStatus | 'all' | (keyof HeroStatus)[], - floorId: FloorIds = core.status.floorId -): any { - if (name instanceof Array) { - const res: any = {}; - name.forEach(v => { - res[v] = getRealStatus(status, v, floorId); - }); - return res; - } - - if (name === 'all') { - const res: any = {}; - for (const [key, value] of Object.entries(core.status.hero)) { - if (typeof value === 'number') { - res[key] = getRealStatus( - status, - key as keyof HeroStatus, - floorId - ); - } else { - res[key] = value; - } - } - - return res; - } - - let s = (status[name] ?? core.status.hero[name]) as number; - if (s === null || s === void 0) { - throw new ReferenceError( - `Wrong hero status property name is delivered: ${name}` - ); - } - - if (typeof s !== 'number') return s; - - // buff - s *= core.status.hero.buff[name] ?? 1; - s = Math.floor(s); - - // 衰弱效果 - if ((name === 'atk' || name === 'def') && flags.weak) { - const weak = core.values.weakValue; - if (weak < 1) { - // 百分比衰弱 - s *= 1 - weak; - } else { - s -= weak; - } - } - - return s; -} - -// 下面的内容暂时无用 - -export interface IHeroStatusDefault { - atk: number; - def: number; - hp: number; -} - -interface HeroStateEvent { - set: [key: string | number | symbol, value: any]; -} - -type HeroStatusCalculate = ( - hero: HeroState, - key: string | number | symbol, - value: any -) => any; - -export class HeroState< - T extends object = IHeroStatusDefault -> extends EventEmitter { - readonly status: T; - readonly computedStatus: T; - - readonly buffable: Set = new Set(); - readonly buffMap: Map = new Map(); - - private static cal: HeroStatusCalculate = (_0, _1, value) => value; - - constructor(init: T) { - super(); - this.status = init; - this.computedStatus = cloneDeep(init); - } - - /** - * 设置某个属性的值 - * @param key 要设置的属性 - * @param value 属性值 - * @returns 是否设置成功 - */ - setStatus(key: K, value: T[K]): boolean { - this.status[key] = value; - this.emit('set', key, value); - return this.refreshStatus(key); - } - - /** - * 增加或减少一个属性的值,只对数字型的属性有效 - * @param key 要修改的属性 - * @param value 属性的增量 - * @returns 是否设置成功 - */ - addStatus>(key: K, value: number): boolean { - if (typeof this.status[key] !== 'number') { - logger.warn(14, String(key)); - return false; - } - return this.setStatus(key, (this.status[key] + value) as T[K]); - } - - /** - * 获取某个属性的原始值 - * @param key 要获取的属性 - * @returns 属性的值 - */ - getStatus(key: K): T[K] { - return this.status[key]; - } - - /** - * 获取一个属性计算后的值,也就是2.x所说的勇士真实属性 - * @param key 要获取的属性值 - */ - getComputedStatus(key: K): T[K] { - return this.computedStatus[key]; - } - - /** - * 标记某个属性为可以被buff加成 - */ - markBuffable(key: SelectKey): void { - if (typeof this.status[key] !== 'number') { - logger.warn(12, String(key)); - return; - } - this.buffable.add(key); - this.buffMap.set(key, 1); - } - - /** - * 设置某个属性的buff值 - * @param key 要设置buff的属性 - * @param value buff值 - * @returns 是否设置成功 - */ - setBuff(key: SelectKey, value: number): boolean { - if (!this.buffable.has(key) || typeof this.status[key] !== 'number') { - logger.warn(13, String(key)); - return false; - } - this.buffMap.set(key, value); - return this.refreshStatus(key); - } - - /** - * 增加或减少一个buff值 - * @param key 要修改的buff属性 - * @param value buff增量 - * @returns 是否修改成功 - */ - addBuff(key: SelectKey, value: number): boolean { - if (!this.buffable.has(key) || typeof this.status[key] !== 'number') { - logger.warn(13, String(key)); - return false; - } - return this.setBuff(key, this.buffMap.get(key)! + value); - } - - /** - * 刷新某个或所有属性,重新进行计算 - * @param key 要刷新的属性名,不填表示刷新所有属性 - * @returns 是否计算成功 - */ - refreshStatus(key?: keyof T): boolean { - if (key === void 0) { - for (const [key, value] of Object.entries(this.status)) { - // @ts-expect-error 暂时无法推导 - this.computedStatus[key] = HeroState.cal(this, key, value); - } - return true; - } - this.computedStatus[key] = HeroState.cal(this, key, this.status[key]); - return true; - } - - /** - * 计算所有可以buff加成的属性 - * @returns 是否计算成功 - */ - refreshBuffable(): boolean { - for (const key of this.buffable) { - this.computedStatus[key] = HeroState.cal( - this, - key, - this.status[key] - ); - } - return true; - } - - /** - * 复写属性计算函数,默认函数不进行计算,直接将原属性返回 - * @param fn 计算函数,传入两个参数,key表示属性名,value表示属性值,返回值表示计算结果 - */ - static overrideCalculate(fn: HeroStatusCalculate) { - this.cal = fn; - } -} - -interface _IHeroItem { - items: Map, number>; - - /** - * 设置勇士拥有的物品数量 - * @param item 物品id - * @param value 物品数量 - * @returns 是否设置成功 - */ - setItem(item: AllIdsOf<'items'>, value: number): boolean; - - /** - * 增加或减少勇士拥有的物品数量 - * @param item 物品id - * @param value 物品数量增量 - * @returns 是否设置成功 - */ - addItem(item: AllIdsOf<'items'>, value: number): boolean; - - /** - * 使用一个物品 - * @param item 物品id - * @returns 是否使用成功 - */ - useItem(item: AllIdsOf<'items'>, x?: number, y?: number): boolean; - - /** - * 获得一个物品 - * @param item 物品id - * @param num 获得的数量 - */ - getItem(item: AllIdsOf<'items'>, num: number): void; - /** - * 获得一个物品 - * @param item 物品id - * @param x 物品所在x坐标 - * @param y 物品所在y坐标 - * @param floorId 物品所在楼层 - * @param num 获得的数量 - */ - getItem( - item: AllIdsOf<'items'>, - x: number, - y: number, - floorId?: FloorIds, - num?: number - ): void; - - /** - * 获取某个物品的数量 - * @param item 物品id - */ - itemCount(item: AllIdsOf<'items'>): number; - - /** - * 判断勇士是否拥有这个物品 - * @param item 物品id - */ - hasItem(item: AllIdsOf<'items'>): boolean; -} diff --git a/packages-user/data-state/src/types.ts b/packages-user/data-state/src/types.ts new file mode 100644 index 0000000..a635bc3 --- /dev/null +++ b/packages-user/data-state/src/types.ts @@ -0,0 +1,32 @@ +import { ILayerState } from './map'; +import { IHeroFollower, IHeroState } from './hero'; +import { IRoleFaceBinder } from './common'; + +export interface IStateSaveData { + /** 跟随者列表 */ + readonly followers: readonly IHeroFollower[]; +} + +export interface ICoreState { + /** 地图状态 */ + readonly layer: ILayerState; + /** 勇士状态 */ + readonly hero: IHeroState; + /** 朝向绑定 */ + readonly roleFace: IRoleFaceBinder; + /** id 到图块数字的映射 */ + readonly idNumberMap: Map; + /** 图块数字到 id 的映射 */ + readonly numberIdMap: Map; + + /** + * 保存状态 + */ + saveState(): IStateSaveData; + + /** + * 加载状态 + * @param data 状态对象 + */ + loadState(data: IStateSaveData): void; +} diff --git a/packages-user/entry-client/package.json b/packages-user/entry-client/package.json index cbb54fd..c5076f1 100644 --- a/packages-user/entry-client/package.json +++ b/packages-user/entry-client/package.json @@ -5,6 +5,7 @@ "@motajs/client-base": "workspace:*", "@motajs/common": "workspace:*", "@motajs/render": "workspace:*", + "@motajs/render-assets": "workspace:*", "@motajs/render-core": "workspace:*", "@motajs/render-elements": "workspace:*", "@motajs/render-style": "workspace:*", @@ -17,7 +18,8 @@ "@motajs/legacy-data": "workspace:*", "@motajs/legacy-ui": "workspace:*", "@motajs/legacy-system": "workspace:*", + "@user/client-base": "workspace:*", "@user/client-modules": "workspace:*", "@user/legacy-plugin-client": "workspace:*" } -} \ No newline at end of file +} diff --git a/packages-user/entry-client/src/create.ts b/packages-user/entry-client/src/create.ts index 19dc306..d641901 100644 --- a/packages-user/entry-client/src/create.ts +++ b/packages-user/entry-client/src/create.ts @@ -5,6 +5,7 @@ import * as LegacyClient from '@motajs/legacy-client'; import * as LegacySystem from '@motajs/legacy-system'; import * as LegacyUI from '@motajs/legacy-ui'; import * as Render from '@motajs/render'; +import * as RenderAssets from '@motajs/render-assets'; import * as RenderCore from '@motajs/render-core'; import * as RenderElements from '@motajs/render-elements'; import * as RenderStyle from '@motajs/render-style'; @@ -12,6 +13,7 @@ import * as RenderVue from '@motajs/render-vue'; import * as System from '@motajs/system'; import * as SystemAction from '@motajs/system-action'; import * as SystemUI from '@motajs/system-ui'; +import * as UserClientBase from '@user/client-base'; import * as ClientModules from '@user/client-modules'; import * as LegacyPluginClient from '@user/legacy-plugin-client'; import * as MutateAnimate from 'mutate-animate'; @@ -28,6 +30,7 @@ export function create() { Mota.register('@motajs/legacy-system', LegacySystem); Mota.register('@motajs/legacy-ui', LegacyUI); Mota.register('@motajs/render', Render); + Mota.register('@motajs/render-assets', RenderAssets); Mota.register('@motajs/render-core', RenderCore); Mota.register('@motajs/render-elements', RenderElements); Mota.register('@motajs/render-style', RenderStyle); @@ -35,6 +38,7 @@ export function create() { Mota.register('@motajs/system', System); Mota.register('@motajs/system-action', SystemAction); Mota.register('@motajs/system-ui', SystemUI); + Mota.register('@user/client-base', UserClientBase); Mota.register('@user/client-modules', ClientModules); Mota.register('@user/legacy-plugin-client', LegacyPluginClient); Mota.register('MutateAnimate', MutateAnimate); @@ -45,8 +49,9 @@ export function create() { } async function createModule() { - LegacyUI.create(); + UserClientBase.create(); ClientModules.create(); + LegacyUI.create(); await import('ant-design-vue/dist/antd.dark.css'); main.renderLoaded = true; diff --git a/packages-user/entry-data/src/index.ts b/packages-user/entry-data/src/index.ts index 010949b..68c769f 100644 --- a/packages-user/entry-data/src/index.ts +++ b/packages-user/entry-data/src/index.ts @@ -3,12 +3,20 @@ import { create } from './create'; import { patchAll } from '@user/data-fallback'; import { loading } from '@user/data-base'; import { Patch } from '@motajs/legacy-common'; +import { logger } from '@motajs/common'; export function createData() { createMota(); patchAll(); create(); + if (main.replayChecking) { + logger.log( + `如果需要调试录像验证,请在 script/build-game.ts 中将 DEBUG_REPLAY 设为 true,` + + `此时录像验证中可以看到完整正确的报错栈。调试完毕后,记得将它重新设为 false` + ); + } + loading.once('coreInit', () => { Patch.patchAll(); }); diff --git a/packages-user/legacy-plugin-data/src/fallback.ts b/packages-user/legacy-plugin-data/src/fallback.ts index d4130ff..5dd540a 100644 --- a/packages-user/legacy-plugin-data/src/fallback.ts +++ b/packages-user/legacy-plugin-data/src/fallback.ts @@ -1,25 +1,27 @@ import type { RenderAdapter } from '@motajs/render'; import type { TimingFn } from 'mutate-animate'; -import { BlockMover, heroMoveCollection, MoveStep } from '@user/data-state'; +import { + BlockMover, + fromDirectionString, + heroMoveCollection, + MoveStep, + state +} from '@user/data-state'; import { hook, loading } from '@user/data-base'; import { Patch, PatchClass } from '@motajs/legacy-common'; import type { - HeroRenderer, LayerDoorAnimate, LayerGroupAnimate, - Layer, FloorViewport, - LayerFloorBinder, LayerGroup } from '@user/client-modules'; +import { isNil } from 'lodash-es'; // 向后兼容用,会充当两个版本间过渡的作用 interface Adapters { - 'hero-adapter'?: RenderAdapter; 'door-animate'?: RenderAdapter; animate?: RenderAdapter; - layer?: RenderAdapter; viewport?: RenderAdapter; } @@ -30,16 +32,12 @@ export function initFallback() { if (!main.replayChecking && main.mode === 'play') { const Adapter = Mota.require('@motajs/render').RenderAdapter; - const hero = Adapter.get('hero-adapter'); const doorAnimate = Adapter.get('door-animate'); const animate = Adapter.get('animate'); - const layer = Adapter.get('layer'); const viewport = Adapter.get('viewport'); - adapters['hero-adapter'] = hero; adapters['door-animate'] = doorAnimate; adapters['animate'] = animate; - adapters['layer'] = layer; adapters['viewport'] = viewport; } @@ -74,7 +72,7 @@ export function initFallback() { /** * 生成跳跃函数 */ - function generateJumpFn(dx: number, dy: number): TimingFn<3> { + function generateJumpFn(dx: number, dy: number): TimingFn<2> { const distance = Math.hypot(dx, dy); const peak = 3 + distance; @@ -82,7 +80,7 @@ export function initFallback() { const x = dx * progress; const y = progress * dy + (progress ** 2 - progress) * peak; - return [x, y, Math.ceil(y)]; + return [x, y]; }; } @@ -112,31 +110,28 @@ export function initFallback() { patch.add('_moveAction_moving', () => {}); - patch2.add( - '_action_moveAction', - function (data: any, x: number, y: number, prefix: any) { - if (core.canMoveHero()) { - const nx = core.nextX(), - ny = core.nextY(); - // 检查noPass决定是撞击还是移动 - if (core.noPass(nx, ny)) { - core.insertAction([{ type: 'trigger', loc: [nx, ny] }]); - } else { - // 先移动一格,然后尝试触发事件 - core.insertAction([ - { - type: 'function', - function: - 'function() { core.moveAction(core.doAction); }', - async: true - }, - { type: '_label' } - ]); - } + patch2.add('_action_moveAction', function () { + if (core.canMoveHero()) { + const nx = core.nextX(), + ny = core.nextY(); + // 检查noPass决定是撞击还是移动 + if (core.noPass(nx, ny)) { + core.insertAction([{ type: 'trigger', loc: [nx, ny] }]); + } else { + // 先移动一格,然后尝试触发事件 + core.insertAction([ + { + type: 'function', + function: + 'function() { core.moveAction(core.doAction); }', + async: true + }, + { type: '_label' } + ]); } - core.doAction(); } - ); + core.doAction(); + }); patch2.add( 'eventMoveHero', @@ -184,21 +179,28 @@ export function initFallback() { if (!core.status.hero) return; // @ts-ignore core.status.hero.loc[name] = value; - if ((name === 'x' || name === 'y') && !noGather) { - core.control.gatherFollowers(); - } if (name === 'direction') { - adapters['hero-adapter']?.sync('turn', value); - adapters['hero-adapter']?.sync('setAnimateDir', value); + const dir = fromDirectionString(value as Dir); + state.hero.turn(dir); setHeroDirection(value as Dir); } else if (name === 'x') { // 为了防止逆天样板出问题 core.bigmap.posX = value as number; - adapters['hero-adapter']?.sync('setHeroLoc', value); + if (!noGather) { + state.hero.setPosition( + value as number, + core.status.hero.loc.y + ); + } } else { // 为了防止逆天样板出问题 core.bigmap.posY = value as number; - adapters['hero-adapter']?.sync('setHeroLoc', void 0, value); + if (!noGather) { + state.hero.setPosition( + core.status.hero.loc.x, + value as number + ); + } } } ); @@ -243,10 +245,8 @@ export function initFallback() { ); patch2.add('setHeroIcon', function (name: ImageIds) { - const img = core.material.images.images[name]; - if (!img) return; core.status.hero.image = name; - adapters['hero-adapter']?.sync('setImage', img); + state.hero.setImage(name); }); patch.add('isMoving', function () { @@ -279,10 +279,10 @@ export function initFallback() { // 找寻自动寻路路线 const moveStep = core.automaticRoute(destX, destY); if ( - moveStep.length == 0 && - (destX != core.status.hero.loc.x || - destY != core.status.hero.loc.y || - stepPostfix.length == 0) + moveStep.length === 0 && + (destX !== core.status.hero.loc.x || + destY !== core.status.hero.loc.y || + stepPostfix.length === 0) ) return; moveStep.push(...stepPostfix); @@ -341,9 +341,9 @@ export function initFallback() { const locked = core.status.lockControl; core.lockControl(); core.status.replay.animate = true; - core.removeBlock(x, y); const cb = () => { + core.removeBlock(x, y); core.maps._removeBlockFromMap( core.status.floorId, block @@ -357,10 +357,10 @@ export function initFallback() { y ); callback?.(); - delete core.animateFrame.asyncId[animate]; }; - adapters['door-animate']?.all('openDoor', block).then(cb); + const layer = state.layer.getLayerByAlias('event')!; + layer.openDoor(x, y).then(cb); const animate = fallbackIds++; core.animateFrame.lastAsyncId = animate; @@ -376,10 +376,10 @@ export function initFallback() { id = id || ''; if ( // @ts-ignore - (core.material.icons.animates[id] == null && + (isNil(core.material.icons.animates[id]) && // @ts-ignore - core.material.icons.npc48[id] == null) || - core.getBlock(x, y) != null + isNil(core.material.icons.npc48[id])) || + !isNil(core.getBlock(x, y)) ) { if (callback) callback(); return; @@ -407,11 +407,9 @@ export function initFallback() { if (core.status.replay.speed === 24) { cb(); } else { - adapters['door-animate'] - ?.all('closeDoor', block) - .then(() => { - cb(); - }); + const num = state.idNumberMap.get(id)!; + const layer = state.layer.getLayerByAlias('event')!; + layer.closeDoor(num, x, y).then(cb); const animate = fallbackIds++; core.animateFrame.lastAsyncId = animate; @@ -439,10 +437,10 @@ export function initFallback() { if ( core.isReplaying() || !core.material.animates[name] || - x == null || - y == null + isNil(x) || + isNil(y) ) { - if (callback) callback(); + callback?.(); return -1; } @@ -515,19 +513,10 @@ export function initFallback() { mover.insertMove(...[start, ...resolved]); const controller = mover.startMove(); - const id = fallbackIds++; - core.animateFrame.asyncId[id] = () => { - if (!keep) { - core.removeBlock(mover.x, mover.y); - } - }; - if (controller) { await controller.onEnd; } - delete core.animateFrame.asyncId[id]; - if (!keep) { core.removeBlock(mover.x, mover.y); } @@ -557,36 +546,24 @@ export function initFallback() { const dy = ey - sy; const fn = generateJumpFn(dx, dy); - - const list = adapters.layer?.items ?? []; - const items = [...list].filter(v => { - if (v.layer !== 'event') return false; - const ex = v.getExtends('floor-binder') as LayerFloorBinder; - if (!ex) return false; - return ex.getFloor() === core.status.floorId; - }); - const width = core.status.thisMap.width; - const index = sx + sy * width; - - const promise = Promise.all( - items.map(v => { - return v.moveAs(index, ex, ey, fn, time, keep); - }) + // 先使用 mainMapRenderer 妥协 + const { mainMapRenderer: renderer } = Mota.require( + '@user/client-modules' ); - - core.updateStatusBar(); + if (renderer.layerState !== state.layer) { + callback?.(); + return; + } + const layer = state.layer.getLayerByAlias('event'); + if (!layer) { + callback?.(); + return; + } core.removeBlock(sx, sy); - - const id = fallbackIds++; - core.animateFrame.asyncId[id] = () => { - if (keep) { - core.setBlock(block.id, ex, ey); - } - }; - - await promise; - - delete core.animateFrame.asyncId[id]; + const moving = renderer.addMovingBlock(layer, block.id, sx, sy); + core.updateStatusBar(); + await moving.moveRelative(fn, time); + moving.destroy(); if (keep) { core.setBlock(block.id, ex, ey); @@ -607,30 +584,15 @@ export function initFallback() { ) { if (heroMover.moving) return; - const sx = core.getHeroLoc('x'); - const sy = core.getHeroLoc('y'); adapters.viewport?.all('mutateTo', ex, ey, time); const locked = core.status.lockControl; core.lockControl(); - const list = adapters['hero-adapter']?.items ?? []; - const items = [...list]; time /= core.status.replay.speed; if (core.status.replay.speed === 24) time = 1; - const fn = generateJumpFn(ex - sx, ey - sy); - await Promise.all( - items.map(v => { - if (!v.renderable) return Promise.reject(); - return v.layer.moveRenderable( - v.renderable, - sx, - sy, - fn, - time - ); - }) - ); + + await state.hero.jumpHero(ex, ey, time); if (!locked) core.unlockControl(); core.setHeroLoc('x', ex); diff --git a/packages/client-base/src/glUtils.ts b/packages/client-base/src/glUtils.ts new file mode 100644 index 0000000..d02414a --- /dev/null +++ b/packages/client-base/src/glUtils.ts @@ -0,0 +1,86 @@ +import { logger } from '@motajs/common'; + +export interface ICompiledProgram { + /** 着色器程序 */ + readonly program: WebGLProgram; + /** 顶点着色器对象 */ + readonly vertexShader: WebGLShader; + /** 片段着色器对象 */ + readonly fragmentShader: WebGLShader; +} + +/** + * 编译着色器 + * @param gl WebGL2 上下文 + * @param type 着色器类型 + * @param source 着色器代码 + */ +export function compileShader( + gl: WebGL2RenderingContext, + type: number, + source: string +): WebGLShader | null { + const shader = gl.createShader(type); + if (!shader) return null; + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(shader); + const typeStr = type === gl.VERTEX_SHADER ? 'vertex' : 'fragment'; + logger.error(10, typeStr, info ?? ''); + return null; + } + + return shader; +} + +/** + * 编译链接着色器程序 + * @param gl WebGL2 上下文 + * @param vs 顶点着色器对象 + * @param fs 片段着色器对象 + */ +export function compileProgram( + gl: WebGL2RenderingContext, + vs: WebGLShader, + fs: WebGLShader +) { + const program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const info = gl.getProgramInfoLog(program); + logger.error(9, info ?? ''); + return null; + } + + return program; +} + +/** + * 使用指定着色器代码编译链接程序 + * @param gl WebGL2 上下文 + * @param vs 顶点着色器代码 + * @param fs 片段着色器代码 + */ +export function compileProgramWith( + gl: WebGL2RenderingContext, + vs: string, + fs: string +): ICompiledProgram | null { + const vsShader = compileShader(gl, gl.VERTEX_SHADER, vs); + const fsShader = compileShader(gl, gl.FRAGMENT_SHADER, fs); + + if (!vsShader || !fsShader) return null; + const program = compileProgram(gl, vsShader, fsShader); + if (!program) return null; + + return { + program, + vertexShader: vsShader, + fragmentShader: fsShader + }; +} diff --git a/packages/client-base/src/index.ts b/packages/client-base/src/index.ts index 192a965..a832b94 100644 --- a/packages/client-base/src/index.ts +++ b/packages/client-base/src/index.ts @@ -1,2 +1,3 @@ +export * from './glUtils'; export * from './keyCodes'; export * from './types'; diff --git a/packages/common/src/dirtyTracker.ts b/packages/common/src/dirtyTracker.ts new file mode 100644 index 0000000..4ca0ce6 --- /dev/null +++ b/packages/common/src/dirtyTracker.ts @@ -0,0 +1,133 @@ +import { isNil } from 'lodash-es'; +import { IDirtyMark, IDirtyTracker } from './types'; + +/** + * 布尔类型的脏标记追踪器。当传入 `dirtySince` 的标记不属于当前的追踪器时,会返回 `true` + */ +export class PrivateBooleanDirtyTracker implements IDirtyTracker { + /** 标记映射 */ + private markMap: WeakMap = new WeakMap(); + /** 脏标记 */ + private dirtyFlag: number = 0; + + mark(): IDirtyMark { + const symbol = {}; + this.markMap.set(symbol, this.dirtyFlag); + return symbol; + } + + unmark(mark: IDirtyMark): void { + this.markMap.delete(mark); + } + + dirtySince(mark: IDirtyMark): boolean { + const num = this.markMap.get(mark); + if (isNil(num)) return true; + return num < this.dirtyFlag; + } + + hasMark(symbol: IDirtyMark): boolean { + return this.markMap.has(symbol); + } + + /** + * 将数据标记为脏 + */ + protected dirty() { + this.dirtyFlag++; + } +} + +/** + * 列表的脏标记追踪器。当传入 `dirtySince` 的标记不属于当前的追踪器时,会返回空集合 + */ +export class PrivateListDirtyTracker + implements IDirtyTracker> +{ + /** 标记映射,键表示在索引,值表示其对应的标记数字 */ + private readonly markMap: Map = new Map(); + /** 标记 symbol 映射,值表示这个 symbol 对应的标记数字 */ + private readonly symbolMap: WeakMap<{}, number> = new WeakMap(); + + /** 脏标记数字 */ + private dirtyFlag: number = 0; + + constructor(protected length: number) {} + + mark(): IDirtyMark { + const symbol = {}; + this.symbolMap.set(symbol, this.dirtyFlag); + return symbol; + } + + unmark(mark: IDirtyMark): void { + this.symbolMap.delete(mark); + } + + dirtySince(mark: IDirtyMark): Set { + const num = this.symbolMap.get(mark); + const res = new Set(); + if (isNil(num)) return res; + this.markMap.forEach((v, k) => { + if (v > num) res.add(k); + }); + return res; + } + + hasMark(symbol: IDirtyMark): boolean { + return this.symbolMap.has(symbol); + } + + protected dirty(data: T): void { + if (data >= this.length) return; + this.dirtyFlag++; + this.markMap.set(data, this.dirtyFlag); + } + + protected updateLength(length: number) { + this.length = length; + } +} + +export class PrivateMapDirtyTracker + implements IDirtyTracker> +{ + /** 标记映射,键表示名称,值表示其对应的标记数字 */ + private readonly markMap: Map = new Map(); + /** 标记 symbol 映射,值表示这个 symbol 对应的标记数字 */ + private readonly symbolMap: WeakMap<{}, number> = new WeakMap(); + + /** 脏标记数字 */ + private dirtyFlag: number = 0; + + constructor(protected length: number) {} + + mark(): IDirtyMark { + const symbol = {}; + this.symbolMap.set(symbol, this.dirtyFlag); + return symbol; + } + + unmark(mark: IDirtyMark): void { + this.symbolMap.delete(mark); + } + + dirtySince(mark: IDirtyMark): Record { + const num = this.symbolMap.get(mark) ?? 0; + const obj: Partial> = {}; + this.markMap.forEach((v, k) => { + if (v > num) obj[k] = true; + else obj[k] = false; + }); + return obj as Record; + } + + hasMark(symbol: IDirtyMark): boolean { + return this.symbolMap.has(symbol); + } + + protected dirty(data: T): void { + this.dirtyFlag++; + this.markMap.set(data, this.dirtyFlag); + } +} diff --git a/packages/common/src/hook.ts b/packages/common/src/hook.ts new file mode 100644 index 0000000..6885c94 --- /dev/null +++ b/packages/common/src/hook.ts @@ -0,0 +1,86 @@ +import { logger } from './logger'; +import { IHookable, IHookBase, IHookController, IHookObject } from './types'; + +export abstract class Hookable< + H extends IHookBase = IHookBase, + C extends IHookController = IHookController +> implements IHookable +{ + /** 加载完成的钩子列表 */ + protected readonly loadedList: Set> = new Set(); + + /** 钩子列表 */ + private readonly hookList: Set> = new Set(); + /** 钩子对象到钩子存储对象的映射 */ + private readonly hookMap: Map, IHookObject> = new Map(); + /** 钩子控制器到钩子存储对象的映射 */ + private readonly controllerMap: Map> = new Map(); + + /** + * 创建钩子对象的控制器 + * @param hook 钩子对象 + */ + protected abstract createController(hook: Partial): C; + + addHook(hook: Partial): C { + const controller = this.createController(hook); + const obj: IHookObject = { hook, controller }; + this.hookMap.set(hook, obj); + this.controllerMap.set(controller, obj); + this.hookList.add(obj); + return controller; + } + + loadHook(hook: Partial): void { + const obj = this.hookMap.get(hook); + if (!obj) { + logger.warn(85); + return; + } + hook.awake?.(obj.controller); + this.loadedList.add(obj); + } + + removeHook(hook: Partial): void { + const obj = this.hookMap.get(hook); + if (!obj) return; + obj.hook.destroy?.(obj.controller); + this.hookList.delete(obj); + this.loadedList.delete(obj); + this.hookMap.delete(hook); + this.controllerMap.delete(obj.controller); + } + + removeHookByController(hook: C): void { + const obj = this.controllerMap.get(hook); + if (!obj) return; + obj.hook.destroy?.(obj.controller); + this.hookList.delete(obj); + this.loadedList.delete(obj); + this.controllerMap.delete(hook); + this.hookMap.delete(obj.hook); + } + + forEachHook(fn: (hook: Partial, controller: C) => T): T[] { + const arr: T[] = []; + this.loadedList.forEach(v => { + arr.push(fn(v.hook, v.controller)); + }); + return arr; + } +} + +export class HookController implements IHookController { + constructor( + readonly hookable: IHookable>, + readonly hook: Partial + ) {} + + load(): void { + this.hookable.loadHook(this.hook); + } + + unload(): void { + this.hookable.removeHookByController(this); + } +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 8ff45f4..605e235 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,2 +1,5 @@ +export * from './dirtyTracker'; +export * from './hook'; export * from './logger'; export * from './utils'; +export * from './types'; diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 73f3e08..9016c58 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -26,6 +26,26 @@ "24": "Cannot decode source type of '$1', since there is no registered decoder for that type.", "25": "Unknown audio type. Header: '$1'", "26": "Uncaught error when fetching stream data from '$1'. Error info: $2.", + "27": "No autotile connection data, please ensure you have created autotile connection map.", + "28": "Cannot compile map render shader.", + "29": "Cannot get uniform location of map render shader.", + "30": "", + "31": "No asset data is specified when rending map.", + "32": "Every layer added to map renderer must share the same size. Different layer: $1.", + "33": "Map layer transfered to vertex generator must belong to the renderer that the generator use.", + "34": "The texture of moving block must be a part of built asset.", + "35": "Tile background transfered to map renderer does not exists.", + "36": "Tile background transfered to map renderer has no frame data.", + "37": "Frame of tile background transfered to map renderer must share the same size.", + "38": "Cached texture cannot be convert to asset. This is likely an internal bug, please contact us.", + "39": "Offset pool size exceeds WebGL2 limitation, ensure size type of your big image is less than $1.", + "40": "Material used by map block $1 is not found in built asset. Please ensure you have pack it into asset.", + "41": "You are trying to use a texture on moving block whose offset is not in the offset pool, please build it into asset after loading.", + "42": "The '$1' property of map-render element is required.", + "43": "Cannot bind face direction to main block $1, please call malloc in advance.", + "44": "Cannot bind face direction to main block $1, since main direction cannot be override.", + "45": "Cannot add $1 map renderer extension, since $1 already exists for the given state.", + "46": "Cannot execute close door action on $1,$2, since the given position is not empty.", "1201": "Floor-damage extension needs 'floor-binder' extension as dependency." }, "warn": { @@ -94,6 +114,34 @@ "63": "Uncaught promise error in waiting box component. Error reason: $1", "64": "Text node type and block type mismatch: '$1' vs '$2'", "65": "Cannot bind a weather controller twice.", + "66": "Texture identifier repeated: $1", + "67": "Cannot set alias '$1' for texture $2, since there is already an alias '$3' for it.", + "68": "Cannot set alias '$1' for texture $2, since '$1' is already an alias for $3.", + "69": "Clip totally exceeds texture's dimensions, no operation will be alppied.", + "70": "An TextureAnimater can only be created once.", + "71": "Cannot start animate when animater not created on texture.", + "72": "Frame count delivered to frame-based animater need to exactly divide texture's height.", + "73": "Consistent size is needed when using WebGL2 composer.", + "74": "Frame size not match texture's size, incomplete data will be skipped.", + "75": "Material count splitted by grid does not match alias count, some material may have no alias or some alias may not map to a texture. Splitted: $1, alias: $2.", + "76": "Cannot pipe texture store when asset builder has been started.", + "77": "Texture is clipped to an area of 0. Ensure that the texture contains your clip rect and clip rect's area is not zero.", + "78": "Adding tileset source must follow index order.", + "79": "Assets can only be built once.", + "80": "Parameter count of MapLayer.getMapData must be 0 or 4.", + "81": "Map data to get is partially (or totally) out of range. Overflowed area will be filled with zero.", + "82": "Big image offset size is larger than 64. Considier reduce big image offset bucket.", + "83": "It seems that you call '$1' too frequently. This will extremely affect game's performance, so considering integrate them into one 'updateArea' or 'updateBlockList' call.", + "84": "Cannot set alias '$1' for layer, since '$1' is already an alias for another layer.", + "85": "Hook to load does not belong to current hookable object.", + "86": "Cannot restore vertex data since delivered state does not belong to current vertex generator instance.", + "87": "Map texture asset cache not hit. This has no effect on rendering, but may extremely affect performance, please see the following link to find solution.", + "88": "No hero image is specified, please set image in advance.", + "89": "Cannot set hero image, since there is no image named '$1'.", + "90": "Cannot set hero image, since delivered image has incorrect format or size.", + "91": "Cannot add follower, since specified follower number $1 does not exist.", + "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.", "1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency." } } diff --git a/packages/common/src/logger.ts b/packages/common/src/logger.ts index 6e1ec1c..98dba26 100644 --- a/packages/common/src/logger.ts +++ b/packages/common/src/logger.ts @@ -72,8 +72,15 @@ export class Logger { let paramNum = ''; while (++pointer < text.length) { const char = text[pointer]; + const next = text[pointer + 1]; - if (char === '$' && text[pointer - 1] !== '\\') { + if (char === '\\' && next === '$') { + str += '$'; + pointer++; + continue; + } + + if (char === '$' && nums.has(next)) { inParam = true; continue; } @@ -82,7 +89,7 @@ export class Logger { if (nums.has(char)) { paramNum += char; } - if (!nums.has(text[pointer + 1])) { + if (!nums.has(next)) { inParam = false; const num = Number(paramNum); paramNum = ''; @@ -125,8 +132,7 @@ export class Logger { hideTipText(); } const n = Math.floor(code / 50) + 1; - const n2 = code % 50; - const url = `${location.origin}/_docs/logger/error/error${n}.html#error-code-${n2}`; + const url = `${location.origin}/_docs/logger/error/error${n}.html#error-code-${code}`; console.error(`[ERROR Code ${code}] ${text} See ${url}`); } } @@ -159,8 +165,7 @@ export class Logger { hideTipText(); } const n = Math.floor(code / 50) + 1; - const n2 = code % 50; - const url = `${location.origin}/_docs/logger/warn/warn${n}.html#warn-code-${n2}`; + const url = `${location.origin}/_docs/logger/warn/warn${n}.html#warn-code-${code}`; console.warn(`[WARNING Code ${code}] ${text} See ${url}`); } } diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts new file mode 100644 index 0000000..dd56daf --- /dev/null +++ b/packages/common/src/types.ts @@ -0,0 +1,117 @@ +//#region 脏标记 + +export interface IDirtyMarker { + /** + * 标记为脏,即进行了一次更新 + * @param data 传递给追踪器的数据 + */ + dirty(data: T): void; +} + +export interface IDirtyMark {} + +export interface IDirtyTracker { + /** + * 对状态进行标记 + */ + mark(): IDirtyMark; + + /** + * 取消指定标记符号 + * @param mark 标记符号 + */ + unmark(mark: IDirtyMark): void; + + /** + * 从指定标记符号开始,数据是否发生了变动 + * @param mark 标记符号 + */ + dirtySince(mark: IDirtyMark): T; + + /** + * 当前追踪器是否包含指定标记符号 + * @param symbol 标记符号 + */ + hasMark(symbol: IDirtyMark): boolean; +} + +//#endregion + +//#region 钩子 + +export interface IHookObject< + H extends IHookBase, + C extends IHookController +> { + /** 钩子对象 */ + readonly hook: Partial; + /** 钩子控制器 */ + readonly controller: C; +} + +export interface IHookController { + /** 控制器的钩子对象 */ + readonly hook: Partial; + + /** + * 加载此控制器对应的钩子对象 + */ + load(): void; + + /** + * 卸载此控制器对应的钩子对象,之后此钩子将不会再被触发 + */ + unload(): void; +} + +export interface IHookBase { + /** + * 加载此钩子对象 + * @param controller 钩子控制器对象 + */ + awake(controller: IHookController): void; + + /** + * 摧毁此钩子对象 + * @param controller 钩子控制器对象 + */ + destroy(controller: IHookController): void; +} + +export interface IHookable< + H extends IHookBase = IHookBase, + C extends IHookController = IHookController +> { + /** + * 添加钩子对象,返回控制该钩子对象的控制器 + * @param hook 钩子对象 + */ + addHook(hook: Partial): C; + + /** + * 加载指定的钩子对象 + * @param hook 钩子对象 + */ + loadHook(hook: Partial): void; + + /** + * 移除钩子对象,会调用钩子对象的 `destroy` 方法 + * @param hook 钩子对象 + */ + removeHook(hook: Partial): void; + + /** + * 传入钩子控制器,移除对应的钩子对象 + * @param hook 钩子控制器 + */ + removeHookByController(hook: C): void; + + /** + * 遍历每个钩子,执行顺序不固定 + * @param fn 对每个钩子执行的函数 + * @returns 每个钩子的返回值组成的数组,顺序不固定 + */ + forEachHook(fn: (hook: Partial, controller: C) => T): T[]; +} + +//#endregion diff --git a/packages/render-assets/package.json b/packages/render-assets/package.json new file mode 100644 index 0000000..5cb7221 --- /dev/null +++ b/packages/render-assets/package.json @@ -0,0 +1,6 @@ +{ + "name": "@motajs/render-assets", + "dependencies": { + "@motajs/client-base": "workspace:*" + } +} diff --git a/packages/render-assets/src/animater.ts b/packages/render-assets/src/animater.ts new file mode 100644 index 0000000..4b2ffe4 --- /dev/null +++ b/packages/render-assets/src/animater.ts @@ -0,0 +1,134 @@ +import { ITexture, ITextureAnimater, ITextureRenderable } from './types'; + +/** + * 行动画控制器,将贴图按照从上到下的顺序依次组成帧动画,动画传入的参数代表帧数 + */ +export class TextureRowAnimater implements ITextureAnimater { + *once(texture: ITexture, frames: number): Generator { + if (frames <= 0) return; + const renderable = texture.render(); + const { x: ox, y: oy } = renderable.rect; + const { width: w, height } = texture!; + const h = height / frames; + for (let i = 0; i < frames; i++) { + const renderable: ITextureRenderable = { + source: texture.source, + rect: texture.clampRect({ x: i * w + ox, y: oy, w, h }) + }; + yield renderable; + } + } + + *cycled(texture: ITexture, frames: number): Generator { + if (frames <= 0) return; + const renderable = texture.render(); + const { x: ox, y: oy } = renderable.rect; + const { width: w, height } = texture; + const h = height / frames; + let i = 0; + while (true) { + const renderable: ITextureRenderable = { + source: texture.source, + rect: texture.clampRect({ x: i * w + ox, y: oy, w, h }) + }; + yield renderable; + i++; + if (i === frames) i = 0; + } + } +} + +/** + * 列动画控制器,将贴图按照从左到右的顺序依次组成帧动画,动画传入的参数代表帧数 + */ +export class TextureColumnAnimater implements ITextureAnimater { + *once(texture: ITexture, frames: number): Generator { + if (frames <= 0) return; + const renderable = texture.render(); + const { x: ox, y: oy, w: width, h } = renderable.rect; + const w = width / frames; + for (let i = 0; i < frames; i++) { + const renderable: ITextureRenderable = { + source: texture.source, + rect: texture.clampRect({ x: i * w + ox, y: oy, w, h }) + }; + yield renderable; + } + } + + *cycled(texture: ITexture, frames: number): Generator { + if (frames <= 0) return null; + const renderable = texture.render(); + const { x: ox, y: oy } = renderable.rect; + const { width, height: h } = texture; + const w = width / frames; + let i = 0; + while (true) { + const renderable: ITextureRenderable = { + source: texture.source, + rect: texture.clampRect({ x: i * w + ox, y: oy, w, h }) + }; + yield renderable; + i++; + if (i === frames) i = 0; + } + } +} + +export interface IScanAnimaterData { + /** 每帧的宽度 */ + readonly width: number; + /** 每帧的高度 */ + readonly height: number; + /** 总帧数 */ + readonly frames: number; +} + +/** + * 扫描动画控制器,会按照先从左到右,再从上到下的顺序依次输出,可以用于动画精灵图等 + */ +export class TextureScanAnimater + implements ITextureAnimater +{ + *once( + texture: ITexture, + data: IScanAnimaterData + ): Generator { + const w = texture.width; + const h = texture.height; + + let frame = 0; + for (let y = 0; y < data.width; y++) { + for (let x = 0; x < data.height; x++) { + const renderable: ITextureRenderable = { + source: texture.source, + rect: texture.clampRect({ x: x * w, y: y * h, w, h }) + }; + yield renderable; + frame++; + if (frame === data.frames) break; + } + } + } + + *cycled( + texture: ITexture, + data: IScanAnimaterData + ): Generator { + const w = texture.width; + const h = texture.height; + + let index = 0; + while (true) { + const x = index % data.width; + const y = Math.floor(index / data.height); + const renderable: ITextureRenderable = { + source: texture.source, + rect: texture.clampRect({ x: x * w, y: y * h, w, h }) + }; + yield renderable; + index++; + if (index === data.frames) index = 0; + } + } +} diff --git a/packages/render-assets/src/composer.ts b/packages/render-assets/src/composer.ts new file mode 100644 index 0000000..0debb72 --- /dev/null +++ b/packages/render-assets/src/composer.ts @@ -0,0 +1,451 @@ +import { + IOption, + IRectangle, + MaxRectsPacker, + Rectangle +} from 'maxrects-packer'; +import { Texture } from './texture'; +import { + IRect, + ITexture, + ITextureComposedData, + ITextureComposer, + SizedCanvasImageSource +} from './types'; +import vert from './shader/pack.vert?raw'; +import frag from './shader/pack.frag?raw'; +import { logger } from '@motajs/common'; +import { isNil } from 'lodash-es'; +import { compileProgramWith } from '@motajs/client-base'; + +interface IndexMarkedComposedData { + /** 组合数据 */ + readonly asset: ITextureComposedData; + /** 组合时最后一个用到的贴图的索引 */ + readonly index: number; +} + +export interface IGridComposerData { + /** 单个贴图的宽度,与之不同的贴图将会被剔除并警告 */ + readonly width: number; + /** 单个贴图的宽度,与之不同的贴图将会被剔除并警告 */ + readonly height: number; +} + +export class TextureGridComposer + implements ITextureComposer +{ + /** + * 网格组合器,将等大小的贴图组合成图集,要求每个贴图的尺寸一致。 + * 组合时按照先从左到右,再从上到下的顺序组合。 + * @param maxWidth 图集最大宽度,也是输出纹理的宽度 + * @param maxHeight 图集最大高度,也是输出纹理的高度 + */ + constructor( + readonly maxWidth: number, + readonly maxHeight: number + ) {} + + private nextAsset( + tex: ITexture[], + start: number, + data: IGridComposerData, + rows: number, + cols: number, + index: number + ): IndexMarkedComposedData { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + canvas.width = this.maxWidth; + canvas.height = this.maxHeight; + + const count = Math.min(rows * cols, tex.length - start); + const map = new Map(); + + let x = 0; + let y = 0; + for (let i = 0; i < count; i++) { + const dx = x * data.width; + const dy = y * data.height; + const texture = tex[i + start]; + const renderable = texture.render(); + const { x: sx, y: sy, w: sw, h: sh } = renderable.rect; + ctx.drawImage(renderable.source, sx, sy, sw, sh, dx, dy, sw, sh); + map.set(texture, { x: dx, y: dy, w: sw, h: sh }); + x++; + if (x === cols) { + y++; + x = 0; + } + } + + const texture = new Texture(canvas); + const composed: ITextureComposedData = { + index, + texture, + assetMap: map + }; + + return { asset: composed, index: start + count }; + } + + *compose( + input: Iterable, + data: IGridComposerData + ): Generator { + const arr = [...input]; + + const rows = Math.floor(this.maxWidth / data.width); + const cols = Math.floor(this.maxHeight / data.height); + + let arrindex = 0; + let i = 0; + + while (arrindex < arr.length) { + const res = this.nextAsset(arr, arrindex, data, rows, cols, i); + arrindex = res.index + 1; + i++; + yield res.asset; + } + } +} + +export interface IMaxRectsComposerData extends IOption { + /** 贴图之间的间距 */ + readonly padding: number; +} + +interface MaxRectsRectangle extends IRectangle { + /** 这个矩形对应的贴图对象 */ + readonly data: ITexture; +} + +export class TextureMaxRectsComposer + implements ITextureComposer +{ + /** + * 使用 Max Rects 算法执行贴图整合,输入数据参考 {@link IMaxRectsComposerData}, + * 输出的纹理的图像源将会是不同的画布,注意与 {@link TextureMaxRectsWebGL2Composer} 区分 + * @param maxWidth 图集最大宽度,也是输出纹理的宽度 + * @param maxHeight 图集最大高度,也是输出纹理的高度 + */ + constructor( + public readonly maxWidth: number, + public readonly maxHeight: number + ) {} + + *compose( + input: Iterable, + data: IMaxRectsComposerData + ): Generator { + const packer = new MaxRectsPacker( + this.maxWidth, + this.maxHeight, + data.padding, + data + ); + const arr = [...input]; + const rects = arr.map(v => { + const rect = v.render().rect; + const toPack = new Rectangle(rect.w, rect.h); + toPack.data = v; + return toPack; + }); + packer.addArray(rects); + + let index = 0; + for (const bin of packer.bins) { + const map = new Map(); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + canvas.width = this.maxWidth; + canvas.height = this.maxHeight; + ctx.imageSmoothingEnabled = false; + bin.rects.forEach(v => { + const rect: IRect = { x: v.x, y: v.y, w: v.width, h: v.height }; + map.set(v.data, rect); + const renderable = v.data.render(); + const { x, y, w, h } = renderable.rect; + const source = renderable.source; + ctx.drawImage(source, x, y, w, h, v.x, v.y, v.width, v.height); + }); + const texture = new Texture(canvas); + const data: ITextureComposedData = { + index: index++, + texture, + assetMap: map + }; + yield data; + } + } +} + +interface RectProcessed { + /** 贴图位置映射 */ + readonly texMap: Map>; + /** 顶点数组 */ + readonly attrib: Float32Array; +} + +export class TextureMaxRectsWebGL2Composer + implements ITextureComposer +{ + /** 使用的画布 */ + readonly canvas: HTMLCanvasElement; + /** 画布上下文 */ + readonly gl: WebGL2RenderingContext; + /** WebGL2 程序 */ + readonly program: WebGLProgram; + /** 顶点数组缓冲区 */ + readonly vertBuffer: WebGLBuffer; + /** 纹理数组对象 */ + readonly texArray: WebGLTexture; + /** 纹理数组对象的位置 */ + readonly texArrayPos: WebGLUniformLocation; + /** `a_position` 的索引 */ + readonly posPos: number; + /** `a_texCoord` 的索引 */ + readonly texPos: number; + + /** 本次处理的贴图宽度 */ + private opWidth: number = 0; + /** 本次处理的贴图高度 */ + private opHeight: number = 0; + + /** + * 使用 Max Rects 算法执行贴图整合,使用 WebGL2 执行组合操作。 + * 输入数据参考 {@link IMaxRectsComposerData},要求每个贴图的图像源尺寸一致,贴图大小可以不同。 + * 注意,本组合器同时只能处理一个组合操作,上一个没执行完的时候再次调用 `compose` 会出现问题。 + * 所有输出的内容中,贴图对象的图像源都是同一个画布,因此获取后要么直接使用,要么立刻调用 `toBitmap`, + * 否则在下一次调用 `next` 时,图像源将会被覆盖。 + * @param maxWidth 图集最大宽度,也是输出纹理的宽度 + * @param maxHeight 图集最大高度,也是输出纹理的高度 + */ + constructor( + public readonly maxWidth: number, + public readonly maxHeight: number + ) { + this.canvas = document.createElement('canvas'); + this.canvas.width = maxWidth; + this.canvas.height = maxHeight; + this.gl = this.canvas.getContext('webgl2')!; + const { program } = compileProgramWith(this.gl, vert, frag)!; + this.program = program; + + // 初始化画布数据 + + const texture = this.gl.createTexture(); + this.texArray = texture; + const location = this.gl.getUniformLocation(program, 'u_textArray')!; + this.texArrayPos = location; + + this.posPos = this.gl.getAttribLocation(program, 'a_position'); + this.texPos = this.gl.getAttribLocation(program, 'a_texCoord'); + + this.vertBuffer = this.gl.createBuffer(); + + this.gl.useProgram(program); + } + + /** + * 对贴图进行索引 + * @param textures 贴图数组 + */ + private mapTextures( + textures: ITexture[] + ): Map { + const map = new Map(); + const { width, height } = textures[0].source; + + textures.forEach(v => { + const source = v.source; + if (map.has(source)) return; + if (source.width !== width || source.height !== height) { + logger.warn(73); + return; + } + map.set(source, map.size); + }); + + this.opWidth = width; + this.opHeight = height; + + return map; + } + + /** + * 传递贴图 + * @param texMap 纹理映射 + */ + private setTexture(texMap: Map) { + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.texArray); + + gl.texStorage3D( + gl.TEXTURE_2D_ARRAY, + 1, + gl.RGBA8, + this.opWidth, + this.opHeight, + texMap.size + ); + texMap.forEach((index, source) => { + gl.texSubImage3D( + gl.TEXTURE_2D_ARRAY, + 0, + 0, + 0, + index, + this.opWidth, + this.opHeight, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + source + ); + }); + + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_MAG_FILTER, + gl.NEAREST + ); + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_MIN_FILTER, + gl.NEAREST + ); + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_WRAP_S, + gl.CLAMP_TO_EDGE + ); + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_WRAP_T, + gl.CLAMP_TO_EDGE + ); + } + + /** + * 处理矩形数组,生成 WebGL2 顶点数据 + * @param rects 要处理的矩形数组 + * @param texMap 贴图到贴图数组索引的映射 + */ + private processRects( + rects: MaxRectsRectangle[], + texMap: Map + ): RectProcessed { + const { width: ow, height: oh } = this.canvas; + const map = new Map(); + const attrib = new Float32Array(rects.length * 5 * 6); + rects.forEach((v, i) => { + const rect: IRect = { x: v.x, y: v.y, w: v.width, h: v.height }; + map.set(v.data, rect); + const renderable = v.data.render(); + const { width: tw, height: th } = v.data.source; + const { x, y, w, h } = renderable.rect; + // 画到目标画布上的位置 + const ol = (v.x / ow) * 2 - 1; + const ob = (v.y / oh) * 2 - 1; + const or = ((v.x + v.width) / ow) * 2 - 1; + const ot = ((v.y + v.height) / oh) * 2 - 1; + // 原始贴图位置 + const tl = x / tw; + const tt = y / tw; + const tr = (x + w) / tw; + const tb = (y + h) / th; + // 贴图索引 + const ti = texMap.get(v.data.source); + + if (isNil(ti)) return; + + // Benchmark https://www.measurethat.net/Benchmarks/Show/35246/2/different-method-to-write-a-typedarray + + // prettier-ignore + const data = [ + // x y u v i + ol, -ot, tl, tt, ti, // 左上角 + ol, -ob, tl, tb, ti, // 左下角 + or, -ot, tr, tt, ti, // 右上角 + or, -ot, tr, tt, ti, // 右上角 + ol, -ob, tl, tb, ti, // 左下角 + or, -ob, tr, tb, ti // 右下角 + ]; + + attrib.set(data, i * 30); + }); + + const data: RectProcessed = { + texMap: map, + attrib + }; + return data; + } + + /** + * 执行渲染操作 + * @param attrib 顶点数组 + */ + private renderAtlas(attrib: Float32Array) { + const gl = this.gl; + + gl.clearColor(0, 0, 0, 0); + gl.clearDepth(1); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertBuffer); + gl.bufferData(gl.ARRAY_BUFFER, attrib, gl.DYNAMIC_DRAW); + + gl.vertexAttribPointer(this.posPos, 2, gl.FLOAT, false, 5 * 4, 0); + gl.vertexAttribPointer(this.texPos, 3, gl.FLOAT, false, 5 * 4, 2 * 4); + gl.enableVertexAttribArray(this.posPos); + gl.enableVertexAttribArray(this.texPos); + + gl.uniform1i(this.texArrayPos, 0); + + gl.drawArrays(gl.TRIANGLES, 0, attrib.length / 5); + } + + *compose( + input: Iterable, + data: IMaxRectsComposerData + ): Generator { + this.opWidth = 0; + this.opHeight = 0; + + const packer = new MaxRectsPacker( + this.maxWidth, + this.maxHeight, + data.padding, + data + ); + const arr = [...input]; + const rects = arr.map(v => { + const rect = v.render().rect; + const toPack = new Rectangle(rect.w, rect.h); + toPack.data = v; + return toPack; + }); + packer.addArray(rects); + + const indexMap = this.mapTextures(arr); + this.setTexture(indexMap); + + let index = 0; + for (const bin of packer.bins) { + const { texMap, attrib } = this.processRects(bin.rects, indexMap); + this.renderAtlas(attrib); + const texture = new Texture(this.canvas); + const data: ITextureComposedData = { + index: index++, + texture, + assetMap: texMap + }; + yield data; + } + + this.gl.disableVertexAttribArray(this.posPos); + this.gl.disableVertexAttribArray(this.texPos); + } +} diff --git a/packages/render-assets/src/index.ts b/packages/render-assets/src/index.ts new file mode 100644 index 0000000..aec186a --- /dev/null +++ b/packages/render-assets/src/index.ts @@ -0,0 +1,7 @@ +export * from './animater'; +export * from './composer'; +export * from './splitter'; +export * from './store'; +export * from './streamComposer'; +export * from './texture'; +export * from './types'; diff --git a/packages/render-assets/src/shader/pack.frag b/packages/render-assets/src/shader/pack.frag new file mode 100644 index 0000000..f00195d --- /dev/null +++ b/packages/render-assets/src/shader/pack.frag @@ -0,0 +1,12 @@ +#version 300 es +precision highp float; +precision highp sampler2DArray; + +in vec3 v_texCoord; +out vec4 outColor; + +uniform sampler2DArray u_texArray; + +void main() { + outColor = texture(u_texArray, v_texCoord); +} diff --git a/packages/render-assets/src/shader/pack.vert b/packages/render-assets/src/shader/pack.vert new file mode 100644 index 0000000..e626a84 --- /dev/null +++ b/packages/render-assets/src/shader/pack.vert @@ -0,0 +1,12 @@ +#version 300 es +precision highp float; + +in vec2 a_position; +in vec3 a_texCoord; + +out vec3 v_texCoord; + +void main() { + v_texCoord = a_texCoord; + gl_Position = vec4(a_position, 0.0, 1.0); +} diff --git a/packages/render-assets/src/splitter.ts b/packages/render-assets/src/splitter.ts new file mode 100644 index 0000000..0c6f805 --- /dev/null +++ b/packages/render-assets/src/splitter.ts @@ -0,0 +1,68 @@ +import { Texture } from './texture'; +import { ITexture, ITextureSplitter, IRect } from './types'; + +/** + * 按行分割贴图,即分割成一行行的贴图,按从上到下的顺序输出 + * 输入参数代表每一行的高度 + */ +export class TextureRowSplitter implements ITextureSplitter { + *split(texture: ITexture, data: number): Generator { + const lines = Math.ceil(texture.height / data); + const { x, y } = texture.render().rect; + for (let i = 0; i < lines; i++) { + const tex = new Texture(texture.source); + tex.clip(x, y + i * data, texture.width, data); + yield tex; + } + } +} + +/** + * 按列分割贴图,即分割成一列列的贴图,按从左到右的顺序输出 + * 输入参数代表每一列的宽度 + */ +export class TextureColumnSplitter implements ITextureSplitter { + *split(texture: ITexture, data: number): Generator { + const lines = Math.ceil(texture.width / data); + const { x, y } = texture.render().rect; + for (let i = 0; i < lines; i++) { + const tex = new Texture(texture.source); + tex.clip(x + i * data, y, data, texture.height); + yield tex; + } + } +} + +/** + * 按照网格分割贴图,按照先从左到右,再从上到下的顺序输出 + * 输入参数代表每一列的宽度和高度 + */ +export class TextureGridSplitter implements ITextureSplitter<[number, number]> { + *split(texture: ITexture, data: [number, number]): Generator { + const [w, h] = data; + const rows = Math.ceil(texture.width / w); + const lines = Math.ceil(texture.height / h); + const { x, y } = texture.render().rect; + for (let ny = 0; ny < lines; ny++) { + for (let nx = 0; nx < rows; nx++) { + const tex = new Texture(texture.source); + tex.clip(x + nx * w, y + ny * h, w, h); + yield tex; + } + } + } +} + +/** + * 根据图集信息分割贴图,按照传入的矩形数组的顺序输出 + * 输入参数代表每个贴图对应到图集上的矩形位置 + */ +export class TextureAssetSplitter implements ITextureSplitter { + *split(texture: ITexture, data: IRect[]): Generator { + for (const { x, y, w, h } of data) { + const tex = new Texture(texture.source); + tex.clip(x, y, w, h); + yield tex; + } + } +} diff --git a/packages/render-assets/src/store.ts b/packages/render-assets/src/store.ts new file mode 100644 index 0000000..b9486f2 --- /dev/null +++ b/packages/render-assets/src/store.ts @@ -0,0 +1,107 @@ +import { isNil } from 'lodash-es'; +import { ITexture, ITextureStore } from './types'; +import { logger } from '@motajs/common'; + +export class TextureStore + implements ITextureStore +{ + private readonly texMap: Map = new Map(); + private readonly invMap: Map = new Map(); + private readonly aliasMap: Map = new Map(); + private readonly aliasInvMap: Map = new Map(); + + [Symbol.iterator](): Iterator<[key: number, tex: T]> { + return this.texMap.entries(); + } + + entries(): Iterable<[key: number, tex: T]> { + return this.texMap.entries(); + } + + keys(): Iterable { + return this.texMap.keys(); + } + + values(): Iterable { + return this.texMap.values(); + } + + addTexture(identifier: number, texture: T): void { + if (this.texMap.has(identifier)) { + logger.warn(66, identifier.toString()); + return; + } + this.texMap.set(identifier, texture); + this.invMap.set(texture, identifier); + } + + private removeBy(id: number, tex: T, alias?: string) { + this.texMap.delete(id); + this.invMap.delete(tex); + if (alias) { + this.aliasMap.delete(alias); + this.aliasInvMap.delete(id); + } + } + + removeTexture(identifier: number | string | T): void { + if (typeof identifier === 'string') { + const id = this.aliasMap.get(identifier); + if (isNil(id)) return; + const tex = this.texMap.get(id); + if (isNil(tex)) return; + this.removeBy(id, tex, identifier); + } else if (typeof identifier === 'number') { + const tex = this.texMap.get(identifier); + if (isNil(tex)) return; + const alias = this.aliasInvMap.get(identifier); + this.removeBy(identifier, tex, alias); + } else { + const id = this.invMap.get(identifier); + if (isNil(id)) return; + const alias = this.aliasInvMap.get(id); + this.removeBy(id, identifier, alias); + } + } + + hasTexture(identifier: number): boolean { + return this.texMap.has(identifier); + } + + getTexture(identifier: number): T | null { + return this.texMap.get(identifier) ?? null; + } + + alias(identifier: number, alias: string): void { + const id = this.aliasMap.get(alias); + const al = this.aliasInvMap.get(identifier); + if (!isNil(al)) { + logger.warn(67, alias, identifier.toString(), al); + return; + } + if (!isNil(id)) { + logger.warn(68, alias, identifier.toString(), id.toString()); + return; + } + this.aliasMap.set(alias, identifier); + this.aliasInvMap.set(identifier, alias); + } + + fromAlias(alias: string): ITexture | null { + const id = this.aliasMap.get(alias); + if (isNil(id)) return null; + return this.texMap.get(id) ?? null; + } + + idOf(texture: T): number | undefined { + return this.invMap.get(texture); + } + + aliasOf(identifier: number): string | undefined { + return this.aliasInvMap.get(identifier); + } + + identifierOf(alias: string): number | undefined { + return this.aliasMap.get(alias); + } +} diff --git a/packages/render-assets/src/streamComposer.ts b/packages/render-assets/src/streamComposer.ts new file mode 100644 index 0000000..d8f6e7a --- /dev/null +++ b/packages/render-assets/src/streamComposer.ts @@ -0,0 +1,201 @@ +import { + Bin, + IOption, + IRectangle, + MaxRectsPacker, + Rectangle +} from 'maxrects-packer'; +import { Texture } from './texture'; +import { + IRect, + ITexture, + ITextureComposedData, + ITextureStreamComposer +} from './types'; + +export class TextureGridStreamComposer implements ITextureStreamComposer { + readonly rows: number; + readonly cols: number; + + private nowIndex: number = 0; + private outputIndex: number = -1; + + private nowTexture: ITexture; + private nowCanvas: HTMLCanvasElement; + private nowCtx: CanvasRenderingContext2D; + private nowMap: Map>; + + /** + * 网格流式贴图组合器,将等大小的贴图组合成图集,要求每个贴图的尺寸一致。 + * 组合时按照先从左到右,再从上到下的顺序组合。 + * @param width 单个贴图的宽度 + * @param height 单个贴图的高度 + * @param maxWidth 图集的最大宽度,也是输出贴图对象的宽度 + * @param maxHeight 图集的最大高度,也是输出贴图对象的高度 + */ + constructor( + readonly width: number, + readonly height: number, + readonly maxWidth: number, + readonly maxHeight: number + ) { + this.rows = Math.floor(maxHeight / height); + this.cols = Math.floor(maxWidth / width); + + this.nowCanvas = document.createElement('canvas'); + this.nowCtx = this.nowCanvas.getContext('2d')!; + this.nowMap = new Map(); + this.nowTexture = new Texture(this.nowCanvas); + } + + private nextCanvas() { + this.nowCanvas = document.createElement('canvas'); + this.nowCtx = this.nowCanvas.getContext('2d')!; + this.nowMap = new Map(); + this.nowIndex = 0; + this.nowTexture = new Texture(this.nowCanvas); + this.outputIndex++; + } + + *add(textures: Iterable): Generator { + let index = this.nowIndex; + const max = this.cols * this.rows; + + for (const tex of textures) { + const nowRow = Math.floor(index / this.cols); + const nowCol = index % this.cols; + + const { source, rect } = tex.render(); + const { x: cx, y: cy, w: cw, h: ch } = rect; + const x = nowRow * this.width; + const y = nowCol * this.height; + this.nowCtx.drawImage(source, cx, cy, cw, ch, x, y, cw, ch); + this.nowMap.set(tex, { x, y, w: cw, h: ch }); + + if (++index === max) { + const data: ITextureComposedData = { + index: this.outputIndex, + texture: this.nowTexture, + assetMap: this.nowMap + }; + yield data; + this.nextCanvas(); + } + } + + const data: ITextureComposedData = { + index: this.outputIndex, + texture: this.nowTexture, + assetMap: this.nowMap + }; + yield data; + } + + close(): void { + // We need to do nothing. + } +} + +interface MaxRectsRectangle extends IRectangle { + /** 这个矩形对应的贴图对象 */ + readonly data: ITexture; +} + +export class TextureMaxRectsStreamComposer + implements ITextureStreamComposer +{ + /** Max Rects 打包器 */ + readonly packer: MaxRectsPacker; + + private outputIndex: number = -1; + private nowTexture!: ITexture; + + private nowCanvas!: HTMLCanvasElement; + private nowCtx!: CanvasRenderingContext2D; + private nowMap!: Map>; + private nowBin: number = 0; + + /** + * 使用 Max Rects 算法执行贴图整合。输出的纹理的图像源将会是不同的画布。 + * @param maxWidth 图集最大宽度,也是输出贴图对象的宽度 + * @param maxHeight 图集最大高度,也是输出贴图对象的高度 + * @param padding 每个贴图之间的间距 + * @param options 传递给打包器对象的参数 + */ + constructor( + readonly maxWidth: number, + readonly maxHeight: number, + readonly padding: number, + options?: IOption + ) { + this.packer = new MaxRectsPacker( + this.maxWidth, + this.maxHeight, + padding, + options + ); + + this.nextCanvas(); + } + + private nextCanvas() { + this.nowCanvas = document.createElement('canvas'); + this.nowCtx = this.nowCanvas.getContext('2d')!; + this.nowCanvas.width = this.maxWidth; + this.nowCanvas.height = this.maxHeight; + this.nowMap = new Map(); + this.outputIndex++; + this.nowTexture = new Texture(this.nowCanvas); + } + + *add(textures: Iterable): Generator { + const arr = [...textures]; + const rects = arr.map(v => { + const rect = v.render().rect; + const toPack = new Rectangle(rect.w, rect.h); + toPack.data = v; + return toPack; + }); + this.packer.addArray(rects); + + const bins = this.packer.bins; + if (bins.length === 0) return; + const toYield: Bin[] = [bins[bins.length - 1]]; + if (bins.length > this.nowBin) { + toYield.push(...bins.slice(this.nowBin)); + } + + for (let i = this.nowBin; i < bins.length; i++) { + const rects = bins[i].rects; + rects.forEach(v => { + if (this.nowMap.has(v.data)) return; + const target: IRect = { + x: v.x, + y: v.y, + w: v.width, + h: v.height + }; + this.nowMap.set(v.data, target); + const { source, rect } = v.data.render(); + const { x: cx, y: cy, w: cw, h: ch } = rect; + this.nowCtx.drawImage(source, cx, cy, cw, ch, v.x, v.y, cw, ch); + }); + + const data: ITextureComposedData = { + index: this.outputIndex, + texture: this.nowTexture, + assetMap: this.nowMap + }; + yield data; + if (i < bins.length - 1) { + this.nextCanvas(); + } + } + + this.nowBin = bins.length - 1; + } + + close(): void { + // We need to do nothing. + } +} diff --git a/packages/render-assets/src/texture.ts b/packages/render-assets/src/texture.ts new file mode 100644 index 0000000..0eaf627 --- /dev/null +++ b/packages/render-assets/src/texture.ts @@ -0,0 +1,193 @@ +import { logger } from '@motajs/common'; +import { + IRect, + ITexture, + ITextureComposedData, + ITextureRenderable, + ITextureSplitter, + SizedCanvasImageSource +} from './types'; +import { clamp } from 'lodash-es'; + +export class Texture implements ITexture { + source: SizedCanvasImageSource; + width: number; + height: number; + isBitmap: boolean = false; + + /** 裁剪矩形的左边框位置 */ + private cl: number; + /** 裁剪矩形的上边框位置 */ + private ct: number; + /** 裁剪矩形的右边框位置 */ + private cr: number; + /** 裁剪矩形的下边框位置 */ + private cb: number; + + constructor(source: SizedCanvasImageSource) { + this.source = source; + this.width = source.width; + this.height = source.height; + this.cl = 0; + this.ct = 0; + this.cr = source.width; + this.cb = source.height; + } + + /** + * 对纹理进行裁剪操作,不会改变图像源 + * @param x 裁剪左上角横坐标 + * @param y 裁剪左上角纵坐标 + * @param w 裁剪宽度 + * @param h 裁剪高度 + */ + clip(x: number, y: number, w: number, h: number) { + const left = clamp(this.cl + x, this.cl, this.cr); + const top = clamp(this.ct + y, this.ct, this.cb); + const right = clamp(this.cl + x + w, this.cl, this.cr); + const bottom = clamp(this.ct + y + h, this.ct, this.cb); + if (left === right || top === bottom) { + logger.warn(69); + return; + } + const width = right - left; + const height = bottom - top; + if (width <= 0 || height <= 0) { + logger.warn(77); + return; + } + this.cl = left; + this.ct = top; + this.cr = right; + this.cb = bottom; + this.width = right - left; + this.height = bottom - top; + } + + async toBitmap(): Promise { + if (this.source instanceof ImageBitmap) return; + this.source = await createImageBitmap( + this.source, + this.cl, + this.ct, + this.width, + this.height + ); + this.cl = 0; + this.ct = 0; + this.cr = this.width; + this.cb = this.height; + this.isBitmap = true; + } + + split(splitter: ITextureSplitter, data: U): Generator { + return splitter.split(this, data); + } + + render(): ITextureRenderable { + return { + source: this.source, + rect: { x: this.cl, y: this.ct, w: this.width, h: this.height } + }; + } + + clampRect(rect: Readonly): Readonly { + const l = clamp(rect.x, this.cl, this.cr); + const t = clamp(rect.y, this.ct, this.cb); + const r = clamp(rect.x + rect.w, this.cl, this.cr); + const b = clamp(rect.y + rect.h, this.ct, this.cb); + return { x: l, y: t, w: r - l, h: b - t }; + } + + clipped(rect: Readonly): ITextureRenderable { + return { + source: this.source, + rect: this.clampRect(rect) + }; + } + + dispose(): void { + if (this.source instanceof ImageBitmap) { + this.source.close(); + } + } + + toAsset(asset: ITextureComposedData): boolean { + const rect = asset.assetMap.get(this); + if (!rect) return false; + if (this.isBitmap && this.source instanceof ImageBitmap) { + this.source.close(); + } + this.isBitmap = false; + this.source = asset.texture.source; + this.cl = rect.x; + this.ct = rect.y; + this.width = rect.w; + this.height = rect.h; + this.cr = rect.x + rect.w; + this.cb = rect.y + rect.h; + return true; + } + + /** + * 对贴图的动画执行偏移效果。动画效果可以不来自传入的贴图对象, + * 输出结果的图像源会是传入的贴图对象的图像源而非动画效果对应的图像源 + * @param texture 贴图对象 + * @param animate 动画效果 + * @param ox 偏移横坐标 + * @param oy 偏移纵坐标 + */ + static *translated( + texture: ITexture, + animate: Generator | null, + ox: number, + oy: number + ): Generator | null { + if (!animate) return null; + + while (true) { + const next = animate.next(); + if (next.done) break; + const renderable = next.value; + const { x, y, w, h } = renderable.rect; + const translated: IRect = { x: x + ox, y: y + oy, w, h }; + const res: ITextureRenderable = { + source: texture.source, + rect: texture.clampRect(translated) + }; + yield res; + } + } + + /** + * 对贴图的动画重定位。最终输出的动画会以传入的 `fx` `fy` 为左上角坐标计算 + * @param texture 贴图对象 + * @param origin 动画效果来自的贴图对象 + * @param animate 动画效果 + * @param fx 左上角横坐标 + * @param fy 左上角纵坐标 + */ + static *fixed( + texture: ITexture, + origin: ITexture, + animate: Generator | null, + fx: number, + fy: number + ): Generator | null { + if (!animate) return null; + const { x: ox, y: oy } = origin.render().rect; + + while (true) { + const next = animate.next(); + if (next.done) break; + const renderable = next.value; + const { x, y, w, h } = renderable.rect; + const translated: IRect = { x: x - ox + fx, y: y - oy + fy, w, h }; + const res: ITextureRenderable = { + source: texture.source, + rect: texture.clampRect(translated) + }; + yield res; + } + } +} diff --git a/packages/render-assets/src/types.ts b/packages/render-assets/src/types.ts new file mode 100644 index 0000000..781321a --- /dev/null +++ b/packages/render-assets/src/types.ts @@ -0,0 +1,218 @@ +export type SizedCanvasImageSource = Exclude< + CanvasImageSource, + VideoFrame | SVGElement +>; + +export type CanvasStyle = string | CanvasGradient | CanvasPattern; + +export interface IRect { + x: number; + y: number; + w: number; + h: number; +} + +export interface ITextureRenderable { + /** 可渲染贴图对象的图像源 */ + readonly source: SizedCanvasImageSource; + /** 贴图裁剪区域 */ + readonly rect: Readonly; +} + +export interface ITextureComposedData { + /** 这个纹理图集的贴图对象 */ + readonly texture: ITexture; + /** 这个纹理图集的索引 */ + readonly index: number; + /** 每个参与组合的贴图对应到图集对象的矩形范围 */ + readonly assetMap: Map>; +} + +export interface ITextureComposer { + /** + * 将一系列纹理组合成为一系列纹理图集 + * @param input 输入纹理 + * @param data 输入给组合器的参数 + */ + compose( + input: Iterable, + data: T + ): Generator; +} + +export interface ITextureStreamComposer { + /** + * 将一系列纹理添加到当前流式组合器中,并将这部分纹理的组合结果输出 + * @param textures 输入纹理 + * @param data 输入给组合器的参数 + */ + add( + textures: Iterable, + data: T + ): Generator; + + /** + * 结束此组合器的使用,释放相关资源 + */ + close(): void; +} + +export interface ITextureSplitter { + /** + * 对一个贴图对象执行拆分操作 + * @param texture 要拆分的贴图 + * @param data 传给拆分器的参数 + */ + split(texture: ITexture, data: T): Generator; +} + +export interface ITextureAnimater { + /** + * 开始动画序列 + * @param texture 贴图对象 + * @param data 动画初始化参数 + */ + once(texture: ITexture, data: T): Generator; + + /** + * 开始循环动画序列 + * @param texture 贴图对象 + * @param data 动画初始化参数 + */ + cycled(texture: ITexture, data: T): Generator; +} + +export interface ITexture { + /** 贴图的图像源 */ + readonly source: SizedCanvasImageSource; + /** 贴图宽度 */ + readonly width: number; + /** 贴图高度 */ + readonly height: number; + /** 当前贴图是否是完整 bitmap 图像 */ + readonly isBitmap: boolean; + + /** + * 将此贴图转换为 bitmap 图像,图像源也会转变成 ImageBitmap + */ + toBitmap(): Promise; + + /** + * 使用指定贴图切分器切分贴图 + * @param splitter 贴图切分器 + * @param data 传递给切分器的参数 + * @returns 切分出的贴图所组成的可迭代对象 + */ + split(splitter: ITextureSplitter, data: T): Generator; + + /** + * 获取整张图的可渲染对象 + */ + render(): ITextureRenderable; + + /** + * 限制矩形范围至当前贴图对象范围 + * @param rect 矩形范围 + */ + clampRect(rect: Readonly): Readonly; + + /** + * 获取贴图经过指定矩形裁剪后的可渲染对象,并不是简单地对图像源裁剪,还会处理其他情况 + * @param rect 裁剪矩形 + */ + clipped(rect: Readonly): ITextureRenderable; + + /** + * 释放此贴图的资源,将不能再被使用 + */ + dispose(): void; + + /** + * 将贴图的图像源转换为指定图集的图像源,并将范围限定至图集中对应到此贴图的矩形范围。 + * @param asset 图集信息 + * @returns 是否转换成功,如果图集信息中不包含当前贴图,那么返回 `false` + */ + toAsset(asset: ITextureComposedData): boolean; +} + +export const enum TextureOffsetDirection { + LeftToRight, + RightToLeft, + TopToBottom, + BottomToTop +} + +export interface ITextureStore { + [Symbol.iterator](): Iterator<[key: number, tex: T]>; + + /** + * 获取纹理对象键值对的可迭代对象 + */ + entries(): Iterable<[key: number, tex: T]>; + + /** + * 获取纹理对象的键的可迭代对象 + */ + keys(): Iterable; + + /** + * 获取纹理对象的值的可迭代对象 + */ + values(): Iterable; + + /** + * 添加一个贴图 + * @param identifier 贴图 id + * @param texture 贴图对象 + */ + addTexture(identifier: number, texture: T): void; + + /** + * 移除一个贴图 + * @param identifier 要移除的贴图对象 id 或 别名 或 贴图对象 + */ + removeTexture(identifier: number | string | T): void; + + /** + * 判断当前贴图存储对象是否包含指定贴图 id + * @param identifier 贴图对象 id + */ + hasTexture(identifier: number): boolean; + + /** + * 根据贴图对象 id 获取贴图 + * @param identifier 贴图对象 id + */ + getTexture(identifier: number): T | null; + + /** + * 给贴图对象命名一个别名,如果贴图对象不存在也可以设置 + * @param identifier 贴图对象 id + * @param alias 要命名的别名 + */ + alias(identifier: number, alias: string): void; + + /** + * 根据贴图对象别名获取贴图 + * @param alias 贴图对象别名 + */ + fromAlias(alias: string): ITexture | null; + + /** + * 根据贴图对象别名获取贴图对象 id + * @param alias 贴图对象别名 + */ + identifierOf(alias: string): number | undefined; + + /** + * 根据贴图对象获取此贴图对象在此控制器中的 id,如果贴图不在此控制器,返回 `undefined` + * @param texture 贴图对象 + */ + idOf(texture: ITexture): number | undefined; + + /** + * 根据贴图对象获取此贴图对象在此控制器中的别名,如果不存在此 id,返回 `undefined` + * @param identifier 贴图 id + */ + aliasOf(identifier: number): string | undefined; +} diff --git a/packages/render-core/package.json b/packages/render-core/package.json index c98b2bf..e05f46c 100644 --- a/packages/render-core/package.json +++ b/packages/render-core/package.json @@ -1,6 +1,7 @@ { "name": "@motajs/render-core", "dependencies": { - "@motajs/common": "workspace:*" + "@motajs/common": "workspace:*", + "@motajs/render-assets": "workspace:*" } -} \ No newline at end of file +} diff --git a/packages/render-core/src/gl2.ts b/packages/render-core/src/gl2.ts index 1292be8..076423b 100644 --- a/packages/render-core/src/gl2.ts +++ b/packages/render-core/src/gl2.ts @@ -4,7 +4,7 @@ import { MotaOffscreenCanvas2D } from './canvas2d'; import { ERenderItemEvent, RenderItem, RenderItemPosition } from './item'; import { Transform } from './transform'; import { isWebGL2Supported } from './utils'; -import { SizedCanvasImageSource } from './types'; +import { SizedCanvasImageSource } from '@motajs/render-assets'; export interface IGL2ProgramPrefix { readonly VERTEX: string; @@ -1237,7 +1237,7 @@ export class GL2Program extends EventEmitter { } /** - * 设置渲染模式,目前可选 {@link Shader.DRAW_ARRAYS} 至 {@link Shader.DRAW_INSTANCED} + * 设置渲染模式,目前可选 {@link GL2.DRAW_ARRAYS} 至 {@link GL2.DRAW_INSTANCED} */ mode(mode: RenderMode) { this.renderMode = mode; diff --git a/packages/render-core/src/index.ts b/packages/render-core/src/index.ts index 6b2f23b..02de53c 100644 --- a/packages/render-core/src/index.ts +++ b/packages/render-core/src/index.ts @@ -8,5 +8,4 @@ export * from './render'; export * from './shader'; export * from './sprite'; export * from './transform'; -export * from './types'; export * from './utils'; diff --git a/packages/render-core/src/item.ts b/packages/render-core/src/item.ts index 539c04a..79d041e 100644 --- a/packages/render-core/src/item.ts +++ b/packages/render-core/src/item.ts @@ -225,7 +225,7 @@ export abstract class RenderItem IRenderTickerSupport, IRenderChildable, IRenderVueSupport, - ITransformUpdatable, + ITransformUpdatable, IRenderEvent { /** 渲染的全局ticker */ @@ -301,7 +301,7 @@ export abstract class RenderItem /** 该元素的变换矩阵 */ private _transform: Transform = new Transform(); set transform(value: Transform) { - this._transform.bind(); + this._transform.unbind(this); this._transform = value; value.bind(this); } @@ -671,10 +671,12 @@ export abstract class RenderItem } } - updateTransform() { + updateTransform(transform: Transform) { // 更新变换矩阵时,不需要更新自身的缓存,直接调用父元素的更新即可 - this._parent?.update(); - this.emit('transform', this, this._transform); + if (transform === this.transform) { + this._parent?.update(); + this.emit('transform', this, this._transform); + } } //#endregion @@ -796,7 +798,7 @@ export abstract class RenderItem this._parent = void 0; parent.requestSort(); parent.update(); - this._transform.bind(); + this._transform.unbind(this); if (!success) return false; this._root?.disconnect(this); this._root = void 0; diff --git a/packages/render-core/src/transform.ts b/packages/render-core/src/transform.ts index 97b0d02..c3f38c5 100644 --- a/packages/render-core/src/transform.ts +++ b/packages/render-core/src/transform.ts @@ -1,7 +1,11 @@ import { mat3, mat4, ReadonlyMat3, ReadonlyVec3, vec2, vec3 } from 'gl-matrix'; -export interface ITransformUpdatable { - updateTransform?(): void; +export interface ITransformUpdatable { + /** + * 当变换矩阵更新时触发 + * @param transform 触发更新的变换矩阵 + */ + updateTransform(transform: T): void; } export class Transform { @@ -17,14 +21,26 @@ export class Transform { private modified: boolean = false; /** 绑定的可更新元素 */ - bindedObject?: ITransformUpdatable; + bindedObject: Set> = new Set(); /** * 对这个变换实例添加绑定对象,当矩阵变换时,自动调用其 update 函数 * @param obj 要绑定的对象 */ - bind(obj?: ITransformUpdatable) { - this.bindedObject = obj; + bind(obj: ITransformUpdatable) { + this.bindedObject.add(obj); + } + + /** + * 取消对象的绑定 + * @param obj 要取消绑定的对象 + */ + unbind(obj: ITransformUpdatable) { + this.bindedObject.delete(obj); + } + + private emitUpdate() { + this.bindedObject.forEach(v => v.updateTransform(this)); } /** @@ -38,7 +54,7 @@ export class Transform { this.scaleY = 1; this.rad = 0; this.modified = false; - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); } /** @@ -49,7 +65,7 @@ export class Transform { this.scaleX *= x; this.scaleY *= y; this.modified = true; - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -61,7 +77,7 @@ export class Transform { this.x += x; this.y += y; this.modified = true; - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -76,7 +92,7 @@ export class Transform { this.rad -= n * Math.PI * 2; } this.modified = true; - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -88,7 +104,7 @@ export class Transform { this.scaleX = x; this.scaleY = y; this.modified = true; - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -100,7 +116,7 @@ export class Transform { this.x = x; this.y = y; this.modified = true; - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -111,7 +127,7 @@ export class Transform { mat3.rotate(this.mat, this.mat, rad - this.rad); this.rad = rad; this.modified = true; - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -138,7 +154,7 @@ export class Transform { mat3.fromValues(a, b, 0, c, d, 0, e, f, 1) ); this.calAttributes(); - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -161,7 +177,7 @@ export class Transform { ): this { mat3.set(this.mat, a, b, 0, c, d, 0, e, f, 1); this.calAttributes(); - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -285,14 +301,26 @@ export class Transform3D { mat: mat4 = mat4.create(); /** 绑定的可更新元素 */ - bindedObject?: ITransformUpdatable; + bindedObject: Set> = new Set(); /** - * 绑定可更新对象 + * 对这个变换实例添加绑定对象,当矩阵变换时,自动调用其 update 函数 * @param obj 要绑定的对象 */ - bind(obj?: ITransformUpdatable) { - this.bindedObject = obj; + bind(obj: ITransformUpdatable) { + this.bindedObject.add(obj); + } + + /** + * 取消对象的绑定 + * @param obj 要取消绑定的对象 + */ + unbind(obj: ITransformUpdatable) { + this.bindedObject.delete(obj); + } + + private emitUpdate() { + this.bindedObject.forEach(v => v.updateTransform(this)); } /** @@ -300,7 +328,7 @@ export class Transform3D { */ reset(): this { mat4.identity(this.mat); - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -312,7 +340,7 @@ export class Transform3D { */ scale(x: number, y: number, z: number): this { mat4.scale(this.mat, this.mat, [x, y, z]); - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -324,7 +352,7 @@ export class Transform3D { */ translate(x: number, y: number, z: number): this { mat4.translate(this.mat, this.mat, [x, y, z]); - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -335,7 +363,7 @@ export class Transform3D { */ rotate(rad: number, axis: vec3): this { mat4.rotate(this.mat, this.mat, rad, axis); - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -371,7 +399,7 @@ export class Transform3D { */ lookAt(eye: vec3, center: vec3, up: vec3): this { mat4.lookAt(this.mat, eye, center, up); - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -384,7 +412,7 @@ export class Transform3D { */ perspective(fovy: number, aspect: number, near: number, far: number): this { mat4.perspective(this.mat, fovy, aspect, near, far); - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } @@ -406,7 +434,7 @@ export class Transform3D { far: number ): this { mat4.ortho(this.mat, left, right, bottom, top, near, far); - this.bindedObject?.updateTransform?.(); + this.emitUpdate(); return this; } diff --git a/packages/render-core/src/types.ts b/packages/render-core/src/types.ts deleted file mode 100644 index 133dde7..0000000 --- a/packages/render-core/src/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type SizedCanvasImageSource = Exclude< - CanvasImageSource, - VideoFrame | SVGElement ->; - -export type CanvasStyle = string | CanvasGradient | CanvasPattern; diff --git a/packages/render-elements/src/graphics.ts b/packages/render-elements/src/graphics.ts index a3c7405..f6064fe 100644 --- a/packages/render-elements/src/graphics.ts +++ b/packages/render-elements/src/graphics.ts @@ -2,9 +2,9 @@ import { Transform, ERenderItemEvent, RenderItem, - MotaOffscreenCanvas2D, - CanvasStyle + MotaOffscreenCanvas2D } from '@motajs/render-core'; +import { CanvasStyle } from '@motajs/render-assets'; import { logger } from '@motajs/common'; import { clamp, isEqual, isNil } from 'lodash-es'; diff --git a/packages/render-elements/src/misc.ts b/packages/render-elements/src/misc.ts index a6e596e..a2b88b6 100644 --- a/packages/render-elements/src/misc.ts +++ b/packages/render-elements/src/misc.ts @@ -5,13 +5,12 @@ import { Transform, MotaOffscreenCanvas2D } from '@motajs/render-core'; +import { CanvasStyle } from '@motajs/render-assets'; import { Font } from '@motajs/render-style'; /** 文字的安全填充,会填充在文字的上侧和下侧,防止削顶和削底 */ const SAFE_PAD = 1; -type CanvasStyle = string | CanvasGradient | CanvasPattern; - export interface ETextEvent extends ERenderItemEvent { setText: [text: string, width: number, height: number]; } diff --git a/packages/render-vue/src/props.ts b/packages/render-vue/src/props.ts index 3a2e39d..57620ff 100644 --- a/packages/render-vue/src/props.ts +++ b/packages/render-vue/src/props.ts @@ -7,9 +7,9 @@ import { ElementLocator, ElementScale, CustomContainerRenderFn, - CustomContainerPropagateFn, - CanvasStyle + CustomContainerPropagateFn } from '@motajs/render-core'; +import { CanvasStyle } from '@motajs/render-assets'; import { BezierParams, CircleParams, diff --git a/packages/render/src/index.ts b/packages/render/src/index.ts index e929fe4..738e7ca 100644 --- a/packages/render/src/index.ts +++ b/packages/render/src/index.ts @@ -1,3 +1,4 @@ +export * from '@motajs/render-assets'; export * from '@motajs/render-core'; export * from '@motajs/render-elements'; export * from '@motajs/render-style'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5de67fd..6465811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: lz-string: specifier: ^1.5.0 version: 1.5.0 + maxrects-packer: + specifier: ^2.7.3 + version: 2.7.3 mutate-animate: specifier: ^1.4.2 version: 1.4.2 @@ -480,6 +483,12 @@ importers: specifier: ^3.5.13 version: 3.5.20(typescript@5.9.2) + packages/render-assets: + dependencies: + '@motajs/client-base': + specifier: workspace:* + version: link:../client-base + packages/render-core: dependencies: '@motajs/common': @@ -4513,6 +4522,9 @@ packages: mathjax-full@3.2.2: resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==} + maxrects-packer@2.7.3: + resolution: {integrity: sha512-bG6qXujJ1QgttZVIH4WDanhoJtvbud/xP/XPyf6A69C9RdA61BM4TomFALCq2nrTa+tARRIBB4LuIFsnUQU2wA==} + mdast-util-to-hast@13.2.0: resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} @@ -10722,6 +10734,8 @@ snapshots: mj-context-menu: 0.6.1 speech-rule-engine: 4.1.2 + maxrects-packer@2.7.3: {} + mdast-util-to-hast@13.2.0: dependencies: '@types/hast': 3.0.4 diff --git a/public/_server/table/data.comment.js b/public/_server/table/data.comment.js index b5e25d9..ba2ff60 100644 --- a/public/_server/table/data.comment.js +++ b/public/_server/table/data.comment.js @@ -128,6 +128,19 @@ var data_comment_c456ea59_6018_45ef_8bcc_211a24c627dc = { "_docs": "使用字体", "_data": "在此存放所有可能使用的字体 \n 字体名不能使用中文,不能带空格或特殊字符" }, + "heroImages": { + "_leaf": true, + "_type": "material", + "_range": "editor.mode.checkImages(thiseval, './project/images/')", + "_directory": "./project/images/", + "_transform": (function (one) { + if (one.endsWith('.png') || one.endsWith('.jpg') || one.endsWith('.jpeg') || one.endsWith('.gif') || one.endsWith('.webp')) + return one; + return null; + }).toString(), + "_docs": "勇士贴图", + "_data": "在这里填写游戏中所有可能使用到的勇士贴图,贴图需要先在全塔属性-使用图片中注册。如果一个贴图不会被用作勇士贴图,请不要填写进去!", + }, "nameMap": { "_leaf": true, "_type": "event", diff --git a/public/project/data.js b/public/project/data.js index bd9c723..d10b891 100644 --- a/public/project/data.js +++ b/public/project/data.js @@ -151,7 +151,10 @@ var data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d = ], "font": "normal" }, - "splitImages": [] + "splitImages": [], + "heroImages": [ + "hero.png" + ] }, "firstData": { "title": "魔塔样板", @@ -185,7 +188,9 @@ var data_a1e2fb4a_e986_4524_b0da_9b7ba7c0874d = }, "flags": {}, "followers": [], - "steps": 0 + "steps": 0, + "magicDef": null, + "magicRed": null }, "startCanvas": [ { diff --git a/public/project/icons.js b/public/project/icons.js index c0d6eae..7adb1d0 100644 --- a/public/project/icons.js +++ b/public/project/icons.js @@ -260,7 +260,18 @@ var icons_4665ee12_3a1f_44a4_bea3_0fccba634dc1 = "jumpShoes": 49, "skill1": 30, "wand": 10, - "pack": 46 + "pack": 46, + "I331": 5, + "I332": 7, + "I333": 26, + "I334": 31, + "I335": 32, + "I336": 34, + "I337": 35, + "I338": 36, + "I339": 37, + "I340": 38, + "I341": 39 }, "autotile": { "autotile": 0, diff --git a/public/project/items.js b/public/project/items.js index 131b7b6..a58df7e 100644 --- a/public/project/items.js +++ b/public/project/items.js @@ -528,5 +528,60 @@ var items_296f5d02_12fd_4166_a7c1_b5e830c9ee3a = "name": "钱袋", "itemEffect": "core.status.hero.money += 500", "itemEffectTip": ",金币+500" + }, + "I331": { + "cls": "items", + "name": "新物品", + "canUseItemEffect": "true" + }, + "I332": { + "cls": "items", + "name": "新物品", + "canUseItemEffect": "true" + }, + "I333": { + "cls": "items", + "name": "新物品", + "canUseItemEffect": "true" + }, + "I334": { + "cls": "items", + "name": "新物品", + "canUseItemEffect": "true" + }, + "I335": { + "cls": "items", + "name": "新物品", + "canUseItemEffect": "true" + }, + "I336": { + "cls": "items", + "name": "新物品", + "canUseItemEffect": "true" + }, + "I337": { + "cls": "items", + "name": "新物品", + "canUseItemEffect": "true" + }, + "I338": { + "cls": "items", + "name": "新物品", + "canUseItemEffect": "true" + }, + "I339": { + "cls": "items", + "name": "新物品", + "canUseItemEffect": "true" + }, + "I340": { + "cls": "items", + "name": "新物品", + "canUseItemEffect": "true" + }, + "I341": { + "cls": "items", + "name": "新物品", + "canUseItemEffect": "true" } } \ No newline at end of file diff --git a/public/project/maps.js b/public/project/maps.js index dcfffd7..b0a1474 100644 --- a/public/project/maps.js +++ b/public/project/maps.js @@ -223,5 +223,16 @@ var maps_90f36752_8815_4be8_b32b_d7fad1d0542e = "327": {"cls":"enemy48","id":"bearDown"}, "328": {"cls":"enemy48","id":"bearLeft"}, "329": {"cls":"enemy48","id":"bearRight"}, - "330": {"cls":"enemy48","id":"bearUp"} + "330": {"cls":"enemy48","id":"bearUp"}, + "331": {"cls":"items","id":"I331"}, + "332": {"cls":"items","id":"I332"}, + "333": {"cls":"items","id":"I333"}, + "334": {"cls":"items","id":"I334"}, + "335": {"cls":"items","id":"I335"}, + "336": {"cls":"items","id":"I336"}, + "337": {"cls":"items","id":"I337"}, + "338": {"cls":"items","id":"I338"}, + "339": {"cls":"items","id":"I339"}, + "340": {"cls":"items","id":"I340"}, + "341": {"cls":"items","id":"I341"} } \ No newline at end of file diff --git a/script/build-game.ts b/script/build-game.ts index 5afc8f7..5eae017 100644 --- a/script/build-game.ts +++ b/script/build-game.ts @@ -13,6 +13,9 @@ import { RequiredData, RequiredIconsData, ResourceType } from './types'; import { splitResource, SplittedResource } from './build-resource'; import { formatSize } from './utils'; +/** 打包调试 */ +const DEBUG_BUILD = false; +/** 录像验证调试 */ const DEBUG_REPLAY = false; const ansi = { @@ -52,6 +55,7 @@ async function buildClient(outDir: string) { build: { outDir, copyPublicDir: true, + minify: !DEBUG_BUILD, rollupOptions: { external: ['@wasm-audio-decoders/opus-ml'], output: { @@ -568,13 +572,21 @@ async function buildGame() { ) ); - const scripts = archiver('zip'); - scripts.directory('packages/', resolve(process.cwd(), 'packages')); + const scripts = archiver('zip', { + store: false, + zlib: { + level: 9 + } + }); + scripts.directory(resolve(process.cwd(), 'packages'), 'packages/'); scripts.directory( - 'packages-user/', - resolve(process.cwd(), 'packages-user') + resolve(process.cwd(), 'packages-user'), + 'packages-user/' ); - scripts.directory('src/', resolve(process.cwd(), 'src')); + scripts.directory(resolve(process.cwd(), 'src'), '/src'); + scripts.file(resolve(process.cwd(), 'public', 'main.js'), { + name: 'main.js' + }); const output = createWriteStream(resolve(distDir, 'source-code.zip')); scripts.pipe(output); @@ -622,7 +634,11 @@ async function buildGame() { try { await zip.compressDir( resolve(distDir), - resolve(process.cwd(), 'dist.zip') + resolve(process.cwd(), 'dist.zip'), + { + compress: true, + compressionLevel: 9 + } ); await emptyDir(tempDir); diff --git a/script/build-resource.ts b/script/build-resource.ts index 70bcfd4..572fbb5 100644 --- a/script/build-resource.ts +++ b/script/build-resource.ts @@ -67,7 +67,13 @@ async function compressFiles(files: ResourceContent[]) { const dir = `${v.type}/${v.name}`; zip.file(dir, v.content); }); - const buffer = await zip.generateAsync({ type: 'uint8array' }); + const buffer = await zip.generateAsync({ + type: 'uint8array', + compression: 'DEFLATE', + compressionOptions: { + level: 9 + } + }); const hash = fileHash(buffer); const name = `resource.${hash}.h5data`; diff --git a/script/dev.ts b/script/dev.ts index bf23ce9..0b6249c 100644 --- a/script/dev.ts +++ b/script/dev.ts @@ -468,9 +468,9 @@ const apiWriteMultiFiles = async (req: Request, res: Response) => { } else { const strArray = status.map((v, i) => { if (v === APIStatus.PermissionDeny) { - return `Index: ${i}; Permission Error: Cannot access file outside current working directory`; - } else if (v === APIStatus.Success) { - return `Index: ${i}: Internal Error: Write file error.`; + return `Path: ${pathList[i]}: Permission Error: Cannot access file outside current working directory.`; + } else if (v === APIStatus.WriteError) { + return `Path: ${pathList[i]}: Internal Error: Write file error.`; } else { return 'Success'; } diff --git a/script/lines.ts b/script/lines.ts index 27af861..1d3963a 100644 --- a/script/lines.ts +++ b/script/lines.ts @@ -25,9 +25,16 @@ import { formatSize } from './utils.js'; '.vue', '.less', '.css', - '.html' + '.html', + '.vert', + '.frag' ]; + const mapExt = (ext: string) => { + if (ext === '.vert' || ext === '.frag') return '.shader'; + else return ext; + }; + const check = async (dir: string) => { if (ignoreDir.some(v => dir.includes(v))) return; const d = await fs.readdir(dir); @@ -38,7 +45,7 @@ import { formatSize } from './utils.js'; if (exts.some(v => one.endsWith(v))) { const file = await fs.readFile(resolve(dir, one), 'utf-8'); const lines = file.split('\n').length; - const ext = extname(one); + const ext = mapExt(extname(one)); list[ext] ??= [0, 0, 0]; list[ext][0]++; list[ext][1] += lines; @@ -59,7 +66,7 @@ import { formatSize } from './utils.js'; }); for (const [ext, [file, lines, size]] of sorted) { console.log( - `${ext.slice(1).padEnd(7, ' ')}files: ${file + `${ext.slice(1).padEnd(9, ' ')}files: ${file .toString() .padEnd(6, ' ')}lines: ${lines .toString() @@ -67,7 +74,7 @@ import { formatSize } from './utils.js'; ); } console.log( - `\x1b[33mtotal files: ${totalFiles + `\x1b[33mtotal files: ${totalFiles .toString() .padEnd(6, ' ')}lines: ${totalLines .toString() diff --git a/src/styles.less b/src/styles.less index d9bb99e..ae3357c 100644 --- a/src/styles.less +++ b/src/styles.less @@ -127,6 +127,5 @@ div.toolbar-editor-item { position: fixed; border: none; z-index: 1000; - background-color: black; - transform-origin: 0 0; + background-color: transparent; } diff --git a/src/types/declaration/data.d.ts b/src/types/declaration/data.d.ts index 00b0fdf..e7da15c 100644 --- a/src/types/declaration/data.d.ts +++ b/src/types/declaration/data.d.ts @@ -69,6 +69,11 @@ interface MainData { */ readonly splitImages: SplitImageData; + /** + * 勇士贴图列表 + */ + readonly heroImages: readonly ImageIds[]; + readonly plugin: string[]; } diff --git a/src/types/declaration/eventDec.d.ts b/src/types/declaration/eventDec.d.ts index 296ce3b..5180637 100644 --- a/src/types/declaration/eventDec.d.ts +++ b/src/types/declaration/eventDec.d.ts @@ -225,7 +225,7 @@ interface LevelChooseEvent { /** * 难度的颜色 */ - color: RGBArray; + color?: RGBArray; /** * 选择该难度时执行的事件 diff --git a/src/types/source/cls.d.ts b/src/types/source/cls.d.ts index cefc5b8..83dc675 100644 --- a/src/types/source/cls.d.ts +++ b/src/types/source/cls.d.ts @@ -223,4 +223,15 @@ interface IdToCls { bearLeft: 'enemy48'; bearRight: 'enemy48'; bearUp: 'enemy48'; + I331: 'items'; + I332: 'items'; + I333: 'items'; + I334: 'items'; + I335: 'items'; + I336: 'items'; + I337: 'items'; + I338: 'items'; + I339: 'items'; + I340: 'items'; + I341: 'items'; } \ No newline at end of file diff --git a/src/types/source/items.d.ts b/src/types/source/items.d.ts index 18f55c3..e89e840 100644 --- a/src/types/source/items.d.ts +++ b/src/types/source/items.d.ts @@ -50,4 +50,15 @@ interface ItemDeclaration { skill1: 'constants'; wand: 'items'; pack: 'items'; + I331: 'items'; + I332: 'items'; + I333: 'items'; + I334: 'items'; + I335: 'items'; + I336: 'items'; + I337: 'items'; + I338: 'items'; + I339: 'items'; + I340: 'items'; + I341: 'items'; } \ No newline at end of file diff --git a/src/types/source/maps.d.ts b/src/types/source/maps.d.ts index f1e1646..a6fc152 100644 --- a/src/types/source/maps.d.ts +++ b/src/types/source/maps.d.ts @@ -223,6 +223,17 @@ interface IdToNumber { bearLeft: 328; bearRight: 329; bearUp: 330; + I331: 331; + I332: 332; + I333: 333; + I334: 334; + I335: 335; + I336: 336; + I337: 337; + I338: 338; + I339: 339; + I340: 340; + I341: 341; } interface NumberToId { 1: 'yellowWall'; @@ -449,4 +460,15 @@ interface NumberToId { 328: 'bearLeft'; 329: 'bearRight'; 330: 'bearUp'; + 331: 'I331'; + 332: 'I332'; + 333: 'I333'; + 334: 'I334'; + 335: 'I335'; + 336: 'I336'; + 337: 'I337'; + 338: 'I338'; + 339: 'I339'; + 340: 'I340'; + 341: 'I341'; } \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 77827bd..85d5e89 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,8 @@ import * as glob from 'glob'; const custom = [ 'container', 'image', 'sprite', 'shader', 'text', 'comment', 'custom', - 'layer', 'layer-group', 'animate', 'icon', 'winskin', 'container-custom' + 'layer', 'layer-group', 'animate', 'damage', 'graphics', 'icon', 'winskin', + 'container-custom', 'map-render' ]; const aliases = glob.sync('packages/*/src').map((srcPath) => {