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
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "e4f0c9ce-255e-463b-b907-bec24412c756",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,61 @@
import {createBuildIdentity} from './build-identity.ts';
import {_decorator,Prefab,SceneAsset,Node,assetManager,AssetManager,TextAsset,js} from 'cc';
import versionManifestParser,{type VersionManifest} from './version-manifest.js';
import {GameComposition,type CommonRoomView} from '../../scripts/platform-login/GameComposition.ts';
import {getGameDefinition,gameBundleName,gameResourcePath,type SubgameEntry} from './game-definitions.ts';
import type {GameDefinition} from '../../framework/sdk/contracts/game-definition.ts';
const {ccclass,property}=_decorator;
/** App composition root: each game assigns its own pre-room page, room view and scene. */
@ccclass('SubgameAssets')
export class SubgameAssets extends GameComposition {
@property gameKey:string='';
private loaded:Readonly<{definition:GameDefinition;createRoom:Prefab;room:Prefab}>|null=null;
get createRoom(){return this.loaded?.createRoom??null;}
get room(){return this.loaded?.room??null;}
@property(SceneAsset) roomScene:SceneAsset|null=null;
private manifestValue:Readonly<VersionManifest>|null=null;
get versionManifest(){return this.manifestValue;}
get displayVersion(){if(!this.manifestValue)throw Error('version.xml has not been loaded');return this.manifestValue.version;}
async loadBuildIdentity(){
await this.loadGameAssets();
const key=this.gameKey,path=this.definition.config.versionResource;
if(path!==key+'/version')throw Error('Version resource does not match selected game: '+key);
const bundleName='version-'+key;
const bundle=await new Promise<AssetManager.Bundle>((resolve,reject)=>assetManager.loadBundle(bundleName,(error,result)=>{
if(error)reject(Error('Cannot load '+bundleName+': '+error.message));
else if(!result)reject(Error('Missing '+bundleName));else resolve(result);
}));
const asset=await new Promise<TextAsset>((resolve,reject)=>bundle.load(path,TextAsset,(error,result)=>{
if(error)reject(Error('Cannot load '+path+'.xml: '+error.message));
else if(!result)reject(Error('Missing '+path+'.xml'));else resolve(result);
}));
try {
if(this.gameKey!==key)throw Error('Game selection changed during version.xml load');
const manifest=versionManifestParser.parseVersionManifest(asset.text);
const identity=createBuildIdentity(manifest);
this.manifestValue=manifest;
return identity;
} finally {bundle.release(path,TextAsset);}
}
private async loadGameAssets(){
const key=this.gameKey;
if(this.loaded){if(this.loaded.definition.key!==key)throw Error('Game selection changed after loading');return;}
const bundle=await new Promise<AssetManager.Bundle>((resolve,reject)=>assetManager.loadBundle(gameBundleName(key),(error,result)=>{
if(error)reject(error);else if(!result)reject(Error('Missing game bundle: '+key));else resolve(result);
}));
const definition=getGameDefinition(key,name=>js.getClassByName(name) as unknown as SubgameEntry|null);
const load=(resource:string)=>new Promise<Prefab>((resolve,reject)=>bundle.load(gameResourcePath(definition.config.assetRoot,resource),Prefab,(error,result)=>{
if(error)reject(error);else if(!result)reject(Error('Missing game prefab: '+resource));else resolve(result);
}));
const [createRoom,room]=await Promise.all([load(definition.resources.createRoom),load(definition.resources.room)]);
if(this.gameKey!==key)throw Error('Game selection changed during asset load');
this.loaded={definition,createRoom,room};
}
get definition(){if(!this.loaded)throw Error('Subgame assets have not been loaded');return this.loaded.definition;}
resolveRoomView(root:Node):CommonRoomView {
const name=this.definition.roomViewClass;
const view=root.getComponent(name) as (CommonRoomView|null);
if(!view||typeof view.render!=='function'||typeof view.close!=='function'||typeof view.dissolutionReceived!=='function')throw Error(`Room prefab missing valid ${name}`);
return view;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "ec19e470-cf36-40e8-b420-e4314e5fbf07",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,8 @@
import {resolveIdentity,type ChannelIdentity} from '../../framework/config/identity.ts';
import type {VersionManifest} from './version-manifest.js';
/** XML has no market field. This application value is for Web testing only. */
export const WEB_MARKET_ID=4;
export function createBuildIdentity(manifest:VersionManifest):Readonly<ChannelIdentity> {
return resolveIdentity([()=>({agentid:manifest.agentid,channelid:manifest.channelid,
gameid:manifest.gameid,version:manifest.versionCode,marketid:WEB_MARKET_ID})]);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "b1825c2d-fa8a-4bd5-b20d-cad54a388938",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,19 @@
import type {GameDefinition} from '../../framework/sdk/contracts/game-definition.ts';
export interface SubgameEntry {readonly definition:GameDefinition;}
export function gameBundleName(key:string):string {
if(!/^[A-Za-z0-9_-]+$/.test(key))throw Error('Invalid gameKey: '+key);
return 'game-'+key;
}
/** Resolve only after the selected bundle has registered its entry class. */
export function getGameDefinition(key:string,findEntry:(name:string)=>SubgameEntry|null):GameDefinition {
gameBundleName(key);
const entry=findEntry('GameEntry_'+key);
if(!entry?.definition)throw Error('Missing subgame entry: '+key);
if(entry.definition.key!==key)throw Error('Subgame entry key mismatch: '+key);
return entry.definition;
}
export function gameResourcePath(root:string,resource:string):string {
if(!/^games\/[A-Za-z0-9_-]+$/.test(root)||!resource.startsWith(root+'/')||!resource.endsWith('.prefab')||resource.split('/').some(part=>!part||part==='.'||part==='..')||resource.includes('\\'))throw Error('Invalid private game resource: '+resource);
return resource.slice(root.length+1,-'.prefab'.length);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "8d097469-ffe1-48e5-9e8f-eb8d7c6b8774",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,7 @@
export interface VersionManifest {
readonly agentid:string;readonly agentName:string;readonly channelid:string;readonly channelName:string;
readonly gameid:string;readonly gameName:string;readonly versionCode:number;readonly version:string;
}
export function parseVersionManifest(xml:string):Readonly<VersionManifest>;
declare const parser:{parseVersionManifest:typeof parseVersionManifest};
export default parser;
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4bbb1366-4f50-4d7b-9a67-2f163b4c20c1",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,28 @@
'use strict';
// Shared by Cocos runtime and the build extension: one parser for the native contract.
const {DOMParser}=require('@xmldom/xmldom');
function parseVersionManifest(xml){
const fail=message=>{throw Error('version.xml: '+message);};
if(typeof xml!=='string'||!xml.trim())fail('empty document');
if(/<!DOCTYPE|<!ENTITY/i.test(xml))fail('DTD and entity declarations are unsupported');
let doc;
try {doc=new DOMParser({onError:(_level,message)=>fail(message)}).parseFromString(xml,'text/xml');}
catch(error){fail(error.message);}
const root=doc.documentElement;
if(!root||root.tagName!=='game'||root.namespaceURI)fail('expected game root');
const fields={};
for(let child=root.firstChild;child;child=child.nextSibling){
if(child.nodeType===3&&!child.nodeValue.trim()||child.nodeType===8)continue;
if(child.nodeType!==1||!['agent','game','channel','version'].includes(child.tagName)||child.namespaceURI)fail('unexpected child');
if(fields[child.tagName])fail('duplicate '+child.tagName);
fields[child.tagName]=child;
for(let nested=child.firstChild;nested;nested=nested.nextSibling){if(nested.nodeType!==3||nested.nodeValue.trim())fail('unexpected nested content');}
}
const attr=(tag,name)=>{const node=fields[tag];if(!node)fail('missing '+tag);const value=node.getAttribute(name);if(value===null||!value.trim())fail('missing '+tag+'@'+name);return value;};
const code=attr('version','value');
if(!/^\d+$/.test(code)||!Number.isSafeInteger(Number(code)))fail('version@value must be a nonnegative safe integer');
return Object.freeze({agentid:attr('agent','id'),agentName:attr('agent','name'),
gameid:attr('game','id'),gameName:attr('game','name'),channelid:attr('channel','id'),channelName:attr('channel','name'),
versionCode:Number(code),version:attr('version','name')});
}
exports.parseVersionManifest=parseVersionManifest;
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "javascript",
"imported": true,
"uuid": "2de3f49a-c136-4137-9635-55a8598c867c",
"files": [],
"subMetas": {},
"userData": {}
}