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:
+4
@@ -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]);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "41e1b7ed-8004-4b1e-b41d-3013e5716ac2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+134
@@ -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');}
|
||||
}
|
||||
+9
@@ -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": {}
|
||||
}
|
||||
Reference in New Issue
Block a user