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
@@ -18,6 +18,10 @@ export class OwnedRoomHistory {
joined(raw:Readonly<Record<string,unknown>>):void{
if(Number(raw.state)===99||Number(raw.state)===0&&Number(raw.isowner)===1){const code=this.code(raw.roomcode);this.save(this.read().filter(n=>n!==code));}
}
broken(raw:Readonly<Record<string,unknown>>):void{
if(raw.roomcode===undefined)return;
const code=this.code(raw.roomcode);this.save(this.read().filter(n=>n!==code));
}
exited(raw:Readonly<Record<string,unknown>>,infinite:number):void{
if(Number(raw.isowner)!==1||infinite!==0)return;
const code=this.code(raw.roomcode),list=this.read();if(!list.includes(code))this.save([code,...list]);
@@ -0,0 +1,35 @@
import {logNativeCall} from '../../core/native-call-log.ts';
import type {NativeCallName} from './native-contracts.ts';
export type PlatformNativeHost=
|{kind:'browser'}
|{kind:'settings';settings:object}
|{kind:'bridge';call(name:NativeCallName,data:unknown):void};
// 05_Func.startlocation/getlocation: the browser source owns this test fixture.
// Native hosts always supply their own result through getlocationinfo.
const BROWSER_LOCATION=Object.freeze({
address:'江西省南昌市青云谱区施尧路靠近上海浦东发展银行(长天支行)',city:'南昌市',cityCode:'0791',country:'中国',
district:'青云谱区',latitude:28.623546,longitude:115.900333,province:'江西省',street:'施尧路',errorCode:0,
});
function invoke(host:PlatformNativeHost,name:NativeCallName,data:unknown,args:unknown[]):void {
if(host.kind==='bridge'){host.call(name,data);return;}
if(host.kind==='browser'){logNativeCall('browser',name,args,false);return;}
const method=(host.settings as Record<string,unknown>)[name];
logNativeCall('settings',name,args,typeof method==='function');
if(typeof method!=='function')throw Error(`Native settings missing ${name}`);
Reflect.apply(method,host.settings,args);
}
export function startPlatformLocation(host:PlatformNativeHost,onLocation:(data:unknown)=>void):void {
invoke(host,'startlocation',2,[2]);
if(host.kind==='browser')onLocation(JSON.stringify(BROWSER_LOCATION));
}
export function callPlatformSetting(host:PlatformNativeHost,name:'stopshake'):void;
export function callPlatformSetting(host:PlatformNativeHost,name:'voicePlaying'|'SwitchShake',value:0|1):void;
export function callPlatformSetting(host:PlatformNativeHost,name:'stopshake'|'voicePlaying'|'SwitchShake',value?:0|1):void {
// Settings stopshake takes no args; WVJB explicitly takes an empty string.
invoke(host,name,name==='stopshake'?'':value,name==='stopshake'?[]:[value]);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "41e1b7ed-8004-4b1e-b41d-3013e5716ac2",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,134 @@
import {logNativeCall} from '../../core/native-call-log.ts';
import type {NativeBridgePort, NativeHandler, RegisteredNativeHandlerName} from './native-contracts.ts';
import type {PlatformNativeHost} from './platform-native.ts';
export interface RoomDeviceStatus {
readonly battery:number|null;
readonly network:1|2|3|null;
readonly wifiLevel:number|null;
readonly wifiFrame:1|2|3|4|null;
readonly batteryFrame:2|3|null;
}
export interface RoomDeviceStatusOptions {
host:PlatformNativeHost;
subscribe(name:RegisteredNativeHandlerName,handler:NativeHandler):()=>void;
}
// Browser fixture belongs to this source: 05_Func.getbattery/getnetwork return
// 1 in plain H5; MainScene initially maps every non-WiFi network to frame 4.
const BROWSER_STATUS:RoomDeviceStatus=Object.freeze({battery:1,network:1,wifiLevel:null,wifiFrame:4,batteryFrame:2});
/** Settings calls globals; WVJB registers the same names with different WiFi args. */
export function createRoomDeviceStatusSubscriptions(
host:PlatformNativeHost,globals:Record<string,unknown>,bridge?:Pick<NativeBridgePort,'register'>,
):RoomDeviceStatusOptions['subscribe'] {
if(host.kind==='bridge'&&!bridge)throw Error('Room device status bridge registration is required');
return (name,handler)=>{
if(name!=='getBattery'&&name!=='getwifiLevel'&&name!=='getnetwork')throw Error(`Unexpected room device status handler: ${name}`);
let active=true;
const guarded:NativeHandler=(...args)=>{if(active)return handler(...args);};
if(host.kind==='bridge'){
bridge!.register(name,guarded);
// WVJB exposes no unregister contract; native-retained callbacks become inert.
return ()=>{active=false;};
}
if(host.kind==='browser')return ()=>{active=false;};
const previous=Object.getOwnPropertyDescriptor(globals,name);
globals[name]=guarded;
return ()=>{
active=false;
if(globals[name]!==guarded)return;
if(previous)Object.defineProperty(globals,name,previous);
else delete globals[name];
};
};
}
function sourceNumber(value:unknown,name:string,min:number,max:number,integer=false):number {
if((typeof value!=='number'&&typeof value!=='string')||String(value).trim()==='')throw Error(`Invalid native ${name}`);
const number=Number(value);
if(!Number.isFinite(number)||number<min||number>max||(integer&&!Number.isInteger(number)))throw Error(`Invalid native ${name}: ${String(value)}`);
return number;
}
export class RoomDeviceStatusAdapter {
private state:RoomDeviceStatus={battery:null,network:null,wifiLevel:null,wifiFrame:null,batteryFrame:null};
private readonly listeners=new Set<(status:RoomDeviceStatus)=>void>();
private readonly unsubscribers:Array<()=>void>=[];
private disposed=false;
private readonly options:RoomDeviceStatusOptions;
constructor(options:RoomDeviceStatusOptions) {
this.options=options;
try {
this.unsubscribers.push(options.subscribe('getBattery',value=>{if(!this.disposed)this.receiveBattery(value);}));
this.unsubscribers.push(options.subscribe('getnetwork',value=>{if(!this.disposed)this.receiveNetwork(value,false);}));
this.unsubscribers.push(options.subscribe('getwifiLevel',(...args)=>{if(!this.disposed)this.receiveWifi(args);}));
this.refresh();
}catch(error){this.dispose();throw error;}
}
snapshot():RoomDeviceStatus {return this.state;}
/** Changes only; use snapshot() for the initial render. */
subscribe(listener:(status:RoomDeviceStatus)=>void):()=>void {
this.assertUsable();this.listeners.add(listener);return()=>{this.listeners.delete(listener);};
}
refresh():void {
this.assertUsable();
const host=this.options.host;
if(host.kind==='browser'){this.publish(BROWSER_STATUS);return;}
// Legacy WVJB Func getters return callback-populated app_* variables only.
// There are no getnetwork/getbattery/getwifisignalLevel callHandler methods.
if(host.kind==='bridge')return;
this.receiveNetwork(this.setting('getnetwork'),true);
this.receiveBattery(this.setting('getbattery'));
}
dispose():void {
if(this.disposed)return;
this.disposed=true;this.listeners.clear();
const errors:unknown[]=[];
for(const off of this.unsubscribers.splice(0)){try{off();}catch(error){errors.push(error);}}
if(errors.length)throw errors[0];
}
private receiveBattery(value:unknown):void {
const battery=sourceNumber(value,'battery',0,1);
this.publish({...this.state,battery,batteryFrame:battery<=0.2?3:2});
}
private receiveNetwork(value:unknown,entry:boolean):void {
const network=sourceNumber(value,'network',1,3,true) as 1|2|3;
// 05_Func.getnetwork callback type 1 preserves the displayed frame.
const wifiFrame=network===2?3:network===3||entry?4:this.state.wifiFrame;
this.publish({...this.state,network,wifiFrame});
if(network===2&&this.options.host.kind==='settings')this.setting('getwifisignalLevel');
}
private receiveWifi(args:unknown[]):void {
let raw:unknown;
if(this.options.host.kind==='settings')raw={ssidname:args[0],signalLevel:args[1]};
else raw=typeof args[0]==='string'?JSON.parse(args[0]):args[0];
if(!raw||typeof raw!=='object')throw Error('Invalid native wifi payload');
const wifiLevel=sourceNumber((raw as Record<string,unknown>).signalLevel,'wifi signalLevel',0,4,true);
this.publish({...this.state,wifiLevel,wifiFrame:wifiLevel===0?1:wifiLevel<=2?2:3});
}
private setting(name:'getnetwork'|'getbattery'|'getwifisignalLevel'):unknown {
const host=this.options.host;
if(host.kind!=='settings')throw Error('Device status settings host required');
const method=(host.settings as Record<string,unknown>)[name];
logNativeCall('settings',name,[],typeof method==='function');
if(typeof method!=='function')throw Error(`Native settings missing ${name}`);
return Reflect.apply(method,host.settings,[]);
}
private publish(state:RoomDeviceStatus):void {
this.state=Object.freeze(state);
for(const listener of this.listeners)listener(this.state);
}
private assertUsable():void {if(this.disposed)throw Error('Room device status adapter is disposed');}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e58a8fa9-83ef-4325-a40c-99e91f716092",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,121 @@
import {logNativeCall} from '../../core/native-call-log.ts';
import type {NativeBridgePort, NativeHandler, RegisteredNativeHandlerName} from './native-contracts.ts';
import type {PlatformNativeHost} from './platform-native.ts';
export interface RoomVoiceOptions {
host:PlatformNativeHost;
subscribe(name:RegisteredNativeHandlerName,handler:NativeHandler):()=>void;
/** Preserve the native duration's wire type; the runtime owns the envelope. */
sendVoice(voiceurl:string,time:string|number):void;
onPlayback(user:number,playing:boolean):void;
}
/** Settings calls top-level window functions; WVJB calls registered handlers. */
export function createRoomVoiceSubscriptions(
host:PlatformNativeHost,
globals:Record<string,unknown>,
bridge?:Pick<NativeBridgePort,'register'>,
):RoomVoiceOptions['subscribe'] {
if(host.kind==='bridge'&&!bridge)throw Error('Room voice bridge registration is required');
return (name,handler)=>{
if(name!=='getaudiourl'&&name!=='gameui_play_voice'&&name!=='gameui_stop_voice')throw Error(`Unexpected room voice handler: ${name}`);
let active=true;
const guarded:NativeHandler=(...args)=>{if(active)return handler(...args);};
if(host.kind==='bridge'){
bridge!.register(name,guarded);
// WVJB has no unregister contract. Retained native callbacks become inert.
return ()=>{active=false;};
}
if(host.kind==='browser')return ()=>{active=false;};
const previous=Object.getOwnPropertyDescriptor(globals,name);
globals[name]=guarded;
return ()=>{
active=false;
if(globals[name]!==guarded)return;
if(previous)Object.defineProperty(globals,name,previous);
else delete globals[name];
};
};
}
/** Native owns recorder UI and upload. Legacy defines no native stop/cancel command. */
export class RoomVoiceAdapter {
private pending=false;
private cancelled=false;
private disposed=false;
private readonly unsubscribers:Array<()=>void>=[];
private readonly options:RoomVoiceOptions;
constructor(options:RoomVoiceOptions) {
this.options=options;
try {
this.unsubscribers.push(options.subscribe('getaudiourl',(...args)=>this.uploaded(args)));
for(const [name,playing] of [['gameui_play_voice',true],['gameui_stop_voice',false]] as const){
this.unsubscribers.push(options.subscribe(name,(value)=>{
if(this.disposed)return;
if((typeof value!=='string'&&typeof value!=='number')||String(value).trim()===''||!Number.isInteger(Number(value)))throw Error('Invalid native voice user');
options.onPlayback(Number(value),playing);
}));
}
} catch(error) {this.dispose();throw error;}
}
startRecording():void {
this.assertUsable();
if(this.pending)throw Error('Native voice recording is pending');
this.pending=true;this.cancelled=false;
try {this.invoke('prepareaudio','',[]);}catch(error){this.pending=false;throw error;}
}
/** Pointer release does not end the native recorder; upload remains eligible. */
stopRecording():void {this.assertUsable();}
/** Local abandonment only. Keep the pending slot until native completes. */
cancelRecording():void {this.assertUsable();if(this.pending)this.cancelled=true;}
playVoice(audiourl:string,type:number,user:number):void {
this.assertUsable();
if(typeof audiourl!=='string'||audiourl.length===0)throw Error('Invalid native voice audiourl');
if(!Number.isInteger(type)||!Number.isInteger(user))throw Error('Invalid native voice type/user');
this.invoke('mediaTypeAudio',{audiourl,type,user},[audiourl,type,user]);
}
dispose():void {
if(this.disposed)return;
this.disposed=true;this.pending=false;
const errors:unknown[]=[];
for(const unsubscribe of this.unsubscribers.splice(0)){try{unsubscribe();}catch(error){errors.push(error);}}
if(errors.length)throw errors[0];
}
private uploaded(args:unknown[]):void {
if(this.disposed||!this.pending)return;
this.pending=false;
if(this.cancelled)return;
let raw:unknown;
if(this.options.host.kind==='settings')raw={audiourl:args[0],time:args[1]};
else raw=typeof args[0]==='string'?JSON.parse(args[0]):args[0];
if(!raw||typeof raw!=='object')throw Error('Invalid native voice upload');
const {audiourl,time}=raw as Record<string,unknown>;
if((typeof time!=='number'&&typeof time!=='string')||String(time).trim()===''||!Number.isFinite(Number(time)))throw Error('Invalid native voice time');
// Legacy suppresses zero and negative duration callbacks.
if(Number(time)<=0)return;
if(typeof audiourl!=='string'||audiourl.length===0)throw Error('Invalid native voice audiourl');
this.options.sendVoice(audiourl,time);
}
private invoke(name:'prepareaudio'|'mediaTypeAudio',data:unknown,args:unknown[]):void {
const host=this.options.host;
if(host.kind==='bridge'){host.call(name,data);return;}
if(host.kind==='browser'){
logNativeCall('browser',name,args,false);
throw Error('当前浏览器不支持原生语音录制或播放,请在客户端使用');
}
const method=(host.settings as Record<string,unknown>)[name];
logNativeCall('settings',name,args,typeof method==='function');
if(typeof method!=='function')throw Error(`Native settings missing ${name}`);
Reflect.apply(method,host.settings,args);
}
private assertUsable():void {if(this.disposed)throw Error('Room voice adapter is disposed');}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "17ae6470-6771-4c3a-9db6-409deca12dab",
"files": [],
"subMetas": {},
"userData": {}
}