feat: 将新的动画文件移植入新样板

This commit is contained in:
AncTe committed 2026-11-09 21:32:33 +08:00
1 parent 7bf9fe98f9
commit 0071bb741c
7 files changed
+2219 -2

No files matched your search

+19 -2
View File
@@ -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."
}
}
+1
View File
@@ -1,6 +1,7 @@
{
"name": "@motajs/render-anim",
"dependencies": {
"@motajs/common": "workspace:*",
"@motajs/render": "workspace:*"
}
}
@@ -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<typeof vi.fn>;
} {
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> = {}
): IAnimationObject {
return {
index: 0,
x: 0,
y: 0,
scale: 100,
opacity: 255,
mirror: 0,
rotate: 0,
...overrides
};
}
// 构造一个默认的动画帧
function makeFrame(overrides: Partial<IAnimationFrame> = {}): 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, void>
): 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();
});
});
File diff suppressed because it is too large. Load diff
+111
View File
@@ -0,0 +1,111 @@
import {
ITexture,
ITextureAnimater,
ITextureRenderable,
MotaOffscreenCanvas2D
} from '@motajs/render';
import { IAnimationFrame, IAnimationParsedData } from './types';
/**
* 动画合成器,将解析后的动画逐帧合成为离屏画布。
* 每次 `.next()` 恢复后会释放上一帧,正常播放完毕时释放最后一帧;
* 提前 `.return()` 会跳过正常结束路径,最后一帧交给垃圾回收。
*/
export class RenderAnimater implements ITextureAnimater<IAnimationParsedData> {
/**
* 把当前帧的全部对象按顺序绘制到给定画布上
* @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<ITextureRenderable, void> {
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<ITextureRenderable, void> {
yield* this.sequence(texture, data, false);
}
*cycled(
texture: ITexture,
data: IAnimationParsedData
): Generator<ITextureRenderable, void> {
yield* this.sequence(texture, data, true);
}
}
+2
View File
@@ -1 +1,3 @@
export * from './types';
export * from './parse';
export * from './animater';
+645
View File
@@ -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<string, unknown> {
// 数组与 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<IAnimationParsedData | null> {
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) };
}