diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 8ff7ecf..de4228c 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -79,8 +79,23 @@ "77": "Plugin $1 depends on a non-existent plugin: $2. Related plugins will not be initialized.", "78": "Duplicate plugin id: $1. The duplicate will not be registered.", "79": "$1 side initialize failed since no core config loaded.", - "80": "Plugin $1 load failed since plugin has an unexpected loading status. Expect: $1, Real: $2.", + "80": "Plugin $1 load failed since plugin has an unexpected loading status. Expect: $2, Real: $3.", "81": "Plugin $1 not loaded after game loaded, please check the issues above. Or this maybe an internal bug of engine, please report.", + "82": "Cannot parse animation: invalid header, reason: '$1'.", + "83": "Cannot parse animation: invalid signature.", + "84": "Cannot parse animation: unsupported version.", + "85": "Cannot parse animation: invalid sprite table, reason: '$1'.", + "86": "Cannot parse animation: invalid sprite $1.", + "87": "Cannot parse animation: invalid bitmap table, reason: '$1'.", + "88": "Cannot parse animation: invalid bitmap $1, reason: '$2'.", + "89": "Cannot parse animation: bitmap $1 exceeds the decoded image.", + "90": "Cannot parse animation: frame list is invalid.", + "91": "Cannot parse animation: invalid frame $1, reason: '$2'.", + "92": "Cannot parse animation: invalid object $2 in frame $1, reason: '$3'.", + "93": "Cannot parse animation: invalid scalar field, reason: '$1'.", + "94": "Cannot parse animation: invalid sound $2 in frame $1, reason: '$3'.", + "95": "Cannot parse animation: invalid metadata, reason: '$1'.", + "96": "Cannot parse animation: image failed, reason: '$1'.", "2001": "Replay($1): Replay parameter count mismatch: expected $2, got $3.", "2002": "Replay($1): Replay parameter type mismatch: index $2, expected $3, got $4.", "2003": "Replay(move): Expected hero to be stopped before execute moving step.", @@ -141,7 +156,7 @@ "48": "Sample rate in stream audio must be constant.", "49": "Repeated patch for '$1', key: '$2'.", "50": "Unknown audio extension name: '$1'", - "51": "Cannot decode sound '$1', since audio file may not supported by 2.b.", + "51": "Cannot decode sound '$1', since audio file may not supported by current engine version.", "52": "Cannot play sound '$1', since there is no added data named it.", "53": "Cannot $1 audio route '$2', since there is not added route named it.", "54": "Missing start tag for '$1', index: $2.", @@ -303,6 +318,8 @@ "210": "Cannot add duplicate hotkey command '$1' to group '$2'.", "211": "Expected a hotkey scope stored for a vue component, but no scope was found. This seems like a internal bug of engine, please report.", "212": "Duplicate implements for hotkey command '$1', old one will be overwritten. This probably caused by unclearing implementation config, please check.", + "213": "Setting item '$1' has existed in group '$2'. The item will be moved to new group '$3'.", + "214": "Range setting item get a number range that cannot be divided into integer by step: range=[$1, $2], step=$3. This may cause issues, please ensure number range can be divided by step evenly.", "1001": "Event(setBlock): Unknown tile '$1' when setting block." } } diff --git a/packages/render-anim/package.json b/packages/render-anim/package.json index 4934efa..79c6594 100644 --- a/packages/render-anim/package.json +++ b/packages/render-anim/package.json @@ -1,6 +1,7 @@ { "name": "@motajs/render-anim", "dependencies": { + "@motajs/common": "workspace:*", "@motajs/render": "workspace:*" } } diff --git a/packages/render-anim/src/__test__/animater.test.ts b/packages/render-anim/src/__test__/animater.test.ts new file mode 100644 index 0000000..5afaf80 --- /dev/null +++ b/packages/render-anim/src/__test__/animater.test.ts @@ -0,0 +1,375 @@ +// 测试动画合成器的帧序列、绘制参数、单画布复用与源纹理归属 +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface IFakeContext { + save: any; + restore: any; + setTransform: any; + scale: any; + rotate: any; + translate: any; + clearRect: any; + drawImage: any; + setLineDash: any; + transform: any; + imageSmoothingEnabled: boolean; + globalAlpha: number; +} + +interface IFakeCanvasElement { + width: number; + height: number; + getContext: (type: string) => IFakeContext | null; +} + +interface IFakeCanvas2D { + canvas: IFakeCanvasElement; + ctx: IFakeContext; + width: number; + height: number; + highResolution: boolean; + antiAliasing: boolean; + scale: number; + setHD: (hd: boolean) => void; + setAntiAliasing: (anti: boolean) => void; + size: (width: number, height: number) => void; + clear: () => void; +} + +// 用假画布与假纹理替换渲染包,避免包根的模块加载期循环依赖 +const fakes = vi.hoisted(() => { + const instances: IFakeCanvas2D[] = []; + const createContext = (): IFakeContext => ({ + save: vi.fn(), + restore: vi.fn(), + setTransform: vi.fn(), + scale: vi.fn(), + rotate: vi.fn(), + translate: vi.fn(), + clearRect: vi.fn(), + drawImage: vi.fn(), + setLineDash: vi.fn(), + transform: vi.fn(), + imageSmoothingEnabled: true, + globalAlpha: 1 + }); + class FakeCanvas2D implements IFakeCanvas2D { + canvas: IFakeCanvasElement; + ctx: IFakeContext; + width = 0; + height = 0; + highResolution = true; + antiAliasing = true; + scale = 1; + + constructor() { + const ctx = createContext(); + this.ctx = ctx; + this.canvas = { width: 0, height: 0, getContext: () => ctx }; + this.width = this.canvas.width; + this.height = this.canvas.height; + instances.push(this); + } + + setHD(hd: boolean): void { + this.highResolution = hd; + this.size(this.width, this.height); + } + + setAntiAliasing(anti: boolean): void { + this.antiAliasing = anti; + this.ctx.imageSmoothingEnabled = anti; + } + + size(width: number, height: number): void { + const w = Math.max(width, 1); + const h = Math.max(height, 1); + this.canvas.width = w; + this.canvas.height = h; + this.width = w; + this.height = h; + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.scale(1, 1); + this.ctx.imageSmoothingEnabled = this.antiAliasing; + } + + clear(): void { + this.ctx.save(); + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.restore(); + } + } + class FakeTexture { + source: unknown; + + constructor(source: unknown) { + this.source = source; + } + } + vi.stubGlobal('devicePixelRatio', 1); + return { instances, FakeCanvas2D, FakeTexture }; +}); + +vi.mock('@motajs/render', () => ({ + MotaOffscreenCanvas2D: fakes.FakeCanvas2D, + Texture: fakes.FakeTexture +})); + +import { ITexture, ITextureRenderable } from '@motajs/render'; +import { + AnimationBlendMode, + IAnimationBitmap, + IAnimationFrame, + IAnimationObject, + IAnimationParsedData, + RenderAnimater +} from '../index'; + +// 构造只含测试所需字段的假纹理,并返回其释放函数 +function createTexture(): { + texture: ITexture; + source: { name: string }; + dispose: ReturnType; +} { + const source = { name: 'texture-source' }; + const dispose = vi.fn(); + const texture = { source, dispose } as unknown as ITexture; + return { texture, source, dispose }; +} + +// 构造一个默认的动画对象 +function makeObject( + overrides: Partial = {} +): IAnimationObject { + return { + index: 0, + x: 0, + y: 0, + scale: 100, + opacity: 255, + mirror: 0, + rotate: 0, + ...overrides + }; +} + +// 构造一个默认的动画帧 +function makeFrame(overrides: Partial = {}): IAnimationFrame { + return { + width: 10, + height: 20, + originX: 0, + originY: 0, + objects: [], + ...overrides + }; +} + +// 构造解析后的动画数据 +function makeData( + texture: ITexture, + frames: IAnimationFrame[], + bitmaps: IAnimationBitmap[] = [{ x: 0, y: 0, width: 32, height: 16 }] +): IAnimationParsedData { + return { + texture, + bitmaps, + frames, + blend: AnimationBlendMode.Normal + }; +} + +// 取生成器下一帧的渲染对象 +function nextValue( + generator: Generator +): ITextureRenderable { + const result = generator.next(); + return result.value as ITextureRenderable; +} + +beforeEach(() => { + fakes.instances.length = 0; +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +describe('RenderAnimater', () => { + // once 依次产出每帧,矩形与帧尺寸一致,且两帧复用同一块画布 + it('yields every frame once with the frame rect', () => { + const { texture } = createTexture(); + const frames = [makeFrame(), makeFrame({ width: 5, height: 6 })]; + const animater = new RenderAnimater(); + const generator = animater.once(texture, makeData(texture, frames)); + + const first = nextValue(generator); + const second = nextValue(generator); + + expect(first.rect).toEqual({ x: 0, y: 0, w: 10, h: 20 }); + expect(second.rect).toEqual({ x: 0, y: 0, w: 5, h: 6 }); + expect(fakes.instances.length).toBe(1); + expect(first.source).toBe(fakes.instances[0].canvas); + expect(second.source).toBe(first.source); + expect(fakes.instances[0].highResolution).toBe(false); + expect(fakes.instances[0].antiAliasing).toBe(false); + }); + + // 下一次 next 恢复后复用同一块画布并按新帧重设尺寸 + it('resizes the same canvas when resumed', () => { + const { texture } = createTexture(); + const frames = [makeFrame(), makeFrame({ width: 5, height: 6 })]; + const animater = new RenderAnimater(); + const generator = animater.once(texture, makeData(texture, frames)); + + nextValue(generator); + const canvas = fakes.instances[0]; + expect(canvas.canvas.width).toBe(10); + expect(canvas.canvas.height).toBe(20); + + nextValue(generator); + expect(canvas.canvas.width).toBe(5); + expect(canvas.canvas.height).toBe(6); + expect(fakes.instances.length).toBe(1); + }); + + // 正常播放完毕时清空同一块画布并把尺寸归零 + it('clears and zeroes the canvas on normal completion', () => { + const { texture } = createTexture(); + const animater = new RenderAnimater(); + const generator = animater.once( + texture, + makeData(texture, [makeFrame()]) + ); + + nextValue(generator); + const canvas = fakes.instances[0]; + const end = generator.next(); + + expect(end.done).toBe(true); + expect(canvas.ctx.clearRect).toHaveBeenCalledTimes(1); + expect(canvas.canvas.width).toBe(0); + expect(canvas.canvas.height).toBe(0); + }); + + // 提前 return 不清理最后一帧,保留在画布中交给垃圾回收 + it('leaves the last frame on the canvas on early return', () => { + const { texture } = createTexture(); + const animater = new RenderAnimater(); + const generator = animater.once( + texture, + makeData(texture, [makeFrame()]) + ); + + nextValue(generator); + const canvas = fakes.instances[0]; + generator.return(); + + expect(canvas.ctx.clearRect).not.toHaveBeenCalled(); + expect(canvas.canvas.width).toBe(10); + expect(canvas.canvas.height).toBe(20); + }); + + // cycled 播放到最后一帧后回到第一帧且复用同一图像源 + it('loops back to the first frame when cycled', () => { + const { texture } = createTexture(); + const frames = [makeFrame(), makeFrame({ width: 5, height: 6 })]; + const animater = new RenderAnimater(); + const generator = animater.cycled(texture, makeData(texture, frames)); + + const first = nextValue(generator); + const second = nextValue(generator); + const third = nextValue(generator); + + expect(first.rect.w).toBe(10); + expect(second.rect.w).toBe(5); + expect(third.rect.w).toBe(10); + expect(second.source).toBe(first.source); + expect(third.source).toBe(first.source); + }); + + // 空帧产出 1x1 透明画布且不调用 drawImage + it('yields a transparent 1x1 canvas for an empty frame', () => { + const { texture } = createTexture(); + const animater = new RenderAnimater(); + const generator = animater.once( + texture, + makeData(texture, [makeFrame({ width: 1, height: 1, objects: [] })]) + ); + + const renderable = nextValue(generator); + const canvas = fakes.instances[0]; + + expect(renderable.rect).toEqual({ x: 0, y: 0, w: 1, h: 1 }); + expect(canvas.ctx.drawImage).not.toHaveBeenCalled(); + }); + + // 绘制时按居中变换顺序应用原点、镜像、旋转、透明度与目标矩形 + it('applies the centered transform for each object', () => { + const { texture } = createTexture(); + const object = makeObject({ + x: 5, + y: -3, + scale: 200, + opacity: 128, + mirror: 1, + rotate: 90 + }); + const frame = makeFrame({ + originX: 16, + originY: 10, + objects: [object] + }); + const animater = new RenderAnimater(); + const generator = animater.once(texture, makeData(texture, [frame])); + + nextValue(generator); + const canvas = fakes.instances[0]; + const ctx = canvas.ctx; + + expect(ctx.save).toHaveBeenCalledTimes(1); + expect(ctx.restore).toHaveBeenCalledTimes(1); + expect(ctx.translate).toHaveBeenCalledWith(21, 7); + expect(ctx.scale).toHaveBeenCalledWith(-1, 1); + expect(ctx.rotate.mock.calls[0][0]).toBeCloseTo(-Math.PI / 2, 6); + expect(ctx.globalAlpha).toBeCloseTo(128 / 255, 6); + expect(ctx.drawImage).toHaveBeenCalledWith( + texture.source, + 0, + 0, + 32, + 16, + -32, + -16, + 64, + 32 + ); + }); + + // 没有帧时 once 与 cycled 都立即结束且不创建画布 + it('ends immediately when there are no frames', () => { + const { texture } = createTexture(); + const animater = new RenderAnimater(); + const data = makeData(texture, []); + + expect(animater.once(texture, data).next().done).toBe(true); + expect(animater.cycled(texture, data).next().done).toBe(true); + expect(fakes.instances.length).toBe(0); + }); + + // 动画器不会释放源纹理 + it('does not dispose the source texture', () => { + const { texture, dispose } = createTexture(); + const animater = new RenderAnimater(); + const generator = animater.once( + texture, + makeData(texture, [makeFrame()]) + ); + + nextValue(generator); + generator.next(); + + expect(dispose).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/render-anim/src/__test__/parse.test.ts b/packages/render-anim/src/__test__/parse.test.ts new file mode 100644 index 0000000..9af62c8 --- /dev/null +++ b/packages/render-anim/src/__test__/parse.test.ts @@ -0,0 +1,1066 @@ +// 测试动画解析器的 v2 二进制读取、失败日志与错误码、字段校验与帧包围盒 / 原点计算 +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +// 仓库根类型检查未加载 Node 类型,静态闸门用例需要读取解析器源码 +// @ts-expect-error 此处缺少 node:fs 的模块声明,仅测试环境使用 +import { readFileSync } from 'node:fs'; + +interface IFakeBitmap { + width: number; + height: number; + close: ReturnType; +} + +interface ITestBitmap { + x: number; + y: number; + width: number; + height: number; +} + +interface ITestFrame { + objects?: unknown; + sound?: unknown; +} + +interface ITestMeta { + ratio: number; + blend: number; + bitmaps: ITestBitmap[]; + frame: ITestFrame[]; +} + +interface ISprite { + width: number; + height: number; +} + +// 每个错误码在 logger.json 中占用的参数数量,用于校验调用点 arity +const ERROR_PARAM_COUNTS: Record = { + 82: 1, + 83: 0, + 84: 0, + 85: 1, + 86: 1, + 87: 1, + 88: 2, + 89: 1, + 90: 0, + 91: 2, + 92: 3, + 93: 1, + 94: 3, + 95: 1, + 96: 1 +}; + +// 记录解析器输出的错误码与错误参数 +const loggerMock = vi.hoisted(() => ({ error: vi.fn(), warn: vi.fn() })); + +// 控制假纹理构造器是否抛错,用于覆盖纹理创建失败的 91 号路径 +const renderState = vi.hoisted(() => ({ failTexture: false })); + +// 用假纹理替换渲染包,避免包根的模块加载期循环依赖 +const renderMock = vi.hoisted(() => { + class FakeTexture { + source: unknown; + + constructor(source: unknown) { + if (renderState.failTexture) { + throw new Error('texture failed'); + } + this.source = source; + } + } + class FakeCanvas2D {} + return { FakeTexture, FakeCanvas2D }; +}); + +vi.mock('@motajs/common', () => ({ logger: loggerMock })); + +vi.mock('@motajs/render', () => ({ + Texture: renderMock.FakeTexture, + MotaOffscreenCanvas2D: renderMock.FakeCanvas2D +})); + +import { + AnimationBlendMode, + IAnimationParsedData, + parseRenderAnimation +} from '../index'; + +const DEFAULT_SPRITES: ISprite[] = [{ width: 32, height: 32 }]; + +// 构造只提供解析器所需字段的假位图 +function mockBitmap(width: number, height: number): IFakeBitmap { + const bitmap: IFakeBitmap = { width, height, close: vi.fn() }; + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => bitmap) + ); + return bitmap; +} + +// 构造一份合法的默认元数据 +function makeMeta(overrides: Partial = {}): ITestMeta { + return { + ratio: 1, + blend: 0, + bitmaps: [{ x: 0, y: 0, width: 32, height: 32 }], + frame: [{ objects: [[0, 0, 0, 100, 255]] }], + ...overrides + }; +} + +// 按 v2 布局拼装动画二进制 +function buildAnimation( + meta: unknown, + sprites: ISprite[] = DEFAULT_SPRITES +): ArrayBuffer { + const metaBytes = new TextEncoder().encode(JSON.stringify(meta)); + const webp = new Uint8Array([0x52, 0x49, 0x46, 0x46]); + const imageDataSize = 4 + sprites.length * 8 + webp.length; + const buffer = new ArrayBuffer(16 + imageDataSize + metaBytes.length); + const view = new DataView(buffer); + view.setUint32(0, 0x414e494d, false); + view.setUint32(4, 2, true); + view.setUint32(8, imageDataSize, true); + view.setUint32(12, metaBytes.length, true); + view.setUint32(16, sprites.length, true); + sprites.forEach((sprite, index) => { + view.setUint32(20 + index * 8, sprite.width, true); + view.setUint32(24 + index * 8, sprite.height, true); + }); + const bytes = new Uint8Array(buffer); + bytes.set(webp, 20 + sprites.length * 8); + bytes.set(metaBytes, 16 + imageDataSize); + return buffer; +} + +// 按 v2 布局拼装动画二进制,元数据使用原始字符串以便构造非法 JSON +function buildRawAnimation( + rawMeta: string, + sprites: ISprite[] = DEFAULT_SPRITES +): ArrayBuffer { + const metaBytes = new TextEncoder().encode(rawMeta); + const webp = new Uint8Array([0x52, 0x49, 0x46, 0x46]); + const imageDataSize = 4 + sprites.length * 8 + webp.length; + const buffer = new ArrayBuffer(16 + imageDataSize + metaBytes.length); + const view = new DataView(buffer); + view.setUint32(0, 0x414e494d, false); + view.setUint32(4, 2, true); + view.setUint32(8, imageDataSize, true); + view.setUint32(12, metaBytes.length, true); + view.setUint32(16, sprites.length, true); + sprites.forEach((sprite, index) => { + view.setUint32(20 + index * 8, sprite.width, true); + view.setUint32(24 + index * 8, sprite.height, true); + }); + const bytes = new Uint8Array(buffer); + bytes.set(webp, 20 + sprites.length * 8); + bytes.set(metaBytes, 16 + imageDataSize); + return buffer; +} + +// 断言解析成功并收窄返回值类型,供全部成功用例复用 +async function parseOk(buffer: ArrayBuffer): Promise { + const data = await parseRenderAnimation(buffer); + expect(data).not.toBeNull(); + if (!data) { + throw new Error('parseRenderAnimation returned null'); + } + return data; +} + +// 断言解析失败:返回 null、只输出一次指定错误码,且实参数量与该码占位符一致 +async function expectFailure(buffer: ArrayBuffer, code: number): Promise { + const data = await parseRenderAnimation(buffer); + expect(data).toBeNull(); + expect(loggerMock.error).toHaveBeenCalledTimes(1); + expect(loggerMock.error.mock.calls[0][0]).toBe(code); + expect( + loggerMock.error.mock.calls[0].length === ERROR_PARAM_COUNTS[code] + 1 + ).toBe(true); +} + +// 提取指定顶层函数的函数体,供静态闸门检查 +function extractFunctionBody(source: string, name: string): string { + const start = source.indexOf('function ' + name); + if (start < 0) { + return ''; + } + let depth = 0; + let started = false; + for (let i = start; i < source.length; i++) { + const char = source[i]; + if (char === '{') { + depth += 1; + started = true; + } else if (char === '}') { + depth -= 1; + if (started && depth === 0) { + return source.slice(start, i + 1); + } + } + } + return ''; +} + +beforeEach(() => { + loggerMock.error.mockClear(); + loggerMock.warn.mockClear(); + renderState.failTexture = false; + mockBitmap(32, 32); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +describe('parseRenderAnimation success', () => { + // 正常解析返回纹理、位图、帧与混合模式 + it('parses a valid v2 animation', async () => { + const bitmap = mockBitmap(32, 32); + const data = await parseOk(buildAnimation(makeMeta())); + + expect(data.texture.source).toBe(bitmap); + expect(data.bitmaps).toEqual([{ x: 0, y: 0, width: 32, height: 32 }]); + expect(data.frames.length).toBe(1); + expect(data.blend).toBe(AnimationBlendMode.Normal); + }); + + // blend 为 1 时映射为加算混合 + it('maps blend 1 to the add mode', async () => { + const data = await parseOk(buildAnimation(makeMeta({ blend: 1 }))); + + expect(data.blend).toBe(AnimationBlendMode.Add); + }); + + // ratio 被折入对象的缩放比例 + it('folds ratio into the object scale', async () => { + const meta = makeMeta({ + ratio: 2, + frame: [{ objects: [[0, 0, 0, 50, 255]] }] + }); + const data = await parseOk(buildAnimation(meta)); + + expect(data.frames[0].objects[0].scale).toBe(100); + }); + + // 无旋转的 32x32 对象得到 32x32 帧与 16,16 原点 + it('computes the frame box without rotation', async () => { + const data = await parseOk(buildAnimation(makeMeta())); + const frame = data.frames[0]; + + expect(frame.width).toBe(32); + expect(frame.height).toBe(32); + expect(frame.originX).toBe(16); + expect(frame.originY).toBe(16); + }); + + // 45 度旋转后得到 46x46 帧与约 22.627 的原点 + it('computes the frame box with a 45 degree rotation', async () => { + const meta = makeMeta({ + frame: [{ objects: [[0, 0, 0, 100, 255, 0, 45]] }] + }); + const data = await parseOk(buildAnimation(meta)); + const frame = data.frames[0]; + + expect(frame.width).toBe(46); + expect(frame.height).toBe(46); + expect(frame.originX).toBeCloseTo(22.627, 3); + expect(frame.originY).toBeCloseTo(22.627, 3); + }); + + // 多个对象的包围盒取并集 + it('unions the boxes of multiple objects', async () => { + mockBitmap(64, 32); + const meta = makeMeta({ + bitmaps: [ + { x: 0, y: 0, width: 32, height: 32 }, + { x: 32, y: 0, width: 32, height: 32 } + ], + frame: [ + { + objects: [ + [0, 0, 0, 100, 255], + [1, 100, 0, 100, 255] + ] + } + ] + }); + const data = await parseOk( + buildAnimation(meta, [ + { width: 32, height: 32 }, + { width: 32, height: 32 } + ]) + ); + const frame = data.frames[0]; + + expect(frame.width).toBe(132); + expect(frame.height).toBe(32); + expect(frame.originX).toBe(16); + expect(frame.originY).toBe(16); + }); + + // 空帧得到 1x1 帧与 0,0 原点 + it('returns an empty 1x1 frame without objects', async () => { + const data = await parseOk(buildAnimation(makeMeta({ frame: [{}] }))); + const frame = data.frames[0]; + + expect(frame.objects).toEqual([]); + expect(frame.width).toBe(1); + expect(frame.height).toBe(1); + expect(frame.originX).toBe(0); + expect(frame.originY).toBe(0); + }); + + // 镜像不改变轴对齐包围盒 + it('keeps the frame box unchanged when mirrored', async () => { + const meta = makeMeta({ + frame: [{ objects: [[0, 0, 0, 100, 255, 1]] }] + }); + const data = await parseOk(buildAnimation(meta)); + const frame = data.frames[0]; + + expect(frame.objects[0].mirror).toBe(1); + expect(frame.width).toBe(32); + expect(frame.height).toBe(32); + }); + + // 音效字段被保留且中文名按 UTF-8 正确解码 + it('preserves sound fields including Chinese names', async () => { + const meta = makeMeta({ + frame: [ + { + sound: [{ name: '攻击音效', volume: 0.5, pitch: 1.2 }] + } + ] + }); + const data = await parseOk(buildAnimation(meta)); + + expect(data.frames[0].sound).toEqual([ + { name: '攻击音效', volume: 0.5, pitch: 1.2 } + ]); + }); + + // 只提供 5 个元素的元组时镜像与旋转取默认值 0 + it('defaults mirror and rotate for a 5 element tuple', async () => { + const meta = makeMeta({ + frame: [{ objects: [[0, 0, 0, 100, 255]] }] + }); + const data = await parseOk(buildAnimation(meta)); + const object = data.frames[0].objects[0]; + + expect(object.mirror).toBe(0); + expect(object.rotate).toBe(0); + expect(data.frames[0].width).toBe(32); + }); + + // 成功解析不会关闭解码得到的位图 + it('does not close the decoded bitmap', async () => { + const bitmap = mockBitmap(32, 32); + await parseOk(buildAnimation(makeMeta())); + + expect(bitmap.close).not.toHaveBeenCalled(); + }); +}); + +describe('parseRenderAnimation header failures', () => { + // 缓冲区短于头部长度时返回 null 并记录 82 + it('reports a too short buffer', async () => { + await expectFailure(buildAnimation(makeMeta()).slice(0, 10), 82); + }); + + // 签名字节不匹配时返回 null 并记录 83 + it('reports an invalid signature', async () => { + const signature = new DataView(buildAnimation(makeMeta())); + signature.setUint32(0, 0, false); + await expectFailure(signature.buffer as ArrayBuffer, 83); + }); + + // 版本号不受支持时返回 null 并记录 84 + it('reports an unsupported version', async () => { + const version = new DataView(buildAnimation(makeMeta())); + version.setUint32(4, 3, true); + await expectFailure(version.buffer as ArrayBuffer, 84); + }); + + // 总长度与头部字段不符时返回 null 并记录 82 + it('reports a total length mismatch', async () => { + const total = new DataView(buildAnimation(makeMeta())); + total.setUint32(8, total.getUint32(8, true) + 1, true); + await expectFailure(total.buffer as ArrayBuffer, 82); + }); + + // imageDataSize 小 4 时返回 null 并记录 82 + it('reports an invalid imageDataSize', async () => { + const imageSize = new DataView(buildAnimation(makeMeta())); + imageSize.setUint32(8, 3, true); + await expectFailure(imageSize.buffer as ArrayBuffer, 82); + }); + + // metaDataSize 为 0 时返回 null 并记录 82 + it('reports an invalid metaDataSize', async () => { + const metaSize = new DataView(buildAnimation(makeMeta())); + metaSize.setUint32(12, 0, true); + await expectFailure(metaSize.buffer as ArrayBuffer, 82); + }); +}); + +describe('parseRenderAnimation sprite failures', () => { + // sprite 数量为 0 时返回 null 并记录 85 + it('reports a zero sprite count', async () => { + const zeroCount = new DataView(buildAnimation(makeMeta())); + zeroCount.setUint32(16, 0, true); + await expectFailure(zeroCount.buffer as ArrayBuffer, 85); + }); + + // sprite 宽度为 0 时返回 null 并记录 86 + it('reports a zero sprite width', async () => { + const zeroWidth = new DataView(buildAnimation(makeMeta())); + zeroWidth.setUint32(20, 0, true); + await expectFailure(zeroWidth.buffer as ArrayBuffer, 86); + }); + + // sprite 高度为 0 时返回 null 并记录 86 + it('reports a zero sprite height', async () => { + const zeroHeight = new DataView(buildAnimation(makeMeta())); + zeroHeight.setUint32(24, 0, true); + await expectFailure(zeroHeight.buffer as ArrayBuffer, 86); + }); + + // sprite 尺寸表超出 imageDataSize 时返回 null 并记录 85 + it('reports a sprite table beyond imageDataSize', async () => { + const overflow = new DataView(buildAnimation(makeMeta())); + overflow.setUint32(16, 100, true); + await expectFailure(overflow.buffer as ArrayBuffer, 85); + }); +}); + +describe('parseRenderAnimation bitmap failures', () => { + // bitmaps 不是数组时返回 null 并记录 87 + it('reports a non array bitmap list', async () => { + await expectFailure( + buildAnimation({ ...makeMeta(), bitmaps: 'x' }), + 87 + ); + }); + + // 位图数量与 sprite 数量不一致时返回 null 并记录 87 + it('reports a bitmap count mismatch', async () => { + await expectFailure( + buildAnimation(makeMeta(), [ + { width: 32, height: 32 }, + { width: 32, height: 32 } + ]), + 87 + ); + }); + + // 位图项不是对象时返回 null 并记录 88 + it('reports a non object bitmap item', async () => { + await expectFailure( + buildAnimation({ ...makeMeta(), bitmaps: [42] }), + 88 + ); + }); + + // 位图 x 非数字时返回 null 并记录 88 + it('reports a non numeric bitmap x', async () => { + await expectFailure( + buildAnimation( + makeMeta({ bitmaps: [{ x: NaN, y: 0, width: 32, height: 32 }] }) + ), + 88 + ); + }); + + // 位图 y 非数字时返回 null 并记录 88 + it('reports a non numeric bitmap y', async () => { + await expectFailure( + buildAnimation( + makeMeta({ bitmaps: [{ x: 0, y: NaN, width: 32, height: 32 }] }) + ), + 88 + ); + }); + + // 位图 width 非数字时返回 null 并记录 88 + it('reports a non numeric bitmap width', async () => { + await expectFailure( + buildAnimation( + makeMeta({ bitmaps: [{ x: 0, y: 0, width: NaN, height: 32 }] }) + ), + 88 + ); + }); + + // 位图 height 非数字时返回 null 并记录 88 + it('reports a non numeric bitmap height', async () => { + await expectFailure( + buildAnimation( + makeMeta({ bitmaps: [{ x: 0, y: 0, width: 32, height: NaN }] }) + ), + 88 + ); + }); + + // 位图 x 为负时返回 null 并记录 88 + it('reports a negative bitmap x', async () => { + await expectFailure( + buildAnimation( + makeMeta({ bitmaps: [{ x: -1, y: 0, width: 32, height: 32 }] }) + ), + 88 + ); + }); + + // 位图 y 为负时返回 null 并记录 88 + it('reports a negative bitmap y', async () => { + await expectFailure( + buildAnimation( + makeMeta({ bitmaps: [{ x: 0, y: -1, width: 32, height: 32 }] }) + ), + 88 + ); + }); + + // 位图宽度为 0 时返回 null 并记录 88 + it('reports a zero bitmap width', async () => { + await expectFailure( + buildAnimation( + makeMeta({ bitmaps: [{ x: 0, y: 0, width: 0, height: 32 }] }) + ), + 88 + ); + }); + + // 位图高度为 0 时返回 null 并记录 88 + it('reports a zero bitmap height', async () => { + await expectFailure( + buildAnimation( + makeMeta({ bitmaps: [{ x: 0, y: 0, width: 32, height: 0 }] }) + ), + 88 + ); + }); + + // 解码后位图越界时返回 null、关闭位图并记录索引 0 的 89 + it('reports bitmaps outside the decoded image and closes it', async () => { + const bitmap = mockBitmap(16, 16); + + await expectFailure(buildAnimation(makeMeta()), 89); + expect(bitmap.close).toHaveBeenCalled(); + expect(loggerMock.error.mock.calls[0][1]).toBe('0'); + }); +}); + +describe('parseRenderAnimation metadata failures', () => { + // 元数据为 JSON 数字原语时返回 null 并记录 95 + it('reports numeric primitive metadata', async () => { + await expectFailure(buildRawAnimation('42'), 95); + expect(loggerMock.error.mock.calls[0][1]).toBe('not-object'); + }); + + // 元数据为 JSON null 时返回 null 并记录 95 + it('reports null metadata', async () => { + await expectFailure(buildRawAnimation('null'), 95); + expect(loggerMock.error.mock.calls[0][1]).toBe('not-object'); + }); + + // 元数据为 JSON 数字 0 时返回 null 并记录 95 + it('reports zero metadata', async () => { + await expectFailure(buildRawAnimation('0'), 95); + expect(loggerMock.error.mock.calls[0][1]).toBe('not-object'); + }); + + // 元数据为 JSON false 时返回 null 并记录 95 + it('reports boolean metadata', async () => { + await expectFailure(buildRawAnimation('false'), 95); + expect(loggerMock.error.mock.calls[0][1]).toBe('not-object'); + }); + + // 元数据为 JSON 空字符串时返回 null 并记录 95 + it('reports string metadata', async () => { + await expectFailure(buildRawAnimation('""'), 95); + expect(loggerMock.error.mock.calls[0][1]).toBe('not-object'); + }); + + // 元数据不是合法 JSON 时返回 null 并记录 95 + it('reports invalid metadata JSON', async () => { + await expectFailure(buildRawAnimation('{'), 95); + expect(loggerMock.error.mock.calls[0][1]).toBe('invalid-json'); + }); + + // frame 不是数组时返回 null 并记录 90 + it('reports a non array frame list', async () => { + await expectFailure(buildAnimation({ ...makeMeta(), frame: 'x' }), 90); + }); + + // 帧项不是对象时返回 null 并记录 91 + it('reports a non object frame item', async () => { + await expectFailure(buildAnimation({ ...makeMeta(), frame: [42] }), 91); + }); + + // objects 存在但不是数组时返回 null 并记录 91 + it('reports a non array objects field', async () => { + await expectFailure( + buildAnimation(makeMeta({ frame: [{ objects: 'x' }] })), + 91 + ); + }); + + // 对象项不是数组元组时返回 null 并记录 92 + it('reports a non array object tuple', async () => { + await expectFailure( + buildAnimation(makeMeta({ frame: [{ objects: [42] }] })), + 92 + ); + }); + + // 元组只有 4 个元素时返回 null 并记录 92 + it('reports a short object tuple', async () => { + await expectFailure( + buildAnimation( + makeMeta({ frame: [{ objects: [[0, 0, 0, 100]] }] }) + ), + 92 + ); + }); + + // 元组有 8 个元素时返回 null 并记录 92 + it('reports a long object tuple', async () => { + await expectFailure( + buildAnimation( + makeMeta({ + frame: [{ objects: [[0, 0, 0, 100, 255, 0, 0, 0]] }] + }) + ), + 92 + ); + }); + + // sound 存在但不是数组时返回 null 并记录 91 + it('reports a non array sound field', async () => { + await expectFailure( + buildAnimation(makeMeta({ frame: [{ sound: 'x' }] })), + 91 + ); + }); +}); + +describe('parseRenderAnimation object failures', () => { + // 对象位图索引越界时返回 null 并记录 92 + it('reports an out of range object index', async () => { + await expectFailure( + buildAnimation( + makeMeta({ frame: [{ objects: [[1, 0, 0, 100, 255]] }] }) + ), + 92 + ); + }); + + // 对象位图索引不是整数时返回 null 并记录 92 + it('reports a non integer object index', async () => { + await expectFailure( + buildAnimation( + makeMeta({ frame: [{ objects: [[0.5, 0, 0, 100, 255]] }] }) + ), + 92 + ); + }); + + // 对象横坐标非法时返回 null 并记录帧索引 / 对象索引 / 原因的 92 + it('reports an invalid object x', async () => { + await expectFailure( + buildAnimation( + makeMeta({ + frame: [{ objects: [[0, NaN, 0, 100, 255, 0, 0]] }] + }) + ), + 92 + ); + expect(loggerMock.error.mock.calls[0][1]).toBe('0'); + expect(loggerMock.error.mock.calls[0][2]).toBe('0'); + expect(loggerMock.error.mock.calls[0][3]).toBe('invalid-x'); + }); + + // 对象纵坐标非法时返回 null 并记录 92 + it('reports an invalid object y', async () => { + await expectFailure( + buildAnimation( + makeMeta({ + frame: [{ objects: [[0, 0, NaN, 100, 255, 0, 0]] }] + }) + ), + 92 + ); + }); + + // 对象缩放为 0 时返回 null 并记录 92 + it('reports a zero object scale', async () => { + await expectFailure( + buildAnimation( + makeMeta({ frame: [{ objects: [[0, 0, 0, 0, 255, 0, 0]] }] }) + ), + 92 + ); + }); + + // 对象缩放非法时返回 null 并记录 92 + it('reports an invalid object scale', async () => { + await expectFailure( + buildAnimation( + makeMeta({ frame: [{ objects: [[0, 0, 0, NaN, 255, 0, 0]] }] }) + ), + 92 + ); + }); + + // 对象不透明度越界时返回 null 并记录 92 + it('reports an out of range object opacity', async () => { + await expectFailure( + buildAnimation( + makeMeta({ frame: [{ objects: [[0, 0, 0, 100, 300, 0, 0]] }] }) + ), + 92 + ); + }); + + // 对象不透明度非法时返回 null 并记录 92 + it('reports an invalid object opacity', async () => { + await expectFailure( + buildAnimation( + makeMeta({ frame: [{ objects: [[0, 0, 0, 100, NaN, 0, 0]] }] }) + ), + 92 + ); + }); + + // 对象镜像方向非法时返回 null 并记录 92 + it('reports an invalid object mirror', async () => { + await expectFailure( + buildAnimation( + makeMeta({ + frame: [{ objects: [[0, 0, 0, 100, 255, NaN, 0]] }] + }) + ), + 92 + ); + }); + + // 对象旋转角度非法时返回 null 并记录 92 + it('reports an invalid object rotate', async () => { + await expectFailure( + buildAnimation( + makeMeta({ + frame: [{ objects: [[0, 0, 0, 100, 255, 0, NaN]] }] + }) + ), + 92 + ); + }); +}); + +describe('parseRenderAnimation ratio and blend failures', () => { + // ratio 为 0 时返回 null 并记录 93 + it('reports a zero ratio', async () => { + await expectFailure(buildAnimation(makeMeta({ ratio: 0 })), 93); + }); + + // ratio 为负数时返回 null 并记录 93 + it('reports a negative ratio', async () => { + await expectFailure(buildAnimation(makeMeta({ ratio: -1 })), 93); + }); + + // ratio 非数字时返回 null 并记录 93 + it('reports a non numeric ratio', async () => { + await expectFailure(buildAnimation(makeMeta({ ratio: NaN })), 93); + }); + + // blend 为 2 时返回 null 并记录 93 + it('reports an unsupported blend value', async () => { + await expectFailure(buildAnimation(makeMeta({ blend: 2 })), 93); + }); + + // blend 为负数时返回 null 并记录 93 + it('reports a negative blend value', async () => { + await expectFailure(buildAnimation(makeMeta({ blend: -1 })), 93); + }); + + // blend 非数字时返回 null 并记录 93 + it('reports a non numeric blend value', async () => { + await expectFailure(buildAnimation(makeMeta({ blend: NaN })), 93); + }); +}); + +describe('parseRenderAnimation sound failures', () => { + // 音效名称为空时返回 null 并记录帧索引 / 音效索引 / 原因的 94 + it('reports an empty sound name', async () => { + await expectFailure( + buildAnimation(makeMeta({ frame: [{ sound: [{ name: '' }] }] })), + 94 + ); + expect(loggerMock.error.mock.calls[0][1]).toBe('0'); + expect(loggerMock.error.mock.calls[0][2]).toBe('0'); + expect(loggerMock.error.mock.calls[0][3]).toBe('invalid-name'); + }); + + // 音效名称非字符串时返回 null 并记录 94 + it('reports a non string sound name', async () => { + await expectFailure( + buildAnimation(makeMeta({ frame: [{ sound: [{ name: 1 }] }] })), + 94 + ); + }); + + // 音效音量非法时返回 null 并记录 94 + it('reports an invalid sound volume', async () => { + await expectFailure( + buildAnimation( + makeMeta({ + frame: [{ sound: [{ name: '音效', volume: NaN }] }] + }) + ), + 94 + ); + }); + + // 音效音高非法时返回 null 并记录 94 + it('reports an invalid sound pitch', async () => { + await expectFailure( + buildAnimation( + makeMeta({ frame: [{ sound: [{ name: '音效', pitch: NaN }] }] }) + ), + 94 + ); + }); + + // 音效项不是对象时返回 null 并记录 94 + it('reports a non object sound item', async () => { + await expectFailure( + buildAnimation(makeMeta({ frame: [{ sound: [42] }] })), + 94 + ); + }); +}); + +describe('parseRenderAnimation decode failures', () => { + // 图像解码抛错时返回 null 并记录 96 + it('reports a decode failure', async () => { + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => { + throw new Error('decode failed'); + }) + ); + + await expectFailure(buildAnimation(makeMeta()), 96); + expect(loggerMock.error.mock.calls[0][1]).toBe('decode-failed'); + }); + + // 纹理创建抛错时关闭位图、返回 null 并记录 96 + it('reports a texture failure and closes the bitmap', async () => { + renderState.failTexture = true; + const bitmap = mockBitmap(32, 32); + + await expectFailure(buildAnimation(makeMeta()), 96); + expect(loggerMock.error.mock.calls[0][1]).toBe('texture-failed'); + expect(bitmap.close).toHaveBeenCalled(); + }); +}); + +describe('parseRenderAnimation static guard', () => { + // 解析器不得出现 throw,失败一律记录日志并返回 null + it('keeps the parser free of throw statements', () => { + const source = readFileSync( + 'packages/render-anim/src/parse.ts', + 'utf8' + ); + + expect(/\bthrow\b/.test(source)).toBe(false); + expect(source.includes('@throws')).toBe(false); + expect((source.match(/try\s*\{/g) ?? []).length).toBe(3); + expect(/\bfinally\b/.test(source)).toBe(false); + + const compact = source.replace(/\s+/g, ''); + expect(compact).toContain('try{meta=JSON.parse(text);}catch{'); + expect(compact).toContain( + 'try{image=awaitcreateImageBitmap(webpBlob);}catch{' + ); + expect(compact).toContain('try{texture=newTexture(image);}catch{'); + expect(compact).toContain( + "constwebpBlob=newBlob([webpBytes],{type:'image/webp'});" + ); + expect(compact).toContain( + "if(!isRecord(meta)){logger.error(95,'not-object');returnnull;}returnmeta;" + ); + + const validateMeta = extractFunctionBody(source, 'validateMeta'); + const bounds = extractFunctionBody(source, 'validateBitmapBounds'); + expect(validateMeta.length).toBeGreaterThan(0); + expect(bounds.length).toBeGreaterThan(0); + expect(validateMeta.includes('forEach')).toBe(false); + expect(bounds.includes('forEach')).toBe(false); + + // 越界路径必须先关闭图像再记录 89 号,且宽高仍在 close 前读取用于判定 + const closeIndex = compact.indexOf('image.close();logger.error(89,'); + expect(closeIndex).toBeGreaterThan(-1); + const widthIndex = compact.indexOf('image.width'); + const heightIndex = compact.indexOf('image.height'); + expect(widthIndex).toBeGreaterThan(-1); + expect(heightIndex).toBeGreaterThan(-1); + expect(widthIndex).toBeLessThan(closeIndex); + expect(heightIndex).toBeLessThan(closeIndex); + + // 纹理失败路径必须先关闭图像再记录 96 号 + expect(compact).toContain( + "image.close();logger.error(96,'texture-failed');" + ); + + // 92 与 94 必须帧索引在前、对象或音效索引在后、原因最后 + expect(compact).toContain( + 'logger.error(92,frameIndex.toString(),objectIndex.toString(),' + ); + expect(compact).toContain( + 'logger.error(94,frameIndex.toString(),i.toString(),' + ); + + // 82 到 96 全部出现在调用点,且 92 到 96 均为合法码 + for (let code = 82; code <= 96; code++) { + const callPattern = new RegExp( + 'logger\\.error\\(\\s*' + code + '\\b' + ); + expect(callPattern.test(source)).toBe(true); + } + + // 每个 logger.error 实参只能是索引的 .toString() 或允许的连字符原因 + const allowedReasons = [ + 'buffer-too-short', + 'length-mismatch', + 'invalid-count', + 'table-overflow', + 'not-array', + 'count-mismatch', + 'not-object', + 'invalid-x', + 'invalid-y', + 'invalid-width', + 'invalid-height', + 'objects-not-array', + 'sound-not-array', + 'not-tuple', + 'invalid-field-count', + 'invalid-index', + 'invalid-scale', + 'invalid-opacity', + 'invalid-mirror', + 'invalid-rotate', + 'invalid-ratio', + 'invalid-blend', + 'invalid-name', + 'invalid-volume', + 'invalid-pitch', + 'invalid-json', + 'decode-failed', + 'texture-failed' + ]; + const indexPattern = /^[A-Za-z_][A-Za-z0-9_]*\.toString\(\)$/; + const callPrefix = 'logger.error('; + const callArgs: string[] = []; + let scanFrom = 0; + let callStart = source.indexOf(callPrefix, scanFrom); + while (callStart >= 0) { + let cursor = callStart + callPrefix.length; + let depth = 1; + let inString = false; + let body = ''; + while (depth > 0 && cursor < source.length) { + const char = source[cursor]; + if (inString) { + if (char === '\\') { + body += char + source[cursor + 1]; + cursor += 2; + continue; + } + if (char === "'") { + inString = false; + } + body += char; + cursor += 1; + continue; + } + if (char === "'") { + inString = true; + body += char; + cursor += 1; + continue; + } + if (char === '(') { + depth += 1; + } else if (char === ')') { + depth -= 1; + if (depth === 0) { + cursor += 1; + break; + } + } + body += char; + cursor += 1; + } + callArgs.push(body); + scanFrom = cursor; + callStart = source.indexOf(callPrefix, scanFrom); + } + expect(callArgs.length).toBe(39); + for (const argsText of callArgs) { + const args: string[] = []; + let innerDepth = 0; + let innerString = false; + let part = ''; + for (let index = 0; index < argsText.length; index++) { + const char = argsText[index]; + if (innerString) { + if (char === '\\') { + part += char + (argsText[index + 1] ?? ''); + index += 1; + continue; + } + if (char === "'") { + innerString = false; + } + part += char; + continue; + } + if (char === "'") { + innerString = true; + part += char; + continue; + } + if (char === '(') { + innerDepth += 1; + } else if (char === ')') { + innerDepth -= 1; + } else if (char === ',' && innerDepth === 0) { + args.push(part.trim()); + part = ''; + continue; + } + part += char; + } + if (part.trim().length > 0) { + args.push(part.trim()); + } + expect(/^(8[2-9]|9[0-6])$/.test(args[0])).toBe(true); + for (const rawArg of args.slice(1)) { + const arg = rawArg.trim(); + if (indexPattern.test(arg)) { + continue; + } + expect(arg.startsWith("'")).toBe(true); + expect(arg.endsWith("'")).toBe(true); + expect(allowedReasons.includes(arg.slice(1, -1))).toBe(true); + } + } + }); +}); diff --git a/packages/render-anim/src/animater.ts b/packages/render-anim/src/animater.ts new file mode 100644 index 0000000..4b19c3c --- /dev/null +++ b/packages/render-anim/src/animater.ts @@ -0,0 +1,111 @@ +import { + ITexture, + ITextureAnimater, + ITextureRenderable, + MotaOffscreenCanvas2D +} from '@motajs/render'; +import { IAnimationFrame, IAnimationParsedData } from './types'; + +/** + * 动画合成器,将解析后的动画逐帧合成为离屏画布。 + * 每次 `.next()` 恢复后会释放上一帧,正常播放完毕时释放最后一帧; + * 提前 `.return()` 会跳过正常结束路径,最后一帧交给垃圾回收。 + */ +export class RenderAnimater implements ITextureAnimater { + /** + * 把当前帧的全部对象按顺序绘制到给定画布上 + * @param canvas 复用的离屏画布 + * @param texture 动画纹理 + * @param data 解析后的动画数据 + * @param frame 当前帧 + */ + private composeFrame( + canvas: MotaOffscreenCanvas2D, + texture: ITexture, + data: IAnimationParsedData, + frame: IAnimationFrame + ): void { + // 复用同一块画布,逐帧重设尺寸会重置变换矩阵并清空上一帧内容 + canvas.size(frame.width, frame.height); + const ctx = canvas.ctx; + frame.objects.forEach(object => { + const bitmap = data.bitmaps[object.index]; + const realWidth = (bitmap.width * object.scale) / 100; + const realHeight = (bitmap.height * object.scale) / 100; + ctx.save(); + // 先平移到帧原点,再镜像、旋转,最后按不透明度与目标矩形绘制 + ctx.translate(frame.originX + object.x, frame.originY + object.y); + if (object.mirror) { + ctx.scale(-1, 1); + } + if (object.rotate) { + const angle = (-object.rotate * Math.PI) / 180; + ctx.rotate(angle); + } + ctx.globalAlpha = object.opacity / 255; + ctx.drawImage( + texture.source, + bitmap.x, + bitmap.y, + bitmap.width, + bitmap.height, + -realWidth / 2, + -realHeight / 2, + realWidth, + realHeight + ); + ctx.restore(); + }); + } + + /** + * 依次产出帧图像 + * @param texture 动画纹理 + * @param data 解析后的动画数据 + * @param cycled 是否循环播放 + * @returns 逐帧产出可渲染对象的生成器 + */ + private *sequence( + texture: ITexture, + data: IAnimationParsedData, + cycled: boolean + ): Generator { + const frames = data.frames; + if (frames.length === 0) return; + // 每个序列只创建一块画布,像素素材关闭高清与抗锯齿以避免逐帧缩放模糊 + const canvas = new MotaOffscreenCanvas2D(); + canvas.setHD(false); + canvas.setAntiAliasing(false); + let index = 0; + while (true) { + const frame = frames[index]; + this.composeFrame(canvas, texture, data, frame); + yield { + source: canvas.canvas, + rect: { x: 0, y: 0, w: frame.width, h: frame.height } + }; + if (!cycled && index === frames.length - 1) { + break; + } + index = (index + 1) % frames.length; + } + // 正常播放结束时清空并归零尺寸;提前 return 时不额外兜底,最后一帧随同一块画布交给垃圾回收 + canvas.clear(); + canvas.canvas.width = 0; + canvas.canvas.height = 0; + } + + *once( + texture: ITexture, + data: IAnimationParsedData + ): Generator { + yield* this.sequence(texture, data, false); + } + + *cycled( + texture: ITexture, + data: IAnimationParsedData + ): Generator { + yield* this.sequence(texture, data, true); + } +} diff --git a/packages/render-anim/src/index.ts b/packages/render-anim/src/index.ts index fcb073f..8187bce 100644 --- a/packages/render-anim/src/index.ts +++ b/packages/render-anim/src/index.ts @@ -1 +1,3 @@ export * from './types'; +export * from './parse'; +export * from './animater'; diff --git a/packages/render-anim/src/parse.ts b/packages/render-anim/src/parse.ts new file mode 100644 index 0000000..6c67638 --- /dev/null +++ b/packages/render-anim/src/parse.ts @@ -0,0 +1,645 @@ +import { logger } from '@motajs/common'; +import { + AnimationBlendMode, + IAnimationBitmap, + IAnimationFrame, + IAnimationObject, + IAnimationParsedData +} from './types'; +import { Texture } from '@motajs/render'; + +//#region 常量与内部类型 + +/** 动画文件开头的签名,按大端读取 `ANIM` 的字节 */ +const ANIMATION_SIGNATURE = 0x414e494d; +/** 当前支持的动画文件版本 */ +const ANIMATION_VERSION = 2; +/** 签名、版本与两段长度字段组成的固定头部字节数 */ +const ANIMATION_HEADER_SIZE = 16; + +interface IAnimationHeader { + /** 图像段总字节数,含 sprite 数量、尺寸表与 WebP */ + imageDataSize: number; + /** 元数据 JSON 的字节数 */ + metaDataSize: number; +} + +interface ISpriteDimension { + /** sprite 宽度 */ + width: number; + /** sprite 高度 */ + height: number; +} + +interface IFrameBox { + /** 帧画布宽度 */ + width: number; + /** 帧画布高度 */ + height: number; + /** 帧原点的横坐标 */ + originX: number; + /** 帧原点的纵坐标 */ + originY: number; +} + +type IRawAnimationObject = [ + index: number, + x: number, + y: number, + scale: number, + opacity: number, + mirror?: number, + rotate?: number +]; + +interface IRawAnimationSound { + /** 音效名称 */ + name: string; + /** 播放音量 */ + volume?: number; + /** 播放音高 */ + pitch?: number; +} + +interface IRawAnimationBitmap { + /** 位图在纹理中的横坐标 */ + x: number; + /** 位图在纹理中的纵坐标 */ + y: number; + /** 位图宽度 */ + width: number; + /** 位图高度 */ + height: number; +} + +interface IRawAnimationFrame { + /** 帧内的绘制对象,元素是定长的对象元组 */ + objects?: IRawAnimationObject[]; + /** 帧内播放的音效 */ + sound?: IRawAnimationSound[]; +} + +interface IRawAnimationMeta { + /** 动画整体缩放系数 */ + ratio: number; + /** 混合模式,0 为普通,1 为加算 */ + blend: number; + /** 位图列表 */ + bitmaps: IRawAnimationBitmap[]; + /** 帧列表 */ + frame: IRawAnimationFrame[]; +} + +//#endregion + +//#region 读取与校验 + +/** + * 判断值是否为可读取成员的普通对象 + * @param value 待判断的值 + * @returns 是否为可读取成员的普通对象 + */ +function isRecord(value: unknown): value is Record { + // 数组与 null 都不是可读取成员的普通对象 + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * 判断值是否为有限数字 + * @param value 待判断的值 + * @returns 是否为有限数字 + */ +function isNumber(value: unknown): value is number { + // 排除 NaN 与 Infinity,长度 / 坐标等字段必须有限 + return typeof value === 'number' && Number.isFinite(value); +} + +/** + * 判断值是否为整数 + * @param value 待判断的值 + * @returns 是否为整数 + */ +function isInteger(value: unknown): value is number { + // 位图索引必须是整数,小数索引无法定位位图 + return typeof value === 'number' && Number.isInteger(value); +} + +/** + * 读取并校验文件头部 + * @param data 动画文件字节视图 + * @returns 两段数据的字节长度,头部非法时返回 null + */ +function readHeader(data: DataView): IAnimationHeader | null { + if (data.byteLength < ANIMATION_HEADER_SIZE) { + logger.error(82, 'buffer-too-short'); + return null; + } + // 偏移 0 签名(大端)、偏移 4 版本(小端)、偏移 8 / 12 两段长度(小端) + const signature = data.getUint32(0); + if (signature !== ANIMATION_SIGNATURE) { + logger.error(83); + return null; + } + const version = data.getUint32(4, true); + if (version !== ANIMATION_VERSION) { + logger.error(84); + return null; + } + const imageDataSize = data.getUint32(8, true); + const metaDataSize = data.getUint32(12, true); + if ( + imageDataSize < 4 || + metaDataSize < 1 || + ANIMATION_HEADER_SIZE + imageDataSize + metaDataSize !== data.byteLength + ) { + // 三段长度之和必须与总长度严格相等,多一字节或少一字节都视为损坏 + logger.error(82, 'length-mismatch'); + return null; + } + return { imageDataSize, metaDataSize }; +} + +/** + * 读取并校验 sprite 尺寸表 + * @param data 动画文件字节视图 + * @param count sprite 数量 + * @returns 每个 sprite 的宽高,尺寸非法时返回 null + */ +function readSpriteDimensions( + data: DataView, + count: number +): ISpriteDimension[] | null { + const dimensions: ISpriteDimension[] = []; + // 尺寸表紧跟在 sprite 数量之后,每个 sprite 固定 8 字节(宽 + 高,小端) + for (let i = 0; i < count; i++) { + const offset = ANIMATION_HEADER_SIZE + 4 + i * 8; + const width = data.getUint32(offset, true); + const height = data.getUint32(offset + 4, true); + if (width < 1 || height < 1) { + // 宽高至少为 1,否则后续按位图裁剪会得到空区域 + logger.error(86, i.toString()); + return null; + } + dimensions.push({ width, height }); + } + return dimensions; +} + +/** + * 读取元数据段并解析为 JSON 对象 + * @param buffer 动画文件二进制 + * @param offset 元数据段起始字节 + * @param size 元数据段字节长度 + * @returns 解析后的原始元数据,非法 JSON 或非对象时返回 null + */ +function readRawMeta( + buffer: ArrayBuffer, + offset: number, + size: number +): IRawAnimationMeta | null { + // 元数据段位于头部与图像段之后,直接按 UTF-8 解码 + const bytes = new Uint8Array(buffer, offset, size); + const text = new TextDecoder('utf-8').decode(bytes); + let meta: IRawAnimationMeta; + try { + meta = JSON.parse(text); + } catch { + logger.error(95, 'invalid-json'); + return null; + } + // JSON 允许 primitive(42 / null / false 等),必须先确认是普通对象再交给后续校验 + if (!isRecord(meta)) { + logger.error(95, 'not-object'); + return null; + } + return meta; +} + +/** + * 校验元数据中的位图数量、帧结构与字段取值范围 + * @param meta 原始元数据 + * @param spriteCount sprite 数量 + * @returns 元数据是否通过全部校验 + */ +function validateMeta(meta: IRawAnimationMeta, spriteCount: number): boolean { + if (!isRecord(meta)) { + logger.error(95, 'not-object'); + return false; + } + // 先校验全局字段(ratio / blend / 位图列表),再逐项校验位图与帧,保证后续索引与包围盒计算不会越界 + if (!isNumber(meta.ratio) || meta.ratio <= 0) { + logger.error(93, 'invalid-ratio'); + return false; + } + // blend 目前只接受 0(普通)与 1(加算) + if (!isNumber(meta.blend) || (meta.blend !== 0 && meta.blend !== 1)) { + logger.error(93, 'invalid-blend'); + return false; + } + if (!Array.isArray(meta.bitmaps)) { + logger.error(87, 'not-array'); + return false; + } + if (meta.bitmaps.length !== spriteCount) { + logger.error(87, 'count-mismatch'); + return false; + } + for (let i = 0; i < meta.bitmaps.length; i++) { + const bitmap = meta.bitmaps[i]; + if (!isRecord(bitmap)) { + logger.error(88, i.toString(), 'not-object'); + return false; + } + if (!isNumber(bitmap.x) || bitmap.x < 0) { + logger.error(88, i.toString(), 'invalid-x'); + return false; + } + if (!isNumber(bitmap.y) || bitmap.y < 0) { + logger.error(88, i.toString(), 'invalid-y'); + return false; + } + if (!isNumber(bitmap.width) || bitmap.width < 1) { + logger.error(88, i.toString(), 'invalid-width'); + return false; + } + if (!isNumber(bitmap.height) || bitmap.height < 1) { + logger.error(88, i.toString(), 'invalid-height'); + return false; + } + } + if (!Array.isArray(meta.frame)) { + logger.error(90); + return false; + } + for (let frameIndex = 0; frameIndex < meta.frame.length; frameIndex++) { + const frame = meta.frame[frameIndex]; + if (!isRecord(frame)) { + logger.error(91, frameIndex.toString(), 'not-object'); + return false; + } + const objects = frame.objects; + if (objects !== undefined) { + if (!Array.isArray(objects)) { + logger.error(91, frameIndex.toString(), 'objects-not-array'); + return false; + } + for ( + let objectIndex = 0; + objectIndex < objects.length; + objectIndex++ + ) { + const object = objects[objectIndex]; + if (!Array.isArray(object)) { + logger.error( + 92, + frameIndex.toString(), + objectIndex.toString(), + 'not-tuple' + ); + return false; + } + if (object.length < 5 || object.length > 7) { + logger.error( + 92, + frameIndex.toString(), + objectIndex.toString(), + 'invalid-field-count' + ); + return false; + } + const index = object[0]; + if ( + !isInteger(index) || + index < 0 || + index >= meta.bitmaps.length + ) { + logger.error( + 92, + frameIndex.toString(), + objectIndex.toString(), + 'invalid-index' + ); + return false; + } + if (!isNumber(object[1])) { + logger.error( + 92, + frameIndex.toString(), + objectIndex.toString(), + 'invalid-x' + ); + return false; + } + if (!isNumber(object[2])) { + logger.error( + 92, + frameIndex.toString(), + objectIndex.toString(), + 'invalid-y' + ); + return false; + } + if (!isNumber(object[3]) || object[3] <= 0) { + logger.error( + 92, + frameIndex.toString(), + objectIndex.toString(), + 'invalid-scale' + ); + return false; + } + if (!isNumber(object[4]) || object[4] < 0 || object[4] > 255) { + logger.error( + 92, + frameIndex.toString(), + objectIndex.toString(), + 'invalid-opacity' + ); + return false; + } + if (object[5] !== undefined && !isNumber(object[5])) { + logger.error( + 92, + frameIndex.toString(), + objectIndex.toString(), + 'invalid-mirror' + ); + return false; + } + if (object[6] !== undefined && !isNumber(object[6])) { + logger.error( + 92, + frameIndex.toString(), + objectIndex.toString(), + 'invalid-rotate' + ); + return false; + } + } + } + const sound = frame.sound; + if (sound !== undefined) { + if (!Array.isArray(sound)) { + logger.error(91, frameIndex.toString(), 'sound-not-array'); + return false; + } + for (let i = 0; i < sound.length; i++) { + const item = sound[i]; + if (!isRecord(item)) { + logger.error( + 94, + frameIndex.toString(), + i.toString(), + 'not-object' + ); + return false; + } + // 音效名称可能来自不可信文件,日志只记录其类型而不是原值 + if (typeof item.name !== 'string' || item.name.length === 0) { + logger.error( + 94, + frameIndex.toString(), + i.toString(), + 'invalid-name' + ); + return false; + } + if (item.volume !== undefined && !isNumber(item.volume)) { + logger.error( + 94, + frameIndex.toString(), + i.toString(), + 'invalid-volume' + ); + return false; + } + if (item.pitch !== undefined && !isNumber(item.pitch)) { + logger.error( + 94, + frameIndex.toString(), + i.toString(), + 'invalid-pitch' + ); + return false; + } + } + } + } + return true; +} + +/** + * 校验位图裁剪范围都落在解码后的纹理内 + * @param bitmaps 位图裁剪区域 + * @param image 解码后的图像 + * @returns 所有位图是否都落在图像内 + */ +function validateBitmapBounds( + bitmaps: readonly IRawAnimationBitmap[], + image: ImageBitmap +): boolean { + // 在 close() 之前捕获尺寸,close() 之后 ImageBitmap 的宽高不再可靠 + const imageWidth = image.width; + const imageHeight = image.height; + for (let i = 0; i < bitmaps.length; i++) { + const bitmap = bitmaps[i]; + if ( + bitmap.x < 0 || + bitmap.y < 0 || + bitmap.width < 1 || + bitmap.height < 1 || + bitmap.x + bitmap.width > imageWidth || + bitmap.y + bitmap.height > imageHeight + ) { + // 先关闭位图再记录日志,确保即使日志实现抛错也不会泄漏解码资源 + image.close(); + logger.error(89, i.toString()); + return false; + } + } + return true; +} + +//#endregion + +//#region 帧转换与帧数学 + +/** + * 把 raw 对象元组转换为运行时对象,并把动画整体缩放折入缩放比例 + * @param object 原始对象元组 + * @param ratio 动画整体缩放系数 + * @returns 运行时对象 + */ +function toObject( + object: IRawAnimationObject, + ratio: number +): IAnimationObject { + // 整体缩放折入每个对象的缩放比例,运行时不再保留 ratio + // 可选字段缺省为 0,与运行时对象类型保持非可选 + return { + index: object[0], + x: object[1], + y: object[2], + scale: object[3] * ratio, + opacity: object[4], + mirror: object[5] ?? 0, + rotate: object[6] ?? 0 + }; +} + +/** + * 把 raw 帧转换为运行时帧,并按对象包围盒计算帧尺寸与原点 + * @param frame 原始帧 + * @param bitmaps 已转换的位图列表 + * @param ratio 动画整体缩放系数 + * @returns 运行时帧 + */ +function toFrame( + frame: IRawAnimationFrame, + bitmaps: readonly IAnimationBitmap[], + ratio: number +): IAnimationFrame { + const objects = (frame.objects ?? []).map(object => + toObject(object, ratio) + ); + // 帧尺寸与原点由对象包围盒推导,运行时不需要再计算 + const box = computeFrameBox(objects, bitmaps); + return { + width: box.width, + height: box.height, + originX: box.originX, + originY: box.originY, + objects, + sound: frame.sound + }; +} + +/** + * 计算旋转后所有对象的轴对齐包围盒,得到帧尺寸与帧原点 + * @param objects 运行时对象列表 + * @param bitmaps 位图列表 + * @returns 帧的轴对齐包围盒 + */ +function computeFrameBox( + objects: readonly IAnimationObject[], + bitmaps: readonly IAnimationBitmap[] +): IFrameBox { + // 空帧给 1x1,保证画布尺寸至少为 1 + if (objects.length === 0) { + return { width: 1, height: 1, originX: 0, originY: 0 }; + } + let minX = 0; + let maxX = 0; + let minY = 0; + let maxY = 0; + objects.forEach((object, position) => { + const bitmap = bitmaps[object.index]; + const w = (bitmap.width * object.scale) / 100; + const h = (bitmap.height * object.scale) / 100; + // 旋转 θ 后轴对齐包围盒的半宽 / 半高分别为 |w/2·cosθ| + |h/2·sinθ| 与 |w/2·sinθ| + |h/2·cosθ| + const angle = (object.rotate * Math.PI) / 180; + const hx = + (w / 2) * Math.abs(Math.cos(angle)) + + (h / 2) * Math.abs(Math.sin(angle)); + const hy = + (w / 2) * Math.abs(Math.sin(angle)) + + (h / 2) * Math.abs(Math.cos(angle)); + const left = object.x - hx; + const right = object.x + hx; + const top = object.y - hy; + const bottom = object.y + hy; + // 以第一个对象初始化包围盒,避免使用 ±Infinity 之类的哨兵值 + if (position === 0) { + minX = left; + maxX = right; + minY = top; + maxY = bottom; + } else { + minX = Math.min(minX, left); + maxX = Math.max(maxX, right); + minY = Math.min(minY, top); + maxY = Math.max(maxY, bottom); + } + }); + return { + width: Math.max(1, Math.ceil(maxX - minX)), + height: Math.max(1, Math.ceil(maxY - minY)), + originX: -minX, + originY: -minY + }; +} + +/** + * 把 raw 混合模式转换为运行时枚举 + * @param blend 原始混合模式 + * @returns 运行时混合模式枚举 + */ +function toBlendMode(blend: number): AnimationBlendMode { + // 只有 1 视为加算,其余(已在校验阶段保证为 0)为普通混合 + return blend === 1 ? AnimationBlendMode.Add : AnimationBlendMode.Normal; +} + +//#endregion + +/** + * 将 v2 格式的动画二进制解析为动画数据。 + * @param buffer 动画二进制数据 + * @returns 解析后的动画数据,包含纹理、位图、帧与混合模式;任一步校验或解码失败时输出日志并返回 null + */ +export async function parseRenderAnimation( + buffer: ArrayBuffer +): Promise { + const data = new DataView(buffer); + const header = readHeader(data); + if (!header) return null; + const spriteCount = data.getUint32(ANIMATION_HEADER_SIZE, true); + if (spriteCount < 1) { + logger.error(85, 'invalid-count'); + return null; + } + // sprite 数量与尺寸表都位于图像段最前面,先把整段长度校验掉再读取 WebP + if (spriteCount * 8 + 4 >= header.imageDataSize) { + logger.error(85, 'table-overflow'); + return null; + } + const sprites = readSpriteDimensions(data, spriteCount); + if (!sprites) return null; + // webpOffset / webpSize 由图像段长度减去 sprite 数量与尺寸表得出 + const webpOffset = ANIMATION_HEADER_SIZE + 4 + sprites.length * 8; + const webpSize = header.imageDataSize - 4 - sprites.length * 8; + const webpBytes = new Uint8Array(buffer, webpOffset, webpSize); + const metaOffset = ANIMATION_HEADER_SIZE + header.imageDataSize; + const meta = readRawMeta(buffer, metaOffset, header.metaDataSize); + if (!meta) return null; + if (!validateMeta(meta, spriteCount)) return null; + + // new Blob 不涉及外部异常源,放在 try 之外 + const webpBlob = new Blob([webpBytes], { type: 'image/webp' }); + let image: ImageBitmap; + try { + image = await createImageBitmap(webpBlob); + } catch { + logger.error(96, 'decode-failed'); + return null; + } + if (!validateBitmapBounds(meta.bitmaps, image)) return null; + let texture: Texture; + try { + texture = new Texture(image); + } catch { + // 纹理创建失败时先关闭图像再记录日志,避免解码资源泄漏 + image.close(); + logger.error(96, 'texture-failed'); + return null; + } + const bitmaps: IAnimationBitmap[] = meta.bitmaps.map(bitmap => ({ + x: bitmap.x, + y: bitmap.y, + width: bitmap.width, + height: bitmap.height + })); + const frames: IAnimationFrame[] = meta.frame.map(frame => + toFrame(frame, bitmaps, meta.ratio) + ); + return { texture, bitmaps, frames, blend: toBlendMode(meta.blend) }; +}