Files
youle_cocos/cocoscreator_projects/scripts/ui-migration/convert.mjs
T
joywayerandClaude Opus 5 96cbbd8b0a fix(ui-migration): 拉伸解耦背景判定 + objectIndex 内容级校验 + 缺图抛错回归
评审 Important 修复:
1. spec §2.4: flags.stretch/拉伸widget 适用于全部满屏对象(52个),不只是层内首个
   的 backgroundNodeId(40个,判定规则不变)。此前实现把两者绑死,导致12个非首位
   满屏遮罩(如"创建房间遮罩")套用三区锚点几何,进 Cocos 后会错位。
2. 真实数据护栏补内容级校验: 遍历 manifest.objectIndex 全部991条,核对每条
   layerId 与源数据 BelongLayerID 一致——杀掉"全部条目指向同一层"这类只查条数
   查不出的变异,这是子系统 C 定位 1773 处 set_self 的承重字段。
3. 补"引用不存在的图显式抛错"的回归用例——此前只有代码审查保护,真实数据恰好
   不触发,没有测试防线;错误信息须可定位对象ID与图ID。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 01:40:33 +08:00

162 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, warnings) {
// 拉伸(四边置0)适用于全部满屏对象(spec §2.4: 实测 52 个),不论是否层内首个。
// backgroundNodeId(层内唯一)是另一件事:仅用于"首个+满屏"的背景判定,与此处解耦。
const stretch = isFullScreen(obj.Width, obj.Height);
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: stretch ? stretchWidget() : inferWidget(obj.Left, obj.Width),
flags: {
stretch,
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, 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 };
}