From 7404719c243a42805d66c42cfc50d73bbf88ffa6 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 31 Aug 2026 18:16:56 +0800 Subject: [PATCH] =?UTF-8?q?refactor(framework):=20=E5=88=A0=E9=99=A4=20Fra?= =?UTF-8?q?meSet=20=E5=9E=AB=E7=89=87,=E5=85=A8=E9=9D=A2=E9=87=87=E7=94=A8?= =?UTF-8?q?=20Cocos=20=E5=8E=9F=E7=94=9F=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gameabc 多帧图切帧的迁移垫片(FrameSet)与 Cocos 现代化方向冲突: - 散图命名 + prefix 加载 vs Auto Atlas 合图 + SpriteFrame 引用 - 自研异步组件 vs Cocos 内置 Button.Transition / Animation - 制造"何时用 FrameSet vs Button"的概念困惑 新方案:多状态用 Button.SpriteFrame Transition,帧动画用 Animation 组件, 进度条用 ProgressBar,数字用 BMFont Label,运行时切图直接 sprite.spriteFrame = frame (O(1) 赋值,无需 async)。 保留 ui/theme 与 atlas-* 资源(主流资产)。 90/90 tests pass, typecheck exit 0。 Co-Authored-By: Claude Opus 5 (1M context) --- .../assets/framework/core/frame-file-name.ts | 19 --- .../assets/framework/ui/frame-set.ts | 91 ------------- .../framework/ui/resources-frame-source.ts | 72 ---------- .../ui/frame-file-name.test.ts | 25 ---- .../framework-tests/ui/frame-set.test.ts | 128 ------------------ cocoscreator_projects/tsconfig.framework.json | 3 - 6 files changed, 338 deletions(-) delete mode 100644 cocoscreator_projects/YouleNexus/assets/framework/core/frame-file-name.ts delete mode 100644 cocoscreator_projects/YouleNexus/assets/framework/ui/frame-set.ts delete mode 100644 cocoscreator_projects/YouleNexus/assets/framework/ui/resources-frame-source.ts delete mode 100644 cocoscreator_projects/framework-tests/ui/frame-file-name.test.ts delete mode 100644 cocoscreator_projects/framework-tests/ui/frame-set.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/framework/core/frame-file-name.ts b/cocoscreator_projects/YouleNexus/assets/framework/core/frame-file-name.ts deleted file mode 100644 index a725540..0000000 --- a/cocoscreator_projects/YouleNexus/assets/framework/core/frame-file-name.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * 帧文件名公式:与 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; -} \ No newline at end of file diff --git a/cocoscreator_projects/YouleNexus/assets/framework/ui/frame-set.ts b/cocoscreator_projects/YouleNexus/assets/framework/ui/frame-set.ts deleted file mode 100644 index f92e970..0000000 --- a/cocoscreator_projects/YouleNexus/assets/framework/ui/frame-set.ts +++ /dev/null @@ -1,91 +0,0 @@ -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; -} - -/** - * 帧目标抽象。生产实现是一个薄壳包 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 | 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 { - 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 { - 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 }; \ No newline at end of file diff --git a/cocoscreator_projects/YouleNexus/assets/framework/ui/resources-frame-source.ts b/cocoscreator_projects/YouleNexus/assets/framework/ui/resources-frame-source.ts deleted file mode 100644 index 3e1e7c8..0000000 --- a/cocoscreator_projects/YouleNexus/assets/framework/ui/resources-frame-source.ts +++ /dev/null @@ -1,72 +0,0 @@ -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(); - - async loadByPrefix(prefix: string): Promise { - 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((resolve, reject) => { - resources.loadDir(bucket, SpriteFrame, (err, list) => { - if (err) reject(err); - else resolve(list ?? []); - }); - }); - this.cache.set(bucket, frames); - } - // 按 name === '_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; - }, - }; -} \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/ui/frame-file-name.test.ts b/cocoscreator_projects/framework-tests/ui/frame-file-name.test.ts deleted file mode 100644 index 02b326f..0000000 --- a/cocoscreator_projects/framework-tests/ui/frame-file-name.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -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'); -}); \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/ui/frame-set.test.ts b/cocoscreator_projects/framework-tests/ui/frame-set.test.ts deleted file mode 100644 index d8b7cff..0000000 --- a/cocoscreator_projects/framework-tests/ui/frame-set.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -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): 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); -}); \ No newline at end of file diff --git a/cocoscreator_projects/tsconfig.framework.json b/cocoscreator_projects/tsconfig.framework.json index a37a3a2..650a657 100644 --- a/cocoscreator_projects/tsconfig.framework.json +++ b/cocoscreator_projects/tsconfig.framework.json @@ -13,8 +13,5 @@ "include": [ "YouleNexus/assets/framework/**/*.ts", "framework-tests/**/*.ts" - ], - "exclude": [ - "YouleNexus/assets/framework/ui/resources-frame-source.ts" ] }