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:
2026-08-28 01:29:06 +08:00
co-authored by Claude Opus 5
parent d2502276e0
commit b6c9af25e5
2 changed files with 339 additions and 0 deletions
@@ -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 };
}