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,19 @@
|
|||||||
|
/**
|
||||||
|
* 帧文件名公式:与 scripts/ui-migration/frames.mjs:frameFileName 逐字对齐。
|
||||||
|
* 命名:B §3.4 + A §3.2 — 1 基 + 补零,保证字典序 = 帧序。
|
||||||
|
* FrameIndex=0 按 1 处理(旧引擎 2 个对象如此)。
|
||||||
|
*
|
||||||
|
* @param prefix 中间描述 frameSet.prefix,如 'atlas-login/00014'
|
||||||
|
* @param frameIndex 1 基帧号;0 按 1 处理
|
||||||
|
* @param count 该图总帧数(=中间描述 frameSet.count)
|
||||||
|
*/
|
||||||
|
export function frameFileName(prefix: string, frameIndex: number, count: number): string {
|
||||||
|
if (count <= 1) return `${prefix}.png`;
|
||||||
|
const n = String(frameIndex || 1).padStart(padWidth(count), '0');
|
||||||
|
return `${prefix}_${n}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 补零位数:保证字典序 = 帧序。 */
|
||||||
|
export function padWidth(count: number): number {
|
||||||
|
return String(count).length;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { frameFileName } from '../core/frame-file-name.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 帧源抽象。生产实现从 Resources/Auto Atlas 加载;测试可注入 mock。
|
||||||
|
* 设计动机:FrameSet 组件不应直接 import Cocos 类型,保持逻辑层可单测。
|
||||||
|
*/
|
||||||
|
export interface FrameSource {
|
||||||
|
/**
|
||||||
|
* 按中间描述的 prefix 加载该 prefix 的全部帧。
|
||||||
|
* 例:loadByPrefix('atlas-login/00014') → SpriteFrame[12]
|
||||||
|
* @throws 若 prefix 不存在或加载失败,由实现决定(建议显式抛错)
|
||||||
|
*/
|
||||||
|
loadByPrefix(prefix: string): Promise<unknown[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 帧目标抽象。生产实现是一个薄壳包 Sprite.spriteFrame 赋值;
|
||||||
|
* 抽象出来让 FrameSet 不依赖 Sprite 类型。
|
||||||
|
*/
|
||||||
|
export interface FrameTarget {
|
||||||
|
setSpriteFrame(frame: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FrameSet:多帧图切帧组件(B §3.4)。
|
||||||
|
*
|
||||||
|
* 承载 set_self(spid, 43, n) 的 1773 处 C 移植调用。
|
||||||
|
* - 帧号 1 基(B §3 cpid=43)
|
||||||
|
* - FrameIndex=0 按 1 处理(旧引擎 2 个对象如此,frames.mjs:frameRect 实证)
|
||||||
|
* - 越界显式抛错(第二准则:不兜底)
|
||||||
|
* - 并发去重:多次 setFrame 在加载未完成时只触发一次 loadByPrefix
|
||||||
|
*/
|
||||||
|
export class FrameSet {
|
||||||
|
private state: 'idle' | 'loading' | 'ready' = 'idle';
|
||||||
|
private frames: unknown[] = [];
|
||||||
|
private loadingPromise: Promise<unknown[]> | null = null;
|
||||||
|
/** 当前帧(1 基)。0 = 尚未切过。 */
|
||||||
|
currentFrame: number = 0;
|
||||||
|
|
||||||
|
constructor(private readonly opts: { prefix: string; source: FrameSource; target: FrameTarget }) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切到指定帧(1 基;0 按 1 处理)。
|
||||||
|
* @throws 帧号越界或 prefix 无帧时显式抛错(不静默兜底)
|
||||||
|
*/
|
||||||
|
async setFrame(n: number): Promise<void> {
|
||||||
|
const frames = await this.ensureLoaded();
|
||||||
|
const idx = (n || 1) - 1; // 0 → 1;1 基
|
||||||
|
if (idx < 0 || idx >= frames.length) {
|
||||||
|
throw new RangeError(
|
||||||
|
`FrameSet 帧号越界: prefix=${this.opts.prefix} frameIndex=${n}(有效范围 1..${frames.length})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.opts.target.setSpriteFrame(frames[idx]);
|
||||||
|
this.currentFrame = n || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureLoaded(): Promise<unknown[]> {
|
||||||
|
if (this.state === 'ready') return Promise.resolve(this.frames);
|
||||||
|
if (this.loadingPromise) return this.loadingPromise;
|
||||||
|
this.state = 'loading';
|
||||||
|
this.loadingPromise = this.opts.source.loadByPrefix(this.opts.prefix).then(
|
||||||
|
(frames) => {
|
||||||
|
if (frames.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`FrameSet prefix 无帧: ${this.opts.prefix}(检查 frameSet.count 与图集是否一致)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.frames = frames;
|
||||||
|
this.state = 'ready';
|
||||||
|
this.loadingPromise = null;
|
||||||
|
return frames;
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
// 加载失败:重置状态以便重试
|
||||||
|
this.state = 'idle';
|
||||||
|
this.loadingPromise = null;
|
||||||
|
throw err;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return this.loadingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅用于测试断言状态。 */
|
||||||
|
get debugState(): 'idle' | 'loading' | 'ready' {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新导出便于消费方一行 import
|
||||||
|
export { frameFileName };
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { SpriteFrame, resources } from 'cc';
|
||||||
|
import type { FrameSource } from './frame-set.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生产 FrameSource:从 Resources/Auto Atlas 加载某目录的全部 SpriteFrame,按 prefix 过滤。
|
||||||
|
*
|
||||||
|
* 设计动机(详见 2026-08-27-ui-asset-and-skin-design.md):
|
||||||
|
* - 散图落 assets/framework/ui/atlas-{bucket}/,构建期由 Auto Atlas 打包成图集
|
||||||
|
* - 运行时单张 .png 不可 Resources.load(合图后原文件被替换)
|
||||||
|
* - 必须 loadDir 整个目录拿 SpriteFrame 列表,再按 prefix 过滤
|
||||||
|
*
|
||||||
|
* 调用方先按 spid → intermediate frameSet.prefix('atlas-login/00014')确定加载哪个目录
|
||||||
|
* —— 我们把目录从 prefix 第一段取出('atlas-login'),加载后按 '00014_' 前缀过滤。
|
||||||
|
*/
|
||||||
|
export class ResourcesFrameSource implements FrameSource {
|
||||||
|
/**
|
||||||
|
* 简单 LRU:避免重复 loadDir 已加载过的目录。
|
||||||
|
* key = bucket 目录名(如 'atlas-login')
|
||||||
|
*/
|
||||||
|
private cache = new Map<string, SpriteFrame[]>();
|
||||||
|
|
||||||
|
async loadByPrefix(prefix: string): Promise<unknown[]> {
|
||||||
|
const slash = prefix.indexOf('/');
|
||||||
|
if (slash < 0) {
|
||||||
|
throw new Error(`FrameSet prefix 必须含 bucket 段: '${prefix}'(如 'atlas-login/00014')`);
|
||||||
|
}
|
||||||
|
const bucket = prefix.slice(0, slash);
|
||||||
|
const filePrefix = prefix.slice(slash + 1); // '00014'
|
||||||
|
|
||||||
|
let frames = this.cache.get(bucket);
|
||||||
|
if (!frames) {
|
||||||
|
frames = await new Promise<SpriteFrame[]>((resolve, reject) => {
|
||||||
|
resources.loadDir(bucket, SpriteFrame, (err, list) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(list ?? []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.cache.set(bucket, frames);
|
||||||
|
}
|
||||||
|
// 按 name === '<filePrefix>_NN.png' 过滤并按 NN 升序
|
||||||
|
const matched: SpriteFrame[] = [];
|
||||||
|
const re = new RegExp(`^${escapeRe(filePrefix)}_(\\d+)\\.png$`);
|
||||||
|
for (const f of frames) {
|
||||||
|
const m = (f.name ?? '').match(re);
|
||||||
|
if (m) matched.push(f);
|
||||||
|
}
|
||||||
|
matched.sort((a, b) => {
|
||||||
|
const na = Number((a.name ?? '').match(re)![1]);
|
||||||
|
const nb = Number((b.name ?? '').match(re)![1]);
|
||||||
|
return na - nb;
|
||||||
|
});
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRe(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生产 FrameTarget:把 spriteFrame 写到传入的 Sprite 组件。
|
||||||
|
* 把它做成工厂返回闭包,避免 FrameSet 直接 import Sprite 类型。
|
||||||
|
*/
|
||||||
|
export function makeSpriteTarget(sprite: { spriteFrame: unknown }): {
|
||||||
|
setSpriteFrame: (frame: unknown) => void;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
setSpriteFrame(frame) {
|
||||||
|
sprite.spriteFrame = frame as never;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -13,5 +13,8 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"YouleNexus/assets/framework/**/*.ts",
|
"YouleNexus/assets/framework/**/*.ts",
|
||||||
"framework-tests/**/*.ts"
|
"framework-tests/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"YouleNexus/assets/framework/ui/resources-frame-source.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user