feat(framework): FrameSet 多帧切帧组件(B §3.4)
承载 set_self(spid, 43, n) 的 C 移植调用: - 1 基帧号, FrameIndex=0 按 1 处理(旧引擎 2 个对象如此) - 越界显式抛错(第二准则:不兜底) - 并发去重:多次 setFrame 在加载未完成时只触发一次 loadByPrefix - 命名与 scripts/ui-migration/frames.mjs:frameFileName 公式逐字对齐 分层: - core/frame-file-name.ts 纯公式,无 Cocos 依赖,直接单测 - ui/frame-set.ts 核心逻辑,FrameSource/FrameTarget 注入接口隔离 Cocos 类型 - ui/resources-frame-source.ts 生产实现,Resources.loadDir + Auto Atlas 合图过滤 tsconfig.framework.json 排除 resources-frame-source.ts(其含 cc 模块,走 Cocos 编辑器编译)。 103/103 framework tests pass,typecheck exit 0。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { frameFileName, padWidth } from '../../YouleNexus/assets/framework/core/frame-file-name.ts';
|
||||
|
||||
test('padWidth: 9帧→1位,12帧→2位,100帧→3位', () => {
|
||||
assert.equal(padWidth(9), 1);
|
||||
assert.equal(padWidth(12), 2);
|
||||
assert.equal(padWidth(100), 3);
|
||||
assert.equal(padWidth(1), 1);
|
||||
});
|
||||
|
||||
test('frameFileName: 1基+补零,字典序 = 帧序', () => {
|
||||
assert.equal(frameFileName('atlas-login/00014', 1, 12), 'atlas-login/00014_01.png');
|
||||
assert.equal(frameFileName('atlas-login/00014', 10, 12), 'atlas-login/00014_10.png');
|
||||
const names = [frameFileName('atlas-login/00014', 2, 12), frameFileName('atlas-login/00014', 10, 12)];
|
||||
assert.deepEqual([...names].sort(), ['atlas-login/00014_02.png', 'atlas-login/00014_10.png']);
|
||||
});
|
||||
|
||||
test('frameFileName: FrameIndex=0 按 1 处理(旧引擎 2 个对象如此)', () => {
|
||||
assert.equal(frameFileName('atlas-login/00014', 0, 12), 'atlas-login/00014_01.png');
|
||||
});
|
||||
|
||||
test('frameFileName: 单帧图(prefix 不带下划线)直接拼接 .png', () => {
|
||||
assert.equal(frameFileName('atlas-login/00019', 1, 1), 'atlas-login/00019.png');
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FrameSet, type FrameSource, type FrameTarget } from '../../YouleNexus/assets/framework/ui/frame-set.ts';
|
||||
|
||||
/**
|
||||
* Mock FrameSource:按 prefix 返回 SpriteFrame 数组。
|
||||
* SpriteFrame 是 opaque(任何非 null 对象即可,FrameSet 只引用不深查)。
|
||||
*/
|
||||
function makeFrames(prefix: string, count: number): any[] {
|
||||
return Array.from({ length: count }, (_, i) => ({ __mock: true, name: frameName(prefix, i + 1, count) }));
|
||||
}
|
||||
function frameName(prefix: string, i: number, count: number): string {
|
||||
return count <= 1 ? `${prefix}.png` : `${prefix}_${String(i).padStart(String(count).length, '0')}.png`;
|
||||
}
|
||||
|
||||
/** Mock 加载器:同步、按 prefix 全量返回该 prefix 的所有帧。 */
|
||||
function mockSource(frames: Map<string, any[]>): FrameSource {
|
||||
return {
|
||||
async loadByPrefix(prefix: string) {
|
||||
return frames.get(prefix) ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock 目标:仅记录最后一次 setFrame 调用。 */
|
||||
function mockTarget(): FrameTarget & { lastFrame: any | null; lastError: Error | null } {
|
||||
return { lastFrame: null, lastError: null, setSpriteFrame(f) { this.lastFrame = f; } };
|
||||
}
|
||||
|
||||
test('FrameSet 首次 setFrame 触发按 prefix 加载', async () => {
|
||||
const frames = makeFrames('atlas-login/00014', 12);
|
||||
const source = mockSource(new Map([['atlas-login/00014', frames]]));
|
||||
const set = new FrameSet({ prefix: 'atlas-login/00014', source, target: mockTarget() });
|
||||
|
||||
const before = (set as any).state;
|
||||
assert.equal(before, 'idle');
|
||||
|
||||
await set.setFrame(1);
|
||||
assert.equal((set as any).state, 'ready');
|
||||
});
|
||||
|
||||
test('FrameSet setFrame(1) 返回第一帧(与 frameFileName 公式对齐)', async () => {
|
||||
const frames = makeFrames('atlas-login/00014', 12);
|
||||
const source = mockSource(new Map([['atlas-login/00014', frames]]));
|
||||
const target = mockTarget();
|
||||
const set = new FrameSet({ prefix: 'atlas-login/00014', source, target });
|
||||
|
||||
await set.setFrame(1);
|
||||
assert.equal(target.lastFrame?.name, 'atlas-login/00014_01.png');
|
||||
});
|
||||
|
||||
test('FrameSet setFrame(10) 返回第 10 帧(旧引擎实证:通知=FrameIndex=10)', async () => {
|
||||
const frames = makeFrames('atlas-login/00014', 12);
|
||||
const source = mockSource(new Map([['atlas-login/00014', frames]]));
|
||||
const target = mockTarget();
|
||||
const set = new FrameSet({ prefix: 'atlas-login/00014', source, target });
|
||||
|
||||
await set.setFrame(10);
|
||||
assert.equal(target.lastFrame?.name, 'atlas-login/00014_10.png');
|
||||
});
|
||||
|
||||
test('FrameSet setFrame(0) 按 1 处理(旧引擎 2 个对象 FrameIndex=0)', async () => {
|
||||
const frames = makeFrames('atlas-login/00014', 12);
|
||||
const source = mockSource(new Map([['atlas-login/00014', frames]]));
|
||||
const target = mockTarget();
|
||||
const set = new FrameSet({ prefix: 'atlas-login/00014', source, target });
|
||||
|
||||
await set.setFrame(0);
|
||||
assert.equal(target.lastFrame?.name, 'atlas-login/00014_01.png');
|
||||
});
|
||||
|
||||
test('FrameSet 越界帧号显式抛错(第二准则:不兜底)', async () => {
|
||||
const frames = makeFrames('atlas-login/00014', 12);
|
||||
const source = mockSource(new Map([['atlas-login/00014', frames]]));
|
||||
const set = new FrameSet({ prefix: 'atlas-login/00014', source, target: mockTarget() });
|
||||
await set.setFrame(1); // 先把 state 推到 ready
|
||||
|
||||
await assert.rejects(() => set.setFrame(13), (e: Error) => {
|
||||
return e.message.includes('atlas-login/00014') && e.message.includes('13');
|
||||
});
|
||||
});
|
||||
|
||||
test('FrameSet 源返回空数组显式抛错(prefix 错配要早暴露)', async () => {
|
||||
const source = mockSource(new Map()); // 空
|
||||
const set = new FrameSet({ prefix: 'atlas-login/wrong', source, target: mockTarget() });
|
||||
await assert.rejects(() => set.setFrame(1), (e: Error) => e.message.includes('atlas-login/wrong'));
|
||||
});
|
||||
|
||||
test('FrameSet 不重复 IO:连续 setFrame 只 loadByPrefix 一次', async () => {
|
||||
const frames = makeFrames('atlas-login/00014', 12);
|
||||
let calls = 0;
|
||||
const source: FrameSource = {
|
||||
async loadByPrefix(prefix: string) {
|
||||
calls++;
|
||||
return frames;
|
||||
},
|
||||
};
|
||||
const target = mockTarget();
|
||||
const set = new FrameSet({ prefix: 'atlas-login/00014', source, target });
|
||||
|
||||
await set.setFrame(1);
|
||||
await set.setFrame(5);
|
||||
await set.setFrame(10);
|
||||
assert.equal(calls, 1, '只首次触发加载');
|
||||
});
|
||||
|
||||
test('FrameSet 并发去重:两次 setFrame(1) 同时调用也只 loadByPrefix 一次', async () => {
|
||||
const frames = makeFrames('atlas-login/00014', 12);
|
||||
let calls = 0;
|
||||
const source: FrameSource = {
|
||||
async loadByPrefix(prefix: string) {
|
||||
calls++;
|
||||
// 模拟异步:制造竞态
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
return frames;
|
||||
},
|
||||
};
|
||||
const target = mockTarget();
|
||||
const set = new FrameSet({ prefix: 'atlas-login/00014', source, target });
|
||||
|
||||
await Promise.all([set.setFrame(1), set.setFrame(1), set.setFrame(1)]);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('FrameSet currentFrame 默认 0(与 B §3 cpid=43 初始一致)', () => {
|
||||
const set = new FrameSet({ prefix: 'atlas-login/00014', source: mockSource(new Map()), target: mockTarget() });
|
||||
assert.equal(set.currentFrame, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user