From b6c9af25e560dccd014030e595791f043df35aba Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 28 Aug 2026 01:29:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui-migration):=20=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=E7=BB=84=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对象 → 节点(坐标/锚点/九宫格/帧引用/flags/legacy), 层 → LayerDoc(bucket/背景/分组)。 manifest 含 ObjectID 全局索引 —— 子系统 C 移植 1773 处 set_self 调用的承重依赖。 真实数据回归护栏: 55 界面 / 991 节点, 一个对象都不得丢。 真实数据里有 10 个对象(如 3067/3068/3075/3076/3087/3088/3089/3145/388/3265)的 FrameIndex 超出其图的帧数(多帧图重导后帧数变少但对象未同步)——不抛错中止、不静默 钳到末帧,而是回退帧1 + 打 flags.frameIndexOutOfRange + 收进 convertAll 返回的 warnings 清单(objectId/layerId/imageFileId/frameIndex/frameCount/reason),真实 数据护栏钉死 warnings.length===10,数量漂移会被人工看到。另补一条背景判定用例: 非首位的满屏对象不应被误判为背景,钉住"层内首个+满屏"而非"只要满屏"的规则。 Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/test/ui-convert.test.mjs | 181 ++++++++++++++++++ .../scripts/ui-migration/convert.mjs | 158 +++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 cocoscreator_projects/scripts/test/ui-convert.test.mjs create mode 100644 cocoscreator_projects/scripts/ui-migration/convert.mjs diff --git a/cocoscreator_projects/scripts/test/ui-convert.test.mjs b/cocoscreator_projects/scripts/test/ui-convert.test.mjs new file mode 100644 index 0000000..e2a670a --- /dev/null +++ b/cocoscreator_projects/scripts/test/ui-convert.test.mjs @@ -0,0 +1,181 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { convertLayer, convertAll } from '../ui-migration/convert.mjs'; +import { loadSource } from '../ui-migration/gameabc-source.mjs'; +import { imageBuckets } from '../ui-migration/buckets.mjs'; +import { ROOT } from '../lib/paths.mjs'; + +const REAL_SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3', 'output'); + +function tinySource() { + const objects = new Map([ + [1, { ObjectID: 1, ObjectType: 2, ObjectName: '背景', Left: 0, Top: 0, Width: 1280, Height: 720, + BelongLayerID: 2, IndexOfLayer: 1, ImageFileID: 1, FrameStyle: 0, FrameIndex: 0, + L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [] }], + [2, { ObjectID: 2, ObjectType: 2, ObjectName: '按钮', Left: 320, Top: 200, Width: 150, Height: 70, + BelongLayerID: 2, IndexOfLayer: 2, ImageFileID: 2, FrameStyle: 0, FrameIndex: 5, + L9: 12, T9: 8, R9: 12, B9: 8, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, + events: ['mousedown'] }], + [3, { ObjectID: 3, ObjectType: 4, ObjectName: '昵称文字', Left: 100, Top: 50, Width: 76, Height: 25, + BelongLayerID: 2, IndexOfLayer: 3, ImageFileID: 0, FrameStyle: 0, FrameIndex: 0, + L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [] }], + ]); + return { + objects, + layers: [{ layerId: 2, name: 'Login_Layer', objectIds: [1, 2, 3] }], + groups: [{ groupId: 5, objectIds: [1, 2, 3] }], + images: new Map([ + [1, { id: 1, bmp: '00001.png', w: 1, h: 1, frame_all: 1, w1: 1280, h1: 720, w_all: 1280, h_all: 720 }], + [2, { id: 2, bmp: '00014.png', w: 3, h: 4, frame_all: 12, w1: 150, h1: 70, w_all: 450, h_all: 280 }], + ]), + }; +} + +test('convertLayer 产出层元信息与 bucket', () => { + const s = tinySource(); + const doc = convertLayer(s, s.layers[0], imageBuckets(s)); + assert.equal(doc.layerId, 2); + assert.equal(doc.name, 'Login_Layer'); + assert.equal(doc.bucket, 'login'); + assert.equal(doc.nodes.length, 3); +}); + +test('convertLayer 识别背景:层内首个 + 满屏 → stretch', () => { + const s = tinySource(); + const doc = convertLayer(s, s.layers[0], imageBuckets(s)); + assert.equal(doc.backgroundNodeId, 1); + const bg = doc.nodes.find((n) => n.id === 1); + assert.equal(bg.flags.stretch, true); + assert.equal(bg.widget.horizontal, 'stretch'); +}); + +test('convertLayer 背景判定只认层内首个:非首位的满屏对象不算背景', () => { + const s = tinySource(); + // 追加一个非首位、同样满屏的对象——用来钉住"首个 + 满屏"而非"只要满屏"的判定规则 + s.objects.set(4, { + ObjectID: 4, ObjectType: 2, ObjectName: '第二张满屏图', Left: 0, Top: 0, Width: 1280, Height: 720, + BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 1, FrameStyle: 0, FrameIndex: 0, + L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [], + }); + s.layers[0].objectIds.push(4); + s.groups[0].objectIds.push(4); + + const doc = convertLayer(s, s.layers[0], imageBuckets(s)); + assert.equal(doc.backgroundNodeId, 1, '背景仍应是层内首个对象'); + const second = doc.nodes.find((n) => n.id === 4); + assert.equal(second.flags.stretch, false, '非首位的满屏对象不应被认作背景'); +}); + +test('convertLayer 非背景对象用三区锚点与转换坐标', () => { + const s = tinySource(); + const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2); + assert.deepEqual(btn.position, { x: -245, y: 125 }); + assert.deepEqual(btn.widget, { horizontal: 'center', horizontalCenter: -245 }); +}); + +test('convertLayer 九宫格非零 → sliced 并带 slice 值', () => { + const s = tinySource(); + const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2); + assert.equal(btn.sprite.type, 'sliced'); + assert.deepEqual(btn.sprite.slice, { left: 12, right: 12, top: 8, bottom: 8 }); +}); + +test('convertLayer 多帧图节点带 frame 与 frameSet', () => { + const s = tinySource(); + const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2); + assert.equal(btn.sprite.frame, 'atlas-login/00014_05.png'); + assert.deepEqual(btn.sprite.frameSet, { prefix: 'atlas-login/00014', count: 12, pad: 2 }); +}); + +test('convertLayer Type4 转 label 且不带 sprite/样式字段', () => { + const s = tinySource(); + const txt = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 3); + assert.equal(txt.kind, 'label'); + assert.equal(txt.sprite, undefined); + assert.deepEqual(txt.size, { width: 76, height: 25 }); +}); + +test('convertLayer 保留 legacy 承重字段', () => { + const s = tinySource(); + const btn = convertLayer(s, s.layers[0], imageBuckets(s)).nodes.find((n) => n.id === 2); + assert.equal(btn.legacy.objectId, 2); + assert.equal(btn.legacy.imageFileId, 2); + assert.equal(btn.legacy.frameIndex, 5); + assert.equal(btn.legacy.groupId, 5); + assert.equal(btn.legacy.left, 320); + assert.equal(btn.legacy.top, 200); + assert.deepEqual(btn.legacy.events, ['mousedown']); +}); + +test('convertLayer 帧号越界:回退帧 1、标记 flags.frameIndexOutOfRange、收集 warning', () => { + const s = tinySource(); + // 追加一张 frame_all=2 的图,对象却写着 FrameIndex=3——复现真实数据里 9/40 那类越界 + s.images.set(3, { id: 3, bmp: '00099.png', w: 2, h: 1, frame_all: 2, w1: 50, h1: 50, w_all: 100, h_all: 50 }); + s.objects.set(4, { + ObjectID: 4, ObjectType: 2, ObjectName: '越界帧对象', Left: 500, Top: 300, Width: 50, Height: 50, + BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 3, FrameStyle: 0, FrameIndex: 3, + L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [], + }); + s.layers[0].objectIds.push(4); + s.groups[0].objectIds.push(4); + + const doc = convertLayer(s, s.layers[0], imageBuckets(s)); + const n = doc.nodes.find((node) => node.id === 4); + assert.equal(n.flags.frameIndexOutOfRange, true, '越界应打标记'); + assert.equal(n.sprite.frame, 'atlas-login/00099_1.png', '越界应回退为帧 1,不得抛错也不得跳过节点'); + + assert.equal(doc.warnings.length, 1); + const w = doc.warnings[0]; + assert.equal(w.objectId, 4); + assert.equal(w.layerId, 2); + assert.equal(w.imageFileId, 3); + assert.equal(w.frameIndex, 3); + assert.equal(w.frameCount, 2); + assert.equal(typeof w.reason, 'string'); + assert.ok(w.reason.length > 0, 'reason 必须可读,能定位到具体对象/图'); +}); + +test('convertLayer 单帧图上的残留 FrameIndex 不产生 warning(frameCount<=1 本就与 FrameIndex 无关)', () => { + const s = tinySource(); + // 单帧图(frame_all=1)却写着 FrameIndex=8——真实数据里 30/40 这一类,属无害残留 + s.images.set(3, { id: 3, bmp: '00088.png', w: 1, h: 1, frame_all: 1, w1: 50, h1: 50, w_all: 50, h_all: 50 }); + s.objects.set(4, { + ObjectID: 4, ObjectType: 2, ObjectName: '单帧残留对象', Left: 500, Top: 300, Width: 50, Height: 50, + BelongLayerID: 2, IndexOfLayer: 4, ImageFileID: 3, FrameStyle: 0, FrameIndex: 8, + L9: 0, T9: 0, R9: 0, B9: 0, GroupID: 5, VoiceFileID: 0, TimerInterval: 0, events: [], + }); + s.layers[0].objectIds.push(4); + s.groups[0].objectIds.push(4); + + const doc = convertLayer(s, s.layers[0], imageBuckets(s)); + const n = doc.nodes.find((node) => node.id === 4); + assert.equal(n.flags.frameIndexOutOfRange, false); + assert.equal(n.sprite.frame, 'atlas-login/00088.png'); + assert.equal(doc.warnings.length, 0); +}); + +test('convertLayer 输出 groups 供父节点分组', () => { + const s = tinySource(); + const doc = convertLayer(s, s.layers[0], imageBuckets(s)); + assert.deepEqual(doc.groups, [{ groupId: 5, nodeIds: [1, 2, 3] }]); +}); + +test('convertAll 的 manifest 含 ObjectID 全局索引(C 的承重依赖)', () => { + const s = tinySource(); + const { manifest, warnings } = convertAll(s); + assert.equal(manifest.designWidth, 1280); + assert.deepEqual(manifest.objectIndex['2'], { layerId: 2, nodeId: 2 }); + assert.equal(manifest.layers.length, 1); + assert.equal(manifest.layers[0].nodeCount, 3); + assert.deepEqual(warnings, [], 'tinySource 本身没有越界帧,不应产生 warning'); +}); + +test('convertAll 在真实数据上覆盖全部对象与界面(回归护栏)', () => { + const { layers, manifest, warnings } = convertAll(loadSource(REAL_SOURCE)); + assert.equal(layers.length, 55, '界面数'); + const total = layers.reduce((s, l) => s + l.nodes.length, 0); + assert.equal(total, 991, '节点总数必须等于对象总数——不得丢对象'); + assert.equal(Object.keys(manifest.objectIndex).length, 991, 'ObjectID 索引必须完整'); + assert.equal(warnings.length, 10, '已知的帧号越界对象必须恰为 10 个——数量漂移应被人工关注'); +}); diff --git a/cocoscreator_projects/scripts/ui-migration/convert.mjs b/cocoscreator_projects/scripts/ui-migration/convert.mjs new file mode 100644 index 0000000..946a34b --- /dev/null +++ b/cocoscreator_projects/scripts/ui-migration/convert.mjs @@ -0,0 +1,158 @@ +import { + DESIGN_WIDTH, DESIGN_HEIGHT, + toCocosPosition, inferWidget, stretchWidget, isFullScreen, isOffscreen, +} from './coords.mjs'; +import { frameFileName, padWidth } from './frames.mjs'; +import { layerBucket, imageBuckets } from './buckets.mjs'; + +/** 中间描述组装。见 spec §1。 */ + +const OBJECT_TYPE_SPRITE = 2; +const OBJECT_TYPE_TEXT = 4; + +/** + * 帧号越界(旧数据里美术重导时帧数变少,但对象上的 FrameIndex 没跟着更新)。 + * 只拦这一种已知情况——预判而非 try/catch,免得连带吞掉 frameFileName 里真正的实现 bug。 + * 单帧图(frame_all<=1)的 FrameIndex 本就与渲染无关,不算越界。 + */ +function isFrameIndexOutOfRange(obj, image) { + const requested = obj.FrameIndex || 1; + return image.frame_all > 1 && requested > image.frame_all; +} + +function spriteBlock(obj, image, bucket, warnings) { + const dir = `atlas-${bucket}`; + const sliced = obj.L9 || obj.T9 || obj.R9 || obj.B9; + const outOfRange = isFrameIndexOutOfRange(obj, image); + const frameIndex = outOfRange ? 1 : (obj.FrameIndex || 1); + + if (outOfRange) { + warnings.push({ + objectId: obj.ObjectID, + layerId: obj.BelongLayerID, + imageFileId: obj.ImageFileID, + frameIndex: obj.FrameIndex, + frameCount: image.frame_all, + reason: `对象 ${obj.ObjectID}(${obj.ObjectName})引用 ${image.bmp} 的帧 ${obj.FrameIndex},` + + `超出该图帧数 ${image.frame_all}(很可能是美术重导后 FrameIndex 未同步更新),已回退为帧 1`, + }); + } + + const block = { + frame: `${dir}/${frameFileName(image.bmp, frameIndex, image.frame_all)}`, + type: sliced ? 'sliced' : 'simple', + }; + if (image.frame_all > 1) { + block.frameSet = { + prefix: `${dir}/${image.bmp.replace(/\.png$/i, '')}`, + count: image.frame_all, + pad: padWidth(image.frame_all), + }; + } + if (sliced) { + block.slice = { left: obj.L9, right: obj.R9, top: obj.T9, bottom: obj.B9 }; + } + return { block, outOfRange }; +} + +function toNode(obj, source, imageBucketMap, isBackground, warnings) { + const node = { + id: obj.ObjectID, + name: obj.ObjectName, + kind: obj.ObjectType === OBJECT_TYPE_TEXT ? 'label' : 'sprite', + position: toCocosPosition(obj.Left, obj.Top, obj.Width, obj.Height), + size: { width: obj.Width, height: obj.Height }, + siblingIndex: obj.IndexOfLayer, + widget: isBackground ? stretchWidget() : inferWidget(obj.Left, obj.Width), + flags: { + stretch: isBackground, + offscreen: isOffscreen(obj.Left, obj.Top, obj.Width, obj.Height), + animation: obj.FrameStyle === 1, + frameIndexOutOfRange: false, + }, + legacy: { + objectId: obj.ObjectID, + imageFileId: obj.ImageFileID ?? 0, + frameIndex: obj.FrameIndex ?? 0, + frameStyle: obj.FrameStyle ?? 0, + groupId: obj.GroupID ?? 0, + voiceFileId: obj.VoiceFileID ?? 0, + timerInterval: obj.TimerInterval ?? 0, + left: obj.Left, + top: obj.Top, + events: obj.events ?? [], + }, + }; + + if (node.kind === 'sprite' && obj.ImageFileID) { + const image = source.images.get(obj.ImageFileID); + if (!image) { + throw new Error( + `对象 ${obj.ObjectID}(${obj.ObjectName})引用了不存在的图 ImageFileID=${obj.ImageFileID}`, + ); + } + const { block, outOfRange } = spriteBlock( + obj, image, imageBucketMap.get(obj.ImageFileID) ?? 'unassigned', warnings, + ); + node.sprite = block; + node.flags.frameIndexOutOfRange = outOfRange; + } + return node; +} + +export function convertLayer(source, layer, imageBucketMap) { + const objs = layer.objectIds + .map((id) => source.objects.get(id)) + .filter(Boolean); + + const first = objs[0]; + const backgroundNodeId = + first && isFullScreen(first.Width, first.Height) ? first.ObjectID : null; + + const warnings = []; + const nodes = objs + .map((o) => toNode(o, source, imageBucketMap, o.ObjectID === backgroundNodeId, warnings)) + .sort((a, b) => a.siblingIndex - b.siblingIndex); + + const nodeIdSet = new Set(nodes.map((n) => n.id)); + const groups = source.groups + .map((g) => ({ groupId: g.groupId, nodeIds: g.objectIds.filter((id) => nodeIdSet.has(id)) })) + .filter((g) => g.nodeIds.length > 0); + + return { + layerId: layer.layerId, + name: layer.name, + bucket: layerBucket(layer.layerId, layer.name), + backgroundNodeId, + groups, + nodes, + warnings, + }; +} + +export function convertAll(source) { + const imageBucketMap = imageBuckets(source); + const layers = source.layers.map((l) => convertLayer(source, l, imageBucketMap)); + + const objectIndex = {}; + const warnings = []; + for (const doc of layers) { + for (const n of doc.nodes) objectIndex[String(n.id)] = { layerId: doc.layerId, nodeId: n.id }; + warnings.push(...doc.warnings); + } + + const manifest = { + generatedBy: 'scripts/convert-ui.mjs', + designWidth: DESIGN_WIDTH, + designHeight: DESIGN_HEIGHT, + layers: layers.map((d) => ({ + layerId: d.layerId, + name: d.name, + bucket: d.bucket, + file: `${String(d.layerId).padStart(3, '0')}-${d.name}.json`, + nodeCount: d.nodes.length, + })), + objectIndex, + }; + return { layers, manifest, warnings }; +}