mirror of
https://github.com/motajs/template.git
synced 2026-11-14 23:54:31 +08:00
feat: 将新的动画文件移植入新样板
This commit is contained in:
1 parent
7bf9fe98f9
commit
0071bb741c
7 files changed
+2219
-2
No files matched your search
@@ -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,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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './parse';
|
||||
export * from './animater';
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user