界面迁移更新
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 层与图的 bucket 归属。见 spec §3.3。
|
||||
* bucket 决定资源落进 framework/ui/atlas-{common,login,hall,room} 哪个目录。
|
||||
*/
|
||||
|
||||
/** 登录相关:按层名点名,因其 LayerID 散落在 1..29 段内。 */
|
||||
const LOGIN_LAYERS = new Set(['Logo_Layer', 'Login_Layer', 'Auth_layer', 'Bind_Layer', 'phoneInfo']);
|
||||
|
||||
/** 命名笼统、语义不明的层:标 unassigned 交人工归位,不猜。 */
|
||||
const UNASSIGNED_LAYERS = new Set([
|
||||
'Layer6', 'Layer13', 'Layer403', 'Layer411', 'Layer601', 'Layer603', 'Layer618',
|
||||
]);
|
||||
|
||||
const ROOM_ID_MIN = 50;
|
||||
const COMMON_ID_MIN = 600;
|
||||
|
||||
export function layerBucket(layerId, layerName) {
|
||||
if (UNASSIGNED_LAYERS.has(layerName)) return 'unassigned';
|
||||
if (LOGIN_LAYERS.has(layerName)) return 'login';
|
||||
if (layerId >= COMMON_ID_MIN) return 'common';
|
||||
if (layerId >= ROOM_ID_MIN) return 'room';
|
||||
return 'hall';
|
||||
}
|
||||
|
||||
/**
|
||||
* 每张图按引用它的对象所在层决定 bucket。
|
||||
* 跨多个 bucket → common;无对象引用 → unassigned。
|
||||
*/
|
||||
export function imageBuckets(source) {
|
||||
const layerBucketById = new Map(
|
||||
source.layers.map((l) => [l.layerId, layerBucket(l.layerId, l.name)]),
|
||||
);
|
||||
|
||||
const bucketsPerImage = new Map();
|
||||
for (const obj of source.objects.values()) {
|
||||
if (!obj.ImageFileID) continue;
|
||||
const b = layerBucketById.get(obj.BelongLayerID);
|
||||
if (!b) continue;
|
||||
if (!bucketsPerImage.has(obj.ImageFileID)) bucketsPerImage.set(obj.ImageFileID, new Set());
|
||||
bucketsPerImage.get(obj.ImageFileID).add(b);
|
||||
}
|
||||
|
||||
const out = new Map();
|
||||
for (const id of source.images.keys()) {
|
||||
const set = bucketsPerImage.get(id);
|
||||
if (!set || set.size === 0) { out.set(id, 'unassigned'); continue; }
|
||||
const real = [...set].filter((b) => b !== 'unassigned');
|
||||
if (real.length === 0) out.set(id, 'unassigned');
|
||||
else if (real.length === 1) out.set(id, real[0]);
|
||||
else out.set(id, 'common');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
DESIGN_WIDTH, DESIGN_HEIGHT, CANVAS_WIDTH, CANVAS_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,
|
||||
// 设计分辨率画布(20:9),落地 Layer/Group 根节点尺寸据此设定。
|
||||
canvasWidth: CANVAS_WIDTH,
|
||||
canvasHeight: CANVAS_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 };
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 坐标换算与锚点推断。见 spec §2。
|
||||
* 旧引擎:原点左上、Y 向下、精灵锚点左上。
|
||||
* Cocos:原点在父节点中心、Y 向上、节点锚点 (0.5, 0.5)。
|
||||
*/
|
||||
|
||||
// 旧 UI 内容区基准(数据源事实,不可变)。
|
||||
// 991 个对象的 Left/Top 都是相对旧引擎 1280×720 画布给出的,坐标换算、三区锚点、
|
||||
// 满屏/越界判断都据此判定。它与「设计分辨率画布宽度」是两回事,见 CANVAS_WIDTH。
|
||||
export const DESIGN_WIDTH = 1280;
|
||||
export const DESIGN_HEIGHT = 720;
|
||||
|
||||
// Cocos 设计分辨率画布(20:9 主流移动端比例)。落地时 Layer/Group 根节点据此设定
|
||||
// UITransform 尺寸,运行时 Canvas 按 designResolution + fitHeight 对齐到屏幕。
|
||||
// 对象坐标与 Widget 值仍以 DESIGN_WIDTH 为基准——靠左贴左、靠右贴右、居中在中心,
|
||||
// 画布变宽由 Widget 弹性贴边消化,无需重算坐标。
|
||||
export const CANVAS_WIDTH = 1600;
|
||||
export const CANVAS_HEIGHT = 720;
|
||||
|
||||
const HALF_W = DESIGN_WIDTH / 2;
|
||||
const HALF_H = DESIGN_HEIGHT / 2;
|
||||
const LEFT_ZONE = DESIGN_WIDTH / 4; // 320
|
||||
const RIGHT_ZONE = (DESIGN_WIDTH * 3) / 4; // 960
|
||||
|
||||
/** 左上坐标 → Cocos 中心锚点坐标。spec §2.2。 */
|
||||
export function toCocosPosition(left, top, width, height) {
|
||||
return {
|
||||
x: left + width / 2 - HALF_W,
|
||||
y: HALF_H - top - height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 水平锚点三区推断。spec §2.3。
|
||||
* fitHeight 下垂直方向永远精确撑满,故不推断垂直锚点。
|
||||
*/
|
||||
export function inferWidget(left, width) {
|
||||
const cx = left + width / 2;
|
||||
if (cx < LEFT_ZONE) return { horizontal: 'left', left };
|
||||
if (cx > RIGHT_ZONE) return { horizontal: 'right', right: DESIGN_WIDTH - (left + width) };
|
||||
return { horizontal: 'center', horizontalCenter: cx - HALF_W };
|
||||
}
|
||||
|
||||
/** 背景与遮罩:四边拉伸。spec §2.4。 */
|
||||
export function stretchWidget() {
|
||||
return { horizontal: 'stretch', vertical: 'stretch', left: 0, right: 0, top: 0, bottom: 0 };
|
||||
}
|
||||
|
||||
export function isFullScreen(width, height) {
|
||||
return width >= DESIGN_WIDTH && height >= DESIGN_HEIGHT;
|
||||
}
|
||||
|
||||
/** 坐标超出画布。spec §2.5:不裁剪,只标记。 */
|
||||
export function isOffscreen(left, top, width, height) {
|
||||
return left < 0 || top < 0 || left + width > DESIGN_WIDTH || top + height > DESIGN_HEIGHT;
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { PNG } from 'pngjs';
|
||||
import { frameRect } from './frames.mjs';
|
||||
|
||||
/**
|
||||
* PNG 切分与重组。见 spec §4.2 ②。
|
||||
* 切分是可逆操作,故「重组(切分(原图)) 的像素 === 原图像素」是对全部帧的完全验证。
|
||||
*/
|
||||
|
||||
/** 切分整图 → 帧 Buffer 数组,下标 0 对应帧 1。 */
|
||||
export function splitSheet(sheetBuffer, image) {
|
||||
const sheet = PNG.sync.read(sheetBuffer);
|
||||
const out = [];
|
||||
for (let i = 1; i <= image.frame_all; i++) {
|
||||
const r = frameRect(image, i);
|
||||
const cell = new PNG({ width: r.width, height: r.height });
|
||||
PNG.bitblt(sheet, cell, r.x, r.y, r.width, r.height, 0, 0);
|
||||
out.push(PNG.sync.write(cell));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 把帧按原网格拼回整图。 */
|
||||
export function reassemble(frameBuffers, image) {
|
||||
const sheet = new PNG({ width: image.w * image.w1, height: image.h * image.h1 });
|
||||
frameBuffers.forEach((buf, i) => {
|
||||
const r = frameRect(image, i + 1);
|
||||
const cell = PNG.sync.read(buf);
|
||||
PNG.bitblt(cell, sheet, 0, 0, r.width, r.height, r.x, r.y);
|
||||
});
|
||||
return PNG.sync.write(sheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两张 PNG 的**解码像素数据**。
|
||||
* 不能比较文件字节——编码参数不同会让同像素的图产生不同字节。
|
||||
*/
|
||||
export function pixelsEqual(bufA, bufB) {
|
||||
const a = PNG.sync.read(bufA);
|
||||
const b = PNG.sync.read(bufB);
|
||||
if (a.width !== b.width || a.height !== b.height) return false;
|
||||
return Buffer.compare(a.data, b.data) === 0;
|
||||
}
|
||||
|
||||
/** 对一张多帧图做重组校验。 */
|
||||
export function verifySheet(sheetBuffer, image) {
|
||||
const sheet = PNG.sync.read(sheetBuffer);
|
||||
const expectW = image.w * image.w1;
|
||||
const expectH = image.h * image.h1;
|
||||
if (sheet.width !== expectW || sheet.height !== expectH) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `尺寸不符: 实际 ${sheet.width}x${sheet.height},按网格应为 ${expectW}x${expectH}`,
|
||||
};
|
||||
}
|
||||
const back = reassemble(splitSheet(sheetBuffer, image), image);
|
||||
if (!pixelsEqual(sheetBuffer, back)) {
|
||||
return { ok: false, reason: '重组后像素与原图不一致(切分逻辑有误)' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 帧矩形与命名。见 spec §0.3、§3。
|
||||
* 帧号 1 基、行优先——已由读取 00014.png 的网格内容与 11 个按钮的 FrameIndex 对照实证。
|
||||
*/
|
||||
|
||||
/** 帧号 → 源矩形。FrameIndex=0 按帧 1 处理(实测仅 2 个对象如此)。 */
|
||||
export function frameRect(image, frameIndex) {
|
||||
const normalized = frameIndex === 0 ? 1 : frameIndex;
|
||||
const idx = normalized - 1;
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= image.frame_all) {
|
||||
throw new RangeError(
|
||||
`帧号非法或越界: ${image.bmp} frameIndex=${frameIndex}(有效范围 1..${image.frame_all},0 视同 1)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
x: (idx % image.w) * image.w1,
|
||||
y: Math.floor(idx / image.w) * image.h1,
|
||||
width: image.w1,
|
||||
height: image.h1,
|
||||
};
|
||||
}
|
||||
|
||||
/** 补零位数:保证字典序 = 帧序。 */
|
||||
export function padWidth(frameCount) {
|
||||
return String(frameCount).length;
|
||||
}
|
||||
|
||||
/** 00014.png + 帧 5/共 12 → 00014_05.png;单帧图原样返回。 */
|
||||
export function frameFileName(bmp, frameIndex, frameCount) {
|
||||
if (frameCount <= 1) return bmp;
|
||||
const normalized = frameIndex === 0 ? 1 : frameIndex;
|
||||
if (!Number.isInteger(normalized) || normalized <= 0 || normalized > frameCount) {
|
||||
throw new RangeError(
|
||||
`帧号非法或越界: ${bmp} frameIndex=${frameIndex}(有效范围 1..${frameCount},0 视同 1)`,
|
||||
);
|
||||
}
|
||||
const base = bmp.replace(/\.png$/i, '');
|
||||
const n = String(normalized).padStart(padWidth(frameCount), '0');
|
||||
return `${base}_${n}.png`;
|
||||
}
|
||||
|
||||
/** 该图全部帧的文件名,下标 0 对应帧 1。 */
|
||||
export function frameNames(image) {
|
||||
return Array.from({ length: image.frame_all }, (_, i) =>
|
||||
frameFileName(image.bmp, i + 1, image.frame_all));
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** 源数据非法时抛出。第二准则:缺失/非法一律显式暴露,不兜底。 */
|
||||
export class SourceError extends Error {
|
||||
constructor(message) { super(message); this.name = 'SourceError'; }
|
||||
}
|
||||
|
||||
function readJson(dir, name) {
|
||||
const p = join(dir, name);
|
||||
if (!existsSync(p)) throw new SourceError(`缺少源数据文件: ${p}`);
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf8'));
|
||||
} catch (e) {
|
||||
throw new SourceError(`源数据 JSON 解析失败: ${p} — ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readArray(dir, fileName, key) {
|
||||
const json = readJson(dir, fileName);
|
||||
const arr = json[key];
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new SourceError(
|
||||
`源数据 ${fileName} 缺少顶层数组 "${key}"(实际为 ${typeof arr})`,
|
||||
);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验 gameabc 导出数据。
|
||||
* 源数组用 `{}` 占位空槽(下标即 ID),须跳过。
|
||||
*/
|
||||
export function loadSource(outputDir) {
|
||||
const objects = new Map();
|
||||
for (const entry of readArray(outputDir, 'gameabc_Object.json', 'ObjectList')) {
|
||||
if (!entry || !entry.Property) continue;
|
||||
const p = entry.Property;
|
||||
objects.set(p.ObjectID, { ...p, events: Object.keys(entry.Event ?? {}) });
|
||||
}
|
||||
|
||||
const images = new Map();
|
||||
for (const i of readArray(outputDir, 'gameabc_Image.json', 'ImageFileList')) {
|
||||
if (!i || i.id === undefined) continue;
|
||||
if (i.w * i.h !== i.frame_all) {
|
||||
throw new SourceError(
|
||||
`图 ${i.bmp} 的网格与帧数不符: ${i.w}x${i.h}=${i.w * i.h} != frame_all=${i.frame_all}`,
|
||||
);
|
||||
}
|
||||
images.set(i.id, i);
|
||||
}
|
||||
|
||||
const layers = readArray(outputDir, 'gameabc_Layer.json', 'LayerList')
|
||||
.filter((l) => l && l.Property)
|
||||
.map((l) => ({
|
||||
layerId: l.Property.LayerID,
|
||||
name: l.Property.LayerName,
|
||||
objectIds: l.ObjectList ?? [],
|
||||
}));
|
||||
|
||||
const groups = readArray(outputDir, 'gameabc_GroupList.json', 'GroupList')
|
||||
.filter((g) => g && g.GroupID !== undefined)
|
||||
.map((g) => ({ groupId: g.GroupID, objectIds: g.ObjectList ?? [] }));
|
||||
|
||||
return { objects, images, layers, groups };
|
||||
}
|
||||
Reference in New Issue
Block a user