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:
@@ -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": {}
|
||||
}
|
||||
Reference in New Issue
Block a user