Complete room UI and protocol integration, move game definitions and resources behind bundle entries, publish authoritative version XML, and document single-game builds. Include all current resource changes and experiment artifacts.
307 lines
14 KiB
JavaScript
307 lines
14 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(testDir, '..', '..', '..');
|
|
const sourcePath = path.join(repoRoot, 'projects', 'Game_Surface_3', 'save', 'Layer00418.xml');
|
|
const objectDataPath = path.join(
|
|
repoRoot,
|
|
'projects',
|
|
'Game_Surface_3',
|
|
'output',
|
|
'gameabc_Object.json',
|
|
);
|
|
const imageDataPath = path.join(
|
|
repoRoot,
|
|
'projects',
|
|
'Game_Surface_3',
|
|
'output',
|
|
'gameabc_Image.json',
|
|
);
|
|
const prefabPath = path.join(
|
|
repoRoot,
|
|
'cocoscreator_projects',
|
|
'YouleNexus',
|
|
'assets',
|
|
'framework',
|
|
'ui',
|
|
'prefabs',
|
|
'widgets',
|
|
'Layer418_ChatPannel.prefab',
|
|
);
|
|
const atlasRoomPath = path.join(
|
|
repoRoot,
|
|
'cocoscreator_projects',
|
|
'YouleNexus',
|
|
'assets',
|
|
'framework',
|
|
'ui',
|
|
'atlas-room',
|
|
);
|
|
const atlasPaths = [
|
|
atlasRoomPath,
|
|
path.join(repoRoot, 'cocoscreator_projects', 'YouleNexus', 'assets', 'framework', 'ui', 'atlas-common'),
|
|
path.join(repoRoot, 'cocoscreator_projects', 'YouleNexus', 'assets', 'framework', 'ui', 'atlas-hall'),
|
|
path.join(repoRoot, 'cocoscreator_projects', 'YouleNexus', 'assets', 'framework', 'ui', 'atlas-login'),
|
|
path.join(repoRoot, 'cocoscreator_projects', 'YouleNexus', 'assets', 'framework', 'ui', 'atlas-unassigned'),
|
|
];
|
|
|
|
function property(block, name) {
|
|
const match = block.match(new RegExp(`<Property Name="${name}" Value="([^"]*)"\\s*/>`));
|
|
assert.ok(match, `source property ${name} should exist`);
|
|
return match[1];
|
|
}
|
|
|
|
function optionalProperty(block, name) {
|
|
return block.match(new RegExp(`<Property Name="${name}" Value="([^"]*)"\\s*/>`))?.[1];
|
|
}
|
|
|
|
function parseSourceSpirits(xml) {
|
|
return [...xml.matchAll(/<Spirit>([\s\S]*?)<\/Spirit>/g)].map((match) => {
|
|
const block = match[1];
|
|
return {
|
|
id: Number(property(block, 'ID')),
|
|
name: property(block, 'Name'),
|
|
x: Number(property(block, 'X')),
|
|
y: Number(property(block, 'Y')),
|
|
width: Number(property(block, 'Width')),
|
|
height: Number(property(block, 'Height')),
|
|
index: Number(property(block, 'IndexOfLayer')),
|
|
visible: property(block, 'IsVisible') === '1',
|
|
text: optionalProperty(block, 'Text'),
|
|
fontSize: Number(optionalProperty(block, 'FontSize')),
|
|
fontColorAlpha: Number(optionalProperty(block, 'FontColorA')),
|
|
autoSize: Number(optionalProperty(block, 'AutoSize')),
|
|
wordWrap: Number(optionalProperty(block, 'WordWrap')),
|
|
};
|
|
});
|
|
}
|
|
|
|
function nodeComponent(prefab, node, type) {
|
|
return node._components.map((ref) => prefab[ref.__id__]).find((component) => component.__type__ === type);
|
|
}
|
|
|
|
function expectedSpriteFrameUuid(imageFileId, frameIndex, imageFiles) {
|
|
const image = imageFiles[imageFileId];
|
|
assert.equal(image.id, imageFileId, `ImageFileList[${imageFileId}] must be direct indexed`);
|
|
|
|
const frameAll = Number(image.frame_all);
|
|
const frameNumber = frameAll === 1 ? 1 : ((Number(frameIndex) - 1) % frameAll) + 1;
|
|
const baseName = String(imageFileId).padStart(5, '0');
|
|
const frameSuffix = frameAll > 1 ? `_${String(frameNumber).padStart(String(frameAll).length, '0')}` : '';
|
|
const fileName = `${baseName}${frameSuffix}.png.meta`;
|
|
const metaPath = atlasPaths.map((atlasPath) => path.join(atlasPath, fileName)).find(fs.existsSync);
|
|
assert.ok(metaPath, `${fileName} should exist in the Layer418 atlas search order`);
|
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
const spriteFrame = Object.values(meta.subMetas).find((entry) => entry.importer === 'sprite-frame');
|
|
assert.ok(spriteFrame, `${fileName} should expose a SpriteFrame subasset`);
|
|
return spriteFrame.uuid;
|
|
}
|
|
|
|
function round(value) {
|
|
return Math.round(value * 1000) / 1000;
|
|
}
|
|
|
|
function sourceSignature(spirit) {
|
|
const isFullScreen = spirit.width === 1280 && spirit.height === 720;
|
|
const autoSizeText = spirit.text !== undefined && spirit.autoSize === 1;
|
|
return JSON.stringify([
|
|
spirit.name,
|
|
round(spirit.x + spirit.width / 2 - 640),
|
|
round(360 - (spirit.y + spirit.height / 2)),
|
|
autoSizeText ? null : isFullScreen ? 1600 : spirit.width,
|
|
autoSizeText ? null : isFullScreen ? 720 : spirit.height,
|
|
[3112,3114,3117].includes(spirit.id) ? false : spirit.visible,
|
|
]);
|
|
}
|
|
|
|
function prefabSignature(prefab, node) {
|
|
const uiTransformRef = node._components
|
|
.map((ref) => prefab[ref.__id__])
|
|
.find((component) => component.__type__ === 'cc.UITransform');
|
|
assert.ok(uiTransformRef, `${node._name} should have UITransform`);
|
|
const autoSizeText = nodeComponent(prefab,node,'cc.Label')?._overflow === 0;
|
|
|
|
return JSON.stringify([
|
|
node._name,
|
|
round(node._lpos.x),
|
|
round(node._lpos.y),
|
|
autoSizeText ? null : uiTransformRef._contentSize.width,
|
|
autoSizeText ? null : uiTransformRef._contentSize.height,
|
|
node._active,
|
|
]);
|
|
}
|
|
|
|
// Approved replacements: EditBox input; dynamic phrase rows; expression grid;
|
|
// history row template; scrollable announcements and their dynamic cost label.
|
|
// All other source art stays. Spirit3117 retains the user's authored font size 16.
|
|
const dynamicSourceIds = new Set([
|
|
204, 206, 207, 208, 209, 210, 211, 212,
|
|
...Array.from({length:20},(_,index)=>528+index),
|
|
467, 696, 697, 163, 164, 165, 480, 3109, 3110, 3117,
|
|
]);
|
|
|
|
test('Layer418 static source art preserves its layout and sprite contract', () => {
|
|
const sourceSpirits = parseSourceSpirits(fs.readFileSync(sourcePath, 'utf8'));
|
|
const objectList = JSON.parse(fs.readFileSync(objectDataPath, 'utf8')).ObjectList;
|
|
const imageFileList = JSON.parse(fs.readFileSync(imageDataPath, 'utf8')).ImageFileList;
|
|
const prefab = JSON.parse(fs.readFileSync(prefabPath, 'utf8'));
|
|
const nodes = prefab.filter((entry) => entry.__type__ === 'cc.Node');
|
|
const root = nodes.find((node) => node._name === 'Layer418_ChatPannel');
|
|
const group = nodes.find((node) => node._name === 'group-30');
|
|
const staticSpirits = sourceSpirits.filter(spirit=>!dynamicSourceIds.has(spirit.id));
|
|
const staticNames = new Set(staticSpirits.map(spirit=>spirit.name));
|
|
const spiritNodes = nodes.filter(node=>staticNames.has(node._name));
|
|
assert.equal(spiritNodes.length,staticSpirits.length);
|
|
assert.deepEqual(root._children.map((ref) => prefab[ref.__id__]._name), ['group-30']);
|
|
assert.deepEqual(
|
|
group._children.map((ref) => prefab[ref.__id__]._name).filter(name=>staticNames.has(name)),
|
|
[...staticSpirits].sort((a, b) => a.index - b.index).map((spirit) => spirit.name),
|
|
);
|
|
assert.deepEqual(
|
|
spiritNodes.map((node) => prefabSignature(prefab, node)).sort(),
|
|
staticSpirits.map(sourceSignature).sort(),
|
|
);
|
|
|
|
for (const spirit of staticSpirits) {
|
|
const sourceObject = objectList[spirit.id]?.Property;
|
|
assert.equal(sourceObject?.ObjectID, spirit.id, `ObjectList[${spirit.id}] must be direct indexed`);
|
|
assert.equal(sourceObject.ObjectName, spirit.name);
|
|
|
|
const node = spiritNodes.find((candidate) => candidate._name === spirit.name);
|
|
assert.ok(node, `${spirit.name} should exist`);
|
|
if (sourceObject.ObjectType === 2) {
|
|
const sprite = nodeComponent(prefab, node, 'cc.Sprite');
|
|
assert.ok(sprite, `${spirit.name} should be a Sprite`);
|
|
assert.equal(
|
|
sprite._spriteFrame?.__uuid__,
|
|
expectedSpriteFrameUuid(sourceObject.ImageFileID, sourceObject.FrameIndex, imageFileList),
|
|
`${spirit.name} SpriteFrame`,
|
|
);
|
|
const sliced = [sourceObject.L9, sourceObject.T9, sourceObject.R9, sourceObject.B9].some(Number);
|
|
assert.equal(sprite._type, sliced ? 1 : 0, `${spirit.name} Sprite.Type`);
|
|
assert.equal(sprite._sizeMode, 0, `${spirit.name} Sprite.SizeMode`);
|
|
} else if (sourceObject.ObjectType === 4) {
|
|
assert.ok(nodeComponent(prefab, node, 'cc.Label'), `${spirit.name} should be a Label`);
|
|
} else {
|
|
assert.fail(`${spirit.name} has unsupported ObjectType ${sourceObject.ObjectType}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('Layer418 labels preserve text, color, wrapping, and AutoSize semantics', () => {
|
|
const sourceSpirits = parseSourceSpirits(fs.readFileSync(sourcePath, 'utf8'));
|
|
const objectList = JSON.parse(fs.readFileSync(objectDataPath, 'utf8')).ObjectList;
|
|
const prefab = JSON.parse(fs.readFileSync(prefabPath, 'utf8'));
|
|
const nodes = prefab.filter((entry) => entry.__type__ === 'cc.Node');
|
|
const labels = sourceSpirits.filter((spirit) => !dynamicSourceIds.has(spirit.id)&&objectList[spirit.id]?.Property.ObjectType === 4);
|
|
|
|
assert.ok(labels.length>0);
|
|
for (const spirit of labels) {
|
|
const sourceObject = objectList[spirit.id].Property;
|
|
const node = nodes.find((candidate) => candidate._name === spirit.name);
|
|
const label = nodeComponent(prefab, node, 'cc.Label');
|
|
assert.equal(label._string, spirit.text, `${spirit.name} text`);
|
|
assert.equal(label._fontSize, spirit.fontSize, `${spirit.name} font size`);
|
|
assert.deepEqual(
|
|
[label._color.r, label._color.g, label._color.b, label._color.a],
|
|
[sourceObject.FontColorR, sourceObject.FontColorG, sourceObject.FontColorB, spirit.fontColorAlpha],
|
|
`${spirit.name} font color`,
|
|
);
|
|
assert.equal(label._enableWrapText, spirit.wordWrap === 1, `${spirit.name} word wrap`);
|
|
assert.equal(label._overflow, spirit.autoSize === 1 ? 0 : 1, `${spirit.name} AutoSize`);
|
|
}
|
|
});
|
|
|
|
test('Layer418 sliced sprite frames preserve their source border contract', () => {
|
|
const expectedBorders = new Map([
|
|
['00084.png.meta', [20, 20, 20, 20]],
|
|
['00342.png.meta', [13, 13, 13, 13]],
|
|
]);
|
|
|
|
for (const [fileName, expected] of expectedBorders) {
|
|
const meta = JSON.parse(fs.readFileSync(path.join(atlasRoomPath, fileName), 'utf8'));
|
|
const userData = meta.subMetas.f9941.userData;
|
|
assert.deepEqual(
|
|
[userData.borderLeft, userData.borderTop, userData.borderRight, userData.borderBottom],
|
|
expected,
|
|
fileName,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('Layer418 dynamic panels own clipped scroll content, layouts and reusable rows',()=>{
|
|
const prefab=JSON.parse(fs.readFileSync(prefabPath,'utf8'));
|
|
const nodes=prefab.filter(entry=>entry.__type__==='cc.Node');
|
|
const group=nodes.find(node=>node._name==='group-30');
|
|
const child=(node,name)=>{
|
|
const matches=node._children.map(ref=>prefab[ref.__id__]).filter(item=>item._name===name);
|
|
assert.equal(matches.length,1,`${node._name}/${name} must be unique`);
|
|
return matches[0];
|
|
};
|
|
const input=child(group,'聊天输入框'),edit=nodeComponent(prefab,input,'cc.EditBox');
|
|
assert.ok(edit,'input must be a real EditBox');
|
|
for(const field of ['_textLabel','_placeholderLabel']){
|
|
const label=prefab[edit[field]?.__id__];
|
|
assert.equal(label?.__type__,'cc.Label',`${field} must resolve to a Label`);
|
|
assert.equal(prefab[prefab[label.node.__id__]._parent.__id__],input);
|
|
}
|
|
const cost=child(group,'Spirit3117'),costLabel=nodeComponent(prefab,cost,'cc.Label');
|
|
assert.equal(cost._active,false,'announcement cost is hidden until policy selection');
|
|
assert.equal(costLabel._fontSize,16,'preserve authored cost-label font size');
|
|
assert.equal(costLabel._overflow,1,'cost uses a bounded line');
|
|
assert.equal(costLabel._string,'','cost is populated from remote policy');
|
|
assert.equal(nodeComponent(prefab,cost,'cc.UITransform')._contentSize.width,520);
|
|
for(const name of ['Phrases','History','Expressions','Announcements']){
|
|
const panel=child(group,name),viewport=child(panel,'Viewport'),content=child(viewport,'Content');
|
|
assert.equal(panel._active,name==='Phrases',`${name} initial tab state`);
|
|
const scroll=nodeComponent(prefab,panel,'cc.ScrollView');
|
|
assert.ok(scroll,`${name} ScrollView`);
|
|
assert.equal(prefab[scroll._content.__id__],content,`${name} content reference`);
|
|
assert.equal(scroll.horizontal,false);assert.equal(scroll.vertical,true);
|
|
assert.ok(nodeComponent(prefab,viewport,'cc.Mask'),`${name} clipping mask`);
|
|
const layout=nodeComponent(prefab,content,'cc.Layout');
|
|
assert.ok(layout,`${name} Layout`);
|
|
assert.equal(layout._resizeMode,1,`${name} grows content container`);
|
|
assert.equal(layout._layoutType,name==='Expressions'?3:2,`${name} layout mode`);
|
|
assert.equal(nodeComponent(prefab,content,'cc.UITransform')._anchorPoint.y,1,`${name} content top anchor`);
|
|
if(name==='Phrases'){
|
|
const row=child(content,'ChatPhraseItem');
|
|
assert.ok(nodeComponent(prefab,child(row,'Text'),'cc.Label'));
|
|
assert.ok(nodeComponent(prefab,child(row,'Background'),'cc.Sprite')._spriteFrame?.__uuid__);
|
|
}else if(name==='History'){
|
|
const row=child(content,'ChatHistoryItem');
|
|
for(const label of ['Spirit164','Spirit3110','Spirit480'])assert.ok(nodeComponent(prefab,child(row,label),'cc.Label'));
|
|
for(const sprite of ['Spirit163','Spirit165','Spirit697'])assert.ok(nodeComponent(prefab,child(row,sprite),'cc.Sprite')._spriteFrame?.__uuid__);
|
|
}else if(name==='Expressions'){
|
|
assert.equal(content._children.length,20);
|
|
for(let index=1;index<=20;index++)assert.ok(nodeComponent(prefab,child(content,`Expression${index}`),'cc.Sprite')._spriteFrame?.__uuid__);
|
|
}else assert.equal(content._children.length,0,'announcements are populated from state');
|
|
}
|
|
});
|
|
|
|
test('Layer418 serialized node, component and prefab references remain intact',()=>{
|
|
const prefab=JSON.parse(fs.readFileSync(prefabPath,'utf8'));
|
|
const visit=value=>{
|
|
if(!value||typeof value!=='object')return;
|
|
if(Object.prototype.hasOwnProperty.call(value,'__id__')){
|
|
assert.ok(Number.isInteger(value.__id__)&&value.__id__>=0&&value.__id__<prefab.length,`dangling reference ${value.__id__}`);
|
|
}
|
|
Object.values(value).forEach(visit);
|
|
};
|
|
prefab.forEach(visit);
|
|
for(const [index,node] of prefab.entries()){
|
|
if(node.__type__!=='cc.Node')continue;
|
|
assert.equal(prefab[node._prefab?.__id__]?.__type__,'cc.PrefabInfo',`${node._name} prefab info`);
|
|
for(const ref of node._children)assert.equal(prefab[ref.__id__]._parent.__id__,index,`${node._name} child parent backlink`);
|
|
for(const ref of node._components){
|
|
const component=prefab[ref.__id__];
|
|
assert.equal(component.node.__id__,index,`${node._name} component backlink`);
|
|
assert.equal(prefab[component.__prefab?.__id__]?.__type__,'cc.CompPrefabInfo',`${node._name} component prefab info`);
|
|
}
|
|
}
|
|
});
|