feat(ui-migration): 中间描述组装
对象 → 节点(坐标/锚点/九宫格/帧引用/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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 个——数量漂移应被人工关注');
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user