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({
)
}
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)