diff --git a/.gitignore b/.gitignore index 61dcaf3a..84e55acf 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ data/ # 运行产物 output/ +tools/cocos-importer/dist/ +tools/cocos-importer/test/.tmp-*/ # 构建缓存 .ruff_cache/ diff --git a/frontend/src/features/export-package/README.md b/frontend/src/features/export-package/README.md index 46f2bad8..ea3bc6f2 100644 --- a/frontend/src/features/export-package/README.md +++ b/frontend/src/features/export-package/README.md @@ -41,11 +41,18 @@ Aster-character-1-Explorer-outfit-1/ 已发布 Character 动作的 `expectedFrameCount` 来自后端声明,不能用 `frames.length` 自己推算;两者不同时导出立即失败。WorkflowRun 中尚未发布的生成结果没有独立的期望总帧数字段,因此当前只能校验帧序从 0 连续,不能识别生成服务在末尾少返回帧。所有阶段遇到图片读取失败、PNG 无透明信息或尺寸不一致都会失败,不会用透明占位掩盖问题。`action-assets` 可保留 `pending` 质量状态供检查;`playtest` 只接受 `passed` 动作。 -## Cocos Creator 边界 +## Cocos Creator 3.8.x 一键导入 -Issue #94 要求先用真实 Cocos Creator 3.x 验证图集切分数据、`.anim`、`.meta`、UUID 与小版本差异。当前仓库没有该实测结论,因此本模块只落地已确定的通用层和 target 扩展接口,不伪造 Cocos 原生文件。 +`cocosCreatorTarget` 在通用 ZIP 的 `targets/cocos-creator/` 中追加 `cocos-import.json` 1.1 清单。清单保留动作方向、精确帧序、FPS 或逐帧时长、循环设置、锚点、脚线、单帧文件和 atlas 信息;通用锚点 `(x, y)` 转换为 Creator 的 `(x, 1-y)`。 -Cocos 坐标转换规则已经明确:通用锚点 `(x, y)` 转成 Creator 锚点 `(x, 1-y)`。等实测字段回填后,只需新增一个 `AssetExportTarget`,不改 `meta.json` 与通用打包逻辑。 +`ExportPanel` 同时提供: + +- “一键导入 Cocos”:检查本机插件、配对状态、Creator 版本和目标工程,随后上传同一份 ZIP 并轮询导入进度。 +- “下载 Cocos 包”:浏览器或插件不可用时的离线回退,不重复渲染已经缓存的 ZIP。 + +Creator 扩展位于 `tools/cocos-importer/extension/`,构建产物是 `tools/cocos-importer/dist/windup-cocos-importer.zip`。扩展支持 `>=3.8.8 <3.9.0`,只监听本机回环地址,并完成事务写入、AssetDB 刷新、Prefab/AnimationClip 引用校验、失败回滚和 Prefab 定位。CLI 仍保留给 CI 与离线排查。 + +2026-08-20 已用 256×256 的“网站看板娘 / 默认造型”真实 2D 资产在 Creator 3.8.8 导入,验证 67 张 SpriteFrame、2 个 AnimationClip、64 个动作帧和 1 个 Prefab。 ## 验证 diff --git a/frontend/src/features/export-package/asset-export.test.ts b/frontend/src/features/export-package/asset-export.test.ts index e6ca479c..c6dd67db 100644 --- a/frontend/src/features/export-package/asset-export.test.ts +++ b/frontend/src/features/export-package/asset-export.test.ts @@ -204,8 +204,9 @@ describe('asset export', () => { }) }) - it('明确 Cocos 尚未就绪,并只落地已确认的锚点坐标转换', () => { - expect(COCOS_TARGET_READINESS.ready).toBe(false) + it('记录 Cocos 3.8.8 实测就绪状态,并转换锚点坐标', () => { + expect(COCOS_TARGET_READINESS.ready).toBe(true) + expect(COCOS_TARGET_READINESS.reason).toContain('Creator 3.8.8') const anchor = toCocosAnchor({ x: 0.5, y: 0.9 }) expect(anchor.x).toBe(0.5) expect(anchor.y).toBeCloseTo(0.1) diff --git a/frontend/src/features/export-package/cocos-bridge-client.test.ts b/frontend/src/features/export-package/cocos-bridge-client.test.ts new file mode 100644 index 00000000..886a6442 --- /dev/null +++ b/frontend/src/features/export-package/cocos-bridge-client.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from 'vitest' + +import { + COCOS_BRIDGE_PROTOCOL, + CocosBridgeClient, + type CocosBridgeClientOptions, +} from './cocos-bridge-client' + +class MemoryStorage implements Storage { + readonly values = new Map() + + get length(): number { + return this.values.size + } + + clear(): void { + this.values.clear() + } + + getItem(key: string): string | null { + return this.values.get(key) ?? null + } + + key(index: number): string | null { + return [...this.values.keys()][index] ?? null + } + + removeItem(key: string): void { + this.values.delete(key) + } + + setItem(key: string, value: string): void { + this.values.set(key, value) + } +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + +function options( + fetch: typeof globalThis.fetch, + storage = new MemoryStorage(), +): CocosBridgeClientOptions { + return { + fetch, + storage, + baseUrl: 'http://127.0.0.1:17832', + healthTimeoutMs: 20, + uploadTimeoutMs: 20, + } +} + +describe('CocosBridgeClient', () => { + it('reads a compatible Creator health response', async () => { + const fetch: typeof globalThis.fetch = async () => + json({ + protocol: COCOS_BRIDGE_PROTOCOL, + creatorVersion: '3.8.8', + projectName: 'WindupCocosTest', + projectOpen: true, + paired: true, + }) + const health = await new CocosBridgeClient(options(fetch)).health() + + expect(health.creatorVersion).toBe('3.8.8') + expect(health.projectName).toBe('WindupCocosTest') + expect(health.projectOpen).toBe(true) + }) + + it('accepts the privacy-preserving health response before pairing', async () => { + const fetch: typeof globalThis.fetch = async () => + json({ protocol: COCOS_BRIDGE_PROTOCOL, paired: false }) + await expect(new CocosBridgeClient(options(fetch)).health()).resolves.toEqual({ + protocol: COCOS_BRIDGE_PROTOCOL, + creatorVersion: null, + projectName: null, + projectOpen: false, + paired: false, + }) + }) + + it('accepts a paired Creator health response without a project name', async () => { + const fetch: typeof globalThis.fetch = async () => + json({ + protocol: COCOS_BRIDGE_PROTOCOL, + creatorVersion: '3.8.8', + projectName: null, + projectOpen: false, + paired: true, + }) + + await expect(new CocosBridgeClient(options(fetch)).health()).resolves.toMatchObject({ + projectName: null, + projectOpen: false, + paired: true, + }) + }) + + it('stores the issued token after a valid one-time pairing code', async () => { + const storage = new MemoryStorage() + const fetch: typeof globalThis.fetch = async (input, init) => { + expect(String(input)).toBe('http://127.0.0.1:17832/v1/pair') + expect(init?.method).toBe('POST') + expect(JSON.parse(String(init?.body))).toEqual({ code: '123456' }) + return json({ protocol: COCOS_BRIDGE_PROTOCOL, token: 'issued-token' }) + } + const client = new CocosBridgeClient(options(fetch, storage)) + + await client.pair('123456') + + expect(storage.getItem('windup:cocos-bridge:token:v1')).toBe('issued-token') + }) + + it('uploads the raw zip with request id, bearer token and SHA-256', async () => { + const storage = new MemoryStorage() + storage.setItem('windup:cocos-bridge:token:v1', 'secret-token') + const fetch: typeof globalThis.fetch = async (_input, init) => { + const headers = new Headers(init?.headers) + expect(headers.get('Authorization')).toBe('Bearer secret-token') + expect(headers.get('Content-Type')).toBe('application/zip') + expect(headers.get('X-Windup-Protocol')).toBe(COCOS_BRIDGE_PROTOCOL) + expect(headers.get('X-Windup-Request-Id')).toBe('11111111-1111-4111-8111-111111111111') + expect(headers.get('X-Windup-SHA256')).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ) + const requestBody = init?.body + expect(requestBody).toBeInstanceOf(Blob) + if (!(requestBody instanceof Blob)) throw new Error('expected Blob request body') + expect(await requestBody.text()).toBe('abc') + return json({ protocol: COCOS_BRIDGE_PROTOCOL, jobId: 'job-1' }, 202) + } + const client = new CocosBridgeClient(options(fetch, storage)) + + const submitted = await client.submit( + new Blob(['abc'], { type: 'application/zip' }), + '11111111-1111-4111-8111-111111111111', + ) + + expect(submitted.jobId).toBe('job-1') + }) + + it('returns a completed import job with its summary', async () => { + const storage = new MemoryStorage() + storage.setItem('windup:cocos-bridge:token:v1', 'secret-token') + const fetch: typeof globalThis.fetch = async (_input, init) => { + const headers = new Headers(init?.headers) + expect(headers.get('Authorization')).toBe('Bearer secret-token') + expect(headers.get('X-Windup-Protocol')).toBe(COCOS_BRIDGE_PROTOCOL) + return json({ + protocol: COCOS_BRIDGE_PROTOCOL, + jobId: 'job-1', + status: 'completed', + phase: 'verifying', + result: { + projectName: 'Game', + dbUrl: 'db://assets/windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab', + animationCount: 2, + frameCount: 64, + }, + }) + } + const job = await new CocosBridgeClient(options(fetch, storage)).getJob('job-1') + + expect(job.status).toBe('completed') + expect(job.result?.animationCount).toBe(2) + expect(job.result?.frameCount).toBe(64) + }) + + it('clears an expired token and maps 401 to PAIRING_REQUIRED', async () => { + const storage = new MemoryStorage() + storage.setItem('windup:cocos-bridge:token:v1', 'expired-token') + const client = new CocosBridgeClient( + options(async () => json({ error: 'TOKEN_INVALID' }, 401), storage), + ) + + await expect(client.getJob('job-1')).rejects.toMatchObject({ code: 'PAIRING_REQUIRED' }) + expect(storage.getItem('windup:cocos-bridge:token:v1')).toBeNull() + }) + + it('requires pairing before authenticated requests without calling localhost', async () => { + let called = false + const client = new CocosBridgeClient( + options(async () => { + called = true + return json({}) + }), + ) + + await expect(client.getJob('job-1')).rejects.toMatchObject({ code: 'PAIRING_REQUIRED' }) + expect(called).toBe(false) + }) + + it.each([ + [403, 'ORIGIN_DENIED'], + [426, 'VERSION_UNSUPPORTED'], + [500, 'IMPORT_FAILED'], + ] as const)('maps HTTP %s to %s', async (status, code) => { + const storage = new MemoryStorage() + storage.setItem('windup:cocos-bridge:token:v1', 'token') + const client = new CocosBridgeClient( + options(async () => json({ message: 'server reason' }, status), storage), + ) + + await expect(client.getJob('job-1')).rejects.toMatchObject({ code, status }) + }) + + it('rejects non-JSON plugin responses', async () => { + const client = new CocosBridgeClient( + options( + async () => + new Response('not json', { status: 200, headers: { 'Content-Type': 'text/plain' } }), + ), + ) + + await expect(client.health()).rejects.toMatchObject({ code: 'IMPORT_FAILED' }) + }) + + it('parses a failed job including rollback state', async () => { + const storage = new MemoryStorage() + storage.setItem('windup:cocos-bridge:token:v1', 'token') + const client = new CocosBridgeClient( + options( + async () => + json({ + protocol: COCOS_BRIDGE_PROTOCOL, + jobId: 'job-2', + status: 'failed', + phase: 'verifying', + error: { code: 'IMPORT_UUID_UNRESOLVED', message: '引用不存在', rolledBack: true }, + }), + storage, + ), + ) + + const job = await client.getJob('job-2') + expect(job.error).toEqual({ + code: 'IMPORT_UUID_UNRESOLVED', + message: '引用不存在', + rolledBack: true, + }) + }) + + it('rejects malformed job states instead of treating them as completed', async () => { + const storage = new MemoryStorage() + storage.setItem('windup:cocos-bridge:token:v1', 'token') + const client = new CocosBridgeClient( + options( + async () => + json({ + protocol: COCOS_BRIDGE_PROTOCOL, + jobId: 'job-3', + status: 'done-ish', + phase: 'verifying', + }), + storage, + ), + ) + + await expect(client.getJob('job-3')).rejects.toMatchObject({ code: 'IMPORT_FAILED' }) + }) + + it.each([ + { + name: 'non-object health body', + body: [] as unknown, + message: 'health 返回格式错误', + }, + { + name: 'empty Creator version', + body: { + protocol: COCOS_BRIDGE_PROTOCOL, + creatorVersion: '', + projectName: 'Game', + projectOpen: true, + paired: true, + }, + message: 'health.creatorVersion 必须是非空字符串', + }, + { + name: 'non-boolean pairing state', + body: { protocol: COCOS_BRIDGE_PROTOCOL, paired: 'yes' }, + message: 'health.paired 必须是布尔值', + }, + ])('rejects $name at the health boundary', async ({ body, message }) => { + const client = new CocosBridgeClient(options(async () => json(body))) + + await expect(client.health()).rejects.toThrow(message) + }) + + it('rejects non-numeric import totals instead of accepting a corrupt result', async () => { + const storage = new MemoryStorage() + storage.setItem('windup:cocos-bridge:token:v1', 'token') + const client = new CocosBridgeClient( + options( + async () => + json({ + protocol: COCOS_BRIDGE_PROTOCOL, + jobId: 'job-bad-total', + status: 'completed', + phase: 'verifying', + result: { + projectName: 'Game', + dbUrl: 'db://assets/result.prefab', + animationCount: null, + frameCount: 64, + }, + }), + storage, + ), + ) + + await expect(client.getJob('job-bad-total')).rejects.toThrow( + 'job.result.animationCount 必须是数字', + ) + }) + + it.each([{ message: '' }, null])( + 'uses a stable fallback when an HTTP error has no usable message: %j', + async (body) => { + const storage = new MemoryStorage() + storage.setItem('windup:cocos-bridge:token:v1', 'token') + const client = new CocosBridgeClient(options(async () => json(body, 500), storage)) + + await expect(client.getJob('job-1')).rejects.toThrow('Cocos 导入失败') + }, + ) + + it('rejects a bridge using another protocol version', async () => { + const client = new CocosBridgeClient( + options(async () => + json({ + creatorVersion: '3.8.8', + projectName: 'Game', + projectOpen: true, + paired: true, + protocol: 'other/2', + }), + ), + ) + + await expect(client.health()).rejects.toMatchObject({ code: 'VERSION_UNSUPPORTED' }) + }) + + it('maps refused localhost connections to PLUGIN_UNAVAILABLE', async () => { + const client = new CocosBridgeClient( + options(async () => { + throw new TypeError('fetch failed') + }), + ) + + await expect(client.health()).rejects.toEqual( + expect.objectContaining({ code: 'PLUGIN_UNAVAILABLE' }), + ) + }) + + it('aborts a health request that exceeds the configured timeout', async () => { + const fetch: typeof globalThis.fetch = async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ) + }) + const client = new CocosBridgeClient(options(fetch)) + + await expect(client.health()).rejects.toMatchObject({ code: 'PLUGIN_UNAVAILABLE' }) + }) +}) diff --git a/frontend/src/features/export-package/cocos-bridge-client.ts b/frontend/src/features/export-package/cocos-bridge-client.ts new file mode 100644 index 00000000..3d6a0608 --- /dev/null +++ b/frontend/src/features/export-package/cocos-bridge-client.ts @@ -0,0 +1,329 @@ +export const COCOS_BRIDGE_PROTOCOL = 'windup-cocos-bridge/1.0.0' +export const COCOS_BRIDGE_TOKEN_KEY = 'windup:cocos-bridge:token:v1' + +export type CocosBridgeErrorCode = + | 'PLUGIN_UNAVAILABLE' + | 'PAIRING_REQUIRED' + | 'ORIGIN_DENIED' + | 'VERSION_UNSUPPORTED' + | 'IMPORT_FAILED' + +export interface CocosBridgeErrorDetails { + jobCode: string + phase: CocosImportPhase + rolledBack: boolean +} + +export class CocosBridgeError extends Error { + readonly code: CocosBridgeErrorCode + readonly status?: number + readonly jobCode?: string + readonly phase?: CocosImportPhase + readonly rolledBack?: boolean + + constructor( + code: CocosBridgeErrorCode, + message: string, + status?: number, + details?: CocosBridgeErrorDetails, + ) { + super(message) + this.name = 'CocosBridgeError' + this.code = code + this.status = status + this.jobCode = details?.jobCode + this.phase = details?.phase + this.rolledBack = details?.rolledBack + } +} + +export interface CocosBridgeHealth { + protocol: typeof COCOS_BRIDGE_PROTOCOL + creatorVersion: string | null + projectName: string | null + projectOpen: boolean + paired: boolean +} + +export type CocosImportPhase = + | 'queued' + | 'validating' + | 'converting' + | 'writing' + | 'refreshing' + | 'verifying' + +export interface CocosImportResult { + projectName: string + dbUrl: string + animationCount: number + frameCount: number +} + +export interface CocosImportJob { + protocol: typeof COCOS_BRIDGE_PROTOCOL + jobId: string + status: 'queued' | 'running' | 'completed' | 'failed' + phase: CocosImportPhase + result?: CocosImportResult + error?: { code: string; message: string; rolledBack: boolean } +} + +export interface CocosBridgeClientOptions { + fetch?: typeof globalThis.fetch + storage?: Storage + crypto?: Crypto + baseUrl?: string + healthTimeoutMs?: number + uploadTimeoutMs?: number +} + +const DEFAULT_BASE_URL = 'http://127.0.0.1:17832' + +export class CocosBridgeClient { + private readonly fetchImpl: typeof globalThis.fetch + private readonly storage: Storage + private readonly cryptoImpl: Crypto + private readonly baseUrl: string + private readonly healthTimeoutMs: number + private readonly uploadTimeoutMs: number + + constructor(options: CocosBridgeClientOptions = {}) { + this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis) + this.storage = options.storage ?? globalThis.localStorage + this.cryptoImpl = options.crypto ?? globalThis.crypto + this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '') + this.healthTimeoutMs = options.healthTimeoutMs ?? 2_000 + this.uploadTimeoutMs = options.uploadTimeoutMs ?? 30_000 + } + + async health(): Promise { + const body = await this.request('/v1/health', {}, this.healthTimeoutMs, false) + const record = expectRecord(body, 'health') + assertProtocol(record) + const paired = expectBoolean(record.paired, 'health.paired') + if (!paired) { + return { + protocol: COCOS_BRIDGE_PROTOCOL, + creatorVersion: null, + projectName: null, + projectOpen: false, + paired: false, + } + } + return { + protocol: COCOS_BRIDGE_PROTOCOL, + creatorVersion: expectString(record.creatorVersion, 'health.creatorVersion'), + projectName: + record.projectName === null ? null : expectString(record.projectName, 'health.projectName'), + projectOpen: expectBoolean(record.projectOpen, 'health.projectOpen'), + paired, + } + } + + async pair(code: string): Promise { + const body = await this.request( + '/v1/pair', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }, + this.healthTimeoutMs, + false, + ) + const record = expectRecord(body, 'pair') + assertProtocol(record) + this.storage.setItem(COCOS_BRIDGE_TOKEN_KEY, expectString(record.token, 'pair.token')) + } + + async submit(blob: Blob, requestId: string): Promise<{ jobId: string }> { + const digest = await this.cryptoImpl.subtle.digest('SHA-256', await blob.arrayBuffer()) + const sha256 = [...new Uint8Array(digest)] + .map((value) => value.toString(16).padStart(2, '0')) + .join('') + const body = await this.request( + '/v1/imports', + { + method: 'POST', + headers: { + 'Content-Type': 'application/zip', + 'X-Windup-Protocol': COCOS_BRIDGE_PROTOCOL, + 'X-Windup-Request-Id': requestId, + 'X-Windup-SHA256': sha256, + }, + body: blob, + }, + this.uploadTimeoutMs, + true, + ) + const record = expectRecord(body, 'submit') + assertProtocol(record) + return { jobId: expectString(record.jobId, 'submit.jobId') } + } + + async getJob(jobId: string): Promise { + const body = await this.request( + `/v1/imports/${encodeURIComponent(jobId)}`, + { headers: { 'X-Windup-Protocol': COCOS_BRIDGE_PROTOCOL } }, + this.healthTimeoutMs, + true, + ) + const record = expectRecord(body, 'job') + assertProtocol(record) + const status = expectEnum( + record.status, + ['queued', 'running', 'completed', 'failed'], + 'job.status', + ) + const phase = expectEnum( + record.phase, + ['queued', 'validating', 'converting', 'writing', 'refreshing', 'verifying'], + 'job.phase', + ) + const job: CocosImportJob = { + protocol: COCOS_BRIDGE_PROTOCOL, + jobId: expectString(record.jobId, 'job.jobId'), + status, + phase, + } + if (record.result !== undefined) job.result = parseResult(record.result) + if (record.error !== undefined) job.error = parseJobError(record.error) + return job + } + + private async request( + path: string, + init: RequestInit, + timeoutMs: number, + authenticated: boolean, + ): Promise { + const headers = new Headers(init.headers) + if (authenticated) { + const token = this.storage.getItem(COCOS_BRIDGE_TOKEN_KEY) + if (!token) throw new CocosBridgeError('PAIRING_REQUIRED', '请先与 Cocos Creator 插件配对') + headers.set('Authorization', `Bearer ${token}`) + } + const controller = new AbortController() + const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...init, + headers, + signal: controller.signal, + }) + const body = await readJson(response) + if (!response.ok) { + if (response.status === 401) { + this.storage.removeItem(COCOS_BRIDGE_TOKEN_KEY) + throw new CocosBridgeError('PAIRING_REQUIRED', '连接已失效,请重新配对', response.status) + } + if (response.status === 403) { + throw new CocosBridgeError( + 'ORIGIN_DENIED', + '当前网页来源未获 Creator 插件授权', + response.status, + ) + } + if (response.status === 426) { + throw new CocosBridgeError( + 'VERSION_UNSUPPORTED', + 'Creator 插件协议版本不兼容', + response.status, + ) + } + throw new CocosBridgeError('IMPORT_FAILED', errorMessage(body), response.status) + } + return body + } catch (error) { + if (error instanceof CocosBridgeError) throw error + throw new CocosBridgeError( + 'PLUGIN_UNAVAILABLE', + error instanceof DOMException && error.name === 'AbortError' + ? '连接 Cocos Creator 插件超时' + : '未检测到 Cocos Creator 插件', + ) + } finally { + globalThis.clearTimeout(timeout) + } + } +} + +async function readJson(response: Response): Promise { + try { + return await response.json() + } catch { + throw new CocosBridgeError('IMPORT_FAILED', 'Creator 插件返回了无法解析的数据', response.status) + } +} + +function assertProtocol(record: Record): void { + if (record.protocol !== COCOS_BRIDGE_PROTOCOL) { + throw new CocosBridgeError('VERSION_UNSUPPORTED', 'Creator 插件协议版本不兼容') + } +} + +function expectRecord(value: unknown, field: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new CocosBridgeError('IMPORT_FAILED', `${field} 返回格式错误`) + } + return value as Record +} + +function expectString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new CocosBridgeError('IMPORT_FAILED', `${field} 必须是非空字符串`) + } + return value +} + +function expectBoolean(value: unknown, field: string): boolean { + if (typeof value !== 'boolean') + throw new CocosBridgeError('IMPORT_FAILED', `${field} 必须是布尔值`) + return value +} + +function expectNumber(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new CocosBridgeError('IMPORT_FAILED', `${field} 必须是数字`) + } + return value +} + +function expectEnum( + value: unknown, + allowed: T, + field: string, +): T[number] { + if (typeof value !== 'string' || !allowed.includes(value)) { + throw new CocosBridgeError('IMPORT_FAILED', `${field} 非法`) + } + return value as T[number] +} + +function parseResult(value: unknown): CocosImportResult { + const record = expectRecord(value, 'job.result') + return { + projectName: expectString(record.projectName, 'job.result.projectName'), + dbUrl: expectString(record.dbUrl, 'job.result.dbUrl'), + animationCount: expectNumber(record.animationCount, 'job.result.animationCount'), + frameCount: expectNumber(record.frameCount, 'job.result.frameCount'), + } +} + +function parseJobError(value: unknown): CocosImportJob['error'] { + const record = expectRecord(value, 'job.error') + return { + code: expectString(record.code, 'job.error.code'), + message: expectString(record.message, 'job.error.message'), + rolledBack: expectBoolean(record.rolledBack, 'job.error.rolledBack'), + } +} + +function errorMessage(value: unknown): string { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const message = (value as Record).message + if (typeof message === 'string' && message.length > 0) return message + } + return 'Cocos 导入失败' +} diff --git a/frontend/src/features/export-package/cocos-one-click.defaults.test.ts b/frontend/src/features/export-package/cocos-one-click.defaults.test.ts new file mode 100644 index 00000000..b7d49b5c --- /dev/null +++ b/frontend/src/features/export-package/cocos-one-click.defaults.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AssetExportResult } from './asset-export' +import type { CocosBridgeApi, CocosOneClickPhase } from './cocos-one-click' +import { importIntoCocos } from './cocos-one-click' +import type { ExportPackageModel } from './model' + +const { exportGameAssetsMock } = vi.hoisted(() => ({ exportGameAssetsMock: vi.fn() })) + +vi.mock('./asset-export', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, exportGameAssets: exportGameAssetsMock } +}) + +const model: ExportPackageModel = { + stage: 'character', + characterId: 'hero', + characterName: 'Hero', + characterImageUrl: 'memory://hero.png', + outfitId: 'default', + outfitName: 'Default', + canvas: { width: 256, height: 256 }, + source: null, + firstFrames: [], + actions: [], + playtest: null, +} + +const packageResult: AssetExportResult = { + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-Hero.zip', +} + +const bridge: CocosBridgeApi = { + health: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + creatorVersion: '3.8.8', + projectName: 'Game', + projectOpen: true, + paired: true, + }), + submit: async () => ({ jobId: 'job-default-exporter' }), + getJob: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + jobId: 'job-default-exporter', + status: 'completed', + phase: 'verifying', + result: { + projectName: 'Game', + dbUrl: 'db://assets/windup-imports/Hero.prefab', + animationCount: 0, + frameCount: 0, + }, + }), +} + +beforeEach(() => { + exportGameAssetsMock.mockReset() + exportGameAssetsMock.mockImplementation( + async (_model: ExportPackageModel, options: { onPhase?: (phase: 'packing') => void }) => { + options.onPhase?.('packing') + return packageResult + }, + ) +}) + +describe('importIntoCocos defaults', () => { + it('uses the Cocos target when no custom package exporter is supplied', async () => { + const phases: CocosOneClickPhase[] = [] + + await importIntoCocos(model, bridge, (phase) => phases.push(phase), { + pollDelay: async () => undefined, + createRequestId: () => '11111111-1111-4111-8111-111111111111', + }) + + expect(exportGameAssetsMock).toHaveBeenCalledTimes(1) + const options = exportGameAssetsMock.mock.calls[0]?.[1] as { + targets: Array<{ id: string }> + } + expect(options.targets.map((target) => target.id)).toEqual(['cocos-creator']) + expect(phases).toEqual(['detecting', 'packing', 'uploading', 'verifying']) + }) +}) diff --git a/frontend/src/features/export-package/cocos-one-click.test.ts b/frontend/src/features/export-package/cocos-one-click.test.ts new file mode 100644 index 00000000..d7ed7602 --- /dev/null +++ b/frontend/src/features/export-package/cocos-one-click.test.ts @@ -0,0 +1,401 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { AssetExportResult } from './asset-export' +import type { CocosBridgeApi, CocosImportCache, CocosOneClickPhase } from './cocos-one-click' +import { importIntoCocos } from './cocos-one-click' +import { CocosBridgeError, type CocosImportJob } from './cocos-bridge-client' +import type { ExportPackageModel } from './model' + +function model(): ExportPackageModel { + return { + stage: 'action-assets', + characterId: 'hero', + characterName: 'Hero', + characterImageUrl: 'memory://hero.png', + outfitId: 'default', + outfitName: 'Ranger', + canvas: { width: 256, height: 256 }, + source: null, + firstFrames: [], + playtest: null, + actions: [], + } +} + +function completedJob(): CocosImportJob { + return { + protocol: 'windup-cocos-bridge/1.0.0', + jobId: 'job-1', + status: 'completed', + phase: 'verifying', + result: { + projectName: 'Game', + dbUrl: 'db://assets/windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab', + animationCount: 2, + frameCount: 64, + }, + } +} + +function bridge(overrides: Partial = {}): CocosBridgeApi { + return { + health: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + creatorVersion: '3.8.8', + projectName: 'Game', + projectOpen: true, + paired: true, + }), + submit: async () => ({ jobId: 'job-1' }), + getJob: async () => completedJob(), + ...overrides, + } +} + +const packageResult: AssetExportResult = { + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-Hero.zip', +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('importIntoCocos', () => { + it('checks Creator, exports once, uploads and returns the completed result', async () => { + const phases: CocosOneClickPhase[] = [] + let exportCount = 0 + const result = await importIntoCocos(model(), bridge(), (phase) => phases.push(phase), { + exporter: async (_model, onPhase) => { + exportCount += 1 + onPhase?.('validating') + onPhase?.('rendering') + onPhase?.('packing') + return packageResult + }, + createRequestId: () => '11111111-1111-4111-8111-111111111111', + pollDelay: async () => undefined, + }) + + expect(exportCount).toBe(1) + expect(result.animationCount).toBe(2) + expect(result.frameCount).toBe(64) + expect(phases).toEqual([ + 'detecting', + 'validating', + 'packing', + 'packing', + 'uploading', + 'verifying', + ]) + }) + + it('reuses the prepared ZIP when a network retry uses the same model and cache', async () => { + const currentModel = model() + const cache: CocosImportCache = {} + let exportCount = 0 + let submitCount = 0 + const api = bridge({ + submit: async () => { + submitCount += 1 + if (submitCount === 1) throw new CocosBridgeError('PLUGIN_UNAVAILABLE', '暂时断开') + return { jobId: 'job-1' } + }, + }) + const options = { + cache, + exporter: async () => { + exportCount += 1 + return packageResult + }, + createRequestId: () => '11111111-1111-4111-8111-111111111111', + pollDelay: async () => undefined, + } + + await expect(importIntoCocos(currentModel, api, () => undefined, options)).rejects.toThrow( + '暂时断开', + ) + await importIntoCocos(currentModel, api, () => undefined, options) + + expect(exportCount).toBe(1) + expect(submitCount).toBe(2) + }) + + it('stops before exporting when Creator has no open project', async () => { + let exported = false + const api = bridge({ + health: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + creatorVersion: '3.8.8', + projectName: null, + projectOpen: false, + paired: true, + }), + }) + + await expect( + importIntoCocos(model(), api, () => undefined, { + exporter: async () => { + exported = true + return packageResult + }, + }), + ).rejects.toThrow('请先在 Cocos Creator 中打开目标工程') + expect(exported).toBe(false) + }) + + it('asks for pairing before exporting when the plugin is unpaired', async () => { + const api = bridge({ + health: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + creatorVersion: '3.8.8', + projectName: 'Game', + projectOpen: true, + paired: false, + }), + }) + + await expect(importIntoCocos(model(), api)).rejects.toMatchObject({ code: 'PAIRING_REQUIRED' }) + }) + + it.each(['3.8.7', '3.9.0', 'invalid'])( + 'rejects unsupported Creator version %s before exporting', + async (creatorVersion) => { + let exported = false + await expect( + importIntoCocos( + model(), + bridge({ + health: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + creatorVersion, + projectName: 'Game', + projectOpen: true, + paired: true, + }), + }), + () => undefined, + { + exporter: async () => { + exported = true + return packageResult + }, + }, + ), + ).rejects.toMatchObject({ code: 'VERSION_UNSUPPORTED' }) + expect(exported).toBe(false) + }, + ) + + it('accepts the newest supported Creator 3.8 patch release', async () => { + await expect( + importIntoCocos( + model(), + bridge({ + health: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + creatorVersion: '3.8.99', + projectName: 'Game', + projectOpen: true, + paired: true, + }), + }), + () => undefined, + { + exporter: async () => packageResult, + pollDelay: async () => undefined, + }, + ), + ).resolves.toMatchObject({ projectName: 'Game' }) + }) + + it('accepts a supported Creator prerelease suffix', async () => { + await expect( + importIntoCocos( + model(), + bridge({ + health: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + creatorVersion: '3.8.8-beta.1', + projectName: 'Game', + projectOpen: true, + paired: true, + }), + }), + () => undefined, + { exporter: async () => packageResult, pollDelay: async () => undefined }, + ), + ).resolves.toMatchObject({ projectName: 'Game' }) + }) + + it.each([null, '4.8.8', '3.7.8'])('rejects incompatible Creator version %s', async (version) => { + await expect( + importIntoCocos( + model(), + bridge({ + health: async () => ({ + protocol: 'windup-cocos-bridge/1.0.0', + creatorVersion: version, + projectName: 'Game', + projectOpen: true, + paired: true, + }), + }), + ), + ).rejects.toMatchObject({ code: 'VERSION_UNSUPPORTED' }) + }) + + it('reports every plugin phase while a job advances', async () => { + const phases: CocosOneClickPhase[] = [] + const pendingPhases = ['queued', 'validating', 'converting', 'writing', 'refreshing'] as const + let call = 0 + const api = bridge({ + getJob: async () => { + const phase = pendingPhases[call] + call += 1 + if (phase === undefined) return completedJob() + return { + protocol: 'windup-cocos-bridge/1.0.0', + jobId: 'job-1', + status: phase === 'queued' ? 'queued' : 'running', + phase, + } + }, + }) + + await importIntoCocos(model(), api, (phase) => phases.push(phase), { + exporter: async () => packageResult, + pollDelay: async () => undefined, + }) + + expect(phases).toEqual([ + 'detecting', + 'uploading', + 'queued', + 'validating', + 'converting', + 'writing', + 'refreshing', + 'verifying', + ]) + }) + + it('rejects a completed job that omits its import result', async () => { + const completedWithoutResult = completedJob() + delete completedWithoutResult.result + + await expect( + importIntoCocos( + model(), + bridge({ getJob: async () => completedWithoutResult }), + () => undefined, + { exporter: async () => packageResult, pollDelay: async () => undefined }, + ), + ).rejects.toThrow('Creator 插件未返回导入结果') + }) + + it('uses stable failure details when a failed job omits its error payload', async () => { + const failedWithoutError = completedJob() + failedWithoutError.status = 'failed' + failedWithoutError.phase = 'converting' + delete failedWithoutError.result + + await expect( + importIntoCocos( + model(), + bridge({ getJob: async () => failedWithoutError }), + () => undefined, + { exporter: async () => packageResult, pollDelay: async () => undefined }, + ), + ).rejects.toMatchObject({ + message: 'Cocos 导入失败', + jobCode: 'IMPORT_FAILED', + phase: 'converting', + rolledBack: false, + }) + }) + + it('uses the default polling delay when no custom delay is supplied', async () => { + vi.useFakeTimers() + const running = completedJob() + running.status = 'running' + running.phase = 'queued' + delete running.result + + const promise = importIntoCocos( + model(), + bridge({ getJob: async () => running }), + () => undefined, + { exporter: async () => packageResult, maxPolls: 1 }, + ) + const rejection = expect(promise).rejects.toThrow('等待 Cocos 导入完成超时') + await vi.advanceTimersByTimeAsync(500) + + await rejection + }) + + it('reports the plugin failure and whether the previous asset was restored', async () => { + const failed = completedJob() + failed.status = 'failed' + failed.error = { + code: 'IMPORT_UUID_UNRESOLVED', + message: 'Prefab 引用不存在', + rolledBack: true, + } + delete failed.result + + const promise = importIntoCocos( + model(), + bridge({ getJob: async () => failed }), + () => undefined, + { + exporter: async () => packageResult, + pollDelay: async () => undefined, + }, + ) + await expect(promise).rejects.toThrow('Prefab 引用不存在(已回滚本次写入)') + await expect(promise).rejects.toMatchObject({ + jobCode: 'IMPORT_UUID_UNRESOLVED', + phase: 'verifying', + rolledBack: true, + }) + }) + + it('preserves a rollback failure as an actionable import error', async () => { + const failed = completedJob() + failed.status = 'failed' + failed.phase = 'writing' + failed.error = { + code: 'IMPORT_ROLLBACK_FAILED', + message: '导入失败且无法完整回滚,请检查工程资产', + rolledBack: false, + } + delete failed.result + + await expect( + importIntoCocos(model(), bridge({ getJob: async () => failed }), () => undefined, { + exporter: async () => packageResult, + pollDelay: async () => undefined, + }), + ).rejects.toMatchObject({ + jobCode: 'IMPORT_ROLLBACK_FAILED', + phase: 'writing', + rolledBack: false, + }) + }) + + it('fails instead of polling forever when the job never finishes', async () => { + const running = completedJob() + running.status = 'running' + running.phase = 'writing' + delete running.result + + await expect( + importIntoCocos(model(), bridge({ getJob: async () => running }), () => undefined, { + exporter: async () => packageResult, + pollDelay: async () => undefined, + maxPolls: 2, + }), + ).rejects.toThrow('等待 Cocos 导入完成超时') + }) +}) diff --git a/frontend/src/features/export-package/cocos-one-click.ts b/frontend/src/features/export-package/cocos-one-click.ts new file mode 100644 index 00000000..e3fc1aaa --- /dev/null +++ b/frontend/src/features/export-package/cocos-one-click.ts @@ -0,0 +1,122 @@ +import { exportGameAssets, type AssetExportPhase, type AssetExportResult } from './asset-export' +import { + CocosBridgeError, + type CocosBridgeHealth, + type CocosImportJob, + type CocosImportResult, +} from './cocos-bridge-client' +import { cocosCreatorTarget } from './cocos-target' +import type { ExportPackageModel } from './model' + +export type CocosOneClickPhase = + | 'detecting' + | 'validating' + | 'packing' + | 'uploading' + | 'queued' + | 'converting' + | 'writing' + | 'refreshing' + | 'verifying' + +export interface CocosBridgeApi { + health(): Promise + submit(blob: Blob, requestId: string): Promise<{ jobId: string }> + getJob(jobId: string): Promise +} + +export interface CocosImportCache { + model?: ExportPackageModel + package?: AssetExportResult +} + +export type CocosPackageExporter = ( + model: ExportPackageModel, + onPhase?: (phase: AssetExportPhase) => void, +) => Promise + +export interface ImportIntoCocosOptions { + exporter?: CocosPackageExporter + cache?: CocosImportCache + createRequestId?: () => string + pollDelay?: () => Promise + maxPolls?: number +} + +const defaultExporter: CocosPackageExporter = (model, onPhase) => + exportGameAssets(model, { targets: [cocosCreatorTarget], onPhase }) + +export async function importIntoCocos( + model: ExportPackageModel, + client: CocosBridgeApi, + onPhase: (phase: CocosOneClickPhase) => void = () => undefined, + options: ImportIntoCocosOptions = {}, +): Promise { + onPhase('detecting') + const health = await client.health() + if (!health.paired) { + throw new CocosBridgeError('PAIRING_REQUIRED', '请先输入 Creator 插件显示的连接码') + } + if (!supportsCreatorVersion(health.creatorVersion)) { + throw new CocosBridgeError('VERSION_UNSUPPORTED', '一键导入仅支持 Cocos Creator 3.8.8 至 3.8.x') + } + if (!health.projectOpen) { + throw new CocosBridgeError('IMPORT_FAILED', '请先在 Cocos Creator 中打开目标工程') + } + + const cache = options.cache ?? {} + let packageResult = cache.model === model ? cache.package : undefined + if (!packageResult) { + const exporter = options.exporter ?? defaultExporter + packageResult = await exporter(model, (phase) => onPhase(mapExportPhase(phase))) + cache.model = model + cache.package = packageResult + } + + onPhase('uploading') + const requestId = (options.createRequestId ?? (() => crypto.randomUUID()))() + const submitted = await client.submit(packageResult.blob, requestId) + const delay = + options.pollDelay ?? (() => new Promise((resolve) => globalThis.setTimeout(resolve, 500))) + const maxPolls = options.maxPolls ?? 240 + + for (let attempt = 0; attempt < maxPolls; attempt += 1) { + const job = await client.getJob(submitted.jobId) + onPhase(mapJobPhase(job)) + if (job.status === 'completed') { + if (!job.result) throw new CocosBridgeError('IMPORT_FAILED', 'Creator 插件未返回导入结果') + return job.result + } + if (job.status === 'failed') { + const detail = job.error?.message ?? 'Cocos 导入失败' + const rollback = job.error?.rolledBack ? '(已回滚本次写入)' : '' + throw new CocosBridgeError('IMPORT_FAILED', `${detail}${rollback}`, undefined, { + jobCode: job.error?.code ?? 'IMPORT_FAILED', + phase: job.phase, + rolledBack: job.error?.rolledBack ?? false, + }) + } + await delay() + } + throw new CocosBridgeError('IMPORT_FAILED', '等待 Cocos 导入完成超时') +} + +function supportsCreatorVersion(version: string | null): boolean { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version ?? '') + if (!match) return false + const [, major, minor, patch] = match.map(Number) + return major === 3 && minor === 8 && patch >= 8 +} + +function mapExportPhase(phase: AssetExportPhase): CocosOneClickPhase { + return phase === 'validating' ? 'validating' : 'packing' +} + +function mapJobPhase(job: CocosImportJob): CocosOneClickPhase { + if (job.phase === 'validating') return 'validating' + if (job.phase === 'converting') return 'converting' + if (job.phase === 'writing') return 'writing' + if (job.phase === 'refreshing') return 'refreshing' + if (job.phase === 'verifying') return 'verifying' + return 'queued' +} diff --git a/frontend/src/features/export-package/cocos-target.e2e.extract.test.ts b/frontend/src/features/export-package/cocos-target.e2e.extract.test.ts new file mode 100644 index 00000000..46d8e63a --- /dev/null +++ b/frontend/src/features/export-package/cocos-target.e2e.extract.test.ts @@ -0,0 +1,152 @@ +/** @vitest-environment jsdom */ +// Standalone e2e dump harness — writes the real generated ZIP to +// `frontend/dist/cocos-e2e/windup-Hero-char-42-Ranger-outfit-7.zip` so a human +// can unzip it and inspect the manifest / README / frames / atlas. +// Run only via `vitest run src/features/export-package/cocos-target.e2e.extract.test.ts`. + +import { describe, expect, it } from 'vitest' +import { Buffer } from 'node:buffer' +import { deflateSync } from 'node:zlib' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { cocosCreatorTarget } from './cocos-target' +import { exportGameAssets } from './asset-export' +import type { AssetExportRuntime, DecodedFrame } from './asset-export' +import type { ExportPackageModel } from './model' + +const CANVAS_W = 64 +const CANVAS_H = 64 + +function crc32(data: Buffer): number { + let c = 0xffffffff + for (let i = 0; i < data.length; i += 1) { + c = c ^ data[i]! + for (let k = 0; k < 8; k += 1) c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + return (c ^ 0xffffffff) >>> 0 +} + +function chunk(type: string, payload: Buffer): Buffer { + const len = Buffer.alloc(4) + len.writeUInt32BE(payload.length, 0) + const t = Buffer.from(type, 'ascii') + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(Buffer.concat([t, payload])), 0) + return Buffer.concat([len, t, payload, crc]) +} + +function makePng(width: number, height: number): Buffer { + const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr.writeUInt8(8, 8) + ihdr.writeUInt8(6, 9) + ihdr.writeUInt8(0, 10) + ihdr.writeUInt8(0, 11) + ihdr.writeUInt8(0, 12) + const row = width * 4 + 1 + const raw = Buffer.alloc(row * height) + for (let y = 0; y < height; y += 1) { + raw[y * row] = 0 + for (let x = 0; x < width; x += 1) { + const off = y * row + 1 + x * 4 + raw[off] = 0 + raw[off + 1] = 0 + raw[off + 2] = 0 + raw[off + 3] = 0 + } + } + return Buffer.concat([ + sig, + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]) +} + +describe('Cocos Creator 一键导出 e2e — 落盘', () => { + it('把真实 ZIP 写到 dist/cocos-e2e/ 给人工 unzip 看', async () => { + const pngBuffer = makePng(CANVAS_W, CANVAS_H) + const model: ExportPackageModel = { + stage: 'action-assets', + characterId: 'char-42', + characterName: 'Hero', + characterImageUrl: 'memory://master.png', + outfitId: 'outfit-7', + outfitName: 'Ranger', + canvas: { width: CANVAS_W, height: CANVAS_H }, + source: { workflowRunId: 'run-99', generationIds: ['gen-1', 'gen-2'] }, + firstFrames: [ + { + actionId: 'walk-abcdef1234', + name: 'Walk', + type: 'walk', + fps: 8, + imageUrl: 'memory://first-walk.png', + }, + ], + playtest: null, + actions: [ + { + id: 'walk-abcdef1234', + name: 'Walk', + type: 'walk', + fps: 8, + sequences: [ + { + direction: 'default', + expectedFrameCount: 3, + loop: true, + anchor: { x: 0.5, y: 0.92 }, + footY: 58, + qualityStatus: 'passed', + frames: [ + { index: 0, imageUrl: 'memory://walk-0.png', durationMs: 125 }, + { index: 1, imageUrl: 'memory://walk-1.png', durationMs: 125 }, + { index: 2, imageUrl: 'memory://walk-2.png', durationMs: 125 }, + ], + }, + ], + }, + ], + } + const runtime: AssetExportRuntime = { + fetchFrame: async () => new Blob([new Uint8Array(pngBuffer)], { type: 'image/png' }), + decodeFrame: async (): Promise => ({ + source: new Blob([new Uint8Array(pngBuffer)], { + type: 'image/png', + }) as unknown as CanvasImageSource, + width: CANVAS_W, + height: CANVAS_H, + close: () => undefined, + }), + createCanvas: (w, h) => { + const ctx = { + clearRect: () => undefined, + drawImage: () => undefined, + } as unknown as CanvasRenderingContext2D + return { + width: w, + height: h, + getContext: () => ctx, + toBlob: (cb: (b: Blob | null) => void) => + cb(new Blob([new Uint8Array(makePng(w, h))], { type: 'image/png' })), + } as unknown as HTMLCanvasElement + }, + } + + const result = await exportGameAssets(model, { runtime, targets: [cocosCreatorTarget] }) + const ab = await result.blob.arrayBuffer() + const buf = Buffer.from(ab) + + const outDir = resolve(process.cwd(), 'dist/cocos-e2e') + if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }) + const zipPath = resolve(outDir, 'windup-Hero-char-42-Ranger-outfit-7.zip') + writeFileSync(zipPath, buf) + expect(buf.length).toBeGreaterThan(0) + // eslint-disable-next-line no-console + console.log(`\n真实 ZIP 已落盘: ${zipPath} (${buf.length} bytes)`) + }, 30_000) +}) diff --git a/frontend/src/features/export-package/cocos-target.e2e.test.ts b/frontend/src/features/export-package/cocos-target.e2e.test.ts new file mode 100644 index 00000000..2884e2df --- /dev/null +++ b/frontend/src/features/export-package/cocos-target.e2e.test.ts @@ -0,0 +1,405 @@ +/** @vitest-environment jsdom */ +// Vitest needs the @vitest-environment jsdom header so createElement('canvas') exists. +// This is an end-to-end probe: actually runs `exportGameAssets` with the cocos target, +// decompresses the resulting ZIP in plain Node, and asserts every expected file is there +// with valid contents (manifest json, README, frames PNG, atlas PNG, schema, meta). + +import { afterAll, describe, expect, it } from 'vitest' +import { Buffer } from 'node:buffer' +import { deflateSync } from 'node:zlib' + +import { cocosCreatorTarget } from './cocos-target' +import { + createAssetExportPlan, + exportGameAssets, + type AssetExportRuntime, + type DecodedFrame, + type PlannedSequence, +} from './asset-export' +import type { ExportPackageModel } from './model' + +// ── Tiny RGBA PNG encoder ──────────────────────────────────────────────── +function crc32(data: Buffer): number { + let c = 0xffffffff + for (let i = 0; i < data.length; i += 1) { + c = c ^ data[i]! + for (let k = 0; k < 8; k += 1) c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + return (c ^ 0xffffffff) >>> 0 +} + +function chunk(type: string, payload: Buffer): Buffer { + const len = Buffer.alloc(4) + len.writeUInt32BE(payload.length, 0) + const typeBytes = Buffer.from(type, 'ascii') + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, payload])), 0) + return Buffer.concat([len, typeBytes, payload, crc]) +} + +function makePng(width: number, height: number): Buffer { + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr.writeUInt8(8, 8) // bit depth + ihdr.writeUInt8(6, 9) // RGBA + ihdr.writeUInt8(0, 10) + ihdr.writeUInt8(0, 11) + ihdr.writeUInt8(0, 12) + // 4 bytes per pixel + 1 filter byte per scanline + const rowBytes = width * 4 + 1 + const raw = Buffer.alloc(rowBytes * height) + for (let y = 0; y < height; y += 1) { + raw[y * rowBytes] = 0 // filter: None + for (let x = 0; x < width; x += 1) { + const off = y * rowBytes + 1 + x * 4 + raw[off] = 0 // R + raw[off + 1] = 0 // G + raw[off + 2] = 0 // B + raw[off + 3] = 0 // A (transparent) + } + } + const idat = deflateSync(raw) + return Buffer.concat([ + signature, + chunk('IHDR', ihdr), + chunk('IDAT', idat), + chunk('IEND', Buffer.alloc(0)), + ]) +} + +// ── Tiny ZIP reader ─────────────────────────────────────────────────────── +interface ZipEntry { + name: string + data: Buffer +} + +function readZip(buffer: Buffer): ZipEntry[] { + // End of Central Directory record: signature 0x06054b50 + let eocdOffset = -1 + for (let i = buffer.length - 22; i >= 0; i -= 1) { + if (buffer.readUInt32LE(i) === 0x06054b50) { + eocdOffset = i + break + } + } + if (eocdOffset < 0) throw new Error('ZIP: 找不到 EOCD') + + const totalEntries = buffer.readUInt16LE(eocdOffset + 10) + const cdOffset = buffer.readUInt32LE(eocdOffset + 16) + const entries: ZipEntry[] = [] + let p = cdOffset + for (let i = 0; i < totalEntries; i += 1) { + if (buffer.readUInt32LE(p) !== 0x02014b50) throw new Error(`ZIP: CDFH 签名错 @${p}`) + const compressedSize = buffer.readUInt32LE(p + 20) + const uncompressedSize = buffer.readUInt32LE(p + 24) + const filenameLength = buffer.readUInt16LE(p + 28) + const extraLength = buffer.readUInt16LE(p + 30) + const commentLength = buffer.readUInt16LE(p + 32) + const localHeaderOffset = buffer.readUInt32LE(p + 42) + const name = buffer.subarray(p + 46, p + 46 + filenameLength).toString('utf8') + p += 46 + filenameLength + extraLength + commentLength + + // Local file header + if (buffer.readUInt32LE(localHeaderOffset) !== 0x04034b50) { + throw new Error(`ZIP: 本地头签名错 @${localHeaderOffset}`) + } + const lhFilenameLen = buffer.readUInt16LE(localHeaderOffset + 26) + const lhExtraLen = buffer.readUInt16LE(localHeaderOffset + 28) + const dataStart = localHeaderOffset + 30 + lhFilenameLen + lhExtraLen + const compressionMethod = buffer.readUInt16LE(localHeaderOffset + 8) + if (compressionMethod !== 0) { + // 全部用 stored,简化路径;若以后需要 deflate 再加 + throw new Error(`ZIP: 不支持压缩方法 ${compressionMethod} for ${name}`) + } + const data = buffer.subarray(dataStart, dataStart + compressedSize) + void uncompressedSize + entries.push({ name, data }) + } + return entries +} + +// ── Build a realistic model ────────────────────────────────────────────── +const CANVAS_W = 64 +const CANVAS_H = 64 + +function buildModel(): ExportPackageModel { + return { + stage: 'action-assets', + characterId: 'char-42', + characterName: 'Hero', + characterImageUrl: 'memory://master.png', + outfitId: 'outfit-7', + outfitName: 'Ranger', + canvas: { width: CANVAS_W, height: CANVAS_H }, + source: { workflowRunId: 'run-99', generationIds: ['gen-1', 'gen-2'] }, + firstFrames: [ + { + actionId: 'walk-abcdef1234', + name: 'Walk', + type: 'walk', + fps: 8, + imageUrl: 'memory://first-walk.png', + }, + ], + playtest: null, + actions: [ + { + id: 'walk-abcdef1234', + name: 'Walk', + type: 'walk', + fps: 8, + sequences: [ + { + direction: 'default', + expectedFrameCount: 3, + loop: true, + anchor: { x: 0.5, y: 0.92 }, + footY: 58, + qualityStatus: 'passed', + frames: [ + { index: 0, imageUrl: 'memory://walk-0.png', durationMs: 125 }, + { index: 1, imageUrl: 'memory://walk-1.png', durationMs: 125 }, + { index: 2, imageUrl: 'memory://walk-2.png', durationMs: 125 }, + ], + }, + ], + }, + ], + } +} + +// ── Provide a runtime that decodes our in-memory PNGs ─────────────────── +const pngBuffer = makePng(CANVAS_W, CANVAS_H) +const pngBlob = new Blob([new Uint8Array(pngBuffer)], { type: 'image/png' }) + +const runtime: AssetExportRuntime = { + fetchFrame: async () => new Blob([new Uint8Array(pngBuffer)], { type: 'image/png' }), + decodeFrame: async (): Promise => { + return { + source: pngBlob as unknown as CanvasImageSource, + width: CANVAS_W, + height: CANVAS_H, + close: () => undefined, + } + }, + // jsdom 没有真实 canvas 实现,返回一个能骗过 context2d + canvasPng 的假 canvas。 + createCanvas: (w, h) => { + const stubContext = { + clearRect: () => undefined, + drawImage: () => undefined, + } as unknown as CanvasRenderingContext2D + const fakeCanvas = { + width: w, + height: h, + getContext: () => stubContext, + toBlob: (cb: (blob: Blob | null) => void) => { + // 输出的图集是空 transparent PNG,只要签名 + 头 + 一个 IDAT + IEND 即合法。 + // 这不是真实合成结果(因为我们跳过了 drawImage),但足以验证打包链。 + const atlasPng = makePng(w, h) + cb(new Blob([new Uint8Array(atlasPng)], { type: 'image/png' })) + }, + } as unknown as HTMLCanvasElement + return fakeCanvas + }, +} + +// ── The e2e test ───────────────────────────────────────────────────────── +describe('Cocos Creator 一键导出 e2e', () => { + const result: { name: string; size: number; entries?: ZipEntry[]; error?: unknown } = { + name: '', + size: 0, + } + const originalCreateObjectURL = URL.createObjectURL + const originalRevokeObjectURL = URL.revokeObjectURL + const originalCreateImageBitmap = (globalThis as { createImageBitmap?: unknown }) + .createImageBitmap + + afterAll(() => { + URL.createObjectURL = originalCreateObjectURL + URL.revokeObjectURL = originalRevokeObjectURL + if (originalCreateImageBitmap === undefined) { + delete (globalThis as { createImageBitmap?: unknown }).createImageBitmap + } else { + ;(globalThis as { createImageBitmap?: unknown }).createImageBitmap = originalCreateImageBitmap + } + }) + + it('生成可解压的 ZIP,且 manifest + README + 通用层完整', async () => { + URL.createObjectURL = () => 'blob:cocos-e2e' as unknown as string + URL.revokeObjectURL = () => undefined + // polyfill createImageBitmap for jsdom + if (typeof (globalThis as { createImageBitmap?: unknown }).createImageBitmap !== 'function') { + ;(globalThis as { createImageBitmap?: unknown }).createImageBitmap = async ( + _blob: Blob, + ): Promise => { + // jsdom 没有真实解码,造一个 stub + return { + width: CANVAS_W, + height: CANVAS_H, + close: () => undefined, + } as unknown as ImageBitmap + } + } + + const model = buildModel() + const plan: readonly PlannedSequence[] = createAssetExportPlan(model) + expect(plan.length).toBe(1) + expect(plan[0]?.frames.length).toBe(3) + + const exportResult = await exportGameAssets(model, { + runtime, + targets: [cocosCreatorTarget], + }) + result.name = exportResult.filename + result.size = exportResult.blob.size + + expect(exportResult.filename).toMatch(/^windup-.*\.zip$/) + expect(exportResult.blob.size).toBeGreaterThan(0) + + const ab = await exportResult.blob.arrayBuffer() + const buffer = Buffer.from(ab) + const entries = readZip(buffer) + result.entries = entries + const paths = entries.map((e) => e.name) + + // 包根 = Hero-char-42-Ranger-outfit-7 + const rootPrefix = 'Hero-char-42-Ranger-outfit-7/' + for (const p of paths) { + expect(p.startsWith(rootPrefix)).toBe(true) + } + + // 通用层必备 + expect(paths).toContain(`${rootPrefix}character/master.png`) + expect(paths.some((p) => p.startsWith(`${rootPrefix}first-frames/Walk-`))).toBe(true) + expect(paths).toContain(`${rootPrefix}meta.json`) + expect(paths).toContain(`${rootPrefix}schema.json`) + expect(paths).toContain(`${rootPrefix}README.md`) + expect(paths).toContain(`${rootPrefix}frames/Walk/Walk_000.png`) + expect(paths).toContain(`${rootPrefix}frames/Walk/Walk_001.png`) + expect(paths).toContain(`${rootPrefix}frames/Walk/Walk_002.png`) + expect(paths).toContain(`${rootPrefix}atlas/Walk.png`) + + // 打印真实产物清单,方便人工目视 + // eslint-disable-next-line no-console + console.log( + '\n=== Cocos e2e 真实产物 ===\n' + + `ZIP 文件名: ${exportResult.filename}\n` + + `ZIP 大小: ${exportResult.blob.size} bytes\n` + + `共 ${entries.length} 个条目:\n` + + paths.map((p) => ` ${p}`).join('\n'), + ) + + // Cocos 适配层 + const cocosManifestEntry = entries.find( + (e) => e.name === `${rootPrefix}targets/cocos-creator/cocos-import.json`, + ) + const cocosReadmeEntry = entries.find( + (e) => e.name === `${rootPrefix}targets/cocos-creator/README.md`, + ) + expect(cocosManifestEntry).toBeDefined() + expect(cocosReadmeEntry).toBeDefined() + + // Cocos 包内没有 .anim / .meta + const cocosPaths = entries + .filter((e) => e.name.includes('targets/cocos-creator/')) + .map((e) => e.name) + expect(cocosPaths.some((p) => p.endsWith('.anim'))).toBe(false) + expect(cocosPaths.some((p) => p.endsWith('.meta'))).toBe(false) + + // 解析 manifest,核对字段 + const manifest = JSON.parse(cocosManifestEntry!.data.toString('utf8')) + expect(manifest.experimental).toBe(true) + expect(manifest.engine).toBe('cocos-creator') + expect(manifest.upstream_issue).toBe(94) + expect(manifest.schema_version).toBe('windup-cocos-import-1.1.0') + expect(manifest.package.character_id).toBe('char-42') + expect(manifest.package.character_name).toBe('Hero') + expect(manifest.package.outfit_id).toBe('outfit-7') + expect(manifest.package.outfit_name).toBe('Ranger') + expect(manifest.package.canvas).toEqual({ w: CANVAS_W, h: CANVAS_H }) + expect(manifest.master.anchor).toEqual({ x: 0.5, y: 0.92 }) + expect(manifest.master.anchor_cocos.x).toBe(0.5) + expect(manifest.master.anchor_cocos.y).toBeCloseTo(0.08, 10) + expect(manifest.master.file).toBe('character/master.png') + + expect(manifest.actions.length).toBe(1) + const action = manifest.actions[0] + expect(action.id).toBe('walk-abcdef1234') + expect(action.export_name).toBe('Walk') + expect(action.direction).toBe('default') + expect(action.fps).toBe(8) + expect(action.timing_mode).toBe('per-frame') + expect(action.loop).toBe(true) + expect(action.quality_status).toBe('passed') + expect(action.anchor).toEqual({ x: 0.5, y: 0.92 }) + expect(action.anchor_cocos.x).toBe(0.5) + expect(action.anchor_cocos.y).toBeCloseTo(0.08, 10) + expect(action.foot_y).toBe(58) + expect(action.frames.map((f: { file: string }) => f.file)).toEqual([ + 'Walk_000.png', + 'Walk_001.png', + 'Walk_002.png', + ]) + expect(action.frames.map((f: { duration_ms: number }) => f.duration_ms)).toEqual([ + 125, 125, 125, + ]) + expect(action.atlas).toEqual({ + file: 'atlas/Walk.png', + cols: 3, + rows: 1, + cell: { w: CANVAS_W, h: CANVAS_H }, + }) + + // README 含诚实声明 + const readme = cocosReadmeEntry!.data.toString('utf8') + expect(readme).toMatch(/实验性|experimental/i) + expect(readme).toMatch(/Issue #94|issues\/94/) + expect(readme).toMatch(/不生成.*\.anim/) + expect(readme).toMatch(/不生成.*meta/) + expect(readme).toMatch(/UUID/) + expect(readme).toMatch(/Hero/) + expect(readme).toMatch(/Ranger/) + + // 打印 manifest 和 README 的实际内容,人眼复核 + // eslint-disable-next-line no-console + console.log('\n=== cocos-import.json ===\n' + JSON.stringify(manifest, null, 2)) + // eslint-disable-next-line no-console + console.log('\n=== targets/cocos-creator/README.md ===\n' + readme) + + // 通用层 PNG 真的是合法 PNG(头 8 字节 0x89 PNG ...) + const masterPng = entries.find((e) => e.name === `${rootPrefix}character/master.png`)! + expect(masterPng.data[0]).toBe(0x89) + expect(masterPng.data[1]).toBe(0x50) + expect(masterPng.data[2]).toBe(0x4e) + expect(masterPng.data[3]).toBe(0x47) + expect(masterPng.data[4]).toBe(0x0d) + expect(masterPng.data[5]).toBe(0x0a) + expect(masterPng.data[6]).toBe(0x1a) + expect(masterPng.data[7]).toBe(0x0a) + + const framePng = entries.find((e) => e.name === `${rootPrefix}frames/Walk/Walk_000.png`)! + expect(framePng.data[0]).toBe(0x89) + + const atlasPng = entries.find((e) => e.name === `${rootPrefix}atlas/Walk.png`)! + expect(atlasPng.data[0]).toBe(0x89) + // 图集是 3 帧横排,宽 3*64=192,高 64 + expect(atlasPng.data.length).toBeGreaterThan(0) + + // meta.json 是合法 JSON 且符合 schema_version 1.1.0 + const metaEntry = entries.find((e) => e.name === `${rootPrefix}meta.json`)! + const meta = JSON.parse(metaEntry.data.toString('utf8')) + expect(meta.schema_version).toBe('1.1.0') + expect(meta.character.id).toBe('char-42') + expect(meta.outfit.id).toBe('outfit-7') + expect(meta.canvas).toEqual({ w: CANVAS_W, h: CANVAS_H }) + expect(meta.actions.length).toBe(1) + expect(meta.actions[0].name).toBe('Walk') + expect(meta.actions[0].fps).toBe(8) + expect(meta.actions[0].atlas.cols).toBe(3) + expect(meta.actions[0].frames.length).toBe(3) + expect(meta.source.workflow_run_id).toBe('run-99') + expect(meta.source.generation_ids).toEqual(['gen-1', 'gen-2']) + }, 30_000) +}) diff --git a/frontend/src/features/export-package/cocos-target.test.ts b/frontend/src/features/export-package/cocos-target.test.ts new file mode 100644 index 00000000..1967246f --- /dev/null +++ b/frontend/src/features/export-package/cocos-target.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from 'vitest' + +import type { AssetExportTargetContext, PlannedFrame, PlannedSequence } from './asset-export' +import { COCOS_IMPORT_SCHEMA_VERSION, cocosCreatorTarget, toCocosAnchor } from './cocos-target' +import type { GenericExportMetadata } from './contract' +import type { ExportAction, ExportPackageModel, ExportSequence } from './model' + +function buildModel(): ExportPackageModel { + return { + stage: 'action-assets', + characterId: 'char-1', + characterName: 'Aster', + characterImageUrl: 'https://example.com/aster.png', + outfitId: 'outfit-1', + outfitName: 'Explorer', + canvas: { width: 256, height: 256 }, + source: null, + firstFrames: [], + playtest: null, + actions: [ + { + id: 'action-1', + name: 'Walk', + type: 'walk', + fps: 12, + sequences: [ + { + direction: 'default', + expectedFrameCount: 4, + loop: true, + anchor: { x: 0.5, y: 0.92 }, + footY: 235, + qualityStatus: 'passed', + frames: [ + { index: 0, imageUrl: 'https://example.com/walk-0.png', durationMs: 80 }, + { index: 1, imageUrl: 'https://example.com/walk-1.png', durationMs: 0 }, + { index: 2, imageUrl: 'https://example.com/walk-2.png', durationMs: 90 }, + { index: 3, imageUrl: 'https://example.com/walk-3.png', durationMs: 0 }, + ], + }, + ], + }, + ], + } +} + +function buildMetadata(plan: readonly PlannedSequence[]): GenericExportMetadata { + return { + schema_version: '1.1.0', + stage: 'action-assets', + character: { id: 'char-1', name: 'Aster', image: 'character/master.png' }, + outfit: { id: 'outfit-1', name: 'Explorer' }, + canvas: { w: 256, h: 256 }, + first_frames: [], + playtest: null, + source: null, + actions: plan.map((item) => ({ + id: item.action.id, + name: item.action.name, + fps: item.action.fps, + loop: item.sequence.loop, + quality_status: item.sequence.qualityStatus, + frames: item.frames.map((frame: PlannedFrame) => ({ + index: frame.index, + file: frame.filename, + })), + anchor: { ...item.sequence.anchor }, + foot_y: item.sequence.footY, + atlas: { + file: item.atlasFile, + cols: item.columns, + rows: item.rows, + cell: { w: 256, h: 256 }, + }, + })), + } +} + +function buildPlan(model: ExportPackageModel): PlannedSequence[] { + const action: ExportAction = model.actions[0] + const sequence: ExportSequence = action.sequences[0] + return [ + { + action, + sequence, + exportName: 'Walk-default', + framesFolder: 'frames/Walk-default', + atlasFile: 'atlas/Walk-default.png', + columns: 4, + rows: 1, + frames: sequence.frames.map((frame, index) => ({ + frame, + index, + filename: `Walk-default_${String(frame.index).padStart(3, '0')}.png`, + relativeFile: `frames/Walk-default/Walk-default_${String(frame.index).padStart(3, '0')}.png`, + })), + }, + ] +} + +function buildContext(): AssetExportTargetContext { + const model = buildModel() + const plan = buildPlan(model) + return { model, metadata: buildMetadata(plan), plan } +} + +describe('cocos-target', () => { + it('toCocosAnchor flips the y axis of normalized anchors', () => { + const flipped = toCocosAnchor({ x: 0.5, y: 0.92 }) + expect(flipped.x).toBe(0.5) + expect(flipped.y).toBeCloseTo(0.08, 10) + const corner0 = toCocosAnchor({ x: 0, y: 0 }) + expect(corner0).toEqual({ x: 0, y: 1 }) + const corner1 = toCocosAnchor({ x: 1, y: 1 }) + expect(corner1).toEqual({ x: 1, y: 0 }) + }) + + it('uses the cocos-creator target id', () => { + expect(cocosCreatorTarget.id).toBe('cocos-creator') + }) + + it('produces exactly two files: a manifest json and a readme', async () => { + const files = await cocosCreatorTarget.createFiles(buildContext()) + const paths = files.map((file) => file.path) + expect(paths).toEqual(['cocos-import.json', 'README.md']) + }) + + it('manifest marks itself experimental and points at issue 94', async () => { + const files = await cocosCreatorTarget.createFiles(buildContext()) + const manifestEntry = files.find((file) => file.path === 'cocos-import.json') + expect(manifestEntry).toBeDefined() + const manifest = JSON.parse(String(manifestEntry?.data)) + expect(manifest.experimental).toBe(true) + expect(manifest.engine).toBe('cocos-creator') + expect(manifest.upstream_issue).toBe(94) + expect(manifest.schema_version).toBe(COCOS_IMPORT_SCHEMA_VERSION) + }) + + it('flips anchor y for both the master image and every action', async () => { + const files = await cocosCreatorTarget.createFiles(buildContext()) + const manifest = JSON.parse( + String(files.find((file) => file.path === 'cocos-import.json')?.data), + ) + + expect(manifest.master.anchor).toEqual({ x: 0.5, y: 0.92 }) + expect(manifest.master.anchor_cocos.x).toBe(0.5) + expect(manifest.master.anchor_cocos.y).toBeCloseTo(0.08, 10) + + const action = manifest.actions[0] + expect(action.anchor).toEqual({ x: 0.5, y: 0.92 }) + expect(action.anchor_cocos.x).toBe(0.5) + expect(action.anchor_cocos.y).toBeCloseTo(0.08, 10) + }) + + it('emits 1.1 per-frame timing without rounding missing frame durations', async () => { + const files = await cocosCreatorTarget.createFiles(buildContext()) + const manifest = JSON.parse( + String(files.find((file) => file.path === 'cocos-import.json')?.data), + ) + + const action = manifest.actions[0] + expect(action.timing_mode).toBe('per-frame') + const frames = action.frames + expect(frames[0].duration_ms).toBe(80) + expect(frames[1].duration_ms).toBeNull() + expect(frames[2].duration_ms).toBe(90) + expect(frames[3].duration_ms).toBeNull() + }) + + it('uses constant-fps timing when no frame has an explicit duration', async () => { + const context = buildContext() + const sequence = context.plan[0]!.sequence + const constantPlan = [ + { + ...context.plan[0]!, + sequence: { + ...sequence, + frames: sequence.frames.map((frame) => ({ ...frame, durationMs: 0 })), + }, + frames: context.plan[0]!.frames.map((planned) => ({ + ...planned, + frame: { ...planned.frame, durationMs: 0 }, + })), + }, + ] + const files = await cocosCreatorTarget.createFiles({ ...context, plan: constantPlan }) + const manifest = JSON.parse(String(files[0]?.data)) + + expect(manifest.schema_version).toBe('windup-cocos-import-1.1.0') + expect(manifest.actions[0].timing_mode).toBe('constant-fps') + expect( + manifest.actions[0].frames.map((frame: { duration_ms: number | null }) => frame.duration_ms), + ).toEqual([null, null, null, null]) + }) + + it('keeps atlas file path and frame count from the generic plan', async () => { + const files = await cocosCreatorTarget.createFiles(buildContext()) + const manifest = JSON.parse( + String(files.find((file) => file.path === 'cocos-import.json')?.data), + ) + + expect(manifest.actions[0].atlas).toEqual({ + file: 'atlas/Walk-default.png', + cols: 4, + rows: 1, + cell: { w: 256, h: 256 }, + }) + expect(manifest.actions[0].frames.map((f: { file: string }) => f.file)).toEqual([ + 'Walk-default_000.png', + 'Walk-default_001.png', + 'Walk-default_002.png', + 'Walk-default_003.png', + ]) + }) + + it('does not fabricate cocos native files (no .anim / .meta paths)', async () => { + const files = await cocosCreatorTarget.createFiles(buildContext()) + const paths = files.map((file) => file.path) + expect(paths).not.toContain('cocos-import.json'.replace('cocos-import', 'cocos.anim')) + expect(paths.some((p) => p.endsWith('.anim'))).toBe(false) + expect(paths.some((p) => p.endsWith('.meta'))).toBe(false) + + const manifestEntry = files.find((file) => file.path === 'cocos-import.json') + const manifest = JSON.parse(String(manifestEntry?.data)) + const allFileFields: string[] = [] + allFileFields.push(manifest.master.file) + for (const action of manifest.actions) { + allFileFields.push(action.atlas.file) + for (const frame of action.frames) allFileFields.push(frame.file) + } + expect(allFileFields.some((p) => p.endsWith('.anim'))).toBe(false) + expect(allFileFields.some((p) => p.endsWith('.meta'))).toBe(false) + }) + + it('readme is honest about experimental status and points at issue 94', async () => { + const files = await cocosCreatorTarget.createFiles(buildContext()) + const readme = String(files.find((file) => file.path === 'README.md')?.data) + expect(readme).toMatch(/实验性|experimental/i) + expect(readme).toMatch(/Issue #94|issues\/94/) + expect(readme).toMatch(/aster|Aster/) + }) + + it('keeps repeated action ids aligned to their plan sequence directions', async () => { + const base = buildContext() + const firstPlan = base.plan[0]! + const northPlan: PlannedSequence = { + ...firstPlan, + sequence: { ...firstPlan.sequence, direction: 'north' }, + exportName: 'Walk-north', + framesFolder: 'frames/Walk-north', + atlasFile: 'atlas/Walk-north.png', + frames: firstPlan.frames.map((frame) => ({ + ...frame, + filename: frame.filename.replace('Walk-default', 'Walk-north'), + relativeFile: frame.relativeFile.replace('Walk-default', 'Walk-north'), + })), + } + const secondMetadata = { + ...base.metadata.actions[0]!, + atlas: { ...base.metadata.actions[0]!.atlas, file: 'atlas/Walk-north.png' }, + frames: northPlan.frames.map((frame) => ({ index: frame.index, file: frame.filename })), + } + const files = await cocosCreatorTarget.createFiles({ + model: buildModel(), + metadata: { ...base.metadata, actions: [base.metadata.actions[0]!, secondMetadata] }, + plan: [firstPlan, northPlan], + }) + const manifest = JSON.parse(String(files[0]?.data)) + expect(manifest.actions.map((action: { direction: string }) => action.direction)).toEqual([ + 'default', + 'north', + ]) + expect(manifest.actions.map((action: { export_name: string }) => action.export_name)).toEqual([ + 'Walk-default', + 'Walk-north', + ]) + }) + + it('rejects metadata and plan action-count drift before creating a manifest', async () => { + const context = buildContext() + + await expect(cocosCreatorTarget.createFiles({ ...context, plan: [] })).rejects.toThrow( + 'meta.json 动作数量 1 与 plan 数量 0 不一致', + ) + }) + + it('rejects a sparse plan entry instead of producing a misaligned action', async () => { + const context = buildContext() + const sparsePlan = new Array(1) + + await expect(cocosCreatorTarget.createFiles({ ...context, plan: sparsePlan })).rejects.toThrow( + 'meta.json 缺少第 0 个 plan 动作', + ) + }) + + it('uses a null duration when metadata contains a frame absent from the plan', async () => { + const context = buildContext() + const metadata = { + ...context.metadata, + actions: context.metadata.actions.map((action) => ({ + ...action, + frames: [...action.frames, { index: 4, file: 'Walk-default_004.png' }], + })), + } + const files = await cocosCreatorTarget.createFiles({ ...context, metadata }) + const manifest = JSON.parse(String(files[0]?.data)) + + expect(manifest.actions[0].frames[4]).toEqual({ + index: 4, + file: 'Walk-default_004.png', + duration_ms: null, + }) + }) +}) diff --git a/frontend/src/features/export-package/cocos-target.ts b/frontend/src/features/export-package/cocos-target.ts index 5085f2a1..9c49cb5c 100644 --- a/frontend/src/features/export-package/cocos-target.ts +++ b/frontend/src/features/export-package/cocos-target.ts @@ -1,15 +1,208 @@ -import type { ExportAnchor } from './model' +import type { + AssetExportTarget, + AssetExportTargetContext, + AssetExportTargetFile, + PlannedSequence, +} from './asset-export' +import type { GenericExportMetadata } from './contract' +import type { ExportAnchor, ExportPackageModel } from './model' -/** - * Issue #94 的 Cocos 原生文件格式仍等待真实 Creator 3.x 实测。 - * 这里公开状态,避免页面或调用方把通用包误标成“Cocos 拖入即用包”。 - */ +/** Creator 3.8.8 的真实 2D 资产导入、引用和 Prefab 定位验收状态。 */ export const COCOS_TARGET_READINESS = { - ready: false, - reason: '等待真实 Cocos Creator 3.x 验证 .anim、.meta、UUID 与图集切分格式', + ready: true, + reason: '已通过 Cocos Creator 3.8.8 真实 2D 资产导入与引用校验', } as const -/** 通用层左上原点转为 Cocos Creator 左下原点;x 不变,y 上下翻转。 */ +/** + * 通用层左上原点转为 Cocos Creator 左下原点;x 不变,y 上下翻转。 + * 在 0-1 归一化坐标下,新 y = 1 - 原 y。 + */ export function toCocosAnchor(anchor: ExportAnchor): ExportAnchor { return { x: anchor.x, y: 1 - anchor.y } } + +export const COCOS_IMPORT_SCHEMA_VERSION = 'windup-cocos-import-1.1.0' + +interface CocosImportActionFrame { + index: number + file: string + duration_ms: number | null +} + +interface CocosImportAction { + id: string + name: string + export_name: string + direction: string + fps: number + timing_mode: 'constant-fps' | 'per-frame' + loop: boolean + quality_status: GenericExportMetadata['actions'][number]['quality_status'] + anchor: { x: number; y: number } + anchor_cocos: { x: number; y: number } + foot_y: number + frames: readonly CocosImportActionFrame[] + atlas: { + file: string + cols: number + rows: number + cell: { w: number; h: number } + } +} + +interface CocosImportManifest { + schema_version: typeof COCOS_IMPORT_SCHEMA_VERSION + experimental: true + engine: 'cocos-creator' + upstream_issue: 94 + package: { + character_id: string + character_name: string + outfit_id: string + outfit_name: string + canvas: { w: number; h: number } + } + master: { + file: string + anchor: { x: number; y: number } + anchor_cocos: { x: number; y: number } + } + actions: readonly CocosImportAction[] +} + +function explicitFrameDuration(planItem: PlannedSequence, frameIndex: number): number | null { + const frame = planItem.frames[frameIndex] + if (frame === undefined) return null + const explicit = frame.frame.durationMs + if (typeof explicit === 'number' && explicit > 0) return explicit + return null +} + +function buildManifest( + model: ExportPackageModel, + metadata: GenericExportMetadata, + plan: readonly PlannedSequence[], +): CocosImportManifest { + if (metadata.actions.length !== plan.length) { + throw new Error( + `cocos-target: meta.json 动作数量 ${metadata.actions.length} 与 plan 数量 ${plan.length} 不一致`, + ) + } + + const masterAnchor = { x: 0.5, y: 0.92 } + return { + schema_version: COCOS_IMPORT_SCHEMA_VERSION, + experimental: true, + engine: 'cocos-creator', + upstream_issue: 94, + package: { + character_id: model.characterId, + character_name: model.characterName, + outfit_id: model.outfitId, + outfit_name: model.outfitName, + canvas: { w: model.canvas.width, h: model.canvas.height }, + }, + master: { + file: 'character/master.png', + anchor: masterAnchor, + anchor_cocos: toCocosAnchor(masterAnchor), + }, + actions: metadata.actions.map( + (action: GenericExportMetadata['actions'][number], index: number) => { + const planItem = plan[index] + if (planItem === undefined) { + throw new Error(`cocos-target: meta.json 缺少第 ${index} 个 plan 动作`) + } + return { + id: action.id, + name: action.name, + export_name: planItem.exportName, + direction: planItem.sequence.direction, + fps: action.fps, + timing_mode: planItem.frames.some( + (frame) => typeof frame.frame.durationMs === 'number' && frame.frame.durationMs > 0, + ) + ? 'per-frame' + : 'constant-fps', + loop: action.loop, + quality_status: action.quality_status, + anchor: { ...action.anchor }, + anchor_cocos: toCocosAnchor(action.anchor), + foot_y: action.foot_y, + frames: action.frames.map( + (frame: GenericExportMetadata['actions'][number]['frames'][number], index: number) => ({ + index: frame.index, + file: frame.file, + duration_ms: explicitFrameDuration(planItem, index), + }), + ), + atlas: { ...action.atlas }, + } + }, + ), + } +} + +function buildReadme(model: ExportPackageModel): string { + return `# Windup → Cocos Creator 导出(实验性) + +这是 Windup 通用包之上的 **Cocos Creator 适配层**。 + +## 状态 + +\`experimental: true\`。本 target 不会自动生成 \`.anim\`、\`meta\`、UUID, +因为这些字段需要在真实 Creator 3.x 项目中实测;详见 Issue #94。 +验证完成前,本包不能被声称"拖入即播放"。 + +## 本目录内容 + +- \`cocos-import.json\` — Windup 定义的中间清单(schema_version: + \`${COCOS_IMPORT_SCHEMA_VERSION}\`)。Cocos 侧需要写一个一次性编辑器插件 + 消费这份清单,按其 \`actions[*]\` 列表建立 SpriteFrame / AnimationClip + / Prefab,并把 \`anchor_cocos\` 写入节点的 \`anchor\`,把 \`foot_y\` 写入 + 对齐脚线约束。 +- 通用层的 \`frames//*.png\` 与 \`atlas/.png\` 保持原样, + 不被 target 修改;Cocos 侧的导入插件按 manifest 中的相对路径引用。 +- 通用层 \`meta.json\` 仍是契约源;target 不重写锚点 / 帧率 / 帧数, + 只把"左上原点"的 anchor 翻转成 Cocos 的"左下原点"。 + +## 坐标规则 + +通用层原点在画布左上、y 轴向下;anchor 范围 0-1。 +Cocos Creator 原点在画布左下、y 轴向上;本 target 输出 \`anchor_cocos = (x, 1-y)\`。 +\`foot_y\` 是从画布顶部的像素距离,Cocos 节点可使用 \`(canvas.h - foot_y)\` 反算。 + +## 已知缺口 + +- 不生成 \`.anim\` 曲线、关键帧时间轴。 +- 不生成 \`meta\` / UUID。 +- 不绑定节点骨骼,仅静态精灵。 + +完成 Issue #94 验证后,只需扩展 \`cocosCreatorTarget.createFiles\` 即可, +不需要修改 \`meta.json\` 或通用打包逻辑。 + +## 参考 + +- 角色: ${model.characterName} (#${model.characterId}) +- 造型: ${model.outfitName} (#${model.outfitId}) +- 画布: ${model.canvas.width}×${model.canvas.height} +- Issue: https://github.com/1024XEngineer/Windup/issues/94 +` +} + +export const cocosCreatorTarget: AssetExportTarget = { + id: 'cocos-creator', + async createFiles(context: AssetExportTargetContext): Promise { + const manifest = buildManifest(context.model, context.metadata, context.plan) + return [ + { + path: 'cocos-import.json', + data: `${JSON.stringify(manifest, null, 2)}\n`, + }, + { + path: 'README.md', + data: buildReadme(context.model), + }, + ] + }, +} diff --git a/frontend/src/features/export-package/export-panel.defaults.test.tsx b/frontend/src/features/export-package/export-panel.defaults.test.tsx new file mode 100644 index 00000000..511cf21b --- /dev/null +++ b/frontend/src/features/export-package/export-panel.defaults.test.tsx @@ -0,0 +1,125 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ExportPackageModel } from './model' + +const { exportGameAssetsMock, importIntoCocosMock, pairMock } = vi.hoisted(() => ({ + exportGameAssetsMock: vi.fn(), + importIntoCocosMock: vi.fn(), + pairMock: vi.fn(), +})) + +vi.mock('./asset-export', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, exportGameAssets: exportGameAssetsMock } +}) + +vi.mock('./cocos-one-click', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, importIntoCocos: importIntoCocosMock } +}) + +vi.mock('./cocos-bridge-client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + CocosBridgeClient: class { + pair(code: string) { + return pairMock(code) + } + }, + } +}) + +import { CocosBridgeError } from './cocos-bridge-client' +import { ExportPanel } from './export-panel' + +const model: ExportPackageModel = { + stage: 'character', + characterId: 'hero', + characterName: 'Hero', + characterImageUrl: 'memory://hero.png', + outfitId: 'default', + outfitName: 'Default', + canvas: { width: 256, height: 256 }, + source: null, + firstFrames: [], + actions: [], + playtest: null, +} + +const importResult = { + projectName: 'DefaultGame', + dbUrl: 'db://assets/windup-imports/Hero.prefab', + animationCount: 0, + frameCount: 0, +} + +beforeEach(() => { + exportGameAssetsMock.mockReset() + importIntoCocosMock.mockReset() + pairMock.mockReset() + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:cocos-default'), + }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('ExportPanel Cocos defaults', () => { + it('uses the default Cocos package exporter for the download fallback', async () => { + exportGameAssetsMock.mockResolvedValue({ + blob: new Blob(['zip'], { type: 'application/zip' }), + filename: 'windup-cocos.zip', + }) + + render() + fireEvent.click(screen.getByRole('button', { name: '下载 Cocos 包' })) + + await waitFor(() => expect(exportGameAssetsMock).toHaveBeenCalledTimes(1)) + const options = exportGameAssetsMock.mock.calls[0]?.[1] as { + targets: Array<{ id: string }> + } + expect(options.targets.map((target) => target.id)).toEqual(['cocos-creator']) + }) + + it('uses the default bridge importer when no importer is injected', async () => { + importIntoCocosMock.mockResolvedValue(importResult) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByText('已导入到当前 Cocos 工程')).toBeTruthy() + expect(importIntoCocosMock).toHaveBeenCalledWith( + model, + expect.anything(), + expect.any(Function), + expect.objectContaining({ cache: {} }), + ) + }) + + it('uses the default bridge pairer before retrying the import', async () => { + importIntoCocosMock + .mockRejectedValueOnce(new CocosBridgeError('PAIRING_REQUIRED', '请先配对')) + .mockResolvedValueOnce(importResult) + pairMock.mockResolvedValue(undefined) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + fireEvent.change(await screen.findByLabelText('Creator 连接码'), { + target: { value: '123456' }, + }) + fireEvent.click(screen.getByRole('button', { name: '连接并导入' })) + + await waitFor(() => expect(pairMock).toHaveBeenCalledWith('123456')) + expect(await screen.findByText('已导入到当前 Cocos 工程')).toBeTruthy() + expect(importIntoCocosMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/features/export-package/export-panel.test.tsx b/frontend/src/features/export-package/export-panel.test.tsx index 8c3d49ab..03797d65 100644 --- a/frontend/src/features/export-package/export-panel.test.tsx +++ b/frontend/src/features/export-package/export-panel.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ExportPackageModel } from './model' import { ExportButton, ExportPanel } from './export-panel' +import { CocosBridgeError } from './cocos-bridge-client' const model = { stage: 'action-assets', @@ -62,6 +63,9 @@ describe('ExportPanel', () => { }) as HTMLButtonElement ).disabled, ).toBe(true) + expect( + (screen.getByRole('button', { name: '一键导入 Cocos' }) as HTMLButtonElement).disabled, + ).toBe(true) }) it('显示进度、阻止重复点击、下载后释放临时地址', async () => { @@ -159,6 +163,240 @@ describe('ExportPanel', () => { await waitFor(() => expect(exporter).toHaveBeenCalledTimes(1)) }) + it('Cocos 下载降级按钮调用适配导出器并下载适配包', async () => { + const exporter = vi.fn().mockResolvedValue({ + blob: new Blob(['cocos-zip'], { type: 'application/zip' }), + filename: 'windup-cocos.zip', + }) + const createObjectURL = vi.fn(() => 'blob:cocos') + const revokeObjectURL = vi.fn() + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: createObjectURL }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revokeObjectURL }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + + render() + fireEvent.click(screen.getByRole('button', { name: '下载 Cocos 包' })) + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Cocos 包下载完成' })).toBeTruthy(), + ) + expect(exporter).toHaveBeenCalledWith(model, expect.any(Function)) + expect(createObjectURL).toHaveBeenCalledTimes(1) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:cocos') + }) + + it('一键导入完成后显示当前工程和资产统计', async () => { + const importer = vi + .fn() + .mockImplementation( + async (_model: ExportPackageModel, onPhase: (phase: 'uploading' | 'verifying') => void) => { + onPhase('uploading') + onPhase('verifying') + return { + projectName: 'CocosGame', + dbUrl: 'db://assets/windup-imports/Aster/Explorer/prefabs/Aster-Explorer.prefab', + animationCount: 2, + frameCount: 64, + } + }, + ) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByText('已导入到当前 Cocos 工程')).toBeTruthy() + expect(screen.getByText('CocosGame · 2 个动作,64 帧')).toBeTruthy() + expect(importer).toHaveBeenCalledTimes(1) + }) + + it('一键导入进行中时忽略绕过禁用属性的重复触发', async () => { + let resolveImport: (result: { + projectName: string + dbUrl: string + animationCount: number + frameCount: number + }) => void = () => { + throw new Error('import promise was not initialized') + } + const importer = vi.fn( + () => + new Promise<{ + projectName: string + dbUrl: string + animationCount: number + frameCount: number + }>((resolve) => { + resolveImport = resolve + }), + ) + + render() + const button = screen.getByRole('button', { name: '一键导入 Cocos' }) as HTMLButtonElement + fireEvent.click(button) + expect(button.disabled).toBe(true) + + button.disabled = false + fireEvent.click(button) + expect(importer).toHaveBeenCalledTimes(1) + + resolveImport({ + projectName: 'CocosGame', + dbUrl: 'db://assets/windup-imports/Aster.prefab', + animationCount: 1, + frameCount: 8, + }) + expect(await screen.findByText('已导入到当前 Cocos 工程')).toBeTruthy() + }) + + it('首次未配对时显示连接码输入框,配对后继续同一次导入', async () => { + const importer = vi + .fn() + .mockRejectedValueOnce(new CocosBridgeError('PAIRING_REQUIRED', '请先配对')) + .mockResolvedValueOnce({ + projectName: 'Game', + dbUrl: 'db://assets/windup-imports/Aster/Explorer/prefabs/Aster-Explorer.prefab', + animationCount: 1, + frameCount: 1, + }) + const pairer = vi.fn().mockResolvedValue(undefined) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + const input = await screen.findByLabelText('Creator 连接码') + fireEvent.change(input, { target: { value: '123456' } }) + fireEvent.click(screen.getByRole('button', { name: '连接并导入' })) + + await waitFor(() => expect(pairer).toHaveBeenCalledWith('123456')) + expect(await screen.findByText('已导入到当前 Cocos 工程')).toBeTruthy() + expect(importer).toHaveBeenCalledTimes(2) + }) + + it('连接码不是六位数字时留在配对界面且不调用插件', async () => { + const importer = vi.fn().mockRejectedValue(new CocosBridgeError('PAIRING_REQUIRED', '请先配对')) + const pairer = vi.fn().mockResolvedValue(undefined) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + const input = await screen.findByLabelText('Creator 连接码') + fireEvent.change(input, { target: { value: '12a3' } }) + fireEvent.click(screen.getByRole('button', { name: '连接并导入' })) + + expect(await screen.findByText('请输入 Creator 显示的 6 位连接码')).toBeTruthy() + expect((input as HTMLInputElement).value).toBe('123') + expect(pairer).not.toHaveBeenCalled() + }) + + it('配对请求失败时显示插件返回的具体错误', async () => { + const importer = vi.fn().mockRejectedValue(new CocosBridgeError('PAIRING_REQUIRED', '请先配对')) + const pairer = vi.fn().mockRejectedValue(new Error('连接码已过期')) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + fireEvent.change(await screen.findByLabelText('Creator 连接码'), { + target: { value: '654321' }, + }) + fireEvent.click(screen.getByRole('button', { name: '连接并导入' })) + + expect(await screen.findByText('导入失败:连接码已过期')).toBeTruthy() + expect(pairer).toHaveBeenCalledWith('654321') + expect(importer).toHaveBeenCalledTimes(1) + }) + + it('插件不可用时保留明确错误和 Cocos 包下载降级入口', async () => { + const importer = vi + .fn() + .mockRejectedValue(new CocosBridgeError('PLUGIN_UNAVAILABLE', '未检测到 Cocos Creator 插件')) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByText('导入失败:未检测到 Cocos Creator 插件')).toBeTruthy() + expect(screen.getByRole('button', { name: '下载 Cocos 包' })).toBeTruthy() + }) + + it('导入失败时显示阶段、稳定错误码和未回滚警告', async () => { + const error = new CocosBridgeError('IMPORT_FAILED', '写入失败', undefined, { + jobCode: 'IMPORT_ROLLBACK_FAILED', + phase: 'writing', + rolledBack: false, + }) + const importer = vi.fn().mockRejectedValue(error) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByText('导入失败:写入失败')).toBeTruthy() + expect(screen.getByText(/错误码:IMPORT_ROLLBACK_FAILED/)).toBeTruthy() + expect(screen.getByText(/回滚:未完成,请检查工程资产/)).toBeTruthy() + }) + + it('导入失败时区分已回滚和无需回滚', async () => { + const rolledBack = new CocosBridgeError('IMPORT_FAILED', '资源刷新失败', undefined, { + jobCode: 'IMPORT_REFRESH_FAILED', + phase: 'refreshing', + rolledBack: true, + }) + const importer = vi.fn().mockRejectedValueOnce(rolledBack) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + expect(await screen.findByText(/回滚:已完成/)).toBeTruthy() + + cleanup() + const noRollback = new CocosBridgeError('IMPORT_FAILED', '校验失败', undefined, { + jobCode: 'IMPORT_VALIDATION_FAILED', + phase: 'validating', + rolledBack: false, + }) + const secondImporter = vi.fn().mockRejectedValue(noRollback) + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + expect(await screen.findByText(/回滚:未执行/)).toBeTruthy() + }) + + it('错误码存在但阶段缺失时使用排队阶段兜底', async () => { + const error = new CocosBridgeError('IMPORT_FAILED', '任务状态缺失', undefined, { + jobCode: 'IMPORT_STATUS_INVALID', + phase: 'queued', + rolledBack: false, + }) + Object.defineProperty(error, 'phase', { value: undefined }) + const importer = vi.fn().mockRejectedValue(error) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByText(/阶段:Cocos Creator 正在排队/)).toBeTruthy() + }) + + it('导入器抛出非 Error 值时展示通用错误', async () => { + const importer = vi.fn().mockRejectedValue({ reason: 'offline' }) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByText('导入失败:未知错误')).toBeTruthy() + }) + + it('Cocos 下载降级失败后显示重试文案和具体原因', async () => { + const cocosExporter = vi.fn().mockRejectedValue(new Error('图集打包失败')) + + render() + fireEvent.click(screen.getByRole('button', { name: '下载 Cocos 包' })) + + const retry = await screen.findByRole('button', { name: '重新导出 Cocos 包' }) + expect(retry.getAttribute('title')).toBe('图集打包失败') + expect(screen.getByText('导出失败:图集打包失败')).toBeTruthy() + }) + + it('可关闭 Cocos 导出入口而不影响通用导出', () => { + render() + + expect(screen.queryByRole('button', { name: '一键导入 Cocos' })).toBeNull() + expect(screen.queryByRole('button', { name: '下载 Cocos 包' })).toBeNull() + expect(screen.getByRole('button', { name: '导出游戏资产包' })).toBeTruthy() + }) + it('导出器抛出非 Error 值时展示通用错误', async () => { const exporter = vi.fn().mockRejectedValue('network unavailable') @@ -186,6 +424,63 @@ describe('ExportPanel', () => { expect(await screen.findByRole('button', { name: '下载完成' })).toBeTruthy() }) + it('紧凑导出按钮提供 Cocos Creator 下载降级入口', async () => { + const cocosExporter = vi.fn().mockResolvedValue({ + blob: new Blob(['cocos-zip'], { type: 'application/zip' }), + filename: 'windup-cocos.zip', + }) + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:cocos-compact'), + }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + + render() + fireEvent.click(screen.getByRole('button', { name: '下载 Cocos 包' })) + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Cocos 包下载完成' })).toBeTruthy(), + ) + expect(cocosExporter).toHaveBeenCalledWith(model, expect.any(Function)) + }) + + it('紧凑导出按钮可直接完成 Cocos 一键导入', async () => { + const importer = vi.fn().mockResolvedValue({ + projectName: 'CompactGame', + dbUrl: 'db://assets/windup-imports/Aster.prefab', + animationCount: 1, + frameCount: 8, + }) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByText('CompactGame · 1 个动作,8 帧')).toBeTruthy() + expect(importer).toHaveBeenCalledWith(model, expect.any(Function)) + }) + + it('紧凑 Cocos 一键导入失败后显示重试文案', async () => { + const importer = vi.fn().mockRejectedValue(new Error('导入连接中断')) + + render() + fireEvent.click(screen.getByRole('button', { name: '一键导入 Cocos' })) + + expect(await screen.findByRole('button', { name: '重新导入 Cocos' })).toBeTruthy() + expect(screen.getByText('导入失败:导入连接中断')).toBeTruthy() + }) + + it('紧凑 Cocos 下载失败时显示重试入口、标题和独立警告', async () => { + const cocosExporter = vi.fn().mockRejectedValue(new Error('Cocos 图集生成失败')) + + render() + fireEvent.click(screen.getByRole('button', { name: '下载 Cocos 包' })) + + const retry = await screen.findByRole('button', { name: '重新导出 Cocos 包' }) + expect(retry.getAttribute('title')).toBe('Cocos 图集生成失败') + expect(screen.getByText('Cocos 导出失败:Cocos 图集生成失败')).toBeTruthy() + }) + it('紧凑导出按钮失败后显示可访问的具体错误', async () => { const exporter = vi.fn().mockRejectedValue(new Error('图片下载失败')) @@ -205,4 +500,12 @@ describe('ExportPanel', () => { const button = screen.getByRole('button', { name: '导出资产包' }) expect(button.className).toContain('rounded-full') }) + + it('紧凑导出按钮可单独关闭 Cocos 入口', () => { + render() + + expect(screen.queryByRole('button', { name: '一键导入 Cocos' })).toBeNull() + expect(screen.queryByRole('button', { name: '下载 Cocos 包' })).toBeNull() + expect(screen.getByRole('button', { name: '导出完整动作资产' })).toBeTruthy() + }) }) diff --git a/frontend/src/features/export-package/export-panel.tsx b/frontend/src/features/export-package/export-panel.tsx index 82538ce6..37d9a3f9 100644 --- a/frontend/src/features/export-package/export-panel.tsx +++ b/frontend/src/features/export-package/export-panel.tsx @@ -1,5 +1,8 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' +import { CocosBridgeClient, CocosBridgeError, type CocosImportResult } from './cocos-bridge-client' +import { cocosCreatorTarget } from './cocos-target' +import { importIntoCocos, type CocosImportCache, type CocosOneClickPhase } from './cocos-one-click' import type { ExportPackageModel } from './model' import { createAssetExportPlan, @@ -13,18 +16,34 @@ export type AssetExporter = ( onPhase?: (phase: AssetExportPhase) => void, ) => Promise +export type CocosImporter = ( + model: ExportPackageModel, + onPhase: (phase: CocosOneClickPhase) => void, +) => Promise + +export type CocosPairer = (code: string) => Promise + export interface ExportPanelProps { model: ExportPackageModel qualityIssueCount?: number exporter?: AssetExporter + cocosExporter?: AssetExporter + cocosImporter?: CocosImporter + cocosPairer?: CocosPairer + /** Cocos Creator 一键导出;默认开启,可用 false 隐藏。 */ + enableCocosExport?: boolean } export interface ExportButtonProps { model: ExportPackageModel exporter?: AssetExporter + cocosExporter?: AssetExporter + cocosImporter?: CocosImporter + cocosPairer?: CocosPairer className?: string idleLabel?: string pill?: boolean + enableCocosExport?: boolean } type ExportState = @@ -40,6 +59,18 @@ const PHASE_LABELS: Readonly> = { packing: '正在打包', } +const COCOS_PHASE_LABELS: Readonly> = { + detecting: '正在连接 Cocos Creator', + validating: '正在检查资产', + packing: '正在生成 Cocos 导入包', + uploading: '正在发送到 Cocos Creator', + queued: 'Cocos Creator 正在排队', + converting: '正在生成 Prefab 和动画', + writing: '正在写入当前工程', + refreshing: '正在刷新 Cocos 资源库', + verifying: '正在校验导入结果', +} + const STAGE_LABELS: Readonly> = { character: '角色母版', 'first-frame': '角色母版与动作首帧', @@ -48,6 +79,8 @@ const STAGE_LABELS: Readonly> = { } const defaultExporter: AssetExporter = (model, onPhase) => exportGameAssets(model, { onPhase }) +const defaultCocosExporter: AssetExporter = (model, onPhase) => + exportGameAssets(model, { targets: [cocosCreatorTarget], onPhase }) function useExportAction(model: ExportPackageModel, exporter: AssetExporter) { const [state, setState] = useState({ status: 'idle' }) @@ -76,18 +109,213 @@ function useExportAction(model: ExportPackageModel, exporter: AssetExporter) { return { state, working, startExport } } +type CocosImportState = + | { status: 'idle' } + | { status: 'working'; phase: CocosOneClickPhase } + | { status: 'pairing'; message: string } + | { status: 'success'; result: CocosImportResult } + | { + status: 'failure' + message: string + jobCode?: string + phase?: CocosOneClickPhase + rolledBack?: boolean + } + +function importFailureState(error: unknown): CocosImportState { + return { + status: 'failure', + message: error instanceof Error ? error.message : '未知错误', + ...(error instanceof CocosBridgeError + ? { jobCode: error.jobCode, phase: error.phase, rolledBack: error.rolledBack } + : {}), + } +} + +function useCocosImportAction( + model: ExportPackageModel, + importer?: CocosImporter, + pairer?: CocosPairer, +) { + const defaults = useRef<{ client: CocosBridgeClient; cache: CocosImportCache } | null>(null) + if (defaults.current === null) { + defaults.current = { client: new CocosBridgeClient(), cache: {} } + } + const resolvedImporter: CocosImporter = + importer ?? + ((currentModel, onPhase) => + importIntoCocos(currentModel, defaults.current!.client, onPhase, { + cache: defaults.current!.cache, + })) + const resolvedPairer = pairer ?? ((code: string) => defaults.current!.client.pair(code)) + const [state, setState] = useState({ status: 'idle' }) + const [pairingCode, setPairingCode] = useState('') + const working = state.status === 'working' + + const handleError = (error: unknown) => { + if (error instanceof CocosBridgeError && error.code === 'PAIRING_REQUIRED') { + setState({ status: 'pairing', message: error.message }) + } else { + setState(importFailureState(error)) + } + } + + const executeImport = async () => { + try { + const result = await resolvedImporter(model, (phase) => + setState({ status: 'working', phase }), + ) + setState({ status: 'success', result }) + } catch (error) { + handleError(error) + } + } + + const runImport = async () => { + if (working) return + setState({ status: 'working', phase: 'detecting' }) + await executeImport() + } + + const pairAndImport = async () => { + if (!/^\d{6}$/.test(pairingCode)) { + setState({ status: 'pairing', message: '请输入 Creator 显示的 6 位连接码' }) + return + } + setState({ status: 'working', phase: 'detecting' }) + try { + await resolvedPairer(pairingCode) + setPairingCode('') + await executeImport() + } catch (error) { + handleError(error) + } + } + + return { + state, + working, + pairingCode, + setPairingCode, + startImport: runImport, + pairAndImport, + } +} + +function CocosImportFeedback({ action }: { action: ReturnType }) { + if (action.state.status === 'working') { + return ( +

+ {COCOS_PHASE_LABELS[action.state.phase]} +

+ ) + } + if (action.state.status === 'pairing') { + return ( +
+

+ {action.state.message} +

+ + +
+ ) + } + if (action.state.status === 'failure') { + return ( +
+

导入失败:{action.state.message}

+ {action.state.jobCode ? ( +

+ 阶段:{COCOS_PHASE_LABELS[action.state.phase ?? 'queued']} · 错误码: + {action.state.jobCode} · 回滚: + {action.state.rolledBack + ? '已完成' + : action.state.jobCode === 'IMPORT_ROLLBACK_FAILED' + ? '未完成,请检查工程资产' + : '未执行'} +

+ ) : null} +
+ ) + } + if (action.state.status === 'success') { + return ( +
+

已导入到当前 Cocos 工程

+

+ {action.state.result.projectName} · {action.state.result.animationCount} 个动作, + {action.state.result.frameCount} 帧 +

+
+ ) + } + return null +} + +function StateBanner({ state }: { state: ExportState }) { + if (state.status === 'working') { + return ( +

+ {PHASE_LABELS[state.phase]} +

+ ) + } + if (state.status === 'failure') { + return ( +

+ 导出失败:{state.message} +

+ ) + } + if (state.status === 'success') { + return

下载完成

+ } + return null +} + export function ExportPanel({ model, qualityIssueCount = 0, exporter = defaultExporter, + cocosExporter = defaultCocosExporter, + cocosImporter, + cocosPairer, + enableCocosExport = true, }: ExportPanelProps) { const plan = createAssetExportPlan(model) const { state, working, startExport } = useExportAction(model, exporter) + const cocos = useExportAction(model, cocosExporter) + const cocosImport = useCocosImportAction(model, cocosImporter, cocosPairer) + const cocosButtonLabel = + cocos.state.status === 'working' + ? PHASE_LABELS[cocos.state.phase] + : cocos.state.status === 'failure' + ? '重新导出 Cocos 包' + : cocos.state.status === 'success' + ? 'Cocos 包下载完成' + : '下载 Cocos 包' return (
@@ -117,27 +345,58 @@ export function ExportPanel({

) : null} - {state.status === 'working' ? ( -

- {PHASE_LABELS[state.phase]} -

- ) : state.status === 'failure' ? ( -

- 导出失败:{state.message} -

- ) : state.status === 'success' ? ( -

下载完成

- ) : null} + + {plan.length === 0 ?

当前包含角色母版

: null} + + {enableCocosExport ? ( +
+
+ + 一键导入 + +

Cocos Creator 3.8 适配

+
+

+ 首次安装并配对插件后,可直接写入当前 2D 工程并刷新 Prefab、动画和 SpriteFrame。 +

+ + + + +

+ 首次使用需要在 Creator 中安装全局插件并输入连接码。 +

+
+ ) : null}
) } @@ -145,11 +404,17 @@ export function ExportPanel({ export function ExportButton({ model, exporter = defaultExporter, + cocosExporter = defaultCocosExporter, + cocosImporter, + cocosPairer, className = '', idleLabel, pill = false, + enableCocosExport = true, }: ExportButtonProps) { const { state, working, startExport } = useExportAction(model, exporter) + const cocos = useExportAction(model, cocosExporter) + const cocosImport = useCocosImportAction(model, cocosImporter, cocosPairer) const label = state.status === 'working' ? PHASE_LABELS[state.phase] @@ -163,7 +428,7 @@ export function ExportButton({
+ + + {cocos.state.status === 'failure' ? ( + + Cocos 导出失败:{cocos.state.message} + + ) : null} + + ) : null}
) } diff --git a/frontend/src/features/export-package/index.ts b/frontend/src/features/export-package/index.ts index b7bfad81..e75960be 100644 --- a/frontend/src/features/export-package/index.ts +++ b/frontend/src/features/export-package/index.ts @@ -1,6 +1,11 @@ /** 将预览台当前角色资产打包下载;与发布到资产库是两件事。 */ export { ExportButton, ExportPanel } from './export-panel' -export type { ExportButtonProps, ExportPanelProps } from './export-panel' +export type { + CocosImporter, + CocosPairer, + ExportButtonProps, + ExportPanelProps, +} from './export-panel' export type { ExportAction, ExportAnchor, @@ -22,7 +27,32 @@ export { validateExportPackageModel, type GenericExportMetadata, } from './contract' -export { COCOS_TARGET_READINESS, toCocosAnchor } from './cocos-target' +export { + COCOS_IMPORT_SCHEMA_VERSION, + COCOS_TARGET_READINESS, + cocosCreatorTarget, + toCocosAnchor, +} from './cocos-target' +export { + COCOS_BRIDGE_PROTOCOL, + COCOS_BRIDGE_TOKEN_KEY, + CocosBridgeClient, + CocosBridgeError, + type CocosBridgeClientOptions, + type CocosBridgeErrorCode, + type CocosBridgeHealth, + type CocosImportJob, + type CocosImportPhase, + type CocosImportResult, +} from './cocos-bridge-client' +export { + importIntoCocos, + type CocosBridgeApi, + type CocosImportCache, + type CocosOneClickPhase, + type CocosPackageExporter, + type ImportIntoCocosOptions, +} from './cocos-one-click' export { createAssetExportPlan, exportGameAssets, diff --git a/tools/cocos-importer/README.md b/tools/cocos-importer/README.md new file mode 100644 index 00000000..1c465d12 --- /dev/null +++ b/tools/cocos-importer/README.md @@ -0,0 +1,80 @@ +# Windup → Cocos Creator 2D 一键导入 + +本目录包含两种导入方式: + +- Cocos Creator 扩展:网页配对后,点击一次即可完成打包、上传、事务写入、AssetDB 刷新、引用校验和 Prefab 定位。 +- Node CLI:用于 CI、离线排查或手动导入,不依赖 Creator 运行时。 + +支持 Cocos Creator `>=3.8.8 <3.9.0`,导入目标固定在当前工程的 `assets/windup-imports/`。 + +## 扩展安装与使用 + +1. 构建扩展: + + ```bash + cd tools/cocos-importer/extension + npm test + npm run build + npm run verify-package + ``` + +2. 在 Cocos Creator 3.8.x 的扩展管理器中导入并启用: + + `tools/cocos-importer/dist/windup-cocos-importer.zip` + +3. 打开目标 2D 工程,在 Creator 菜单选择“Windup → 显示连接码”。 +4. 在 Windup 网页输入 6 位连接码。连接码 5 分钟有效,成功后立即失效。 +5. 点击“一键导入 Cocos”。成功后 Creator 会定位到生成的 Prefab。 + +扩展只监听 `127.0.0.1:17832`。未配对网页只能读取协议与配对状态;工程名称、Creator 版本和工程打开状态只返回给已配对来源。上传接口校验来源、Bearer token、协议版本、请求 UUID、大小和 SHA-256。 + +## 导入行为 + +每次导入先在工程 `temp/windup-importer//` 中生成完整结果,再以目录替换方式写入: + +```text +assets/windup-imports/<角色>/<造型>/ +├── textures/<角色>-master.png +├── animations/ +│ ├── <动作>.anim +│ └── <动作>/<动作>_NNN.png +├── prefabs/<角色>-<造型>.prefab +├── cocos-import.json +├── meta.json +└── schema.json +``` + +若写入、刷新或引用校验失败,扩展会尽力恢复导入前目录,并把真实回滚结果返回网页。错误响应不会暴露本机绝对路径。ZIP 仅接受 Windup 使用的 STORED 格式,并校验 CRC、本地头、中央目录、重复路径和危险路径;同时限制 4096 个 ZIP 条目、32 MiB 单条目、128 个动作、4096 个总帧和 256 MiB 展开输出。 + +## CLI 回退 + +```bash +cd tools/cocos-importer +node bin/windup-cocos-import.mjs --out +node bin/windup-cocos-import.mjs --dry-run +``` + +覆盖已有输出目录时必须显式添加 `--force`。输出目录可复制到 Cocos 工程 `assets/`,但日常使用建议优先走扩展,以获得事务回滚、AssetDB 刷新和引用校验。 + +## 验证 + +```bash +cd tools/cocos-importer +npm test + +cd extension +npm test +npm run build +npm run verify-package + +cd .. +node test/verify-output.mjs 256 256 +``` + +2026-08-20 已用“网站看板娘 / 默认造型”真实 2D 资产在 Cocos Creator 3.8.8 完成导入:67 张 SpriteFrame、2 个 AnimationClip、64 个动作帧和 1 个 Prefab 均被 AssetDB 识别,输出引用校验通过。 + +## 边界 + +- 不支持 Cocos Creator 3.9+、3D 模型、骨骼动画或 DEFLATE ZIP。 +- 同一角色/造型采用整包替换,不做逐文件增量合并。 +- 浏览器无法连接扩展时,网页仍保留“下载 Cocos 包”作为回退。 diff --git a/tools/cocos-importer/bin/windup-cocos-import.mjs b/tools/cocos-importer/bin/windup-cocos-import.mjs new file mode 100644 index 00000000..792b314d --- /dev/null +++ b/tools/cocos-importer/bin/windup-cocos-import.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +// CLI:把 Windup 导出的 windup-*.zip 解到指定目录,产出按 Cocos Creator 结构 +// 组织的资源目录 + 元数据文件,供真实 Creator 实例继续验收。 +// +// 用法: +// node tools/cocos-importer/bin/windup-cocos-import.mjs --out +// node tools/cocos-importer/bin/windup-cocos-import.mjs # 默认 out=./cocos-import-output +// node tools/cocos-importer/bin/windup-cocos-import.mjs --out --dry-run + +import { + readFileSync, + writeFileSync, + mkdirSync, + existsSync, + rmSync, + statSync, + realpathSync, + readdirSync, +} from 'node:fs' +import { resolve, dirname, basename, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { prepareImport, prepareImportFromEntries } from '../src/import-core.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +function parseArgs(argv) { + const args = { input: null, out: null, dryRun: false, force: false } + for (let i = 2; i < argv.length; i += 1) { + const a = argv[i] + if (a === '--out' || a === '-o') { + args.out = argv[++i] + } else if (a === '--dry-run') { + args.dryRun = true + } else if (a === '--force') { + args.force = true + } else if (a === '--help' || a === '-h') { + args.help = true + } else if (!args.input) { + args.input = a + } + } + if (!args.out) args.out = resolve(process.cwd(), 'cocos-import-output') + return args +} + +function canonicalPath(path) { + if (existsSync(path)) return realpathSync.native(path) + const parent = dirname(path) + if (parent === path) return path + return join(canonicalPath(parent), basename(path)) +} + +function isSameOrAncestor(parent, child) { + const relativePath = resolve(parent) === resolve(child) ? '' : relative(parent, child) + return relativePath === '' || (!relativePath.startsWith('..') && !relativePath.includes(':')) +} + +function assertSafeOutputDir(outDir, inputPath) { + const output = canonicalPath(outDir) + const repoRoot = canonicalPath(resolve(__dirname, '..', '..', '..')) + const inputDir = canonicalPath(dirname(inputPath)) + const forbiddenAncestors = [ + ['仓库根目录或其祖先', repoRoot], + ['输入资产所在目录或其祖先', inputDir], + ] + for (const [label, forbidden] of forbiddenAncestors) { + if (isSameOrAncestor(output, forbidden)) { + throw new Error(`拒绝危险输出目录: ${outDir} 是${label}`) + } + } +} + +function readFramesDirectory(framesDir) { + if (basename(framesDir).toLowerCase() !== 'frames') { + throw new Error(`目录输入必须指向名为 frames 的逐帧目录: ${framesDir}`) + } + const packageRoot = dirname(framesDir) + const legacyMetaPath = join(packageRoot, 'meta.json') + if (!existsSync(legacyMetaPath)) { + throw new Error(`frames 同级资产包缺少 meta.json: ${legacyMetaPath}`) + } + + const rootDir = basename(packageRoot) + const entries = [] + const visit = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name) + if (entry.isSymbolicLink()) { + throw new Error(`frames 资产包不允许符号链接: ${fullPath}`) + } + if (entry.isDirectory()) { + visit(fullPath) + continue + } + if (!entry.isFile()) continue + const data = readFileSync(fullPath) + entries.push({ + rootDir, + relativePath: relative(packageRoot, fullPath).replaceAll('\\', '/'), + data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), + size: data.length, + }) + } + } + visit(packageRoot) + return entries +} + +function printHelp() { + // eslint-disable-next-line no-console + console.log(`Windup → Cocos Creator CLI 导入器 + +用法: + node tools/cocos-importer/bin/windup-cocos-import.mjs [--out ] [--dry-run] + +参数: + Windup 导出的 ZIP,或已解压资产包内的 frames 目录 + --out 输出目录(默认 ./cocos-import-output) + --dry-run 只打印计划,不写文件 + --force 允许删除并重建已存在的输出目录 + -h, --help 显示本帮助 + +产物: + //... + textures/ 主母版 PNG + animations//_NNN.png 每张帧 + atlas.png + prefabs/.prefab + .meta + cocos-import.json 原始 manifest 副本 + .meta.json 导入元信息 + +输出目录按 Cocos Creator 3.8.x 资产结构生成,可复制到工程的 assets/ 下。 +日常一键导入请安装 tools/cocos-importer/dist/windup-cocos-importer.zip。 +`) +} + +async function main() { + const args = parseArgs(process.argv) + if (args.help || !args.input) { + printHelp() + process.exit(args.help ? 0 : 2) + } + + const inputPath = resolve(process.cwd(), args.input) + if (!existsSync(inputPath)) { + throw new Error(`输入文件不存在: ${inputPath}`) + } + const stat = statSync(inputPath) + let prepared + if (stat.isFile()) { + const bytes = readFileSync(inputPath) + const stored = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) + // eslint-disable-next-line no-console + console.log(`读取 ZIP: ${basename(inputPath)} (${stored.length} bytes)`) + prepared = prepareImport(stored) + } else if (stat.isDirectory()) { + const flat = readFramesDirectory(inputPath) + // eslint-disable-next-line no-console + console.log(`读取 frames 目录: ${inputPath} (${flat.length} 个文件)`) + prepared = prepareImportFromEntries(flat) + } else { + throw new Error(`输入既不是 ZIP 文件也不是 frames 目录: ${inputPath}`) + } + + const { manifest, plan } = prepared + // eslint-disable-next-line no-console + console.log( + ` manifest: schema=${manifest.schema_version} char=${manifest.package.character_name} ` + + `outfit=${manifest.package.outfit_name} actions=${manifest.actions.length}`, + ) + + // eslint-disable-next-line no-console + console.log(` 计划: ${plan.spriteFrames.length} 个 SpriteFrame,${plan.animations.length} 个动画`) + + const outDir = resolve(process.cwd(), args.out) + assertSafeOutputDir(outDir, inputPath) + + if (args.dryRun) { + // eslint-disable-next-line no-console + console.log(`\n[dry-run] 不会写任何文件,仅打印计划:`) + // eslint-disable-next-line no-console + console.log(` out dir: ${outDir}`) + // eslint-disable-next-line no-console + console.log(` textures: ${plan.spriteFrames.filter((s) => s.sourcePath.startsWith('character/')).length}`) + // eslint-disable-next-line no-console + console.log( + ` animations: ${plan.spriteFrames.filter((s) => s.sourcePath.startsWith('frames/') || s.sourcePath.startsWith('atlas/')).length}`, + ) + // eslint-disable-next-line no-console + console.log(` prefabs: 1 (${plan.prefab.cocosPath})`) + return + } + + if (existsSync(outDir) && !args.force) { + throw new Error(`输出目录已存在: ${outDir};如需覆盖请显式传 --force`) + } + if (existsSync(outDir) && !statSync(outDir).isDirectory()) { + throw new Error(`输出路径不是目录: ${outDir}`) + } + if (existsSync(outDir)) { + // eslint-disable-next-line no-console + console.log(` 清空已存在输出: ${outDir}`) + rmSync(outDir, { recursive: true, force: true }) + } + mkdirSync(outDir, { recursive: true }) + + let writtenBytes = 0 + let fileCount = 0 + for (const [path, bytes] of prepared.files) { + const dst = join(outDir, path) + mkdirSync(dirname(dst), { recursive: true }) + writeFileSync(dst, Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)) + writtenBytes += bytes.length + fileCount += 1 + } + + // eslint-disable-next-line no-console + console.log( + `\n已写入 ${fileCount} 个文件,共 ${writtenBytes} bytes 到:\n ${outDir}\n\n` + + `下一步:将 "${plan.packFolder}" 目录复制到 Cocos Creator 工程的 assets/ 下,` + + `再用 Creator 实例完成导入与播放验收。`, + ) +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(`[error] ${err instanceof Error ? err.stack || err.message : String(err)}`) + process.exit(1) +}) diff --git a/tools/cocos-importer/extension/package.json b/tools/cocos-importer/extension/package.json new file mode 100644 index 00000000..7a677d57 --- /dev/null +++ b/tools/cocos-importer/extension/package.json @@ -0,0 +1,38 @@ +{ + "package_version": 2, + "name": "windup-cocos-importer", + "version": "0.1.0", + "description": "从 Windup 网页一键导入 2D 角色资产到当前 Cocos Creator 工程。", + "author": "xyh202131", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/main.js", + "editor": ">=3.8.8 <3.9.0", + "scripts": { + "test": "node --test test/*.test.mjs", + "build": "node scripts/build.mjs", + "verify-package": "node scripts/verify-package.mjs ../dist/windup-cocos-importer.zip" + }, + "contributions": { + "menu": [ + { + "path": "Windup", + "label": "显示连接码", + "message": "show-pairing-code" + }, + { + "path": "Windup", + "label": "连接状态", + "message": "show-connection-status" + } + ], + "messages": { + "show-pairing-code": { + "methods": ["showPairingCode"] + }, + "show-connection-status": { + "methods": ["showConnectionStatus"] + } + } + } +} diff --git a/tools/cocos-importer/extension/scripts/build.mjs b/tools/cocos-importer/extension/scripts/build.mjs new file mode 100644 index 00000000..c592cfcb --- /dev/null +++ b/tools/cocos-importer/extension/scripts/build.mjs @@ -0,0 +1,105 @@ +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const extensionDir = resolve(scriptDir, '..') +const importerDir = resolve(extensionDir, '..') +const outputDir = resolve(importerDir, 'dist') +const stagingDir = resolve(outputDir, '.extension-build') +const zipPath = resolve(outputDir, 'windup-cocos-importer.zip') + +function crc32(bytes) { + let crc = 0xffffffff + for (const byte of bytes) { + crc ^= byte + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function storedZip(entries) { + const localParts = [] + const centralParts = [] + let offset = 0 + for (const entry of entries) { + const name = Buffer.from(entry.name.replaceAll('\\', '/')) + const data = Buffer.from(entry.data) + const checksum = crc32(data) + const local = Buffer.alloc(30) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(20, 4) + local.writeUInt16LE(0x0800, 6) + local.writeUInt32LE(checksum, 14) + local.writeUInt32LE(data.length, 18) + local.writeUInt32LE(data.length, 22) + local.writeUInt16LE(name.length, 26) + localParts.push(local, name, data) + + const central = Buffer.alloc(46) + central.writeUInt32LE(0x02014b50, 0) + central.writeUInt16LE(20, 4) + central.writeUInt16LE(20, 6) + central.writeUInt16LE(0x0800, 8) + central.writeUInt32LE(checksum, 16) + central.writeUInt32LE(data.length, 20) + central.writeUInt32LE(data.length, 24) + central.writeUInt16LE(name.length, 28) + central.writeUInt32LE(offset, 42) + centralParts.push(central, name) + offset += local.length + name.length + data.length + } + + const centralSize = centralParts.reduce((size, part) => size + part.length, 0) + const end = Buffer.alloc(22) + end.writeUInt32LE(0x06054b50, 0) + end.writeUInt16LE(entries.length, 8) + end.writeUInt16LE(entries.length, 10) + end.writeUInt32LE(centralSize, 12) + end.writeUInt32LE(offset, 16) + return Buffer.concat([...localParts, ...centralParts, end]) +} + +async function listFiles(root, directory = root) { + const files = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) files.push(...(await listFiles(root, path))) + else files.push({ name: relative(root, path).replaceAll('\\', '/'), data: await readFile(path) }) + } + return files +} + +async function copyRuntime(source, destination) { + await mkdir(destination, { recursive: true }) + for (const entry of await readdir(source, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.js')) continue + let contents = await readFile(join(source, entry.name), 'utf8') + if (entry.name === 'main.js') { + contents = contents.replace("'../../src/import-core.js'", "'./importer/import-core.js'") + } + await writeFile(join(destination, entry.name), contents) + } +} + +await rm(stagingDir, { recursive: true, force: true }) +await mkdir(join(stagingDir, 'dist', 'runtime'), { recursive: true }) +await copyRuntime(join(extensionDir, 'source'), join(stagingDir, 'dist', 'runtime')) +await copyRuntime(join(importerDir, 'src'), join(stagingDir, 'dist', 'runtime', 'importer')) + +const sourcePackage = JSON.parse(await readFile(join(extensionDir, 'package.json'), 'utf8')) +delete sourcePackage.scripts +delete sourcePackage.type +sourcePackage.main = './dist/main.js' +await writeFile(join(stagingDir, 'package.json'), `${JSON.stringify(sourcePackage, null, 2)}\n`) +await writeFile( + join(stagingDir, 'dist', 'main.js'), + `"use strict"\nlet runtime\nasync function getRuntime() { return runtime ??= import('./runtime/main.js') }\nexports.methods = {\n async showPairingCode() { return (await getRuntime()).methods.showPairingCode() },\n async showConnectionStatus() { return (await getRuntime()).methods.showConnectionStatus() },\n}\nexports.load = async function load() { return (await getRuntime()).load() }\nexports.unload = async function unload() { return (await getRuntime()).unload() }\n`, +) +await writeFile(join(stagingDir, 'dist', 'runtime', 'package.json'), '{"type":"module"}\n') + +const files = await listFiles(stagingDir) +await mkdir(outputDir, { recursive: true }) +await writeFile(zipPath, storedZip(files)) +await rm(stagingDir, { recursive: true, force: true }) +console.log(zipPath) diff --git a/tools/cocos-importer/extension/scripts/verify-package.mjs b/tools/cocos-importer/extension/scripts/verify-package.mjs new file mode 100644 index 00000000..c06e3649 --- /dev/null +++ b/tools/cocos-importer/extension/scripts/verify-package.mjs @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +import { readStoredZip } from '../../src/zip-reader.js' + +const zipPath = resolve(process.argv[2] ?? '../dist/windup-cocos-importer.zip') +const entries = readStoredZip(await readFile(zipPath)) +const names = entries.map((entry) => entry.name) +for (const required of ['package.json', 'dist/main.js', 'dist/runtime/main.js', 'dist/runtime/importer/import-core.js']) { + if (!names.includes(required)) throw new Error(`PACKAGE_FILE_MISSING: ${required}`) +} + +const forbiddenName = names.find((name) => /(^|\/)(test|temp|\.tmp|node_modules)(\/|$)/i.test(name)) +if (forbiddenName) throw new Error(`PACKAGE_FORBIDDEN_FILE: ${forbiddenName}`) + +for (const entry of entries) { + const text = new TextDecoder().decode(entry.data) + if (/[A-Z]:\\|\/Users\/|tokenDigest"\s*:\s*"[0-9a-f]{64}"/i.test(text)) { + throw new Error(`PACKAGE_LOCAL_OR_SECRET_DATA: ${entry.name}`) + } +} + +const metadata = JSON.parse(new TextDecoder().decode(entries.find((entry) => entry.name === 'package.json').data)) +if (metadata.package_version !== 2 || metadata.main !== './dist/main.js') { + throw new Error('PACKAGE_METADATA_INVALID') +} +if (metadata.contributions.menu.some((item) => item.path !== 'Windup')) { + throw new Error('PACKAGE_MENU_PATH_INVALID') +} +console.log(`OK: ${zipPath} (${entries.length} files)`) diff --git a/tools/cocos-importer/extension/source/creator-assets.js b/tools/cocos-importer/extension/source/creator-assets.js new file mode 100644 index 00000000..6aa1d029 --- /dev/null +++ b/tools/cocos-importer/extension/source/creator-assets.js @@ -0,0 +1,24 @@ +export class CreatorAssets { + #Editor + + constructor(Editor) { + if (!Editor?.Message?.request || !Editor?.Message?.send) { + throw new Error('CREATOR_MESSAGE_API_UNAVAILABLE') + } + this.#Editor = Editor + } + + refresh(dbUrl) { + return this.#Editor.Message.request('asset-db', 'refresh-asset', dbUrl) + } + + query(dbUrl) { + return this.#Editor.Message.request('asset-db', 'query-asset-info', dbUrl) + } + + async reveal(dbUrl) { + const asset = await this.query(dbUrl) + if (!asset?.uuid) throw new Error(`CREATOR_ASSET_NOT_FOUND: ${dbUrl}`) + this.#Editor.Message.send('assets', 'twinkle', asset.uuid) + } +} diff --git a/tools/cocos-importer/extension/source/http-server.js b/tools/cocos-importer/extension/source/http-server.js new file mode 100644 index 00000000..264bbdf5 --- /dev/null +++ b/tools/cocos-importer/extension/source/http-server.js @@ -0,0 +1,208 @@ +import { createHash } from 'node:crypto' +import { createServer } from 'node:http' + +import { PROTOCOL } from './protocol.js' + +const DEFAULT_MAX_UPLOAD_BYTES = 256 * 1024 * 1024 +const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const ALLOWED_HEADERS = [ + 'Authorization', + 'Content-Type', + 'X-Windup-Protocol', + 'X-Windup-Request-Id', + 'X-Windup-SHA256', +].join(', ') + +function cors(origin) { + return origin ? { 'Access-Control-Allow-Origin': origin, Vary: 'Origin' } : {} +} + +function json(response, status, body, headers = {}) { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers }) + response.end(JSON.stringify(body)) +} + +function error(response, status, code, headers = {}) { + json(response, status, { protocol: PROTOCOL, error: { code } }, headers) +} + +async function readBody(request, maxBytes) { + const contentLength = request.headers['content-length'] + if (contentLength !== undefined && Number(contentLength) > maxBytes) { + throw new Error('UPLOAD_TOO_LARGE') + } + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.length + if (size > maxBytes) throw new Error('UPLOAD_TOO_LARGE') + chunks.push(chunk) + } + return Buffer.concat(chunks) +} + +function bearer(request) { + const value = request.headers.authorization + return typeof value === 'string' && value.startsWith('Bearer ') ? value.slice(7) : null +} + +function requestPath(request) { + return new URL(request.url, 'http://127.0.0.1').pathname +} + +async function authenticate(request, response, pairing) { + const origin = request.headers.origin + if (!(await pairing.isOriginAllowed(origin))) { + error(response, 403, 'ORIGIN_FORBIDDEN') + return null + } + const headers = cors(origin) + const token = bearer(request) + if (!token || !(await pairing.authorize(origin, token))) { + error(response, 401, 'UNAUTHORIZED', headers) + return null + } + if (request.headers['x-windup-protocol'] !== PROTOCOL) { + error(response, 426, 'PROTOCOL_INCOMPATIBLE', headers) + return null + } + return { origin, headers } +} + +export async function startServer({ + host = '127.0.0.1', + port = 17_832, + pairing, + jobs, + health, + maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES, +}) { + if (host !== '127.0.0.1') throw new Error('BRIDGE_HOST_FORBIDDEN') + + const nodeServer = createServer(async (request, response) => { + try { + const path = requestPath(request) + const origin = request.headers.origin + + if (request.method === 'GET' && path === '/v1/health') { + const paired = await pairing.isOriginAllowed(origin) + const body = paired + ? { protocol: PROTOCOL, ...(await health()), paired: true } + : { protocol: PROTOCOL, paired: false } + json(response, 200, body, cors(origin)) + return + } + + if (request.method === 'OPTIONS' && path === '/v1/pair') { + response.writeHead(204, { + ...cors(origin), + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '600', + }) + response.end() + return + } + + if (request.method === 'POST' && path === '/v1/pair') { + const headers = cors(origin) + let payload + try { + payload = JSON.parse((await readBody(request, 1024)).toString('utf8')) + } catch (cause) { + error(response, cause.message === 'UPLOAD_TOO_LARGE' ? 413 : 400, 'PAIR_REQUEST_INVALID', headers) + return + } + try { + const token = await pairing.pair(payload.code, origin) + json(response, 200, { protocol: PROTOCOL, token }, headers) + } catch (cause) { + const locked = cause.message === 'PAIR_CODE_LOCKED' + error(response, locked ? 429 : 400, cause.message, headers) + } + return + } + + const importPreflight = path === '/v1/imports' || /^\/v1\/imports\/[^/]+$/.test(path) + if (request.method === 'OPTIONS' && importPreflight) { + if (!(await pairing.isOriginAllowed(origin))) { + error(response, 403, 'ORIGIN_FORBIDDEN') + return + } + const methods = path === '/v1/imports' ? 'POST, OPTIONS' : 'GET, OPTIONS' + response.writeHead(204, { + ...cors(origin), + 'Access-Control-Allow-Methods': methods, + 'Access-Control-Allow-Headers': ALLOWED_HEADERS, + 'Access-Control-Max-Age': '600', + }) + response.end() + return + } + + const jobMatch = path.match(/^\/v1\/imports\/([^/]+)$/) + if (request.method === 'GET' && jobMatch) { + const auth = await authenticate(request, response, pairing) + if (!auth) return + const job = await jobs.get(decodeURIComponent(jobMatch[1])) + if (!job) { + error(response, 404, 'IMPORT_JOB_NOT_FOUND', auth.headers) + return + } + json(response, 200, job, auth.headers) + return + } + + if (request.method === 'POST' && path === '/v1/imports') { + const auth = await authenticate(request, response, pairing) + if (!auth) return + if (request.headers['content-type'] !== 'application/zip') { + error(response, 415, 'CONTENT_TYPE_UNSUPPORTED', auth.headers) + return + } + const requestId = request.headers['x-windup-request-id'] + const expectedSha = request.headers['x-windup-sha256'] + if (!/^[0-9a-f]{64}$/.test(expectedSha ?? '') || !REQUEST_ID.test(requestId ?? '')) { + error(response, 400, 'IMPORT_HEADERS_INVALID', auth.headers) + return + } + + let zipBytes + try { + zipBytes = await readBody(request, maxUploadBytes) + } catch { + error(response, 413, 'UPLOAD_TOO_LARGE', auth.headers) + return + } + const actualSha = createHash('sha256').update(zipBytes).digest('hex') + if (actualSha !== expectedSha) { + error(response, 400, 'UPLOAD_DIGEST_MISMATCH', auth.headers) + return + } + const { jobId } = await jobs.submit({ requestId, zipBytes, sha256: actualSha }) + json(response, 202, { protocol: PROTOCOL, jobId }, auth.headers) + return + } + + error(response, 404, 'NOT_FOUND') + } catch { + if (!response.headersSent) error(response, 500, 'BRIDGE_INTERNAL_ERROR') + else response.destroy() + } + }) + + try { + await new Promise((resolve, reject) => { + nodeServer.once('error', reject) + nodeServer.listen(port, host, resolve) + }) + } catch (cause) { + if (cause?.code === 'EADDRINUSE') throw new Error('BRIDGE_PORT_IN_USE') + throw cause + } + + return { + address: () => nodeServer.address(), + close: () => new Promise((resolve, reject) => nodeServer.close((error) => (error ? reject(error) : resolve()))), + } +} diff --git a/tools/cocos-importer/extension/source/import-job.js b/tools/cocos-importer/extension/source/import-job.js new file mode 100644 index 00000000..726bc6ef --- /dev/null +++ b/tools/cocos-importer/extension/source/import-job.js @@ -0,0 +1,228 @@ +import { createHash } from 'node:crypto' +import { lstat, mkdir, rename, rm, rmdir, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' + +const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const FILE_SYSTEM = { lstat, mkdir, rename, rm, rmdir, writeFile } +const PUBLIC_IMPORT_CODES = new Set([ + 'IMPORT_ABORTED', + 'IMPORT_PATH_FORBIDDEN', + 'IMPORT_PATH_SYMLINK', + 'IMPORT_SHA256_MISMATCH', + 'IMPORT_VERIFY_FAILED', +]) + +async function pathExists(fileSystem, path) { + try { + await fileSystem.lstat(path) + return true + } catch (cause) { + if (cause?.code === 'ENOENT') return false + throw cause + } +} + +function assertWithin(root, path) { + const offset = relative(resolve(root), resolve(path)) + if (offset === '' || (!offset.startsWith(`..${sep}`) && offset !== '..' && !isAbsolute(offset))) return + throw new Error(`IMPORT_PATH_FORBIDDEN: ${path}`) +} + +async function assertNoSymlinkPath(fileSystem, root, path) { + assertWithin(root, path) + const segments = relative(resolve(root), resolve(path)).split(sep).filter(Boolean) + let current = resolve(root) + for (const segment of ['', ...segments]) { + if (segment) current = join(current, segment) + try { + if ((await fileSystem.lstat(current)).isSymbolicLink()) throw new Error(`IMPORT_PATH_SYMLINK: ${current}`) + } catch (cause) { + if (cause?.code === 'ENOENT') return + throw cause + } + } +} + +function safeRelativePath(value) { + if (typeof value !== 'string' || value.includes('\\')) throw new Error(`IMPORT_PATH_FORBIDDEN: ${value}`) + const segments = value.split('/') + if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) { + throw new Error(`IMPORT_PATH_FORBIDDEN: ${value}`) + } + return segments.join(sep) +} + +function throwIfAborted(signal) { + if (signal?.aborted) throw new Error('IMPORT_ABORTED') +} + +async function removeEmptyDirectory(fileSystem, path) { + try { + await fileSystem.rmdir(path) + } catch (cause) { + if (cause?.code !== 'ENOENT' && cause?.code !== 'ENOTEMPTY') throw cause + } +} + +function importFailure(cause, rolledBack, overrideCode) { + const message = cause instanceof Error ? cause.message : String(cause) + const candidate = /^([A-Z][A-Z0-9_]+)(?::|$)/.exec(message)?.[1] + const error = new Error(message, { cause }) + error.code = overrideCode ?? (PUBLIC_IMPORT_CODES.has(candidate) ? candidate : 'IMPORT_FAILED') + error.rolledBack = rolledBack + return error +} + +export class ImportJobRunner { + #projectPath + #assets + #prepareImport + #fileSystem + #requests = new Map() + + constructor({ projectPath, assets, prepareImport, fileSystem = {} }) { + this.#projectPath = resolve(projectPath) + this.#assets = assets + this.#prepareImport = prepareImport + this.#fileSystem = { ...FILE_SYSTEM, ...fileSystem } + } + + run(request) { + const existing = this.#requests.get(request.requestId) + if (existing) { + if (existing.sha256 !== request.sha256) return Promise.reject(new Error('IMPORT_REQUEST_ID_CONFLICT')) + return existing.promise + } + const promise = this.#execute(request) + this.#requests.set(request.requestId, { sha256: request.sha256, promise }) + void promise.finally(() => this.#requests.delete(request.requestId)).catch(() => {}) + return promise + } + + async #execute({ requestId, zipBytes, sha256, onPhase = () => {}, signal }) { + if (!REQUEST_ID.test(requestId)) throw new Error('IMPORT_REQUEST_ID_INVALID') + const actualSha = createHash('sha256').update(zipBytes).digest('hex') + if (actualSha !== sha256) throw new Error('IMPORT_SHA256_MISMATCH') + + throwIfAborted(signal) + onPhase('converting') + const prepared = this.#prepareImport(zipBytes) + const packRelative = safeRelativePath(prepared.packFolder) + if (!prepared.packFolder.startsWith('windup-imports/')) { + throw new Error(`IMPORT_PATH_FORBIDDEN: ${prepared.packFolder}`) + } + + const assetsRoot = join(this.#projectPath, 'assets') + const importRoot = join(assetsRoot, 'windup-imports') + const destination = join(assetsRoot, packRelative) + assertWithin(importRoot, destination) + + const tempRoot = join(this.#projectPath, 'temp', 'windup-importer') + const transactionRoot = join(tempRoot, requestId) + const outputRoot = join(transactionRoot, 'output') + const stagedPack = join(outputRoot, packRelative) + const backup = join(transactionRoot, 'backup') + assertWithin(tempRoot, transactionRoot) + + let backupCreated = false + let installed = false + const dbFolder = `db://assets/${prepared.packFolder}` + const prefabDbUrl = `db://assets/${prepared.plan.prefab.cocosPath}` + + try { + await assertNoSymlinkPath(this.#fileSystem, this.#projectPath, destination) + await assertNoSymlinkPath(this.#fileSystem, this.#projectPath, transactionRoot) + await this.#fileSystem.rm(transactionRoot, { recursive: true, force: true }) + for (const [filePath, bytes] of prepared.files) { + throwIfAborted(signal) + const fileRelative = safeRelativePath(filePath) + if (filePath !== prepared.packFolder && !filePath.startsWith(`${prepared.packFolder}/`)) { + throw new Error(`IMPORT_PATH_FORBIDDEN: ${filePath}`) + } + const stagedFile = join(outputRoot, fileRelative) + assertWithin(stagedPack, stagedFile) + await this.#fileSystem.mkdir(dirname(stagedFile), { recursive: true }) + await this.#fileSystem.writeFile(stagedFile, bytes) + } + + throwIfAborted(signal) + onPhase('writing') + await this.#fileSystem.mkdir(dirname(destination), { recursive: true }) + if (await pathExists(this.#fileSystem, destination)) { + await this.#fileSystem.rename(destination, backup) + backupCreated = true + } + await this.#fileSystem.rename(stagedPack, destination) + installed = true + + throwIfAborted(signal) + onPhase('refreshing') + await this.#assets.refresh(dbFolder) + + throwIfAborted(signal) + onPhase('verifying') + const requiredDbUrls = [ + prefabDbUrl, + ...prepared.plan.animations.map( + (animation) => `db://assets/${prepared.packFolder}/animations/${animation.name}.anim`, + ), + ] + for (const dbUrl of requiredDbUrls) { + throwIfAborted(signal) + if (!(await this.#assets.query(dbUrl))) throw new Error(`IMPORT_VERIFY_FAILED: ${dbUrl}`) + } + await this.#assets.reveal(prefabDbUrl) + + await this.#fileSystem.rm(transactionRoot, { recursive: true, force: true }) + await removeEmptyDirectory(this.#fileSystem, tempRoot) + return { + dbUrl: prefabDbUrl, + animationCount: prepared.summary.animationCount, + frameCount: prepared.summary.frameCount, + } + } catch (cause) { + const rollbackNeeded = installed || backupCreated + const rollbackErrors = [] + if (installed) { + try { + await this.#fileSystem.rm(destination, { recursive: true, force: true }) + } catch (error) { + rollbackErrors.push(error) + } + } + if (backupCreated) { + try { + if (await pathExists(this.#fileSystem, backup)) { + await this.#fileSystem.mkdir(dirname(destination), { recursive: true }) + await this.#fileSystem.rename(backup, destination) + } else { + rollbackErrors.push(new Error('IMPORT_BACKUP_MISSING')) + } + } catch (error) { + rollbackErrors.push(error) + } + } + if (rollbackNeeded) { + try { + await this.#assets.refresh(dbFolder) + } catch (error) { + rollbackErrors.push(error) + } + } + try { + await this.#fileSystem.rm(transactionRoot, { recursive: true, force: true }) + } catch { + // Cleanup is best-effort and does not change whether the user asset was restored. + } + try { + await removeEmptyDirectory(this.#fileSystem, tempRoot) + } catch { + // Keep any stale transaction data for diagnosis; user assets are already restored. + } + if (rollbackErrors.length > 0) { + throw importFailure(cause, false, 'IMPORT_ROLLBACK_FAILED') + } + throw importFailure(cause, rollbackNeeded) + } + } +} diff --git a/tools/cocos-importer/extension/source/import-jobs.js b/tools/cocos-importer/extension/source/import-jobs.js new file mode 100644 index 00000000..be7db391 --- /dev/null +++ b/tools/cocos-importer/extension/source/import-jobs.js @@ -0,0 +1,103 @@ +import { randomUUID } from 'node:crypto' + +import { PROTOCOL } from './protocol.js' + +function publicJob(job) { + const output = { + protocol: PROTOCOL, + jobId: job.jobId, + status: job.status, + phase: job.phase, + } + if (job.result) output.result = job.result + if (job.error) output.error = job.error + return output +} + +const PUBLIC_ERRORS = new Map([ + ['IMPORT_ABORTED', '导入已取消'], + ['IMPORT_PATH_FORBIDDEN', '导入包包含不安全路径'], + ['IMPORT_PATH_SYMLINK', '导入目标包含符号链接'], + ['IMPORT_SHA256_MISMATCH', '导入包完整性校验失败'], + ['IMPORT_VERIFY_FAILED', '导入结果校验失败'], + ['IMPORT_ROLLBACK_FAILED', '导入失败且无法完整回滚,请检查工程资产'], +]) + +function publicError(cause) { + const code = cause instanceof Error && PUBLIC_ERRORS.has(cause.code) ? cause.code : 'IMPORT_FAILED' + return { + code, + message: PUBLIC_ERRORS.get(code) ?? 'Cocos 导入失败', + rolledBack: cause instanceof Error && cause.rolledBack === true, + } +} + +export function createImportJobs({ runner, projectName, randomUUID: createId = randomUUID, maxJobs = 20 }) { + const jobs = new Map() + const requests = new Map() + let closed = false + + return { + async submit(request) { + if (closed) throw new Error('IMPORT_SERVICE_CLOSED') + const existing = requests.get(request.requestId) + if (existing) { + if (existing.sha256 !== request.sha256) throw new Error('IMPORT_REQUEST_ID_CONFLICT') + return { jobId: existing.jobId } + } + + for (const [jobId, job] of jobs) { + if (jobs.size < maxJobs) break + if (job.status === 'running') continue + jobs.delete(jobId) + requests.delete(job.requestId) + } + if (jobs.size >= maxJobs) throw new Error('IMPORT_QUEUE_FULL') + + const jobId = createId() + const controller = new AbortController() + const job = { jobId, requestId: request.requestId, status: 'running', phase: 'converting', controller } + jobs.set(jobId, job) + requests.set(request.requestId, { jobId, sha256: request.sha256 }) + + void runner + .run({ + ...request, + signal: controller.signal, + onPhase(phase) { + if (job.status === 'running') job.phase = phase + }, + }) + .then((result) => { + if (job.status !== 'running') return + if (controller.signal.aborted) { + job.status = 'failed' + job.error = { code: 'IMPORT_ABORTED', message: '导入已取消', rolledBack: false } + return + } + job.status = 'completed' + job.phase = 'verifying' + job.result = { projectName: projectName(), ...result } + }) + .catch((cause) => { + if (job.status !== 'running') return + job.status = 'failed' + job.error = publicError(cause) + }) + return { jobId } + }, + + async get(jobId) { + const job = jobs.get(jobId) + return job ? publicJob(job) : null + }, + + close() { + closed = true + for (const job of jobs.values()) { + if (job.status !== 'running') continue + job.controller.abort() + } + }, + } +} diff --git a/tools/cocos-importer/extension/source/main.js b/tools/cocos-importer/extension/source/main.js new file mode 100644 index 00000000..d3c9256e --- /dev/null +++ b/tools/cocos-importer/extension/source/main.js @@ -0,0 +1,101 @@ +import { startServer } from './http-server.js' +import { PairingStore } from './pairing-store.js' +import { CreatorAssets } from './creator-assets.js' +import { ImportJobRunner } from './import-job.js' +import { createImportJobs } from './import-jobs.js' +import { prepareImport } from '../../src/import-core.js' + +const PACKAGE_NAME = 'windup-cocos-importer' +const PORT = 17_832 + +let activeExtension = null + +function dialog(Editor, title, message) { + if (Editor.Dialog?.info) return Editor.Dialog.info(message, { title }) + console.info(`[${title}] ${message}`) +} + +function profileAdapter(Editor) { + return { + load: () => Editor.Profile.getConfig(PACKAGE_NAME, 'pairing', 'global'), + save: (value) => Editor.Profile.setConfig(PACKAGE_NAME, 'pairing', value, 'global'), + } +} + +export function createExtension({ Editor, jobs, serverFactory = startServer }) { + const pairing = new PairingStore({ profile: profileAdapter(Editor) }) + const activeJobs = + jobs ?? + createImportJobs({ + runner: new ImportJobRunner({ + projectPath: Editor.Project.path, + assets: new CreatorAssets(Editor), + prepareImport, + }), + projectName: () => Editor.Project.name, + }) + let server = null + + return { + pairing, + async load() { + try { + server = await serverFactory({ + host: '127.0.0.1', + port: PORT, + pairing, + jobs: activeJobs, + health: async () => ({ + creatorVersion: Editor.App.version, + projectName: Editor.Project.name, + projectOpen: Boolean(Editor.Project.path), + }), + }) + console.info(`[Windup] Cocos 一键导入服务已启动:http://127.0.0.1:${PORT}`) + } catch (cause) { + const message = cause?.message === 'BRIDGE_PORT_IN_USE' ? 'BRIDGE_PORT_IN_USE' : 'BRIDGE_START_FAILED' + console.error(`[Windup] ${message}`, cause) + throw cause + } + }, + async unload() { + const current = server + server = null + await current?.close() + activeJobs.close?.() + }, + async showPairingCode() { + const code = pairing.createCode() + await dialog(Editor, 'Windup 一键导入', `连接码:${code}\n有效期 5 分钟,请在 Windup 网页中输入。`) + return code + }, + async showConnectionStatus() { + const pairingValue = await profileAdapter(Editor).load() + const status = pairingValue?.origin + ? `已授权网页:${pairingValue.origin}\n服务地址:http://127.0.0.1:${PORT}` + : '尚未授权 Windup 网页。请先选择“显示连接码”。' + await dialog(Editor, 'Windup 连接状态', status) + return status + }, + } +} + +export const methods = { + showPairingCode() { + return activeExtension?.showPairingCode() + }, + showConnectionStatus() { + return activeExtension?.showConnectionStatus() + }, +} + +export async function load() { + activeExtension = createExtension({ Editor: globalThis.Editor }) + await activeExtension.load() +} + +export async function unload() { + const current = activeExtension + activeExtension = null + await current?.unload() +} diff --git a/tools/cocos-importer/extension/source/pairing-store.js b/tools/cocos-importer/extension/source/pairing-store.js new file mode 100644 index 00000000..33be63e8 --- /dev/null +++ b/tools/cocos-importer/extension/source/pairing-store.js @@ -0,0 +1,89 @@ +import { createHash, randomBytes, randomInt, timingSafeEqual } from 'node:crypto' + +const CODE_TTL_MS = 5 * 60_000 +const MAX_ATTEMPTS = 5 + +function digest(value) { + return createHash('sha256').update(value).digest('hex') +} + +function validOrigin(origin) { + if (typeof origin !== 'string') return false + try { + const parsed = new URL(origin) + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.origin === origin + } catch { + return false + } +} + +function equalDigest(left, right) { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) +} + +export class PairingStore { + #profile + #now + #randomCode + #randomToken + #code = null + + constructor({ + profile, + now = Date.now, + randomCode = () => randomInt(0, 1_000_000).toString().padStart(6, '0'), + randomToken = () => randomBytes(32).toString('hex'), + }) { + if (!profile?.load || !profile?.save) throw new Error('PAIR_PROFILE_REQUIRED') + this.#profile = profile + this.#now = now + this.#randomCode = randomCode + this.#randomToken = randomToken + } + + createCode() { + const value = this.#randomCode() + if (!/^\d{6}$/.test(value)) throw new Error('PAIR_CODE_INVALID_FORMAT') + this.#code = { value, expiresAt: this.#now() + CODE_TTL_MS, attempts: 0 } + return value + } + + hasActiveCode() { + return this.#code !== null && this.#code.attempts < MAX_ATTEMPTS && this.#now() <= this.#code.expiresAt + } + + async pair(code, origin) { + if (!this.hasActiveCode()) { + this.#code = null + throw new Error('PAIR_CODE_EXPIRED') + } + if (!validOrigin(origin)) throw new Error('PAIR_ORIGIN_INVALID') + if (code !== this.#code.value) { + this.#code.attempts += 1 + if (this.#code.attempts >= MAX_ATTEMPTS) throw new Error('PAIR_CODE_LOCKED') + throw new Error('PAIR_CODE_INVALID') + } + + const token = this.#randomToken() + await this.#profile.save({ origin, tokenDigest: digest(token) }) + this.#code = null + return token + } + + async authorize(origin, token) { + if (!validOrigin(origin) || typeof token !== 'string') return false + const pairing = await this.#profile.load() + return ( + pairing?.origin === origin && + typeof pairing.tokenDigest === 'string' && + equalDigest(pairing.tokenDigest, digest(token)) + ) + } + + async isOriginAllowed(origin) { + if (!validOrigin(origin)) return false + return (await this.#profile.load())?.origin === origin + } +} diff --git a/tools/cocos-importer/extension/source/protocol.js b/tools/cocos-importer/extension/source/protocol.js new file mode 100644 index 00000000..d19cb4f3 --- /dev/null +++ b/tools/cocos-importer/extension/source/protocol.js @@ -0,0 +1 @@ +export const PROTOCOL = 'windup-cocos-bridge/1.0.0' diff --git a/tools/cocos-importer/extension/test/creator-assets.test.mjs b/tools/cocos-importer/extension/test/creator-assets.test.mjs new file mode 100644 index 00000000..85b93272 --- /dev/null +++ b/tools/cocos-importer/extension/test/creator-assets.test.mjs @@ -0,0 +1,48 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { CreatorAssets } from '../source/creator-assets.js' + +test('CreatorAssets isolates the Creator 3.8.8 AssetDB message contract', async () => { + const calls = [] + const Editor = { + Message: { + async request(...args) { + calls.push(['request', ...args]) + if (args[1] === 'query-asset-info') return { uuid: 'asset-uuid', url: args[2] } + return true + }, + send(...args) { + calls.push(['send', ...args]) + }, + }, + } + const assets = new CreatorAssets(Editor) + + await assets.refresh('db://assets/windup-imports/Hero/Ranger') + assert.deepEqual(await assets.query('db://assets/windup-imports/Hero/Ranger/p.prefab'), { + uuid: 'asset-uuid', + url: 'db://assets/windup-imports/Hero/Ranger/p.prefab', + }) + await assets.reveal('db://assets/windup-imports/Hero/Ranger/p.prefab') + + assert.deepEqual(calls, [ + ['request', 'asset-db', 'refresh-asset', 'db://assets/windup-imports/Hero/Ranger'], + ['request', 'asset-db', 'query-asset-info', 'db://assets/windup-imports/Hero/Ranger/p.prefab'], + ['request', 'asset-db', 'query-asset-info', 'db://assets/windup-imports/Hero/Ranger/p.prefab'], + ['send', 'assets', 'twinkle', 'asset-uuid'], + ]) +}) + +test('CreatorAssets rejects missing assets before attempting to reveal them', async () => { + const Editor = { + Message: { + request: async () => null, + send: () => assert.fail('must not reveal a missing asset'), + }, + } + await assert.rejects( + () => new CreatorAssets(Editor).reveal('db://assets/missing.prefab'), + /CREATOR_ASSET_NOT_FOUND/, + ) +}) diff --git a/tools/cocos-importer/extension/test/http-server.test.mjs b/tools/cocos-importer/extension/test/http-server.test.mjs new file mode 100644 index 00000000..744d5b4f --- /dev/null +++ b/tools/cocos-importer/extension/test/http-server.test.mjs @@ -0,0 +1,237 @@ +import { afterEach, test } from 'node:test' +import assert from 'node:assert/strict' + +import { startServer } from '../source/http-server.js' +import { PairingStore } from '../source/pairing-store.js' +import { PROTOCOL } from '../source/protocol.js' + +const servers = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())) +}) + +function dependencies() { + const profile = { + value: null, + async load() { + return this.value + }, + async save(value) { + this.value = value + }, + } + const pairing = new PairingStore({ + profile, + randomCode: () => '123456', + randomToken: () => 'a'.repeat(64), + }) + const submitted = [] + const jobs = { + async submit(request) { + submitted.push(request) + return { jobId: 'job-1' } + }, + async get(jobId) { + return { + protocol: PROTOCOL, + jobId, + status: 'completed', + phase: 'verifying', + result: { projectName: 'Game', dbUrl: 'db://assets/p.prefab', animationCount: 2, frameCount: 64 }, + } + }, + } + return { pairing, jobs, submitted } +} + +async function running(options = {}) { + const deps = dependencies() + const server = await startServer({ + host: '127.0.0.1', + port: 0, + pairing: deps.pairing, + jobs: deps.jobs, + health: async () => ({ creatorVersion: '3.8.8', projectName: 'Game', projectOpen: true }), + ...options, + }) + servers.push(server) + const address = server.address() + assert.equal(address.address, '127.0.0.1') + return { ...deps, server, baseUrl: `http://127.0.0.1:${address.port}` } +} + +async function pair(baseUrl, pairing, origin = 'https://windup.example') { + pairing.createCode() + const response = await fetch(`${baseUrl}/v1/pair`, { + method: 'POST', + headers: { Origin: origin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: '123456' }), + }) + assert.equal(response.status, 200) + return (await response.json()).token +} + +test('server exposes only protocol and pairing state before the origin is paired', async () => { + const { baseUrl } = await running() + const response = await fetch(`${baseUrl}/v1/health`, { headers: { Origin: 'https://windup.example' } }) + const body = await response.json() + + assert.equal(response.status, 200) + assert.equal(response.headers.get('Access-Control-Allow-Origin'), 'https://windup.example') + assert.deepEqual(body, { protocol: PROTOCOL, paired: false }) + assert.equal(JSON.stringify(body).includes('projectPath'), false) +}) + +test('server exposes project health only to the paired origin', async () => { + const { baseUrl, pairing } = await running() + await pair(baseUrl, pairing) + const response = await fetch(`${baseUrl}/v1/health`, { + headers: { Origin: 'https://windup.example' }, + }) + assert.deepEqual(await response.json(), { + protocol: PROTOCOL, + creatorVersion: '3.8.8', + projectName: 'Game', + projectOpen: true, + paired: true, + }) +}) + +test('server pairs once and accepts an authenticated ZIP import from the exact origin', async () => { + const { baseUrl, pairing, submitted } = await running() + const origin = 'https://windup.example' + const token = await pair(baseUrl, pairing, origin) + const bytes = new TextEncoder().encode('zip') + const response = await fetch(`${baseUrl}/v1/imports`, { + method: 'POST', + headers: { + Origin: origin, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/zip', + 'X-Windup-Protocol': PROTOCOL, + 'X-Windup-Request-Id': '11111111-1111-4111-8111-111111111111', + 'X-Windup-SHA256': '4a70fe9aa6436e02c2dea340fbd1e352e4ef2d8ce6ca52ad25d4b95471fc8bf2', + }, + body: bytes, + }) + + assert.equal(response.status, 202) + assert.equal(response.headers.get('Access-Control-Allow-Origin'), origin) + assert.deepEqual(await response.json(), { protocol: PROTOCOL, jobId: 'job-1' }) + assert.equal(submitted.length, 1) + assert.equal(new TextDecoder().decode(submitted[0].zipBytes), 'zip') +}) + +test('server rejects wrong origins and missing bearer tokens', async () => { + const { baseUrl, pairing } = await running() + const token = await pair(baseUrl, pairing) + const headers = { + Origin: 'https://evil.example', + Authorization: `Bearer ${token}`, + 'X-Windup-Protocol': PROTOCOL, + } + const wrongOrigin = await fetch(`${baseUrl}/v1/imports/job-1`, { headers }) + assert.equal(wrongOrigin.status, 403) + + const missingToken = await fetch(`${baseUrl}/v1/imports/job-1`, { + headers: { Origin: 'https://windup.example', 'X-Windup-Protocol': PROTOCOL }, + }) + assert.equal(missingToken.status, 401) +}) + +test('server handles an authorized CORS preflight with the fixed header allowlist', async () => { + const { baseUrl, pairing } = await running() + await pair(baseUrl, pairing) + const response = await fetch(`${baseUrl}/v1/imports`, { + method: 'OPTIONS', + headers: { + Origin: 'https://windup.example', + 'Access-Control-Request-Method': 'POST', + }, + }) + + assert.equal(response.status, 204) + assert.equal(response.headers.get('Access-Control-Allow-Origin'), 'https://windup.example') + assert.match(response.headers.get('Access-Control-Allow-Headers'), /Authorization/) +}) + +test('server handles pairing and job polling preflights', async () => { + const { baseUrl, pairing } = await running() + const origin = 'https://windup.example' + const pairingPreflight = await fetch(`${baseUrl}/v1/pair`, { + method: 'OPTIONS', + headers: { Origin: origin, 'Access-Control-Request-Method': 'POST' }, + }) + assert.equal(pairingPreflight.status, 204) + assert.equal(pairingPreflight.headers.get('Access-Control-Allow-Origin'), origin) + assert.match(pairingPreflight.headers.get('Access-Control-Allow-Methods'), /POST/) + assert.match(pairingPreflight.headers.get('Access-Control-Allow-Headers'), /Content-Type/) + + await pair(baseUrl, pairing, origin) + const pollingPreflight = await fetch(`${baseUrl}/v1/imports/job-1`, { + method: 'OPTIONS', + headers: { Origin: origin, 'Access-Control-Request-Method': 'GET' }, + }) + assert.equal(pollingPreflight.status, 204) + assert.equal(pollingPreflight.headers.get('Access-Control-Allow-Origin'), origin) + assert.match(pollingPreflight.headers.get('Access-Control-Allow-Methods'), /GET/) + assert.match(pollingPreflight.headers.get('Access-Control-Allow-Headers'), /Authorization/) +}) + +test('server rejects non-loopback binding and oversized uploads', async () => { + await assert.rejects( + () => running({ host: '0.0.0.0' }), + /BRIDGE_HOST_FORBIDDEN/, + ) + const { baseUrl, pairing, submitted } = await running({ maxUploadBytes: 2 }) + const token = await pair(baseUrl, pairing) + const response = await fetch(`${baseUrl}/v1/imports`, { + method: 'POST', + headers: { + Origin: 'https://windup.example', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/zip', + 'X-Windup-Protocol': PROTOCOL, + 'X-Windup-Request-Id': '11111111-1111-4111-8111-111111111111', + 'X-Windup-SHA256': '4a70fe9aa6436e02c2dea340fbd1e352e4ef2d8ce6ca52ad25d4b95471fc8bf2', + }, + body: 'zip', + }) + assert.equal(response.status, 413) + assert.equal(submitted.length, 0) +}) + +test('server rejects a 36-character request id that is not a UUID', async () => { + const { baseUrl, pairing, submitted } = await running() + const token = await pair(baseUrl, pairing) + const bytes = new TextEncoder().encode('zip') + const response = await fetch(`${baseUrl}/v1/imports`, { + method: 'POST', + headers: { + Origin: 'https://windup.example', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/zip', + 'X-Windup-Protocol': PROTOCOL, + 'X-Windup-Request-Id': '------------------------------------', + 'X-Windup-SHA256': '4a70fe9aa6436e02c2dea340fbd1e352e4ef2d8ce6ca52ad25d4b95471fc8bf2', + }, + body: bytes, + }) + + assert.equal(response.status, 400) + assert.equal(submitted.length, 0) +}) + +test('server returns 426 for incompatible protocol versions', async () => { + const { baseUrl, pairing } = await running() + const token = await pair(baseUrl, pairing) + const response = await fetch(`${baseUrl}/v1/imports/job-1`, { + headers: { + Origin: 'https://windup.example', + Authorization: `Bearer ${token}`, + 'X-Windup-Protocol': 'windup-cocos-bridge/2.0.0', + }, + }) + assert.equal(response.status, 426) +}) diff --git a/tools/cocos-importer/extension/test/import-job.test.mjs b/tools/cocos-importer/extension/test/import-job.test.mjs new file mode 100644 index 00000000..25156920 --- /dev/null +++ b/tools/cocos-importer/extension/test/import-job.test.mjs @@ -0,0 +1,180 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, test } from 'node:test' +import assert from 'node:assert/strict' + +import { ImportJobRunner } from '../source/import-job.js' + +const roots = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function fixture(overrides = {}) { + const projectPath = await mkdtemp(join(tmpdir(), 'windup-cocos-job-')) + roots.push(projectPath) + const calls = [] + const assets = { + async refresh(dbUrl) { + calls.push(['refresh', dbUrl]) + }, + async query(dbUrl) { + calls.push(['query', dbUrl]) + return { uuid: `uuid-${calls.length}`, url: dbUrl } + }, + async reveal(dbUrl) { + calls.push(['reveal', dbUrl]) + }, + ...overrides.assets, + } + const prepared = { + packFolder: 'windup-imports/Hero/Ranger', + files: new Map([ + ['windup-imports/Hero/Ranger/animations/Walk.anim', new TextEncoder().encode('animation')], + ['windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab', new TextEncoder().encode('prefab')], + ]), + plan: { + animations: [{ name: 'Walk' }], + prefab: { cocosPath: 'windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab' }, + }, + summary: { animationCount: 1, frameCount: 3 }, + ...overrides.prepared, + } + let prepares = 0 + const runner = new ImportJobRunner({ + projectPath, + assets, + fileSystem: overrides.fileSystem, + prepareImport: () => { + prepares += 1 + if (overrides.prepareError) throw overrides.prepareError + return prepared + }, + }) + return { projectPath, calls, runner, prepared, prepares: () => prepares } +} + +function request(bytes = new TextEncoder().encode('zip')) { + return { + requestId: '11111111-1111-4111-8111-111111111111', + zipBytes: bytes, + sha256: createHash('sha256').update(bytes).digest('hex'), + } +} + +test('ImportJobRunner writes, refreshes, verifies and reveals a new import', async () => { + const { projectPath, calls, runner } = await fixture() + const phases = [] + const result = await runner.run({ ...request(), onPhase: (phase) => phases.push(phase) }) + + assert.equal( + await readFile(join(projectPath, 'assets/windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab'), 'utf8'), + 'prefab', + ) + assert.deepEqual(phases, ['converting', 'writing', 'refreshing', 'verifying']) + assert.equal(result.dbUrl, 'db://assets/windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab') + assert.equal(result.animationCount, 1) + assert.deepEqual(calls.at(-1), ['reveal', result.dbUrl]) + assert.equal(existsSync(join(projectPath, 'temp/windup-importer')), false) +}) + +test('ImportJobRunner returns the same promise for a duplicate request id', async () => { + const context = await fixture() + const first = context.runner.run(request()) + const second = context.runner.run(request()) + assert.equal(first, second) + assert.deepEqual(await second, await first) + assert.equal(context.prepares(), 1) +}) + +test('ImportJobRunner rejects digest mismatch before conversion or disk writes', async () => { + const context = await fixture() + await assert.rejects( + () => context.runner.run({ ...request(), sha256: '0'.repeat(64) }), + /IMPORT_SHA256_MISMATCH/, + ) + assert.equal(context.prepares(), 0) + assert.equal(existsSync(join(context.projectPath, 'assets')), false) +}) + +test('ImportJobRunner rejects a prepared path outside assets/windup-imports', async () => { + const context = await fixture({ + prepared: { + packFolder: '../escape', + files: new Map([['../escape/p.prefab', new TextEncoder().encode('bad')]]), + plan: { animations: [], prefab: { cocosPath: '../escape/p.prefab' } }, + }, + }) + await assert.rejects(() => context.runner.run(request()), /IMPORT_PATH_FORBIDDEN/) + assert.equal(existsSync(join(context.projectPath, 'escape')), false) +}) + +test('ImportJobRunner restores an existing pack when AssetDB refresh fails', async () => { + let refreshes = 0 + const context = await fixture({ + assets: { + async refresh() { + refreshes += 1 + if (refreshes === 1) throw new Error('refresh failed') + }, + }, + }) + const oldPrefab = join(context.projectPath, 'assets/windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab') + await mkdir(join(oldPrefab, '..'), { recursive: true }) + await writeFile(oldPrefab, 'original prefab') + + await assert.rejects( + () => context.runner.run(request()), + (error) => error.code === 'IMPORT_FAILED' && error.rolledBack === true, + ) + assert.equal(await readFile(oldPrefab, 'utf8'), 'original prefab') + assert.equal(refreshes, 2) + assert.equal(existsSync(join(context.projectPath, 'temp/windup-importer')), false) +}) + +test('ImportJobRunner removes a new pack when verification fails', async () => { + const context = await fixture({ assets: { query: async () => null } }) + await assert.rejects( + () => context.runner.run(request()), + (error) => error.code === 'IMPORT_VERIFY_FAILED' && error.rolledBack === true, + ) + assert.equal(existsSync(join(context.projectPath, 'assets/windup-imports/Hero/Ranger')), false) + assert.equal(existsSync(join(context.projectPath, 'temp/windup-importer')), false) +}) + +test('ImportJobRunner reports rollback failure without skipping transaction cleanup', async () => { + const context = await fixture({ + assets: { query: async () => null }, + fileSystem: { + async rm(path, options) { + if (path.endsWith(join('Hero', 'Ranger'))) throw new Error('private disk path') + return rm(path, options) + }, + }, + }) + + await assert.rejects( + () => context.runner.run(request()), + (error) => error.code === 'IMPORT_ROLLBACK_FAILED' && error.rolledBack === false, + ) + assert.equal(existsSync(join(context.projectPath, 'temp/windup-importer')), false) +}) + +test('ImportJobRunner rejects a symlinked import root before writing outside the project', async () => { + const context = await fixture() + const outside = await mkdtemp(join(tmpdir(), 'windup-cocos-outside-')) + roots.push(outside) + await mkdir(join(context.projectPath, 'assets'), { recursive: true }) + await symlink( + outside, + join(context.projectPath, 'assets/windup-imports'), + process.platform === 'win32' ? 'junction' : 'dir', + ) + + await assert.rejects(() => context.runner.run(request()), /IMPORT_PATH_SYMLINK/) + assert.equal(existsSync(join(outside, 'Hero/Ranger')), false) +}) diff --git a/tools/cocos-importer/extension/test/import-jobs.test.mjs b/tools/cocos-importer/extension/test/import-jobs.test.mjs new file mode 100644 index 00000000..2c0ceaf2 --- /dev/null +++ b/tools/cocos-importer/extension/test/import-jobs.test.mjs @@ -0,0 +1,167 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { createImportJobs } from '../source/import-jobs.js' +import { PROTOCOL } from '../source/protocol.js' + +function deferred() { + let resolve + let reject + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +async function nextTurn() { + await new Promise((resolve) => setImmediate(resolve)) +} + +test('import jobs expose live phases and a completed protocol result', async () => { + const run = deferred() + let request + const jobs = createImportJobs({ + runner: { + run(value) { + request = value + return run.promise + }, + }, + projectName: () => 'Game', + randomUUID: () => 'job-1', + }) + + assert.deepEqual(await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array([1]), sha256: 'abc' }), { + jobId: 'job-1', + }) + request.onPhase('writing') + assert.deepEqual(await jobs.get('job-1'), { + protocol: PROTOCOL, + jobId: 'job-1', + status: 'running', + phase: 'writing', + }) + + run.resolve({ dbUrl: 'db://assets/p.prefab', animationCount: 2, frameCount: 64 }) + await nextTurn() + assert.deepEqual(await jobs.get('job-1'), { + protocol: PROTOCOL, + jobId: 'job-1', + status: 'completed', + phase: 'verifying', + result: { + projectName: 'Game', + dbUrl: 'db://assets/p.prefab', + animationCount: 2, + frameCount: 64, + }, + }) +}) + +test('import jobs reuse a job and expose a safe runner-provided failure', async () => { + const run = deferred() + const jobs = createImportJobs({ + runner: { + run(request) { + request.onPhase('verifying') + return run.promise + }, + }, + projectName: () => 'Game', + randomUUID: () => 'job-1', + }) + const submitted = await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array(), sha256: 'abc' }) + assert.deepEqual(await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array(), sha256: 'abc' }), submitted) + + const failure = new Error('internal detail must not leak') + failure.code = 'IMPORT_VERIFY_FAILED' + failure.rolledBack = true + run.reject(failure) + await nextTurn() + assert.deepEqual(await jobs.get('job-1'), { + protocol: PROTOCOL, + jobId: 'job-1', + status: 'failed', + phase: 'verifying', + error: { code: 'IMPORT_VERIFY_FAILED', message: '导入结果校验失败', rolledBack: true }, + }) + assert.equal(await jobs.get('missing'), null) +}) + +test('import jobs redact arbitrary filesystem errors', async () => { + const jobs = createImportJobs({ + runner: { run: async () => { throw new Error('ENOENT: C:\\Users\\private\\secret.png') } }, + projectName: () => 'Game', + randomUUID: () => 'job-1', + }) + await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array(), sha256: 'abc' }) + await nextTurn() + const job = await jobs.get('job-1') + assert.deepEqual(job.error, { code: 'IMPORT_FAILED', message: 'Cocos 导入失败', rolledBack: false }) + assert.equal(JSON.stringify(job).includes('C:\\Users'), false) +}) + +test('import jobs evict completed history at the configured bound', async () => { + let nextId = 0 + const jobs = createImportJobs({ + runner: { run: async () => ({ dbUrl: 'db://assets/p.prefab', animationCount: 1, frameCount: 1 }) }, + projectName: () => 'Game', + randomUUID: () => `job-${++nextId}`, + maxJobs: 2, + }) + for (let index = 1; index <= 3; index += 1) { + await jobs.submit({ requestId: `request-${index}`, zipBytes: new Uint8Array(), sha256: `${index}` }) + await nextTurn() + } + assert.equal(await jobs.get('job-1'), null) + assert.equal((await jobs.get('job-3')).status, 'completed') +}) + +test('import jobs reject a new request when every bounded slot is running', async () => { + const run = deferred() + const jobs = createImportJobs({ + runner: { run: () => run.promise }, + projectName: () => 'Game', + randomUUID: () => 'job-1', + maxJobs: 1, + }) + await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array(), sha256: 'abc' }) + await assert.rejects( + () => jobs.submit({ requestId: 'request-2', zipBytes: new Uint8Array(), sha256: 'def' }), + /IMPORT_QUEUE_FULL/, + ) + run.resolve({ dbUrl: 'db://assets/p.prefab', animationCount: 1, frameCount: 1 }) +}) + +test('closing jobs aborts running imports and rejects later submissions', async () => { + const run = deferred() + let signal + const jobs = createImportJobs({ + runner: { + run(request) { + signal = request.signal + return run.promise + }, + }, + projectName: () => 'Game', + randomUUID: () => 'job-1', + }) + await jobs.submit({ requestId: 'request-1', zipBytes: new Uint8Array(), sha256: 'abc' }) + jobs.close() + assert.equal(signal.aborted, true) + await assert.rejects( + () => jobs.submit({ requestId: 'request-2', zipBytes: new Uint8Array(), sha256: 'def' }), + /IMPORT_SERVICE_CLOSED/, + ) + const aborted = new Error('internal abort detail') + aborted.code = 'IMPORT_ABORTED' + aborted.rolledBack = true + run.reject(aborted) + await nextTurn() + assert.deepEqual((await jobs.get('job-1')).error, { + code: 'IMPORT_ABORTED', + message: '导入已取消', + rolledBack: true, + }) +}) diff --git a/tools/cocos-importer/extension/test/main.test.mjs b/tools/cocos-importer/extension/test/main.test.mjs new file mode 100644 index 00000000..7fd3c34d --- /dev/null +++ b/tools/cocos-importer/extension/test/main.test.mjs @@ -0,0 +1,67 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { createExtension } from '../source/main.js' + +function editor() { + const profile = new Map() + const dialogs = [] + return { + App: { version: '3.8.8' }, + Project: { name: 'Game', path: 'D:/Game' }, + Message: { + request: async () => null, + send: () => {}, + }, + Profile: { + async getConfig(packageName, key, scope) { + return profile.get(`${packageName}:${key}:${scope}`) + }, + async setConfig(packageName, key, value, scope) { + profile.set(`${packageName}:${key}:${scope}`, value) + }, + }, + Dialog: { + async info(message, options) { + dialogs.push({ message, options }) + }, + }, + dialogs, + } +} + +test('extension starts the fixed loopback service and closes it on unload', async () => { + const Editor = editor() + const calls = [] + const extension = createExtension({ + Editor, + serverFactory: async (options) => { + calls.push(options) + return { close: async () => calls.push('closed') } + }, + }) + + await extension.load() + assert.equal(calls[0].host, '127.0.0.1') + assert.equal(calls[0].port, 17_832) + assert.deepEqual(await calls[0].health(), { + creatorVersion: '3.8.8', + projectName: 'Game', + projectOpen: true, + }) + await extension.unload() + assert.equal(calls[1], 'closed') +}) + +test('extension menu exposes pairing code and persisted connection status', async () => { + const Editor = editor() + const extension = createExtension({ Editor, serverFactory: async () => ({ close: async () => {} }) }) + + const code = await extension.showPairingCode() + assert.match(code, /^\d{6}$/) + assert.match(Editor.dialogs[0].message, new RegExp(code)) + await extension.pairing.pair(code, 'https://windup.example') + const status = await extension.showConnectionStatus() + assert.match(status, /https:\/\/windup\.example/) + assert.equal(Editor.dialogs.length, 2) +}) diff --git a/tools/cocos-importer/extension/test/pairing-store.test.mjs b/tools/cocos-importer/extension/test/pairing-store.test.mjs new file mode 100644 index 00000000..4aa52aca --- /dev/null +++ b/tools/cocos-importer/extension/test/pairing-store.test.mjs @@ -0,0 +1,73 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { PairingStore } from '../source/pairing-store.js' + +class MemoryProfile { + value = null + + async load() { + return this.value + } + + async save(value) { + this.value = value + } +} + +function store({ now = 1_000, profile = new MemoryProfile() } = {}) { + let clock = now + const instance = new PairingStore({ + profile, + now: () => clock, + randomCode: () => '123456', + randomToken: () => 'a'.repeat(64), + }) + return { instance, profile, advance: (milliseconds) => (clock += milliseconds) } +} + +test('PairingStore issues a six-digit code valid for five minutes', () => { + const { instance, advance } = store() + assert.equal(instance.createCode(), '123456') + assert.equal(instance.hasActiveCode(), true) + advance(5 * 60_000 + 1) + assert.equal(instance.hasActiveCode(), false) +}) + +test('PairingStore stores only token digest and authorizes exact origin', async () => { + const { instance, profile } = store() + instance.createCode() + const token = await instance.pair('123456', 'https://windup.example') + + assert.equal(token, 'a'.repeat(64)) + assert.equal(profile.value.origin, 'https://windup.example') + assert.notEqual(profile.value.tokenDigest, token) + assert.equal(profile.value.tokenDigest.length, 64) + assert.equal(await instance.authorize('https://windup.example', token), true) + assert.equal(await instance.authorize('https://evil.example', token), false) + assert.equal(await instance.authorize('https://windup.example', 'b'.repeat(64)), false) +}) + +test('PairingStore invalidates a code after five wrong attempts', async () => { + const { instance } = store() + instance.createCode() + for (let attempt = 0; attempt < 4; attempt += 1) { + await assert.rejects(() => instance.pair('000000', 'https://windup.example'), /PAIR_CODE_INVALID/) + } + await assert.rejects(() => instance.pair('000000', 'https://windup.example'), /PAIR_CODE_LOCKED/) + await assert.rejects(() => instance.pair('123456', 'https://windup.example'), /PAIR_CODE_EXPIRED/) +}) + +test('PairingStore consumes a successful code and restores persisted pairing', async () => { + const first = store() + first.instance.createCode() + await first.instance.pair('123456', 'https://windup.example') + await assert.rejects( + () => first.instance.pair('123456', 'https://windup.example'), + /PAIR_CODE_EXPIRED/, + ) + + const restored = store({ profile: first.profile }).instance + assert.equal(await restored.isOriginAllowed('https://windup.example'), true) + assert.equal(await restored.isOriginAllowed('https://other.example'), false) +}) diff --git a/tools/cocos-importer/package.json b/tools/cocos-importer/package.json new file mode 100644 index 00000000..173845cd --- /dev/null +++ b/tools/cocos-importer/package.json @@ -0,0 +1,15 @@ +{ + "name": "windup-importer", + "version": "0.1.0", + "description": "把 Windup 一键导出的 Cocos Creator 适配包自动导入到 Cocos Creator 工程。", + "author": "xyh202131", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "cli": "node ./bin/windup-cocos-import.mjs", + "test": "node --test test/manifest-reader.test.mjs test/asset-planner.test.mjs test/bridge-uuid.test.mjs test/import-core.test.mjs test/e2e-cli.test.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/tools/cocos-importer/src/asset-planner.js b/tools/cocos-importer/src/asset-planner.js new file mode 100644 index 00000000..5b9100aa --- /dev/null +++ b/tools/cocos-importer/src/asset-planner.js @@ -0,0 +1,158 @@ +// 决定把 Windup 适配包里的哪些文件落到 Cocos 工程的哪个位置, +// 产出 SpriteFrame / AnimationClip / Prefab 三类资产的元数据。 +// 输出是计划,真正落盘交给 adapter(Node CLI 或 Cocos 扩展)。 + +/** + * @typedef {import('./manifest-reader.js').WindupCocosManifest} WindupCocosManifest + */ + +/** + * @typedef {{ + * packFolder: string, // 例如 'windup-imports/Hero/Ranger' + * spriteFrames: Array<{ + * sourcePath: string, // ZIP 内相对路径,例如 'character/master.png' + * cocosPath: string, // 目标 Cocos 资产路径 + * rect: { x: number, y: number, w: number, h: number }, + * trim: { x: number, y: number, w: number, h: number }, + * }>, + * animations: Array<{ + * id: string, + * name: string, + * direction: string, + * fps: number, + * loop: boolean, + * duration: number, // 秒 + * frames: Array<{ spriteFramePath: string, index: number, time: number, duration: number }>, + * }>, + * prefab: { + * name: string, + * cocosPath: string, + * nodeName: string, + * anchor: { x: number, y: number }, + * footY: number, + * canvas: { w: number, h: number }, + * }, + * }} ImportPlan + */ + +/** + * @param {WindupCocosManifest} manifest + * @returns {ImportPlan} + */ +export function planImport(manifest) { + const characterSlug = safeSegment(manifest.package.character_name, 'character') + const outfitSlug = safeSegment(manifest.package.outfit_name, 'outfit') + const packFolder = `windup-imports/${characterSlug}/${outfitSlug}` + + const spriteFrames = [] + const animations = [] + const usedNames = new Set() + + // master → 单张 SpriteFrame + const masterSlug = safeSegment(`${characterSlug}-master`, 'master') + spriteFrames.push({ + sourcePath: manifest.master.file, + cocosPath: `${packFolder}/textures/${masterSlug}.png`, + rect: { x: 0, y: 0, w: manifest.package.canvas.w, h: manifest.package.canvas.h }, + trim: { x: 0, y: 0, w: manifest.package.canvas.w, h: manifest.package.canvas.h }, + }) + + for (const action of manifest.actions) { + const actionSlug = safeSegment(action.export_name, 'action') + const uniqueName = actionSlug + if (usedNames.has(uniqueName)) { + throw new Error(`动作名重复: ${uniqueName}(同一适配包内 export_name 必须唯一)`) + } + usedNames.add(uniqueName) + + const cellW = action.atlas.cell.w + const cellH = action.atlas.cell.h + const cols = action.atlas.cols + const legacyTiming = action.timing_mode === undefined + const fallbackSeconds = legacyTiming ? Math.round(1000 / action.fps) / 1000 : 1 / action.fps + const frameDurations = action.frames.map((frame) => + typeof frame.duration_ms === 'number' && frame.duration_ms > 0 + ? frame.duration_ms / 1000 + : fallbackSeconds, + ) + const frameTimes = [] + let elapsed = 0 + for (let index = 0; index < action.frames.length; index += 1) { + frameTimes.push(action.timing_mode === 'constant-fps' ? index / action.fps : elapsed) + elapsed += frameDurations[index] ?? 0 + } + + // 每张 frame → SpriteFrame(atlas 子区域) + // 注意:manifest 里 frame.file 是 basename(Walk_000.png),但 ZIP 里 + // 实际路径是 frames//。需要拼完整路径去找源图。 + const spriteFramePaths = [] + action.frames.forEach((frame) => { + const spriteSlug = `${uniqueName}_${String(frame.index).padStart(3, '0')}` + const sfPath = `${packFolder}/animations/${uniqueName}/${spriteSlug}.png` + spriteFramePaths.push(sfPath) + spriteFrames.push({ + sourcePath: `frames/${action.export_name}/${frame.file}`, + cocosPath: sfPath, + // 这里复制的是单帧 PNG,不是 atlas;每张单帧纹理的 SpriteFrame + // 裁剪原点都必须是(0,0),否则后续帧会被按 atlas 偏移裁成空图。 + rect: { x: 0, y: 0, w: cellW, h: cellH }, + trim: { x: 0, y: 0, w: cellW, h: cellH }, + }) + }) + + // 整张 atlas → 单张图集纹理(Windup 输出是一张大图,Cocos SpriteAtlas 需要它) + const atlasSlug = uniqueName + spriteFrames.push({ + sourcePath: action.atlas.file, + cocosPath: `${packFolder}/animations/${atlasSlug}/atlas.png`, + rect: { x: 0, y: 0, w: cellW * cols, h: cellH * Math.ceil(action.frames.length / cols) }, + trim: { x: 0, y: 0, w: cellW * cols, h: cellH * Math.ceil(action.frames.length / cols) }, + }) + + const durationSec = action.timing_mode === 'constant-fps' + ? action.frames.length / action.fps + : frameDurations.reduce((sum, duration) => sum + duration, 0) + animations.push({ + id: action.id, + name: uniqueName, + direction: action.direction, + fps: action.fps, + loop: action.loop, + duration: durationSec, + frames: action.frames.map((frame, idx) => ({ + spriteFramePath: spriteFramePaths[idx], + index: frame.index, + time: frameTimes[idx] ?? 0, + duration: frameDurations[idx] ?? 0, + })), + }) + } + + // prefab:节点带 Sprite(主母版) + Animation 组件(指向第一个动作) + const prefabName = `${characterSlug}-${outfitSlug}` + const firstAction = animations[0] + return { + packFolder, + spriteFrames, + animations, + prefab: { + name: prefabName, + cocosPath: `${packFolder}/prefabs/${prefabName}.prefab`, + nodeName: prefabName, + anchor: manifest.master.anchor_cocos, + footY: manifest.master.foot_y ?? 0, + canvas: manifest.package.canvas, + defaultAnimation: firstAction ? firstAction.name : null, + }, + } +} + +/** + * @param {string} value + * @param {string} fallback + * @returns {string} + */ +function safeSegment(value, fallback) { + const normalized = value.normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '') + return normalized || fallback +} diff --git a/tools/cocos-importer/src/cocos-bridge.js b/tools/cocos-importer/src/cocos-bridge.js new file mode 100644 index 00000000..c92d098c --- /dev/null +++ b/tools/cocos-importer/src/cocos-bridge.js @@ -0,0 +1,414 @@ +// 把"计划 + 字节内容"翻译成 Cocos Creator 真正能识别的 .meta / .prefab / .anim 文件。 +// 关键:每个资产分配稳定的 RFC 4122 UUID,Cocos 打开时会保留 .meta 里的 uuid +// 并让 prefab/anim 里的 __uuid__ 引用对得上。Cocos Creator 3.x 会把非 RFC +// 格式的短 ID 重写成新的 UUID,导致导出的引用在编辑器里变成未解析占位符。 + +/** + * @typedef {import('./asset-planner.js').ImportPlan} ImportPlan + */ + +import { createHash } from 'node:crypto' + +/** + * 路径 → RFC 4122 UUID v5(确定性,跨机器一致)。 + * @param {string} path + * @returns {string} + */ +export function uuidForPath(path) { + const hex = createHash('sha1').update(`windup-cocos-importer:${path}`).digest('hex').slice(0, 32).split('') + // Mark the deterministic SHA-1-derived value as UUID v5 and set the RFC + // 4122 variant. Creator accepts and preserves this canonical form. + hex[12] = '5' + hex[16] = (Number.parseInt(hex[16], 16) & 0x3 | 0x8).toString(16) + return `${hex.slice(0, 8).join('')}-${hex.slice(8, 12).join('')}-${hex.slice(12, 16).join('')}-${hex.slice(16, 20).join('')}-${hex.slice(20).join('')}` +} + +/** + * @param {WindupCocosManifest} manifest + * @param {ImportPlan} plan + * @param {string} packName + * @returns {Record} filename → JSON 文本 + */ +export function buildCocosMetaFiles(manifest, plan, packName) { + const files = {} + + // 给每个 PNG 分配纹理 UUID 和 SpriteFrame 子资源 UUID。 + // Cocos Creator 的 AnimationClip / Prefab 引用的是子资源 UUID,不是 + // PNG 顶层纹理 UUID;两者都写进 .meta 才能保持 Creator 的引用语义。 + const spriteFrameUuids = new Map() + for (const sf of plan.spriteFrames) { + const textureUuid = uuidForPath(sf.cocosPath) + // Cocos Creator 3.x reserves these sub-asset ids for image imports. A + // random/short child UUID is accepted by the CLI but is not materialized + // into library metadata, leaving Sprite/Animation references unresolved. + const textureSubUuid = `${textureUuid}@6c48a` + const spriteFrameUuid = `${textureUuid}@f9941` + const displayName = sf.cocosPath.split('/').pop()?.replace(/\.png$/i, '') || 'sprite' + const rawWidth = sf.rect.w + const rawHeight = sf.rect.h + const trim = sf.trim || sf.rect + const trimX = trim.x ?? 0 + const trimY = trim.y ?? 0 + const trimWidth = trim.w ?? rawWidth + const trimHeight = trim.h ?? rawHeight + const halfWidth = trimWidth / 2 + const halfHeight = trimHeight / 2 + spriteFrameUuids.set(sf.cocosPath, spriteFrameUuid) + const metaPath = `${sf.cocosPath}.meta` + files[metaPath] = JSON.stringify( + { + ver: '1.0.27', + importer: 'image', + imported: true, + uuid: textureUuid, + files: ['.json', '.png'], + subMetas: { + '6c48a': { + importer: 'texture', + uuid: textureSubUuid, + displayName, + id: '6c48a', + name: 'texture', + userData: { + wrapModeS: 'clamp-to-edge', + wrapModeT: 'clamp-to-edge', + imageUuidOrDatabaseUri: textureUuid, + isUuid: true, + visible: false, + minfilter: 'linear', + magfilter: 'linear', + mipfilter: 'none', + anisotropy: 0, + }, + ver: '1.0.22', + imported: true, + files: ['.json'], + subMetas: {}, + }, + f9941: { + importer: 'sprite-frame', + uuid: spriteFrameUuid, + displayName, + id: 'f9941', + name: 'spriteFrame', + userData: { + trimThreshold: 1, + rotated: false, + offsetX: 0, + offsetY: 0, + trimX, + trimY, + width: trimWidth, + height: trimHeight, + rawWidth, + rawHeight, + borderTop: 0, + borderBottom: 0, + borderLeft: 0, + borderRight: 0, + packable: true, + pixelsToUnit: 100, + pivotX: 0.5, + pivotY: 0.5, + meshType: 0, + vertices: { + rawPosition: [ + -halfWidth, + -halfHeight, + 0, + halfWidth, + -halfHeight, + 0, + -halfWidth, + halfHeight, + 0, + halfWidth, + halfHeight, + 0, + ], + indexes: [0, 1, 2, 2, 1, 3], + uv: [0, rawHeight, rawWidth, rawHeight, 0, 0, rawWidth, 0], + nuv: [0, 0, 1, 0, 0, 1, 1, 1], + minPos: [-halfWidth, -halfHeight, 0], + maxPos: [halfWidth, halfHeight, 0], + }, + isUuid: true, + imageUuidOrDatabaseUri: textureSubUuid, + atlasUuid: '', + trimType: 'auto', + }, + ver: '1.0.12', + imported: true, + files: ['.json'], + subMetas: {}, + }, + }, + userData: { + type: 'sprite-frame', + fixAlphaTransparencyArtifacts: false, + hasAlpha: true, + // This is the redirect emitted by Creator for image assets; the + // actual SpriteFrame ref above remains the @f9941 child asset. + redirect: textureSubUuid, + }, + }, + null, + 2, + ) + } + + // 每个 AnimationClip:写真正的 .anim(Cocos 3.x AnimationClip JSON)+ .anim.meta + for (const anim of plan.animations) { + const animName = `${anim.name}.anim` + const animPath = `${plan.packFolder}/animations/${animName}` + const animUuid = uuidForPath(animPath) + let frameTime = 0 + const times = anim.frames.map((frame) => { + const time = Number.isFinite(frame.time) ? frame.time : frameTime + frameTime += frame.duration + return time + }) + const values = anim.frames.map((frame) => ({ + __uuid__: spriteFrameUuids.get(frame.spriteFramePath) || `frame:${frame.spriteFramePath}`, + __expectedType__: 'cc.SpriteFrame', + })) + files[animPath] = JSON.stringify( + { + __type__: 'cc.AnimationClip', + _name: anim.name, + _objFlags: 0, + __editorExtras__: {}, + _native: '', + sample: anim.fps, + speed: 1, + wrapMode: anim.loop ? 2 : 1, + enableTrsBlending: false, + _duration: anim.duration, + _hash: 0, + _tracks: [ + { + __type__: 'cc.animation.ObjectTrack', + _binding: { + __type__: 'cc.animation.TrackBinding', + path: { + __type__: 'cc.animation.TrackPath', + _paths: [ + { __type__: 'cc.animation.ComponentPath', component: 'cc.Sprite' }, + 'spriteFrame', + ], + }, + }, + _channel: { + __type__: 'cc.animation.Channel', + _curve: { + __type__: 'cc.ObjectCurve', + _times: times, + _values: values, + }, + }, + }, + ], + _exoticAnimation: null, + _events: [], + _embeddedPlayers: [], + _additiveSettings: { + __type__: 'cc.AnimationClipAdditiveSettings', + enabled: false, + refClip: null, + }, + _auxiliaryCurveEntries: [], + _windupDirection: anim.direction, + }, + null, + 2, + ) + files[`${animPath}.meta`] = JSON.stringify( + { + ver: '1.0.0', + uuid: animUuid, + subMetas: {}, + _windupDirection: anim.direction, + }, + null, + 2, + ) + } + + // 主 Prefab + const prefabUuid = uuidForPath(plan.prefab.cocosPath) + const masterSlug = safeSegment(`${manifest.package.character_name}-master`, 'master') + const masterCocosPath = `${plan.packFolder}/textures/${masterSlug}.png` + const masterUuid = spriteFrameUuids.get(masterCocosPath) + files[`${plan.prefab.cocosPath}.meta`] = JSON.stringify( + { + ver: '1.0.0', + uuid: prefabUuid, + asyncLoadAssets: false, + autoReleaseAssets: false, + subMetas: {}, + }, + null, + 2, + ) + // Cocos Creator 3.x prefab files are serialized object arrays. The first + // object is the prefab asset and `data` points at the root Node by numeric + // id; a hand-written object tree makes the editor importer fail while it + // resolves Node/Component references. + const animationRefs = plan.animations.map((a) => ({ + __uuid__: uuidForPath(`${plan.packFolder}/animations/${a.name}.anim`), + })) + const firstAnimationRef = animationRefs[0] || null + const prefabInfoId = 8 + const fileId = prefabFileId(`${plan.prefab.cocosPath}#prefab-info`) + const uiFileId = prefabFileId(`${plan.prefab.cocosPath}#ui-transform`) + const spriteFileId = prefabFileId(`${plan.prefab.cocosPath}#sprite`) + const animationFileId = prefabFileId(`${plan.prefab.cocosPath}#animation`) + files[plan.prefab.cocosPath] = JSON.stringify( + [ + { + __type__: 'cc.Prefab', + _name: plan.prefab.nodeName, + _objFlags: 0, + _native: '', + data: { __id__: 1 }, + optimizationPolicy: 0, + asyncLoadAssets: false, + persistent: false, + _windupPack: packName, + }, + { + __type__: 'cc.Node', + _name: plan.prefab.nodeName, + _objFlags: 0, + _parent: null, + _children: [], + _active: true, + _components: [{ __id__: 2 }, { __id__: 4 }, { __id__: 6 }], + _prefab: { __id__: prefabInfoId }, + _lpos: { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 }, + _lrot: { __type__: 'cc.Quat', x: 0, y: 0, z: 0, w: 1 }, + _lscale: { __type__: 'cc.Vec3', x: 1, y: 1, z: 1 }, + _layer: 33554432, + _euler: { __type__: 'cc.Vec3', x: 0, y: 0, z: 0 }, + _id: '', + }, + { + __type__: 'cc.UITransform', + _name: '', + _objFlags: 0, + node: { __id__: 1 }, + _enabled: true, + _priority: 0, + _contentSize: { + __type__: 'cc.Size', + width: plan.prefab.canvas.w, + height: plan.prefab.canvas.h, + }, + _anchorPoint: { + __type__: 'cc.Vec2', + x: plan.prefab.anchor.x, + y: plan.prefab.anchor.y, + }, + _id: '', + __prefab: { __id__: 3 }, + }, + { + __type__: 'cc.CompPrefabInfo', + fileId: uiFileId, + }, + { + __type__: 'cc.Sprite', + _name: '', + _objFlags: 0, + node: { __id__: 1 }, + _enabled: true, + _srcBlendFactor: 2, + _dstBlendFactor: 4, + _color: { __type__: 'cc.Color', r: 255, g: 255, b: 255, a: 255 }, + _sharedMaterial: null, + _spriteFrame: masterUuid ? { __uuid__: masterUuid } : null, + _type: 0, + _fillType: 0, + _sizeMode: 0, + _fillCenter: { __type__: 'cc.Vec2', x: 0, y: 0 }, + _fillStart: 0, + _fillRange: 0, + _isTrimmedMode: false, + _useGrayscale: false, + _atlas: null, + _id: '', + __prefab: { __id__: 5 }, + }, + { + __type__: 'cc.CompPrefabInfo', + fileId: spriteFileId, + }, + { + __type__: 'cc.Animation', + _name: '', + _objFlags: 0, + node: { __id__: 1 }, + _enabled: true, + playOnLoad: Boolean(firstAnimationRef), + _clips: animationRefs, + _defaultClip: firstAnimationRef, + _id: '', + __prefab: { __id__: 7 }, + }, + { + __type__: 'cc.CompPrefabInfo', + fileId: animationFileId, + }, + { + __type__: 'cc.PrefabInfo', + root: { __id__: 1 }, + asset: { __id__: 0 }, + fileId, + }, + ], + null, + 2, + ) + + return files +} + +/** + * @param {string} value + * @param {string} fallback + * @returns {string} + */ +function safeSegment(value, fallback) { + const normalized = value.normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '') + return normalized || fallback +} + +/** + * Cocos' prefab fileId is a 24-character base64 token (not a UUID). Keep it + * deterministic so repeated CLI imports produce stable prefab diffs while + * still matching the editor's serialized format. + * @param {string} path + * @returns {string} + */ +function prefabFileId(path) { + return createHash('sha1') + .update(`windup-cocos-prefab:${path}`) + .digest('base64') + .replace(/=+$/g, '') + .slice(0, 24) +} + +/** + * 计算"哪个 spriteFrame uuid 对应哪个文件路径",给 Cocos 端 / .meta 端用。 + * 实际上是 `frame:` 的反向表。 + * + * @param {ImportPlan} plan + * @returns {Map} + */ +export function buildSpriteFrameIndex(plan) { + const idx = new Map() + for (const sf of plan.spriteFrames) { + idx.set(sf.cocosPath, sf.sourcePath) + } + return idx +} diff --git a/tools/cocos-importer/src/import-core.js b/tools/cocos-importer/src/import-core.js new file mode 100644 index 00000000..1f71de5b --- /dev/null +++ b/tools/cocos-importer/src/import-core.js @@ -0,0 +1,205 @@ +import { readStoredZip, flattenZipEntries } from './zip-reader.js' +import { buildManifestFromLegacyMeta, parseManifest } from './manifest-reader.js' +import { planImport } from './asset-planner.js' +import { buildCocosMetaFiles, uuidForPath } from './cocos-bridge.js' +import { IMPORT_LIMITS } from './limits.js' + +const encoder = new TextEncoder() +const decoder = new TextDecoder('utf-8') + +/** + * @typedef {{ relativePath: string, data: Uint8Array, size: number, rootDir: string }} FlatEntry + * @typedef {{ + * manifest: import('./manifest-reader.js').WindupCocosManifest, + * manifestText: string, + * plan: import('./asset-planner.js').ImportPlan, + * packFolder: string, + * files: Map, + * summary: { characterName: string, outfitName: string, animationCount: number, frameCount: number, fileCount: number }, + * }} PreparedImport + */ + +/** + * Convert a STORED Windup ZIP entirely in memory. + * @param {Uint8Array} input + * @returns {PreparedImport} + */ +export function prepareImport(input) { + return prepareImportFromEntries(flattenZipEntries(readStoredZip(input))) +} + +/** + * Shared entry point for the ZIP adapter and the CLI's legacy frames adapter. + * @param {FlatEntry[]} entries + * @returns {PreparedImport} + */ +export function prepareImportFromEntries(entries) { + if (entries.length === 0) throw new Error('IMPORT_EMPTY: 资产包没有文件') + const byPath = new Map(entries.map((entry) => [entry.relativePath, entry])) + const manifestEntry = byPath.get('targets/cocos-creator/cocos-import.json') + const legacyMetaEntry = byPath.get('meta.json') + + let manifest + let manifestText + if (manifestEntry) { + manifestText = decoder.decode(manifestEntry.data) + manifest = parseManifest(manifestText) + } else if (legacyMetaEntry) { + let legacy + try { + legacy = JSON.parse(decoder.decode(legacyMetaEntry.data)) + } catch (error) { + throw new Error(`IMPORT_MANIFEST_INVALID: 旧版 meta.json 不是合法 JSON: ${error instanceof Error ? error.message : String(error)}`) + } + manifest = buildManifestFromLegacyMeta(legacy) + manifestText = `${JSON.stringify(manifest, null, 2)}\n` + } else { + throw new Error('IMPORT_MANIFEST_MISSING: 找不到 targets/cocos-creator/cocos-import.json 或 meta.json') + } + + const plan = planImport(manifest) + const files = new Map() + const sources = [] + let expandedBytes = 0 + for (const spriteFrame of plan.spriteFrames) { + const source = byPath.get(spriteFrame.sourcePath) + if (!source) throw new Error(`IMPORT_SOURCE_MISSING: ${spriteFrame.sourcePath}`) + expandedBytes += source.data.byteLength + if (expandedBytes > IMPORT_LIMITS.expandedBytes) { + throw new Error(`IMPORT_OUTPUT_TOO_LARGE: 素材展开超过 ${IMPORT_LIMITS.expandedBytes} 字节`) + } + sources.push([spriteFrame, source]) + } + for (const [spriteFrame, source] of sources) { + files.set(spriteFrame.cocosPath, copyBytes(source.data)) + } + + const generated = buildCocosMetaFiles(manifest, plan, entries[0].rootDir) + for (const [path, text] of Object.entries(generated)) files.set(path, encoder.encode(text)) + + for (const path of ['meta.json', 'schema.json', 'README.md']) { + const entry = byPath.get(path) + if (entry) files.set(`${plan.packFolder}/${path}`, copyBytes(entry.data)) + } + files.set(`${plan.packFolder}/cocos-import.json`, encoder.encode(manifestText)) + addAuxiliaryMetaFiles(files, plan.packFolder) + + const prepared = { + manifest, + manifestText, + plan, + packFolder: plan.packFolder, + files, + summary: { + characterName: manifest.package.character_name, + outfitName: manifest.package.outfit_name, + animationCount: plan.animations.length, + frameCount: plan.animations.reduce((total, animation) => total + animation.frames.length, 0), + fileCount: files.size, + }, + } + validatePreparedImport(prepared) + return prepared +} + +function addAuxiliaryMetaFiles(files, packFolder) { + const assetPaths = [...files.keys()].filter((path) => !path.endsWith('.meta')) + const directories = new Set() + for (const path of assetPaths) { + let directory = path.slice(0, path.lastIndexOf('/')) + while (directory.startsWith(`${packFolder}/`)) { + directories.add(directory) + directory = directory.slice(0, directory.lastIndexOf('/')) + } + } + for (const directory of directories) { + const metaPath = `${directory}.meta` + if (!files.has(metaPath)) files.set(metaPath, encoder.encode(auxiliaryMeta(directory, 'directory'))) + } + for (const path of assetPaths) { + const importer = path.endsWith('.json') ? 'json' : path.endsWith('.md') ? 'text' : null + const metaPath = `${path}.meta` + if (importer && !files.has(metaPath)) files.set(metaPath, encoder.encode(auxiliaryMeta(path, importer))) + } +} + +function auxiliaryMeta(path, importer) { + return JSON.stringify( + { + ver: importer === 'directory' ? '1.2.0' : importer === 'json' ? '2.0.1' : '1.0.1', + importer, + imported: true, + uuid: uuidForPath(path), + files: importer === 'directory' ? [] : ['.json'], + subMetas: {}, + userData: {}, + }, + null, + 2, + ) +} + +/** + * Validate output completeness and every serialized asset UUID reference. + * @param {PreparedImport} prepared + * @returns {PreparedImport['summary']} + */ +export function validatePreparedImport(prepared) { + const required = new Set([ + ...prepared.plan.spriteFrames.flatMap((asset) => [asset.cocosPath, `${asset.cocosPath}.meta`]), + ...prepared.plan.animations.flatMap((animation) => { + const path = `${prepared.packFolder}/animations/${animation.name}.anim` + return [path, `${path}.meta`] + }), + prepared.plan.prefab.cocosPath, + `${prepared.plan.prefab.cocosPath}.meta`, + `${prepared.packFolder}/cocos-import.json`, + ]) + for (const path of required) { + if (!prepared.files.has(path)) throw new Error(`IMPORT_OUTPUT_MISSING: ${path}`) + } + + const definedUuids = new Set() + for (const [path, bytes] of prepared.files) { + if (!path.endsWith('.meta')) continue + const meta = parseGeneratedJson(bytes, path) + collectDefinedUuids(meta, definedUuids) + } + for (const [path, bytes] of prepared.files) { + if (!path.endsWith('.anim') && !path.endsWith('.prefab')) continue + const asset = parseGeneratedJson(bytes, path) + for (const uuid of collectReferencedUuids(asset)) { + if (!definedUuids.has(uuid)) throw new Error(`IMPORT_UUID_UNRESOLVED: ${path} -> ${uuid}`) + } + } + return prepared.summary +} + +function copyBytes(bytes) { + return new Uint8Array(bytes) +} + +function parseGeneratedJson(bytes, path) { + try { + return JSON.parse(decoder.decode(bytes)) + } catch (error) { + throw new Error(`IMPORT_OUTPUT_JSON_INVALID: ${path}: ${error instanceof Error ? error.message : String(error)}`) + } +} + +function collectDefinedUuids(value, output) { + if (!value || typeof value !== 'object') return + if (typeof value.uuid === 'string') output.add(value.uuid) + if (value.subMetas && typeof value.subMetas === 'object') { + for (const subMeta of Object.values(value.subMetas)) collectDefinedUuids(subMeta, output) + } +} + +function collectReferencedUuids(value, output = []) { + if (!value || typeof value !== 'object') return output + if (typeof value.__uuid__ === 'string') output.push(value.__uuid__) + for (const child of Array.isArray(value) ? value : Object.values(value)) { + collectReferencedUuids(child, output) + } + return output +} diff --git a/tools/cocos-importer/src/limits.js b/tools/cocos-importer/src/limits.js new file mode 100644 index 00000000..d734165f --- /dev/null +++ b/tools/cocos-importer/src/limits.js @@ -0,0 +1,8 @@ +export const IMPORT_LIMITS = Object.freeze({ + zipEntries: 4096, + zipEntryBytes: 32 * 1024 * 1024, + expandedBytes: 256 * 1024 * 1024, + actions: 128, + framesPerAction: 2048, + totalFrames: 4096, +}) diff --git a/tools/cocos-importer/src/manifest-reader.js b/tools/cocos-importer/src/manifest-reader.js new file mode 100644 index 00000000..12be693e --- /dev/null +++ b/tools/cocos-importer/src/manifest-reader.js @@ -0,0 +1,374 @@ +// 解析与校验 Windup → Cocos Creator 适配包里的 cocos-import.json。 +// 暴露纯函数,无副作用,便于 Node CLI 与 Cocos 扩展共享。 + +import { IMPORT_LIMITS } from './limits.js' + +/** + * @typedef {{ + * schema_version: string, + * experimental: true, + * engine: 'cocos-creator', + * upstream_issue: number, + * package: { character_id: string, character_name: string, outfit_id: string, outfit_name: string, canvas: {w:number, h:number} }, + * master: { file: string, anchor: {x:number, y:number}, anchor_cocos: {x:number, y:number} }, + * actions: Array<{ + * id: string, name: string, export_name: string, direction: string, + * fps: number, timing_mode?: 'constant-fps'|'per-frame', loop: boolean, quality_status: 'passed'|'pending'|'failed', + * anchor: {x:number, y:number}, anchor_cocos: {x:number, y:number}, + * foot_y: number, frames: Array<{index:number, file:string, duration_ms: number|null}>, + * atlas: { file: string, cols: number, rows: number, cell: {w:number, h:number} } + * }> + * }} WindupCocosManifest + */ + +const REQUIRED_TOP_KEYS = [ + 'schema_version', + 'experimental', + 'engine', + 'upstream_issue', + 'package', + 'master', + 'actions', +] + +const REQUIRED_PACKAGE_KEYS = [ + 'character_id', + 'character_name', + 'outfit_id', + 'outfit_name', + 'canvas', +] + +const REQUIRED_MASTER_KEYS = ['file', 'anchor', 'anchor_cocos'] + +const REQUIRED_ACTION_KEYS = [ + 'id', + 'name', + 'export_name', + 'direction', + 'fps', + 'loop', + 'quality_status', + 'anchor', + 'anchor_cocos', + 'foot_y', + 'frames', + 'atlas', +] + +const VALID_QUALITY = new Set(['passed', 'pending', 'failed']) +const VALID_SCHEMA_VERSIONS = new Set([ + 'windup-cocos-import-1.0.0', + 'windup-cocos-import-1.1.0', +]) +const VALID_TIMING_MODES = new Set(['constant-fps', 'per-frame']) +const VALID_DIRECTIONS = new Set([ + 'default', + 'east', 'west', 'north', 'south', + 'north_east', 'north_west', 'south_east', 'south_west', +]) + +function record(value, field) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${field} 必须是对象`) + } + return /** @type {Record} */ (value) +} + +/** + * @param {string} jsonText + * @returns {WindupCocosManifest} + */ +export function parseManifest(jsonText) { + let data + try { + data = JSON.parse(jsonText) + } catch (err) { + throw new Error(`cocos-import.json 不是合法 JSON: ${err instanceof Error ? err.message : String(err)}`) + } + return validateManifest(data) +} + +/** + * @param {unknown} data + * @returns {WindupCocosManifest} + */ +export function validateManifest(data) { + if (typeof data !== 'object' || data === null) { + throw new Error('cocos-import.json 顶层必须是对象') + } + const obj = /** @type {Record} */ (data) + + for (const key of REQUIRED_TOP_KEYS) { + if (!(key in obj)) throw new Error(`cocos-import.json 缺少字段: ${key}`) + } + + if (obj.engine !== 'cocos-creator') { + throw new Error(`engine 必须是 'cocos-creator',实际是 ${JSON.stringify(obj.engine)}`) + } + if (obj.experimental !== true) { + throw new Error(`experimental 必须为 true,实际是 ${JSON.stringify(obj.experimental)}`) + } + if (!VALID_SCHEMA_VERSIONS.has(/** @type {string} */ (obj.schema_version))) { + throw new Error(`schema_version 不受支持: ${obj.schema_version}`) + } + if (!Number.isInteger(obj.upstream_issue) || obj.upstream_issue < 1) { + throw new Error(`upstream_issue 必须为正整数: ${obj.upstream_issue}`) + } + + // package + const pkg = record(obj.package, 'cocos-import.json.package') + for (const key of REQUIRED_PACKAGE_KEYS) { + if (!(key in pkg)) throw new Error(`cocos-import.json.package 缺少字段: ${key}`) + } + for (const key of ['character_id', 'character_name', 'outfit_id', 'outfit_name']) { + nonEmptyString(pkg[key], `package.${key}`) + } + const canvas = record(pkg.canvas, 'cocos-import.json.package.canvas') + if (!Number.isInteger(canvas.w) || canvas.w < 1) throw new Error('package.canvas.w 必须为正整数') + if (!Number.isInteger(canvas.h) || canvas.h < 1) throw new Error('package.canvas.h 必须为正整数') + + // master + const master = record(obj.master, 'cocos-import.json.master') + for (const key of REQUIRED_MASTER_KEYS) { + if (!(key in master)) throw new Error(`cocos-import.json.master 缺少字段: ${key}`) + } + assetPath(master.file, 'master.file') + anchor(master.anchor, 'master.anchor') + anchor(master.anchor_cocos, 'master.anchor_cocos') + + // actions + if (!Array.isArray(obj.actions)) { + throw new Error('cocos-import.json.actions 必须是数组') + } + const actions = /** @type {Array>} */ (obj.actions) + if (actions.length > IMPORT_LIMITS.actions) { + throw new Error(`actions 动作数超过限制 ${IMPORT_LIMITS.actions}`) + } + let totalFrames = 0 + for (let i = 0; i < actions.length; i += 1) { + totalFrames += validateAction(actions[i], i) + if (totalFrames > IMPORT_LIMITS.totalFrames) { + throw new Error(`actions 总帧数超过限制 ${IMPORT_LIMITS.totalFrames}`) + } + } + + return /** @type {WindupCocosManifest} */ (data) +} + +/** + * 把旧版 Windup action-assets 包里的 meta.json 转成当前 Cocos 适配清单。 + * 旧包没有 targets/cocos-creator/cocos-import.json,但它仍包含导入器所需 + * 的角色、画布、动作、帧和图集信息;帧时长按旧包的 fps 补齐。 + * + * @param {Record} legacy + * @returns {WindupCocosManifest} + */ +export function buildManifestFromLegacyMeta(legacy) { + const character = record(legacy.character, 'meta.json.character') + const outfit = record(legacy.outfit, 'meta.json.outfit') + const canvas = record(legacy.canvas, 'meta.json.canvas') + const actions = Array.isArray(legacy.actions) ? legacy.actions : [] + const fallbackAnchor = { x: 0.5, y: 0.92 } + const masterAnchor = firstAnchor(actions) || fallbackAnchor + const manifest = { + schema_version: 'windup-cocos-import-1.1.0', + experimental: true, + engine: 'cocos-creator', + upstream_issue: 94, + package: { + character_id: String(character.id ?? ''), + character_name: String(character.name ?? ''), + outfit_id: String(outfit.id ?? ''), + outfit_name: String(outfit.name ?? ''), + canvas: { w: canvas.w, h: canvas.h }, + }, + master: { + file: typeof character.image === 'string' ? character.image : 'character/master.png', + anchor: masterAnchor, + anchor_cocos: { x: masterAnchor.x, y: 1 - masterAnchor.y }, + }, + actions: actions.map((rawAction, index) => { + const action = record(rawAction, `meta.json.actions[${index}]`) + const anchor = anchorOrFallback(action.anchor, masterAnchor) + const fps = Number(action.fps) + const frameList = Array.isArray(action.frames) ? action.frames : [] + const atlas = record(action.atlas, `meta.json.actions[${index}].atlas`) + return { + id: String(action.id ?? `legacy-action-${index}`), + name: String(action.name ?? `action-${index}`), + export_name: legacyExportName(action, atlas, frameList, index), + direction: validDirection(action.direction) ? action.direction : 'default', + fps, + timing_mode: 'constant-fps', + loop: action.loop !== false, + quality_status: action.quality_status === 'failed' || action.quality_status === 'pending' + ? action.quality_status + : 'passed', + anchor, + anchor_cocos: { x: anchor.x, y: 1 - anchor.y }, + foot_y: Number.isFinite(action.foot_y) ? action.foot_y : 0, + frames: frameList.map((rawFrame, frameIndex) => { + const frame = record(rawFrame, `meta.json.actions[${index}].frames[${frameIndex}]`) + return { + index: frame.index ?? frameIndex, + file: String(frame.file ?? ''), + duration_ms: null, + } + }), + atlas, + } + }), + } + return validateManifest(manifest) +} + +function legacyExportName(action, atlas, frames, index) { + const atlasMatch = typeof atlas.file === 'string' + ? /^atlas\/([^/\\\0]+)\.png$/u.exec(atlas.file) + : null + if (usableLegacySegment(atlasMatch?.[1])) return atlasMatch[1] + + for (const frame of frames) { + if (!frame || typeof frame !== 'object' || Array.isArray(frame)) continue + const frameMatch = typeof frame.file === 'string' + ? /^([^/\\\0]+)_\d+\.png$/u.exec(frame.file) + : null + if (usableLegacySegment(frameMatch?.[1])) return frameMatch[1] + } + + return String(action.name ?? `action-${index}`) +} + +function usableLegacySegment(value) { + return typeof value === 'string' && value !== '.' && value !== '..' +} + +function firstAnchor(actions) { + for (const rawAction of actions) { + if (rawAction && typeof rawAction === 'object' && !Array.isArray(rawAction)) { + const anchor = anchorOrFallback(rawAction.anchor, null) + if (anchor) return anchor + } + } + return null +} + +function anchorOrFallback(value, fallback) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + Number.isFinite(value.x) && + Number.isFinite(value.y) && + value.x >= 0 && + value.x <= 1 && + value.y >= 0 && + value.y <= 1 + ) { + return { x: value.x, y: value.y } + } + return fallback +} + +function validDirection(value) { + return typeof value === 'string' && VALID_DIRECTIONS.has(value) +} + +function validateAction(action, index) { + action = record(action, `actions[${index}]`) + for (const key of REQUIRED_ACTION_KEYS) { + if (!(key in action)) throw new Error(`actions[${index}] 缺少字段: ${key}`) + } + for (const key of ['id', 'name', 'export_name']) { + nonEmptyString(action[key], `actions[${index}].${key}`) + } + if (!VALID_QUALITY.has(/** @type {string} */ (action.quality_status))) { + throw new Error(`actions[${index}].quality_status 非法: ${action.quality_status}`) + } + if (!VALID_DIRECTIONS.has(/** @type {string} */ (action.direction))) { + throw new Error(`actions[${index}].direction 非法: ${action.direction}`) + } + if (!Number.isFinite(/** @type {number} */ (action.fps)) || /** @type {number} */ (action.fps) <= 0) { + throw new Error(`actions[${index}].fps 必须为正数`) + } + if ( + action.timing_mode !== undefined && + !VALID_TIMING_MODES.has(/** @type {string} */ (action.timing_mode)) + ) { + throw new Error(`actions[${index}].timing_mode 非法: ${action.timing_mode}`) + } + if (typeof action.loop !== 'boolean') { + throw new Error(`actions[${index}].loop 必须是布尔值`) + } + anchor(action.anchor, `actions[${index}].anchor`) + anchor(action.anchor_cocos, `actions[${index}].anchor_cocos`) + if (!Number.isFinite(/** @type {number} */ (action.foot_y)) || /** @type {number} */ (action.foot_y) < 0) { + throw new Error(`actions[${index}].foot_y 必须是非负数`) + } + const frames = action.frames + if (!Array.isArray(frames)) throw new Error(`actions[${index}].frames 必须是数组`) + if (frames.length === 0) { + throw new Error(`actions[${index}].frames 不能为空`) + } + if (frames.length > IMPORT_LIMITS.framesPerAction) { + throw new Error(`actions[${index}] 单动作帧数超过限制 ${IMPORT_LIMITS.framesPerAction}`) + } + const atlas = record(action.atlas, `actions[${index}].atlas`) + assetPath(atlas.file, `actions[${index}].atlas.file`) + if (!Number.isInteger(/** @type {number} */ (atlas.cols)) || /** @type {number} */ (atlas.cols) < 1) { + throw new Error(`actions[${index}].atlas.cols 必须为正整数`) + } + if (!Number.isInteger(/** @type {number} */ (atlas.rows)) || /** @type {number} */ (atlas.rows) < 1) { + throw new Error(`actions[${index}].atlas.rows 必须为正整数`) + } + const cell = record(atlas.cell, `actions[${index}].atlas.cell`) + if (!Number.isInteger(cell.w) || cell.w < 1) { + throw new Error(`actions[${index}].atlas.cell.w 必须为正整数`) + } + if (!Number.isInteger(cell.h) || cell.h < 1) { + throw new Error(`actions[${index}].atlas.cell.h 必须为正整数`) + } + if (frames.length > atlas.cols * atlas.rows) { + throw new Error(`actions[${index}].frames 超出图集容量`) + } + for (let j = 0; j < frames.length; j += 1) { + const f = record(frames[j], `actions[${index}].frames[${j}]`) + if (!Number.isInteger(f.index) || f.index !== j) { + throw new Error(`actions[${index}].frames[${j}].index 必须从 0 连续递增`) + } + assetPath(f.file, `actions[${index}].frames[${j}].file`) + if (f.duration_ms !== null && (!Number.isFinite(f.duration_ms) || f.duration_ms < 0)) { + throw new Error(`actions[${index}].frames[${j}].duration_ms 必须是非负数或 null`) + } + } + return frames.length +} + +function nonEmptyString(value, field) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${field} 必须是非空字符串`) + } +} + +function assetPath(value, field) { + nonEmptyString(value, field) + if ( + value.includes('\\') || + value.includes('\0') || + value.startsWith('/') || + /^[A-Za-z]:/.test(value) || + value.split('/').some((segment) => segment === '' || segment === '..' || segment === '.') + ) { + throw new Error(`${field} 必须是安全的相对路径`) + } +} + +function anchor(value, field) { + const point = record(value, field) + for (const axis of ['x', 'y']) { + if (!Number.isFinite(point[axis]) || point[axis] < 0 || point[axis] > 1) { + throw new Error(`${field}.${axis} 必须在 0 到 1 之间`) + } + } +} diff --git a/tools/cocos-importer/src/zip-reader.js b/tools/cocos-importer/src/zip-reader.js new file mode 100644 index 00000000..4ff2c24d --- /dev/null +++ b/tools/cocos-importer/src/zip-reader.js @@ -0,0 +1,181 @@ +// Minimal ZIP reader for STORED-only (no compression) ZIPs. +// Windup's asset-export.ts always writes stored (compression method 0) so this +// is sufficient and avoids pulling in a full ZIP library. +// +// Layout: https://pkware.files.wordpress.com/2024/06/appnote-6.0.0-20240424.pdf +// [Local File Header + file data] * N +// [Central Directory File Header] * N +// [End of Central Directory Record] + +import { IMPORT_LIMITS } from './limits.js' + +/** + * @typedef {{ + * name: string, + * data: Uint8Array, + * size: number, + * }} ZipEntry + */ + +/** + * @param {Uint8Array} bytes + * @param {{maxEntries?:number, maxEntryBytes?:number, maxTotalBytes?:number}} [limits] + * @returns {ZipEntry[]} + */ +export function readStoredZip(bytes, limits = {}) { + const maxEntries = limits.maxEntries ?? IMPORT_LIMITS.zipEntries + const maxEntryBytes = limits.maxEntryBytes ?? IMPORT_LIMITS.zipEntryBytes + const maxTotalBytes = limits.maxTotalBytes ?? IMPORT_LIMITS.expandedBytes + if (!(bytes instanceof Uint8Array) || bytes.length < 22) throw new Error('ZIP: 文件过短') + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const u16 = (off) => dv.getUint16(off, true) + const u32 = (off) => dv.getUint32(off, true) + const within = (offset, length, limit = bytes.length) => + Number.isSafeInteger(offset) && Number.isSafeInteger(length) && offset >= 0 && length >= 0 && offset + length <= limit + const decodeName = (nameBytes) => { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(nameBytes) + } catch { + throw new Error('ZIP: 文件名不是合法 UTF-8') + } + } + + // Find End of Central Directory Record + let eocd = -1 + for (let i = bytes.length - 22; i >= 0; i -= 1) { + if (u32(i) === 0x06054b50) { + eocd = i + break + } + } + if (eocd < 0) throw new Error('ZIP: 找不到 End of Central Directory Record') + if (!within(eocd, 22)) throw new Error('ZIP: EOCD 不完整') + const commentLength = u16(eocd + 20) + if (!within(eocd, 22 + commentLength)) throw new Error('ZIP: EOCD 注释越界') + if (u16(eocd + 4) !== 0 || u16(eocd + 6) !== 0 || u16(eocd + 8) !== u16(eocd + 10)) { + throw new Error('ZIP: 不支持多磁盘压缩包') + } + + const total = u16(eocd + 10) + if (total > maxEntries) throw new Error(`ZIP: 条目数超过限制 ${maxEntries}`) + const cdSize = u32(eocd + 12) + const cdOffset = u32(eocd + 16) + const cdEnd = cdOffset + cdSize + if (!within(cdOffset, cdSize, eocd) || cdEnd !== eocd) throw new Error('ZIP: 中央目录越界') + const entries = [] + const names = new Set() + let totalBytes = 0 + + let p = cdOffset + for (let i = 0; i < total; i += 1) { + if (!within(p, 46, cdEnd)) throw new Error(`ZIP: 中央目录条目越界 @${p}`) + if (u32(p) !== 0x02014b50) throw new Error(`ZIP: 中央目录签名错 @${p}`) + const hostSystem = u16(p + 4) >> 8 + const unixMode = u32(p + 38) >>> 16 + if (hostSystem === 3 && (unixMode & 0xf000) === 0xa000) { + throw new Error('ZIP: 包含符号链接') + } + const compMethod = u16(p + 10) + if (compMethod !== 0) { + throw new Error( + `ZIP: 不支持压缩方法 ${compMethod} (仅支持 STORED);${{ + 8: 'Deflate', + 12: 'Bzip2', + 14: 'LZMA', + }[compMethod] ?? ''}`, + ) + } + const flags = u16(p + 8) + if ((flags & 0x0001) !== 0) throw new Error('ZIP: 不支持加密条目') + if ((flags & 0x0008) !== 0) throw new Error('ZIP: 不支持数据描述符') + const expectedCrc = u32(p + 16) + const csize = u32(p + 20) + const usize = u32(p + 24) + if (csize !== usize) throw new Error('ZIP: STORED 条目压缩前后大小不一致') + if (usize > maxEntryBytes) throw new Error(`ZIP: 单条目超过限制 ${maxEntryBytes}`) + totalBytes += usize + if (totalBytes > maxTotalBytes) throw new Error(`ZIP: 总解包大小超过限制 ${maxTotalBytes}`) + const fnLen = u16(p + 28) + const exLen = u16(p + 30) + const cmLen = u16(p + 32) + const lhOffset = u32(p + 42) + const centralLength = 46 + fnLen + exLen + cmLen + if (!within(p, centralLength, cdEnd)) throw new Error(`ZIP: 中央目录字段越界 @${p}`) + const nameBytes = bytes.subarray(p + 46, p + 46 + fnLen) + const name = decodeName(nameBytes) + assertSafeZipPath(name) + if (names.has(name)) throw new Error(`ZIP: 包含重复路径: ${name}`) + names.add(name) + p += centralLength + + // Local file header + if (!within(lhOffset, 30, cdOffset)) throw new Error(`ZIP: 本地头越界 @${lhOffset}`) + if (u32(lhOffset) !== 0x04034b50) throw new Error(`ZIP: 本地头签名错 @${lhOffset}`) + if (u16(lhOffset + 6) !== flags) throw new Error(`ZIP: 本地头标志不一致: ${name}`) + if (u16(lhOffset + 8) !== compMethod) throw new Error(`ZIP: 本地头压缩方法不一致: ${name}`) + if (u32(lhOffset + 14) !== expectedCrc) throw new Error(`ZIP: 本地头 CRC 不一致: ${name}`) + if (u32(lhOffset + 18) !== csize || u32(lhOffset + 22) !== usize) { + throw new Error(`ZIP: 本地头大小不一致: ${name}`) + } + const lhFn = u16(lhOffset + 26) + const lhEx = u16(lhOffset + 28) + const dataStart = lhOffset + 30 + lhFn + lhEx + if (!within(lhOffset, 30 + lhFn + lhEx + csize, cdOffset)) { + throw new Error(`ZIP: 文件内容越界: ${name}`) + } + const localName = decodeName(bytes.subarray(lhOffset + 30, lhOffset + 30 + lhFn)) + if (localName !== name) throw new Error(`ZIP: 中央目录和本地头文件名不一致: ${name}`) + const data = bytes.subarray(dataStart, dataStart + csize) + if (crc32(data) !== expectedCrc) throw new Error(`ZIP: CRC 校验失败: ${name}`) + entries.push({ name, data, size: csize }) + } + if (p !== cdEnd) throw new Error('ZIP: 中央目录大小不一致') + return entries +} + +/** + * Extract entries to a flat list filtered by prefix. The root package directory + * (e.g. "Hero-char-42-Ranger-outfit-7/") is stripped, leaving relative paths. + * + * @param {ZipEntry[]} entries + * @returns {{relativePath: string, data: Uint8Array, size: number, rootDir: string}[]} + */ +export function flattenZipEntries(entries) { + if (entries.length === 0) throw new Error('ZIP: 空包') + const firstName = entries[0].name + const slash = firstName.indexOf('/') + if (slash < 0) throw new Error(`ZIP: 顶层条目没有包根目录: ${firstName}`) + const rootDir = firstName.slice(0, slash) + const prefix = `${rootDir}/` + return entries + .filter((e) => e.name.startsWith(prefix)) + .map((e) => ({ + rootDir, + relativePath: e.name.slice(prefix.length), + data: e.data, + size: e.size, + })) +} + +function assertSafeZipPath(name) { + if ( + name.includes('\\') || + name.includes('\0') || + name.startsWith('/') || + /^[A-Za-z]:/.test(name) || + name.split('/').some((segment) => segment === '..' || segment === '.' || segment === '') + ) { + throw new Error(`ZIP: 包内路径不安全: ${name}`) + } +} + +function crc32(bytes) { + let crc = 0xffffffff + for (const byte of bytes) { + crc ^= byte + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)) + } + } + return (crc ^ 0xffffffff) >>> 0 +} diff --git a/tools/cocos-importer/test/asset-planner.test.mjs b/tools/cocos-importer/test/asset-planner.test.mjs new file mode 100644 index 00000000..aa1650c0 --- /dev/null +++ b/tools/cocos-importer/test/asset-planner.test.mjs @@ -0,0 +1,134 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { planImport } from '../src/asset-planner.js' + +const MANIFEST = { + schema_version: 'windup-cocos-import-1.0.0', + experimental: true, + engine: 'cocos-creator', + upstream_issue: 94, + package: { + character_id: 'c1', + character_name: 'Hero', + outfit_id: 'o1', + outfit_name: 'Ranger', + canvas: { w: 64, h: 64 }, + }, + master: { + file: 'character/master.png', + anchor: { x: 0.5, y: 0.92 }, + anchor_cocos: { x: 0.5, y: 0.08 }, + }, + actions: [ + { + id: 'walk', + name: 'Walk', + export_name: 'Walk', + direction: 'default', + fps: 8, + loop: true, + quality_status: 'passed', + anchor: { x: 0.5, y: 0.92 }, + anchor_cocos: { x: 0.5, y: 0.08 }, + foot_y: 58, + frames: [ + { index: 0, file: 'Walk_000.png', duration_ms: 125 }, + { index: 1, file: 'Walk_001.png', duration_ms: 125 }, + { index: 2, file: 'Walk_002.png', duration_ms: 0 }, + ], + atlas: { file: 'atlas/Walk.png', cols: 3, rows: 1, cell: { w: 64, h: 64 } }, + }, + { + id: 'attack', + name: 'Attack', + export_name: 'Attack', + direction: 'east', + fps: 12, + loop: false, + quality_status: 'passed', + anchor: { x: 0.5, y: 0.92 }, + anchor_cocos: { x: 0.5, y: 0.08 }, + foot_y: 58, + frames: [ + { index: 0, file: 'Attack_000.png', duration_ms: 100 }, + { index: 1, file: 'Attack_001.png', duration_ms: 200 }, + ], + atlas: { file: 'atlas/Attack.png', cols: 2, rows: 1, cell: { w: 64, h: 64 } }, + }, + ], +} + +test('planImport 规划 master + N 个动作 + atlas', () => { + const plan = planImport(MANIFEST) + assert.equal(plan.packFolder, 'windup-imports/Hero/Ranger') + // 1 master + 2 actions: Walk(3 frames + 1 atlas) + Attack(2 frames + 1 atlas) = 8 + assert.equal(plan.spriteFrames.length, 1 + 4 + 3) + assert.equal(plan.animations.length, 2) +}) + +test('planImport master anchor 来自 manifest', () => { + const plan = planImport(MANIFEST) + assert.equal(plan.prefab.anchor.x, 0.5) + assert.equal(plan.prefab.anchor.y, 0.08) + assert.equal(plan.prefab.canvas.w, 64) + assert.equal(plan.prefab.canvas.h, 64) +}) + +test('planImport 动画 duration 等于帧 duration_ms 之和(秒)', () => { + const plan = planImport(MANIFEST) + const walk = plan.animations.find((a) => a.name === 'Walk') + // 125 + 125 + 125(fallback 1000/8)=375ms = 0.375s + assert.ok(Math.abs(walk.duration - 0.375) < 0.01, `walk.duration=${walk.duration}`) +}) + +test('planImport 保留 export_name 且不重复追加方向后缀', () => { + const plan = planImport(MANIFEST) + const walk = plan.animations.find((a) => a.name === 'Walk') + assert.ok(walk, 'Walk animation 存在') + assert.equal(walk.direction, 'default') + const attack = plan.animations.find((a) => a.name === 'Attack') + assert.ok(attack, 'Attack 存在') + assert.equal(attack.direction, 'east') +}) + +test('planImport spriteFrame 路径与 export_name 对齐', () => { + const plan = planImport(MANIFEST) + const walkFrames = plan.spriteFrames.filter((s) => s.cocosPath.includes('/Walk/')) + assert.equal(walkFrames.length, 4) // 3 frames + 1 atlas + assert.ok(walkFrames.some((s) => s.cocosPath.endsWith('/Walk/Walk_000.png'))) + assert.ok(walkFrames.some((s) => s.cocosPath.endsWith('/Walk/atlas.png'))) + // 源路径:master=character/master.png,frames=frames//,atlas=atlas/.png + assert.ok(walkFrames.find((s) => s.cocosPath.endsWith('Walk_000.png')).sourcePath === 'frames/Walk/Walk_000.png') + assert.ok(walkFrames.find((s) => s.cocosPath.endsWith('atlas.png')).sourcePath === 'atlas/Walk.png') + const secondFrame = walkFrames.find((s) => s.cocosPath.endsWith('Walk_001.png')) + assert.deepEqual(secondFrame.rect, { x: 0, y: 0, w: 64, h: 64 }) +}) + +test('planImport 使用 manifest 声明的 atlas 文件路径', () => { + const custom = JSON.parse(JSON.stringify(MANIFEST)) + custom.actions[0].atlas.file = 'atlas/custom-walk.png' + const plan = planImport(custom) + const atlas = plan.spriteFrames.find((s) => s.cocosPath.endsWith('/Walk/atlas.png')) + assert.equal(atlas.sourcePath, 'atlas/custom-walk.png') +}) + +test('planImport 接受 master 缺失 foot_y(向后兼容)', () => { + const broken = JSON.parse(JSON.stringify(MANIFEST)) + delete broken.master.foot_y + const plan = planImport(broken) + assert.equal(plan.prefab.footY, 0) +}) + +test('planImport 对 1.1 constant-fps 使用精确 index/fps 时间', () => { + const manifest = JSON.parse(JSON.stringify(MANIFEST)) + manifest.schema_version = 'windup-cocos-import-1.1.0' + manifest.actions = [manifest.actions[0]] + manifest.actions[0].fps = 12 + manifest.actions[0].timing_mode = 'constant-fps' + for (const frame of manifest.actions[0].frames) frame.duration_ms = null + + const animation = planImport(manifest).animations[0] + assert.deepEqual(animation.frames.map((frame) => frame.time), [0, 1 / 12, 2 / 12]) + assert.deepEqual(animation.frames.map((frame) => frame.duration), [1 / 12, 1 / 12, 1 / 12]) + assert.equal(animation.duration, 3 / 12) +}) diff --git a/tools/cocos-importer/test/bridge-uuid.test.mjs b/tools/cocos-importer/test/bridge-uuid.test.mjs new file mode 100644 index 00000000..19bc9034 --- /dev/null +++ b/tools/cocos-importer/test/bridge-uuid.test.mjs @@ -0,0 +1,120 @@ +// 验 uuidForPath 确定性 + RFC 4122 格式 + 不同 path 不同 uuid。 +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { buildCocosMetaFiles, uuidForPath } from '../src/cocos-bridge.js' + +test('uuidForPath 总是 Cocos 可保留的 RFC 4122 UUID', () => { + const u = uuidForPath('windup-imports/Hero/Ranger/animations/Walk.anim') + assert.equal(u.length, 36, `length=${u.length}`) + assert.match(u, /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) +}) + +test('uuidForPath 确定性:同 path → 同 uuid', () => { + const u1 = uuidForPath('windup-imports/Hero/Ranger/animations/Walk.anim') + const u2 = uuidForPath('windup-imports/Hero/Ranger/animations/Walk.anim') + assert.equal(u1, u2) +}) + +test('uuidForPath 唯一性:不同 path → 不同 uuid', () => { + const u1 = uuidForPath('a') + const u2 = uuidForPath('b') + const u3 = uuidForPath('c') + assert.notEqual(u1, u2) + assert.notEqual(u2, u3) + assert.notEqual(u1, u3) +}) + +test('uuidForPath 带 namespace 前缀,不和 Cocos 内部 UUID 撞', () => { + // 使用标准 UUID 形态,避免 Creator 将短 ID 重写成另一套内部 UUID。 + const u = uuidForPath('assets/scenes/main.scene') + assert.match(u, /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) +}) + +test('buildCocosMetaFiles 生成 Cocos 3.x image 子资源结构', () => { + const plan = { + packFolder: 'windup-imports/Hero/Ranger', + spriteFrames: [ + { + sourcePath: 'character/master.png', + cocosPath: 'windup-imports/Hero/Ranger/textures/Hero-master.png', + rect: { x: 0, y: 0, w: 64, h: 64 }, + trim: { x: 0, y: 0, w: 64, h: 64 }, + }, + ], + animations: [], + prefab: { + cocosPath: 'windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab', + nodeName: 'Hero-Ranger', + anchor: { x: 0.5, y: 0.08 }, + canvas: { w: 64, h: 64 }, + }, + } + const files = buildCocosMetaFiles({ package: { character_name: 'Hero' } }, plan, 'Hero-Ranger') + const meta = JSON.parse(files['windup-imports/Hero/Ranger/textures/Hero-master.png.meta']) + assert.equal(meta.importer, 'image') + assert.equal(meta.subMetas['6c48a'].uuid, `${meta.uuid}@6c48a`) + assert.equal(meta.subMetas.f9941.uuid, `${meta.uuid}@f9941`) + assert.equal(meta.subMetas.f9941.name, 'spriteFrame') + assert.equal(meta.subMetas.f9941.userData.imageUuidOrDatabaseUri, `${meta.uuid}@6c48a`) + assert.equal(meta.userData.redirect, `${meta.uuid}@6c48a`) +}) + +test('buildCocosMetaFiles 生成 Creator 3.8 可播放的 SpriteFrame 对象轨道', () => { + const packFolder = 'windup-imports/Hero/Ranger' + const framePaths = [0, 1].map((index) => `${packFolder}/textures/Idle_${index}.png`) + const plan = { + packFolder, + spriteFrames: framePaths.map((cocosPath, index) => ({ + sourcePath: `frames/Idle_${index}.png`, + cocosPath, + rect: { x: 0, y: 0, w: 64, h: 64 }, + trim: { x: 0, y: 0, w: 64, h: 64 }, + })), + animations: [{ + name: 'Idle', + direction: 'default', + fps: 12, + loop: true, + duration: 1 / 6, + frames: framePaths.map((spriteFramePath, index) => ({ + spriteFramePath, + index, + time: index === 0 ? 0 : 0.1, + duration: 1 / 12, + })), + }], + prefab: { + cocosPath: `${packFolder}/prefabs/Hero-Ranger.prefab`, + nodeName: 'Hero-Ranger', + anchor: { x: 0.5, y: 0.08 }, + canvas: { w: 64, h: 64 }, + }, + } + + const files = buildCocosMetaFiles({ package: { character_name: 'Hero' } }, plan, 'Hero-Ranger') + const clip = JSON.parse(files[`${packFolder}/animations/Idle.anim`]) + + assert.equal(clip._duration, 1 / 6) + assert.equal('duration' in clip, false) + assert.equal('curveData' in clip, false) + assert.equal(clip._tracks.length, 1) + + const track = clip._tracks[0] + assert.equal(track.__type__, 'cc.animation.ObjectTrack') + assert.deepEqual(track._binding.path._paths, [ + { __type__: 'cc.animation.ComponentPath', component: 'cc.Sprite' }, + 'spriteFrame', + ]) + assert.deepEqual(track._channel._curve._times, [0, 0.1]) + assert.deepEqual( + track._channel._curve._values.map(({ __uuid__ }) => __uuid__), + framePaths.map((path) => `${uuidForPath(path)}@f9941`), + ) + + const prefab = JSON.parse(files[`${packFolder}/prefabs/Hero-Ranger.prefab`]) + const uiTransform = prefab.find((entry) => entry.__type__ === 'cc.UITransform') + const sprite = prefab.find((entry) => entry.__type__ === 'cc.Sprite') + assert.deepEqual(uiTransform._contentSize, { __type__: 'cc.Size', width: 64, height: 64 }) + assert.equal(sprite._sizeMode, 0) + assert.equal(sprite._isTrimmedMode, false) +}) diff --git a/tools/cocos-importer/test/creator-runtime-check.mjs b/tools/cocos-importer/test/creator-runtime-check.mjs new file mode 100644 index 00000000..79bbd4a3 --- /dev/null +++ b/tools/cocos-importer/test/creator-runtime-check.mjs @@ -0,0 +1,150 @@ +import { createHash, randomUUID } from 'node:crypto' +import { lstat, readFile, readdir } from 'node:fs/promises' +import { basename, dirname, join, relative, resolve } from 'node:path' + +const PROTOCOL = 'windup-cocos-bridge/1.0.0' + +function option(name) { + const index = process.argv.indexOf(name) + return index < 0 ? null : process.argv[index + 1] +} + +function crc32(bytes) { + let crc = 0xffffffff + for (const byte of bytes) { + crc ^= byte + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function storedZip(entries) { + const localParts = [] + const centralParts = [] + let offset = 0 + for (const entry of entries) { + const name = Buffer.from(entry.name.replaceAll('\\', '/')) + const data = Buffer.from(entry.data) + const checksum = crc32(data) + const local = Buffer.alloc(30) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(20, 4) + local.writeUInt16LE(0x0800, 6) + local.writeUInt32LE(checksum, 14) + local.writeUInt32LE(data.length, 18) + local.writeUInt32LE(data.length, 22) + local.writeUInt16LE(name.length, 26) + localParts.push(local, name, data) + + const central = Buffer.alloc(46) + central.writeUInt32LE(0x02014b50, 0) + central.writeUInt16LE(20, 4) + central.writeUInt16LE(20, 6) + central.writeUInt16LE(0x0800, 8) + central.writeUInt32LE(checksum, 16) + central.writeUInt32LE(data.length, 20) + central.writeUInt32LE(data.length, 24) + central.writeUInt16LE(name.length, 28) + central.writeUInt32LE(offset, 42) + centralParts.push(central, name) + offset += local.length + name.length + data.length + } + const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0) + const end = Buffer.alloc(22) + end.writeUInt32LE(0x06054b50, 0) + end.writeUInt16LE(entries.length, 8) + end.writeUInt16LE(entries.length, 10) + end.writeUInt32LE(centralSize, 12) + end.writeUInt32LE(offset, 16) + return Buffer.concat([...localParts, ...centralParts, end]) +} + +async function packageEntries(packageRoot, directory = packageRoot) { + const rootName = basename(packageRoot) + const entries = [] + for (const item of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, item.name) + const stat = await lstat(path) + if (stat.isSymbolicLink()) throw new Error(`FIXTURE_SYMLINK_FORBIDDEN: ${path}`) + if (item.isDirectory()) entries.push(...(await packageEntries(packageRoot, path))) + else if (item.isFile()) { + entries.push({ + name: `${rootName}/${relative(packageRoot, path).replaceAll('\\', '/')}`, + data: await readFile(path), + }) + } + } + return entries +} + +async function requestJson(url, init) { + const response = await fetch(url, init) + const body = await response.json() + if (!response.ok) throw new Error(`${response.status} ${JSON.stringify(body)}`) + return body +} + +async function main() { + const code = option('--code') + const frames = option('--frames') + const origin = option('--origin') ?? 'http://localhost:4173' + const baseUrl = option('--bridge') ?? 'http://127.0.0.1:17832' + const repeat = Number(option('--repeat') ?? 1) + if (!/^\d{6}$/.test(code ?? '')) throw new Error('--code 必须是六位连接码') + if (!Number.isInteger(repeat) || repeat < 1 || repeat > 3) throw new Error('--repeat 必须是 1 到 3') + if (!frames || basename(resolve(frames)).toLowerCase() !== 'frames') { + throw new Error('--frames 必须指向资产包的 frames 目录') + } + + const packageRoot = dirname(resolve(frames)) + const entries = await packageEntries(packageRoot) + const zipBytes = storedZip(entries) + const sha256 = createHash('sha256').update(zipBytes).digest('hex') + const pairing = await requestJson(`${baseUrl}/v1/pair`, { + method: 'POST', + headers: { Origin: origin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }) + const attempts = [] + for (let attempt = 1; attempt <= repeat; attempt += 1) { + const requestId = randomUUID() + const submitted = await requestJson(`${baseUrl}/v1/imports`, { + method: 'POST', + headers: { + Origin: origin, + Authorization: `Bearer ${pairing.token}`, + 'Content-Type': 'application/zip', + 'X-Windup-Protocol': PROTOCOL, + 'X-Windup-Request-Id': requestId, + 'X-Windup-SHA256': sha256, + }, + body: zipBytes, + }) + + let previousPhase = null + const deadline = Date.now() + 60_000 + while (Date.now() < deadline) { + const job = await requestJson(`${baseUrl}/v1/imports/${submitted.jobId}`, { + headers: { + Origin: origin, + Authorization: `Bearer ${pairing.token}`, + 'X-Windup-Protocol': PROTOCOL, + }, + }) + if (job.phase !== previousPhase) { + console.log(`attempt ${attempt}: ${job.status}: ${job.phase}`) + previousPhase = job.phase + } + if (job.status === 'completed') { + attempts.push({ requestId, jobId: job.jobId, result: job.result }) + break + } + if (job.status === 'failed') throw new Error(JSON.stringify(job.error)) + await new Promise((resolve) => setTimeout(resolve, 250)) + } + if (attempts.length !== attempt) throw new Error('IMPORT_JOB_TIMEOUT') + } + console.log(JSON.stringify({ zipBytes: zipBytes.length, attempts }, null, 2)) +} + +await main() diff --git a/tools/cocos-importer/test/e2e-cli.test.mjs b/tools/cocos-importer/test/e2e-cli.test.mjs new file mode 100644 index 00000000..e130a004 --- /dev/null +++ b/tools/cocos-importer/test/e2e-cli.test.mjs @@ -0,0 +1,244 @@ +// E2E:真的跑 CLI 走完整 ZIP → 输出目录 → 校验内容 +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync, readdirSync, statSync, rmSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +// 真正生成的 ZIP 在 frontend/dist/cocos-e2e/ 下面(由 vitest e2e 测试产出)。 +// 这里直接 fork 一个子进程跑 vitest 产出 ZIP,确保 e2e 自包含。 +const repoRoot = resolve(__dirname, '..', '..', '..') +const frontendDir = join(repoRoot, 'frontend') +const zipPath = join(frontendDir, 'dist', 'cocos-e2e', 'windup-Hero-char-42-Ranger-outfit-7.zip') +const outDir = join(__dirname, '.tmp-cli-out') +const framesPackageRoot = join(__dirname, '.tmp-frames-package') + +function ensureZip() { + if (existsSync(zipPath)) return + // 用 vitest 跑一次 extract test 落盘 + execFileSync( + 'cmd', + [ + '/c', + 'npx', + 'vitest', + 'run', + '--passWithNoTests', + 'src/features/export-package/cocos-target.e2e.extract.test.ts', + ], + { cwd: frontendDir, stdio: 'inherit' }, + ) + if (!existsSync(zipPath)) throw new Error(`vitest 跑完也没产出 ZIP: ${zipPath}`) +} + +test('CLI 把 ZIP 解析 + 写到指定目录', () => { + ensureZip() + if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true }) + mkdirSync(outDir, { recursive: true }) + + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + execFileSync('node', [cliPath, zipPath, '--out', outDir, '--force'], { + stdio: 'pipe', + cwd: repoRoot, + }) + + // 检查输出结构 + const packRoot = join(outDir, 'windup-imports', 'Hero', 'Ranger') + assert.ok(existsSync(packRoot), `缺包根: ${packRoot}`) + assert.ok(existsSync(join(packRoot, 'cocos-import.json'))) + assert.ok(existsSync(join(packRoot, 'textures'))) + + // master.png 应该被拷到 textures/-master.png + const masterFile = readdirSync(join(packRoot, 'textures')).find((n) => n.endsWith('.png')) + assert.ok(masterFile, 'master.png 没出现在 textures 目录') + + // 至少一个动作的纹理目录 + const animDir = join(packRoot, 'animations', 'Walk') + assert.ok(existsSync(animDir), `缺动画目录: ${animDir}`) + const frames = readdirSync(animDir) + const framePngs = frames.filter((f) => f.endsWith('.png')) + assert.ok(framePngs.length >= 4, `期望至少 4 张 PNG(3 帧 + 1 atlas),实际 ${framePngs.length}`) + + // 至少一个 .prefab + const prefabDir = join(packRoot, 'prefabs') + assert.ok(existsSync(prefabDir), '缺 prefabs 目录') + const prefabFiles = readdirSync(prefabDir).filter((f) => f.endsWith('.prefab')) + assert.equal(prefabFiles.length, 1) + + // .prefab 是合法 JSON + const prefabContent = readFileSync(join(prefabDir, prefabFiles[0]), 'utf-8') + const prefabJson = JSON.parse(prefabContent) + const prefabAsset = Array.isArray(prefabJson) ? prefabJson[0] : prefabJson + assert.equal(prefabAsset.__type__, 'cc.Prefab') + assert.ok(Array.isArray(prefabJson), 'Cocos Creator 3.x prefab 应使用对象数组序列化') + assert.equal(prefabAsset.data.__id__, 1) + + // cocos-import.json 副本存在 + const manifest = JSON.parse(readFileSync(join(packRoot, 'cocos-import.json'), 'utf-8')) + assert.equal(manifest.engine, 'cocos-creator') +}) + +test('CLI --dry-run 不写任何文件', () => { + ensureZip() + if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true }) + + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + execFileSync('node', [cliPath, zipPath, '--out', outDir, '--dry-run'], { + stdio: 'pipe', + cwd: repoRoot, + }) + assert.equal(existsSync(outDir), false, 'dry-run 不应创建输出目录') +}) + +test('CLI 允许直接选择旧资产包的 frames 目录', () => { + if (existsSync(framesPackageRoot)) rmSync(framesPackageRoot, { recursive: true, force: true }) + if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true }) + + const framesDir = join(framesPackageRoot, 'frames') + const idleDir = join(framesDir, '待机') + mkdirSync(idleDir, { recursive: true }) + mkdirSync(join(framesPackageRoot, 'character'), { recursive: true }) + mkdirSync(join(framesPackageRoot, 'atlas'), { recursive: true }) + + const masterBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x01]) + const frameBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x02]) + const atlasBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x03]) + writeFileSync(join(framesPackageRoot, 'character', 'master.png'), masterBytes) + writeFileSync(join(idleDir, '待机_000.png'), frameBytes) + writeFileSync(join(framesPackageRoot, 'atlas', '待机.png'), atlasBytes) + writeFileSync( + join(framesPackageRoot, 'meta.json'), + JSON.stringify({ + character: { id: '46', name: '网站看板娘', image: 'character/master.png' }, + outfit: { id: 'default', name: '默认造型' }, + canvas: { w: 1, h: 1 }, + actions: [ + { + id: 'idle', + name: '待机', + fps: 12, + loop: true, + anchor: { x: 0.5, y: 0.92 }, + frames: [{ index: 0, file: '待机_000.png' }], + atlas: { file: 'atlas/待机.png', cols: 1, rows: 1, cell: { w: 1, h: 1 } }, + }, + ], + }), + ) + + try { + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + execFileSync('node', [cliPath, framesDir, '--out', outDir, '--force'], { + stdio: 'pipe', + cwd: repoRoot, + }) + + const packRoot = join(outDir, 'windup-imports', '网站看板娘', '默认造型') + assert.deepEqual( + readFileSync(join(packRoot, 'animations', '待机', '待机_000.png')), + frameBytes, + '导出的动画帧必须来自用户选中的 frames 目录', + ) + } finally { + if (existsSync(framesPackageRoot)) rmSync(framesPackageRoot, { recursive: true, force: true }) + } +}) + +test('CLI 拒绝不存在的输入文件', () => { + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + let exitCode = 0 + try { + execFileSync('node', [cliPath, join(__dirname, '.tmp-does-not-exist.zip'), '--out', outDir], { + stdio: 'pipe', + }) + } catch (err) { + exitCode = err.status + } + assert.notEqual(exitCode, 0, '不存在的文件应让 CLI 退出非 0') +}) + +test('CLI 拒绝会覆盖仓库根目录的输出路径', () => { + ensureZip() + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + let exitCode = 0 + try { + execFileSync('node', [cliPath, zipPath, '--out', repoRoot, '--force'], { stdio: 'pipe' }) + } catch (err) { + exitCode = err.status + } + assert.notEqual(exitCode, 0, '仓库根目录不应成为可递归删除的输出目录') +}) + +test('CLI 拒绝没有 manifest 的 ZIP', () => { + // 造一个最小 STORED ZIP,只有一个 README,没 manifest + const fakeZip = join(__dirname, '.tmp-no-manifest.zip') + const innerName = 'README.md' + const innerData = Buffer.from('# not a windup package\n') + + // 手工拼一个 stored ZIP + const lh = Buffer.alloc(30) + const nameBuf = Buffer.from(innerName, 'utf-8') + lh.writeUInt32LE(0x04034b50, 0) + lh.writeUInt16LE(20, 4) // version + lh.writeUInt16LE(0, 6) // flags + lh.writeUInt16LE(0, 8) // method = stored + lh.writeUInt16LE(0, 10) // mtime + lh.writeUInt16LE(0, 12) // mdate + lh.writeUInt32LE(0, 14) // crc + lh.writeUInt32LE(innerData.length, 18) // csize + lh.writeUInt32LE(innerData.length, 22) // usize + lh.writeUInt16LE(nameBuf.length, 26) + lh.writeUInt16LE(0, 28) // extra + + const cdh = Buffer.alloc(46) + cdh.writeUInt32LE(0x02014b50, 0) + cdh.writeUInt16LE(20, 4) // version made by + cdh.writeUInt16LE(20, 6) // version needed + cdh.writeUInt16LE(0, 8) + cdh.writeUInt16LE(0, 10) // method + cdh.writeUInt16LE(0, 12) // mtime + cdh.writeUInt16LE(0, 14) // mdate + cdh.writeUInt32LE(0, 16) // crc + cdh.writeUInt32LE(innerData.length, 20) // csize + cdh.writeUInt32LE(innerData.length, 24) // usize + cdh.writeUInt16LE(nameBuf.length, 28) + cdh.writeUInt16LE(0, 30) // extra + cdh.writeUInt16LE(0, 32) // comment + cdh.writeUInt16LE(0, 34) // disk + cdh.writeUInt16LE(0, 36) // int attrs + cdh.writeUInt32LE(0, 38) // ext attrs + cdh.writeUInt32LE(0, 42) // local header offset + + const eocd = Buffer.alloc(22) + eocd.writeUInt32LE(0x06054b50, 0) + eocd.writeUInt16LE(0, 4) + eocd.writeUInt16LE(0, 6) + eocd.writeUInt16LE(1, 8) // entries on this disk + eocd.writeUInt16LE(1, 10) // total entries + eocd.writeUInt32LE(46, 12) // cd size + eocd.writeUInt32LE(30 + nameBuf.length + innerData.length, 16) // cd offset + eocd.writeUInt16LE(0, 20) + + writeFileSync( + fakeZip, + Buffer.concat([lh, nameBuf, innerData, cdh, eocd]), + ) + try { + const cliPath = join(repoRoot, 'tools', 'cocos-importer', 'bin', 'windup-cocos-import.mjs') + let exitCode = 0 + try { + execFileSync('node', [cliPath, fakeZip, '--out', outDir], { stdio: 'pipe' }) + } catch (err) { + exitCode = err.status + } + assert.notEqual(exitCode, 0, '没有 manifest 的 ZIP 应让 CLI 退出非 0') + } finally { + if (existsSync(fakeZip)) { + try { unlinkSync(fakeZip) } catch {} + } + } +}) diff --git a/tools/cocos-importer/test/import-core.test.mjs b/tools/cocos-importer/test/import-core.test.mjs new file mode 100644 index 00000000..a24ef31e --- /dev/null +++ b/tools/cocos-importer/test/import-core.test.mjs @@ -0,0 +1,236 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { prepareImport, prepareImportFromEntries, validatePreparedImport } from '../src/import-core.js' +import { readStoredZip } from '../src/zip-reader.js' + +const testDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(testDir, '..', '..', '..') +const frontendDir = join(repoRoot, 'frontend') +const zipPath = join(frontendDir, 'dist', 'cocos-e2e', 'windup-Hero-char-42-Ranger-outfit-7.zip') + +function fixtureZipBytes() { + if (!existsSync(zipPath)) { + execFileSync( + 'npx', + ['vitest', 'run', '--passWithNoTests', 'src/features/export-package/cocos-target.e2e.extract.test.ts'], + { cwd: frontendDir, stdio: 'pipe', shell: process.platform === 'win32' }, + ) + } + return new Uint8Array(readFileSync(zipPath)) +} + +function centralEntries(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + let eocd = bytes.length - 22 + while (eocd >= 0 && view.getUint32(eocd, true) !== 0x06054b50) eocd -= 1 + assert.ok(eocd >= 0) + const total = view.getUint16(eocd + 10, true) + let offset = view.getUint32(eocd + 16, true) + const entries = [] + for (let index = 0; index < total; index += 1) { + const nameLength = view.getUint16(offset + 28, true) + const extraLength = view.getUint16(offset + 30, true) + const commentLength = view.getUint16(offset + 32, true) + entries.push({ + centralOffset: offset, + localOffset: view.getUint32(offset + 42, true), + nameLength, + compressedSize: view.getUint32(offset + 20, true), + }) + offset += 46 + nameLength + extraLength + commentLength + } + return entries +} + +test('prepareImport 在内存中把 Windup ZIP 转换为完整 Cocos 文件集合', () => { + const prepared = prepareImport(fixtureZipBytes()) + + assert.equal(prepared.packFolder, 'windup-imports/Hero/Ranger') + assert.deepEqual(prepared.summary, { + characterName: 'Hero', + outfitName: 'Ranger', + animationCount: 1, + frameCount: 3, + fileCount: prepared.files.size, + }) + assert.ok(prepared.files.has('windup-imports/Hero/Ranger/prefabs/Hero-Ranger.prefab')) + assert.ok(prepared.files.has('windup-imports/Hero/Ranger/animations/Walk.anim')) + assert.doesNotThrow(() => validatePreparedImport(prepared)) +}) + +test('prepareImportFromEntries 可导入显示名与旧版实际目录不同的资产包', () => { + const encoder = new TextEncoder() + const bytes = (value) => encoder.encode(value) + const legacy = { + character: { id: '46', name: '网站看板娘', image: 'character/master.png' }, + outfit: { id: 'default', name: '默认造型' }, + canvas: { w: 1, h: 1 }, + actions: [{ + id: 'walk-south', + name: 'Walk / Forward', + direction: 'south', + fps: 12, + loop: true, + anchor: { x: 0.5, y: 0.92 }, + frames: [{ index: 0, file: 'Walk-Forward-south_000.png' }], + atlas: { + file: 'atlas/Walk-Forward-south.png', + cols: 1, + rows: 1, + cell: { w: 1, h: 1 }, + }, + }], + } + const entries = [ + ['meta.json', bytes(JSON.stringify(legacy))], + ['character/master.png', bytes('master')], + ['frames/Walk-Forward-south/Walk-Forward-south_000.png', bytes('frame')], + ['atlas/Walk-Forward-south.png', bytes('atlas')], + ].map(([relativePath, data]) => ({ + relativePath, + data, + size: data.byteLength, + rootDir: 'legacy-fixture', + })) + + const prepared = prepareImportFromEntries(entries) + assert.equal(prepared.manifest.actions[0].export_name, 'Walk-Forward-south') + assert.ok(prepared.files.has( + 'windup-imports/网站看板娘/默认造型/animations/Walk-Forward-south/Walk-Forward-south_000.png', + )) +}) + +test('validatePreparedImport 拒绝缺失 SpriteFrame 源文件的结果', () => { + const prepared = prepareImport(fixtureZipBytes()) + const framePath = [...prepared.files.keys()].find((path) => path.endsWith('/Walk_000.png')) + assert.ok(framePath) + prepared.files.delete(framePath) + + assert.throws(() => validatePreparedImport(prepared), /IMPORT_OUTPUT_MISSING/) +}) + +test('readStoredZip 拒绝中央目录标记为符号链接的条目', () => { + const bytes = fixtureZipBytes().slice() + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + let eocd = bytes.length - 22 + while (eocd >= 0 && view.getUint32(eocd, true) !== 0x06054b50) eocd -= 1 + assert.ok(eocd >= 0) + const centralDirectory = view.getUint32(eocd + 16, true) + view.setUint16(centralDirectory + 4, 0x0314, true) + view.setUint32(centralDirectory + 38, 0xa1ff0000, true) + + assert.throws(() => readStoredZip(bytes), /ZIP: 包含符号链接/) +}) + +test('readStoredZip 拒绝 CRC 不匹配的损坏内容', () => { + const bytes = fixtureZipBytes().slice() + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const entry = centralEntries(bytes).find((candidate) => candidate.compressedSize > 0) + assert.ok(entry) + const localNameLength = view.getUint16(entry.localOffset + 26, true) + const localExtraLength = view.getUint16(entry.localOffset + 28, true) + const dataOffset = entry.localOffset + 30 + localNameLength + localExtraLength + bytes[dataOffset] ^= 0xff + assert.throws(() => readStoredZip(bytes), /CRC/) +}) + +test('readStoredZip 拒绝中央目录和本地头文件名不一致', () => { + const bytes = fixtureZipBytes().slice() + const entry = centralEntries(bytes)[0] + bytes[entry.centralOffset + 46] ^= 1 + assert.throws(() => readStoredZip(bytes), /文件名不一致/) +}) + +test('readStoredZip 拒绝重复路径和反斜杠路径', () => { + const original = fixtureZipBytes() + const entries = centralEntries(original) + const pair = entries.flatMap((first, index) => + entries.slice(index + 1).map((second) => [first, second]), + ).find(([first, second]) => first.nameLength === second.nameLength) + assert.ok(pair) + + const duplicate = original.slice() + const [first, second] = pair + const firstName = duplicate.slice(first.centralOffset + 46, first.centralOffset + 46 + first.nameLength) + duplicate.set(firstName, second.centralOffset + 46) + duplicate.set(firstName, second.localOffset + 30) + assert.throws(() => readStoredZip(duplicate), /重复路径/) + + const backslash = original.slice() + const slashEntry = entries.find((entry) => { + const name = new TextDecoder().decode(backslash.slice(entry.centralOffset + 46, entry.centralOffset + 46 + entry.nameLength)) + return name.includes('/') + }) + assert.ok(slashEntry) + const centralSlash = backslash.indexOf('/'.charCodeAt(0), slashEntry.centralOffset + 46) + const localSlash = backslash.indexOf('/'.charCodeAt(0), slashEntry.localOffset + 30) + backslash[centralSlash] = '\\'.charCodeAt(0) + backslash[localSlash] = '\\'.charCodeAt(0) + assert.throws(() => readStoredZip(backslash), /路径不安全/) +}) + +test('readStoredZip 在解析前限制条目数、单条目和总解包大小', () => { + const bytes = fixtureZipBytes() + assert.throws(() => readStoredZip(bytes, { maxEntries: 1 }), /条目数/) + assert.throws(() => readStoredZip(bytes, { maxEntryBytes: 1 }), /单条目/) + assert.throws(() => readStoredZip(bytes, { maxTotalBytes: 1 }), /总解包大小/) +}) + +test('prepareImportFromEntries 按每次输出复制量限制重复素材引用', () => { + const frameCount = 65 + const manifest = { + schema_version: 'windup-cocos-import-1.1.0', + experimental: true, + engine: 'cocos-creator', + upstream_issue: 94, + package: { + character_id: 'c1', character_name: 'Hero', outfit_id: 'o1', outfit_name: 'Ranger', + canvas: { w: 64, h: 64 }, + }, + master: { + file: 'shared.png', anchor: { x: 0.5, y: 0.9 }, anchor_cocos: { x: 0.5, y: 0.1 }, + }, + actions: [{ + id: 'repeat', name: 'Repeat', export_name: 'Repeat', direction: 'default', fps: 12, + timing_mode: 'constant-fps', loop: true, quality_status: 'passed', + anchor: { x: 0.5, y: 0.9 }, anchor_cocos: { x: 0.5, y: 0.1 }, foot_y: 58, + frames: Array.from({ length: frameCount }, (_, index) => ({ + index, file: 'shared.png', duration_ms: null, + })), + atlas: { file: 'shared.png', cols: frameCount, rows: 1, cell: { w: 64, h: 64 } }, + }], + } + const source = new Uint8Array(4 * 1024 * 1024) + const entries = [ + { relativePath: 'targets/cocos-creator/cocos-import.json', data: new TextEncoder().encode(JSON.stringify(manifest)), size: 1, rootDir: 'fixture' }, + { relativePath: 'shared.png', data: source, size: source.length, rootDir: 'fixture' }, + { relativePath: 'frames/Repeat/shared.png', data: source, size: source.length, rootDir: 'fixture' }, + ] + + assert.throws(() => prepareImportFromEntries(entries), /IMPORT_OUTPUT_TOO_LARGE/) +}) + +test('prepareImport 为 Creator 会缓存的目录和说明文件生成稳定 meta', () => { + const first = prepareImport(fixtureZipBytes()) + const second = prepareImport(fixtureZipBytes()) + const expected = new Map([ + [`${first.packFolder}/animations.meta`, 'directory'], + [`${first.packFolder}/animations/Walk.meta`, 'directory'], + [`${first.packFolder}/prefabs.meta`, 'directory'], + [`${first.packFolder}/textures.meta`, 'directory'], + [`${first.packFolder}/cocos-import.json.meta`, 'json'], + ]) + + for (const [path, importer] of expected) { + assert.ok(first.files.has(path), path) + const firstMeta = JSON.parse(new TextDecoder().decode(first.files.get(path))) + const secondMeta = JSON.parse(new TextDecoder().decode(second.files.get(path))) + assert.equal(firstMeta.importer, importer) + assert.equal(firstMeta.uuid, secondMeta.uuid) + } +}) diff --git a/tools/cocos-importer/test/manifest-reader.test.mjs b/tools/cocos-importer/test/manifest-reader.test.mjs new file mode 100644 index 00000000..ceaa66d8 --- /dev/null +++ b/tools/cocos-importer/test/manifest-reader.test.mjs @@ -0,0 +1,262 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { buildManifestFromLegacyMeta, parseManifest, validateManifest } from '../src/manifest-reader.js' + +const VALID = { + schema_version: 'windup-cocos-import-1.0.0', + experimental: true, + engine: 'cocos-creator', + upstream_issue: 94, + package: { + character_id: 'c1', + character_name: 'Hero', + outfit_id: 'o1', + outfit_name: 'Ranger', + canvas: { w: 64, h: 64 }, + }, + master: { + file: 'character/master.png', + anchor: { x: 0.5, y: 0.92 }, + anchor_cocos: { x: 0.5, y: 0.08 }, + }, + actions: [ + { + id: 'walk', + name: 'Walk', + export_name: 'Walk', + direction: 'default', + fps: 8, + loop: true, + quality_status: 'passed', + anchor: { x: 0.5, y: 0.92 }, + anchor_cocos: { x: 0.5, y: 0.08 }, + foot_y: 58, + frames: [ + { index: 0, file: 'Walk_000.png', duration_ms: 125 }, + { index: 1, file: 'Walk_001.png', duration_ms: 125 }, + ], + atlas: { + file: 'atlas/Walk.png', + cols: 2, + rows: 1, + cell: { w: 64, h: 64 }, + }, + }, + ], +} + +test('parseManifest 接受合法 manifest', () => { + const m = parseManifest(JSON.stringify(VALID)) + assert.equal(m.engine, 'cocos-creator') + assert.equal(m.actions.length, 1) +}) + +test('parseManifest 接受 1.1 constant-fps 的 null 帧时长', () => { + const v11 = JSON.parse(JSON.stringify(VALID)) + v11.schema_version = 'windup-cocos-import-1.1.0' + v11.actions[0].timing_mode = 'constant-fps' + for (const frame of v11.actions[0].frames) frame.duration_ms = null + const manifest = parseManifest(JSON.stringify(v11)) + assert.equal(manifest.actions[0].timing_mode, 'constant-fps') + assert.deepEqual(manifest.actions[0].frames.map((frame) => frame.duration_ms), [null, null]) +}) + +test('parseManifest 拒绝 1.1 的非法 timing_mode', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.schema_version = 'windup-cocos-import-1.1.0' + broken.actions[0].timing_mode = 'rounded-milliseconds' + assert.throws(() => parseManifest(JSON.stringify(broken)), /timing_mode/) +}) + +test('parseManifest 拒绝非 JSON', () => { + assert.throws(() => parseManifest('{not json'), /不是合法 JSON/) +}) + +test('parseManifest 拒绝缺字段', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + delete broken.package.character_id + assert.throws(() => parseManifest(JSON.stringify(broken)), /缺少字段.*character_id/) +}) + +test('parseManifest 拒绝错误 engine', () => { + const broken = { ...JSON.parse(JSON.stringify(VALID)), engine: 'unity' } + assert.throws(() => parseManifest(JSON.stringify(broken)), /engine/) +}) + +test('parseManifest 拒绝非 experimental', () => { + const broken = { ...JSON.parse(JSON.stringify(VALID)), experimental: false } + assert.throws(() => parseManifest(JSON.stringify(broken)), /experimental/) +}) + +test('parseManifest 拒绝错误 schema_version', () => { + const broken = { ...JSON.parse(JSON.stringify(VALID)), schema_version: 'something-else' } + assert.throws(() => parseManifest(JSON.stringify(broken)), /schema_version/) +}) + +test('parseManifest 只接受明确支持的 schema_version', () => { + for (const schema_version of ['windup-cocos-import-0.9.0', 'windup-cocos-import-1.2.0', 'windup-cocos-import-next']) { + const broken = { ...JSON.parse(JSON.stringify(VALID)), schema_version } + assert.throws(() => parseManifest(JSON.stringify(broken)), /schema_version/) + } +}) + +test('parseManifest 拒绝错误 direction', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.actions[0].direction = 'upside_down' + assert.throws(() => parseManifest(JSON.stringify(broken)), /direction 非法/) +}) + +test('parseManifest 拒绝错误 quality_status', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.actions[0].quality_status = 'maybe' + assert.throws(() => parseManifest(JSON.stringify(broken)), /quality_status 非法/) +}) + +test('parseManifest 接受只有角色母版的空 actions 包', () => { + const characterOnly = { ...JSON.parse(JSON.stringify(VALID)), actions: [] } + assert.deepEqual(parseManifest(JSON.stringify(characterOnly)).actions, []) +}) + +test('parseManifest 拒绝非正 canvas', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.package.canvas.w = 0 + assert.throws(() => parseManifest(JSON.stringify(broken)), /canvas\.w/) +}) + +test('parseManifest 拒绝非连续帧序号', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.actions[0].frames[1].index = 3 + assert.throws(() => parseManifest(JSON.stringify(broken)), /index 必须从 0 连续递增/) +}) + +test('parseManifest 拒绝不安全的素材路径', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.master.file = '../outside.png' + assert.throws(() => parseManifest(JSON.stringify(broken)), /master\.file.*安全的相对路径/) +}) + +test('parseManifest 拒绝 Windows 盘符、反斜杠和 NUL 素材路径', () => { + for (const file of ['C:/outside.png', 'frames\\walk.png', 'frames/evil\0.png']) { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.master.file = file + assert.throws(() => parseManifest(JSON.stringify(broken)), /安全的相对路径/) + } +}) + +test('parseManifest 拒绝超出图集容量的帧', () => { + const broken = JSON.parse(JSON.stringify(VALID)) + broken.actions[0].atlas.cols = 1 + broken.actions[0].atlas.rows = 1 + assert.throws(() => parseManifest(JSON.stringify(broken)), /超出图集容量/) +}) + +test('parseManifest 限制动作数、单动作帧数和总帧数', () => { + const tooManyActions = JSON.parse(JSON.stringify(VALID)) + tooManyActions.actions = Array.from({ length: 129 }, (_, index) => ({ + ...tooManyActions.actions[0], + id: `action-${index}`, + })) + assert.throws(() => validateManifest(tooManyActions), /动作数超过限制/) + + const makeFrames = (count) => Array.from({ length: count }, (_, index) => ({ + index, + file: `frame-${index}.png`, + duration_ms: 1, + })) + const tooManyInAction = JSON.parse(JSON.stringify(VALID)) + tooManyInAction.actions[0].frames = makeFrames(2049) + tooManyInAction.actions[0].atlas = { ...tooManyInAction.actions[0].atlas, cols: 2049 } + assert.throws(() => validateManifest(tooManyInAction), /单动作帧数超过限制/) + + const tooManyTotal = JSON.parse(JSON.stringify(VALID)) + tooManyTotal.actions = [2048, 2048, 1].map((count, actionIndex) => ({ + ...tooManyTotal.actions[0], + id: `action-${actionIndex}`, + frames: makeFrames(count), + atlas: { ...tooManyTotal.actions[0].atlas, cols: count }, + })) + assert.throws(() => validateManifest(tooManyTotal), /总帧数超过限制/) +}) + +test('validateManifest 接受对象,parseManifest 接受字符串', () => { + const m = validateManifest(VALID) + assert.equal(m.engine, 'cocos-creator') + const m2 = parseManifest(JSON.stringify(VALID)) + assert.deepEqual(m, m2) +}) + +test('buildManifestFromLegacyMeta 兼容旧版 action-assets 包', () => { + const legacy = { + schema_version: '1.1.0', + stage: 'action-assets', + character: { id: 46, name: '网站看板娘', image: 'character/master.png' }, + outfit: { id: 'outfit-default', name: '默认造型' }, + canvas: { w: 256, h: 256 }, + actions: [ + { + id: 'idle', + name: '待机', + fps: 12, + loop: true, + quality_status: 'passed', + anchor: { x: 0.5, y: 0.92 }, + foot_y: 235, + frames: [{ index: 0, file: '待机_000.png' }], + atlas: { file: 'atlas/待机.png', cols: 8, rows: 4, cell: { w: 256, h: 256 } }, + }, + ], + } + const manifest = buildManifestFromLegacyMeta(legacy) + assert.equal(manifest.schema_version, 'windup-cocos-import-1.1.0') + assert.equal(manifest.package.character_id, '46') + assert.equal(manifest.package.character_name, '网站看板娘') + assert.equal(manifest.master.anchor_cocos.x, 0.5) + assert.ok(Math.abs(manifest.master.anchor_cocos.y - 0.08) < 1e-10) + assert.equal(manifest.actions[0].export_name, '待机') + assert.equal(manifest.actions[0].timing_mode, 'constant-fps') + assert.equal(manifest.actions[0].frames[0].duration_ms, null) +}) + +test('buildManifestFromLegacyMeta 从旧版 atlas 路径恢复实际导出目录', () => { + const legacy = { + character: { id: 46, name: '网站看板娘', image: 'character/master.png' }, + outfit: { id: 'outfit-default', name: '默认造型' }, + canvas: { w: 256, h: 256 }, + actions: [ + { + id: 'walk-south', + name: 'Walk / Forward', + direction: 'south', + fps: 12, + anchor: { x: 0.5, y: 0.92 }, + frames: [{ index: 0, file: 'Walk-Forward-south_000.png' }], + atlas: { + file: 'atlas/Walk-Forward-south.png', + cols: 1, + rows: 1, + cell: { w: 256, h: 256 }, + }, + }, + { + id: 'walk-south-duplicate', + name: 'Walk / Forward', + direction: 'south', + fps: 12, + anchor: { x: 0.5, y: 0.92 }, + frames: [{ index: 0, file: 'Walk-Forward-south-a1b2c3_000.png' }], + atlas: { + file: 'atlas/Walk-Forward-south-a1b2c3.png', + cols: 1, + rows: 1, + cell: { w: 256, h: 256 }, + }, + }, + ], + } + + const manifest = buildManifestFromLegacyMeta(legacy) + assert.deepEqual( + manifest.actions.map((action) => action.export_name), + ['Walk-Forward-south', 'Walk-Forward-south-a1b2c3'], + ) +}) diff --git a/tools/cocos-importer/test/snapshot-uuids.mjs b/tools/cocos-importer/test/snapshot-uuids.mjs new file mode 100644 index 00000000..02fad2fa --- /dev/null +++ b/tools/cocos-importer/test/snapshot-uuids.mjs @@ -0,0 +1,55 @@ +// 快照一个目录里所有 .meta / .prefab / .anim 的关键字段(UUID 引用、SpriteFrame 子资源)。 +// 用法: node test/snapshot-uuids.mjs +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { resolve, join, relative } from 'node:path' + +const dir = resolve(process.cwd(), process.argv[2]) +const out = resolve(process.cwd(), process.argv[3]) + +function walk(d) { + const out = [] + for (const n of readdirSync(d)) { + const f = join(d, n) + const s = statSync(f) + if (s.isDirectory()) out.push(...walk(f)) + else out.push(f) + } + return out +} + +function collectUuids(obj, acc, path) { + if (Array.isArray(obj)) { + obj.forEach((v, i) => collectUuids(v, acc, `${path}[${i}]`)) + return + } + if (obj && typeof obj === 'object') { + if (typeof obj.__uuid__ === 'string') acc.push({ path, uuid: obj.__uuid__ }) + for (const k of Object.keys(obj)) collectUuids(obj[k], acc, `${path}.${k}`) + } +} + +const snap = {} +for (const f of walk(dir)) { + if (!f.endsWith('.meta') && !f.endsWith('.prefab') && !f.endsWith('.anim')) continue + const rel = relative(dir, f).replace(/\\/g, '/') + try { + const j = JSON.parse(readFileSync(f, 'utf-8')) + const uuids = [] + collectUuids(j, uuids, '$') + const ownUuid = j.uuid ?? null + const subMetaUuids = j.subMetas && typeof j.subMetas === 'object' + ? Object.fromEntries( + Object.entries(j.subMetas) + .filter(([, value]) => value && typeof value.uuid === 'string') + .map(([name, value]) => [name, value.uuid]), + ) + : {} + snap[rel] = { ownUuid, subMetaUuids, uuids } + } catch (err) { + snap[rel] = { error: err.message } + } +} + +import { writeFileSync } from 'node:fs' +writeFileSync(out, JSON.stringify(snap, null, 2)) +console.log(`snap: ${Object.keys(snap).length} files → ${out}`) diff --git a/tools/cocos-importer/test/verify-output.mjs b/tools/cocos-importer/test/verify-output.mjs new file mode 100644 index 00000000..a7ca1f37 --- /dev/null +++ b/tools/cocos-importer/test/verify-output.mjs @@ -0,0 +1,367 @@ +// 硬验证 CLI 输出:逐文件解析,PNG 真的能解出像素,.meta 里有合法 uuid, +// prefab/anim 里的 __uuid__ 引用都能在 .meta 里找到。退出码 0 = 全过,非 0 = 有错。 +// +// 用法: node test/verify-output.mjs [ ] + +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs' +import { resolve, join, relative } from 'node:path' + +const args = process.argv.slice(2) +if (args.length === 0) { + // eslint-disable-next-line no-console + console.error('用法: node test/verify-output.mjs [canvas-w canvas-h]') + process.exit(2) +} +const outDir = resolve(process.cwd(), args[0]) +const expectW = args[1] ? Number(args[1]) : 64 +const expectH = args[2] ? Number(args[2]) : 64 + +let fails = 0 +function pass(label) { + // eslint-disable-next-line no-console + console.log(` ✓ ${label}`) +} +function fail(label, why) { + fails += 1 + // eslint-disable-next-line no-console + console.log(` ✗ ${label} — ${why}`) +} + +if (!existsSync(outDir)) { + // eslint-disable-next-line no-console + console.error(`输出目录不存在: ${outDir}`) + process.exit(1) +} + +// ── PNG 解码器(只解 IHDR + 算 IDAT 解压字节数,验证尺寸/类型,不深解像素) ── +function parsePng(bytes) { + if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) { + throw new Error('PNG signature missing') + } + let off = 8 + let width = 0 + let height = 0 + let bitDepth = 0 + let colorType = 0 + let idatBytes = 0 + let iend = false + while (off + 12 <= bytes.length) { + const len = bytes.readUInt32BE(off) + const type = String.fromCharCode(bytes[off + 4], bytes[off + 5], bytes[off + 6], bytes[off + 7]) + if (type === 'IHDR') { + width = bytes.readUInt32BE(off + 8) + height = bytes.readUInt32BE(off + 12) + bitDepth = bytes[off + 16] + colorType = bytes[off + 17] + } else if (type === 'IDAT') { + idatBytes += len + } else if (type === 'IEND') { + iend = true + off += 8 + len + 4 + break + } + off += 8 + len + 4 + } + if (width === 0) throw new Error('no IHDR') + if (!iend) throw new Error('no IEND') + return { width, height, bitDepth, colorType, idatBytes } +} + +function walk(dir) { + const out = [] + for (const name of readdirSync(dir)) { + const full = join(dir, name) + const st = statSync(full) + if (st.isDirectory()) out.push(...walk(full)) + else out.push(full) + } + return out +} + +// eslint-disable-next-line no-console +console.log(`验证 ${outDir} (期望 canvas ${expectW}x${expectH})\n`) + +const allFiles = walk(outDir) +const pngs = allFiles.filter((f) => f.endsWith('.png')) +const metas = allFiles.filter((f) => f.endsWith('.meta')) +const prefabs = allFiles.filter((f) => f.endsWith('.prefab') && !f.endsWith('.meta')) +const animMetas = allFiles.filter((f) => f.endsWith('.anim.meta')) +const anims = allFiles.filter((f) => f.endsWith('.anim') && !f.endsWith('.meta')) +const manifestPath = join(outDir, 'cocos-import.json') +const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf-8')) : null +const expectedActions = new Map((manifest?.actions ?? []).map((action) => [action.export_name, action])) + +// eslint-disable-next-line no-console +console.log( + `发现 ${pngs.length} 个 PNG, ${metas.length} 个 .meta, ${prefabs.length} 个 .prefab, ${anims.length} 个 .anim (${animMetas.length} .anim.meta)`, +) + +// ── Step 0: 建 UUID → file 映射表(.meta 顶层和 SpriteFrame 子资源) ── +const uuidToFile = new Map() +for (const meta of metas) { + try { + const j = JSON.parse(readFileSync(meta, 'utf-8')) + if (typeof j.uuid === 'string' && j.uuid.length > 0) { + uuidToFile.set(j.uuid, meta.slice(0, -'.meta'.length)) + } + if (j.subMetas && typeof j.subMetas === 'object') { + for (const [name, subMeta] of Object.entries(j.subMetas)) { + if (subMeta && typeof subMeta.uuid === 'string' && subMeta.uuid.length > 0) { + const childName = subMeta.name === 'spriteFrame' || name === 'f9941' ? 'spriteFrame' : name + uuidToFile.set(subMeta.uuid, `${meta.slice(0, -'.meta'.length)}#${childName}`) + } + } + } + } catch { + // ignore + } +} +// eslint-disable-next-line no-console +console.log(`\nUUID 索引: ${uuidToFile.size} 个 uuid\n`) + +// ── Step 1: 验 PNG 全部能解 + 尺寸对得上 ── +const expectedFrameSize = `${expectW}x${expectH}` +for (const png of pngs) { + try { + const bytes = readFileSync(png) + const info = parsePng(bytes) + const key = `${info.width}x${info.height}` + const isAtlas = /[\\/]atlas\.png$/.test(png) + const validAtlasSize = isAtlas && info.width >= expectW && info.height >= expectH && info.width % expectW === 0 && info.height % expectH === 0 + if (key !== expectedFrameSize && !validAtlasSize) { + fail(relative(outDir, png), `尺寸 ${key} 不符合帧 ${expectedFrameSize} 或整格图集尺寸`) + } else if (info.colorType !== 6 || info.bitDepth !== 8) { + fail(relative(outDir, png), `颜色类型 ${info.colorType} bit ${info.bitDepth} (期望 RGBA/8)`) + } else if (info.idatBytes === 0) { + fail(relative(outDir, png), 'IDAT 字节为 0') + } else { + pass(`${relative(outDir, png)} (${key} RGBA8, IDAT ${info.idatBytes}B)`) + } + } catch (err) { + fail(relative(outDir, png), err.message) + } +} + +// ── Step 2: 验 .meta 与 PNG 尺寸对得上 + SpriteFrame 子资源 UUID 正确 ── +for (const meta of metas) { + if (meta.endsWith('.prefab.meta') || meta.endsWith('.anim.meta')) { + try { + const j = JSON.parse(readFileSync(meta, 'utf-8')) + if (typeof j.uuid !== 'string' || j.uuid.length < 8) { + fail(relative(outDir, meta), `uuid 字段缺失或过短: ${j.uuid}`) + } else { + pass(`${relative(outDir, meta)} (uuid=${j.uuid.slice(0, 8)}…)`) + } + } catch (err) { + fail(relative(outDir, meta), err.message) + } + continue + } + try { + const j = JSON.parse(readFileSync(meta, 'utf-8')) + if (j.importer !== 'image') { + pass(`${relative(outDir, meta)} (${j.importer ?? 'metadata'})`) + continue + } + const png = meta.slice(0, -'.meta'.length) + if (!existsSync(png)) { + fail(relative(outDir, meta), '找不到对应 PNG') + continue + } + if (typeof j.uuid !== 'string' || j.uuid.length < 8) { + fail(relative(outDir, meta), `uuid 字段缺失`) + } else { + const subMeta = j.subMetas?.spriteFrame ?? j.subMetas?.f9941 + const pngInfo = parsePng(readFileSync(png)) + const spriteFrameUserData = subMeta?.userData ?? subMeta + if ( + !subMeta || + typeof subMeta.uuid !== 'string' || + !subMeta.uuid.endsWith('@f9941') || + !j.subMetas?.['6c48a']?.uuid?.endsWith('@6c48a') || + spriteFrameUserData.imageUuidOrDatabaseUri !== j.subMetas['6c48a'].uuid + ) { + fail(relative(outDir, meta), '缺少 SpriteFrame 子资源 UUID 或 rawTextureUuid 链接') + } else if ( + spriteFrameUserData.rawWidth !== pngInfo.width || + spriteFrameUserData.rawHeight !== pngInfo.height + ) { + fail(relative(outDir, meta), `SpriteFrame raw size ${spriteFrameUserData.rawWidth}x${spriteFrameUserData.rawHeight} ≠ PNG ${pngInfo.width}x${pngInfo.height}`) + } else if ( + spriteFrameUserData.trimX < 0 || + spriteFrameUserData.trimY < 0 || + spriteFrameUserData.width <= 0 || + spriteFrameUserData.height <= 0 || + spriteFrameUserData.trimX + spriteFrameUserData.width > pngInfo.width || + spriteFrameUserData.trimY + spriteFrameUserData.height > pngInfo.height + ) { + fail(relative(outDir, meta), 'SpriteFrame 裁剪矩形超出 PNG 原始画布') + } else { + pass(`${relative(outDir, meta)} (texture=${j.uuid.slice(0, 8)}…, spriteFrame=${subMeta.uuid.slice(0, 8)}…, raw ${pngInfo.width}x${pngInfo.height})`) + } + } + } catch (err) { + fail(relative(outDir, meta), err.message) + } +} + +// ── Step 3: 验 .anim(真 AnimationClip JSON)结构 + uuid 引用链 ── +for (const a of anims) { + try { + const j = JSON.parse(readFileSync(a, 'utf-8')) + if (j.__type__ !== 'cc.AnimationClip') { + fail(relative(outDir, a), `__type__=${j.__type__}`) + continue + } + if (j.wrapMode !== 1 && j.wrapMode !== 2) { + fail(relative(outDir, a), `wrapMode ${j.wrapMode}`) + continue + } + const objectTrack = j._tracks?.find((track) => track?.__type__ === 'cc.animation.ObjectTrack') + const paths = objectTrack?._binding?.path?._paths + const curve = objectTrack?._channel?._curve + const times = curve?._times + const frameRefs = curve?._values + const expectedAction = expectedActions.get(j._name) + if ( + j._duration <= 0 || + !Array.isArray(paths) || + paths[0]?.__type__ !== 'cc.animation.ComponentPath' || + paths[0]?.component !== 'cc.Sprite' || + paths[1] !== 'spriteFrame' || + curve?.__type__ !== 'cc.ObjectCurve' || + !Array.isArray(times) || + !Array.isArray(frameRefs) || + frameRefs.length < 1 || + times.length !== frameRefs.length || + times.some((time, index) => index > 0 && time <= times[index - 1]) + ) { + fail(relative(outDir, a), 'Creator 3.8 SpriteFrame 对象轨道无效') + continue + } + if (expectedAction) { + const expectedWrapMode = expectedAction.loop ? 2 : 1 + if (frameRefs.length !== expectedAction.frames.length) { + fail(relative(outDir, a), `动画关键帧 ${frameRefs.length} ≠ manifest ${expectedAction.frames.length}`) + } + if (j.sample !== expectedAction.fps) { + fail(relative(outDir, a), `sample ${j.sample} ≠ manifest fps ${expectedAction.fps}`) + } + if (j.wrapMode !== expectedWrapMode) { + fail(relative(outDir, a), `wrapMode ${j.wrapMode} ≠ manifest loop=${expectedAction.loop}`) + } + } + pass(`${relative(outDir, a)} (AnimationClip ${j._duration}s @ ${j.sample}fps, wrapMode=${j.wrapMode}, ${frameRefs.length} keys)`) + // 检查每个 frame key 的 __uuid__ 在 UUID 索引里能找到 + for (const frameRef of frameRefs) { + const ref = String(frameRef?.__uuid__ ?? '') + if (!ref) { + fail(relative(outDir, a), 'frame key 缺 __uuid__') + } else if (ref.startsWith('frame:')) { + // 兜底伪引用(理论上不应出现):检查路径存在 + const p = ref.replace(/^frame:/, '') + if (!existsSync(join(outDir, p))) { + fail(relative(outDir, a), `frame: 兜底引用 ${p} 不在输出目录`) + } + } else if (!uuidToFile.has(ref)) { + fail(relative(outDir, a), `__uuid__ ${ref.slice(0, 8)}… 在 .meta 索引中找不到`) + } else if (!uuidToFile.get(ref).includes('#spriteFrame')) { + fail(relative(outDir, a), `__uuid__ ${ref.slice(0, 8)}… 不是 SpriteFrame 子资源`) + } else { + pass(`${relative(outDir, a)} frame → uuid ${ref.slice(0, 8)}… (${relative(outDir, uuidToFile.get(ref))})`) + } + } + } catch (err) { + fail(relative(outDir, a), err.message) + } +} + +// ── Step 4: 验 .prefab 结构 + sprite/anim uuid 引用链 ── +for (const pf of prefabs) { + try { + const j = JSON.parse(readFileSync(pf, 'utf-8')) + const prefabAsset = Array.isArray(j) ? j[0] : j + if (prefabAsset?.__type__ !== 'cc.Prefab') { + fail(relative(outDir, pf), `__type__=${prefabAsset?.__type__}`) + continue + } + const objects = Array.isArray(j) ? j : null + const resolveRef = (value) => { + if (!objects || !value || !Number.isInteger(value.__id__)) return value + return objects[value.__id__] + } + const root = resolveRef(prefabAsset.data) + pass(`${relative(outDir, pf)} (cc.Prefab, _name=${prefabAsset._name})`) + const components = (root?._components ?? []).map(resolveRef) + const uiTransform = components.find((component) => component?.__type__ === 'cc.UITransform') + const sprite = components.find((component) => component?.__type__ === 'cc.Sprite') + const animation = components.find((component) => component?.__type__ === 'cc.Animation') + if (uiTransform?._contentSize?.width !== expectW || uiTransform?._contentSize?.height !== expectH) { + fail(relative(outDir, pf), `UITransform 不是稳定的 ${expectW}x${expectH}`) + } else { + pass(`${relative(outDir, pf)} UITransform=${expectW}x${expectH}`) + } + if (sprite?._sizeMode !== 0 || sprite?._isTrimmedMode !== false) { + fail(relative(outDir, pf), 'Sprite 必须使用 CUSTOM size 且关闭 trimmed mode') + } else { + pass(`${relative(outDir, pf)} Sprite=CUSTOM, trimmed=false`) + } + if (!animation || animation.playOnLoad !== true || animation._clips?.length !== expectedActions.size) { + fail(relative(outDir, pf), `Animation 组件未包含 ${expectedActions.size} 个可自动播放 clip`) + } else { + pass(`${relative(outDir, pf)} Animation=${animation._clips.length} clips, playOnLoad=true`) + } + // 找所有 _spriteFrame.__uuid__ + const allSpriteRefs = [] + function walkNode(nodeValue) { + const n = resolveRef(nodeValue) + if (!n) return + if (Array.isArray(n._components)) { + for (const componentRef of n._components) { + const c = resolveRef(componentRef) + if (c?._spriteFrame?.__uuid__) allSpriteRefs.push(c._spriteFrame.__uuid__) + } + } + if (Array.isArray(n._children)) for (const c of n._children) walkNode(c) + } + walkNode(root) + for (const ref of allSpriteRefs) { + if (!uuidToFile.has(ref)) { + fail(relative(outDir, pf), `sprite __uuid__ ${ref.slice(0, 8)}… 在 .meta 索引中找不到`) + } else if (!uuidToFile.get(ref).includes('#spriteFrame')) { + fail(relative(outDir, pf), `sprite __uuid__ ${ref.slice(0, 8)}… 不是 SpriteFrame 子资源`) + } else { + pass(`${relative(outDir, pf)} sprite → uuid ${ref.slice(0, 8)}… (${relative(outDir, uuidToFile.get(ref))})`) + } + } + // 找所有 _clips[].__uuid__ + const allAnimRefs = [] + function walkComps(nodeValue) { + const n = resolveRef(nodeValue) + if (!n) return + if (Array.isArray(n._components)) { + for (const componentRef of n._components) { + const c = resolveRef(componentRef) + if (c?.__type__ === 'cc.Animation' && Array.isArray(c._clips)) { + for (const cl of c._clips) if (cl?.__uuid__) allAnimRefs.push(cl.__uuid__) + } + } + } + if (Array.isArray(n._children)) for (const child of n._children) walkComps(child) + } + walkComps(root) + for (const ref of allAnimRefs) { + if (!uuidToFile.has(ref)) { + fail(relative(outDir, pf), `anim __uuid__ ${ref.slice(0, 8)}… 在 .meta 索引中找不到`) + } else { + pass(`${relative(outDir, pf)} anim → uuid ${ref.slice(0, 8)}… (${relative(outDir, uuidToFile.get(ref))})`) + } + } + } catch (err) { + fail(relative(outDir, pf), err.message) + } +} + +// eslint-disable-next-line no-console +console.log(`\n=== 结果: ${fails === 0 ? '全过' : `${fails} 项失败`} ===`) +process.exit(fails === 0 ? 0 : 1)