feat: integrate room framework and isolated subgame bundles

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.
This commit is contained in:
2026-09-09 03:13:18 +08:00
parent a7ec8aa6a7
commit 35216f75e7
1028 changed files with 835556 additions and 21415 deletions
@@ -107,13 +107,14 @@ function round(value) {
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)),
isFullScreen ? 1600 : spirit.width,
isFullScreen ? 720 : spirit.height,
spirit.visible,
autoSizeText ? null : isFullScreen ? 1600 : spirit.width,
autoSizeText ? null : isFullScreen ? 720 : spirit.height,
[3112,3114,3117].includes(spirit.id) ? false : spirit.visible,
]);
}
@@ -122,18 +123,28 @@ function prefabSignature(prefab, node) {
.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),
uiTransformRef._contentSize.width,
uiTransformRef._contentSize.height,
autoSizeText ? null : uiTransformRef._contentSize.width,
autoSizeText ? null : uiTransformRef._contentSize.height,
node._active,
]);
}
test('Layer418 prefab preserves every source spirit and its layout contract', () => {
// 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;
@@ -141,31 +152,21 @@ test('Layer418 prefab preserves every source spirit and its layout contract', ()
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 spiritNodes = nodes.filter(
(node) => node._name !== 'Layer418_ChatPannel' && node._name !== 'group-30',
);
assert.equal(sourceSpirits.length, 54);
assert.equal(nodes.length, 56);
assert.equal(spiritNodes.length, 54);
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),
[...sourceSpirits].sort((a, b) => a.index - b.index).map((spirit) => spirit.name),
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(),
sourceSpirits.map(sourceSignature).sort(),
staticSpirits.map(sourceSignature).sort(),
);
const types = prefab.reduce((counts, entry) => {
counts[entry.__type__] = (counts[entry.__type__] ?? 0) + 1;
return counts;
}, {});
assert.equal(types['cc.Sprite'], 48);
assert.equal(types['cc.Label'], 6);
for (const spirit of sourceSpirits) {
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);
@@ -196,9 +197,9 @@ test('Layer418 labels preserve text, color, wrapping, and AutoSize semantics', (
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) => objectList[spirit.id]?.Property.ObjectType === 4);
const labels = sourceSpirits.filter((spirit) => !dynamicSourceIds.has(spirit.id)&&objectList[spirit.id]?.Property.ObjectType === 4);
assert.equal(labels.length, 6);
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);
@@ -231,3 +232,75 @@ test('Layer418 sliced sprite frames preserve their source border contract', () =
);
}
});
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`);
}
}
});