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
@@ -1,3 +1,4 @@
import {GameComposition} from './platform-login/GameComposition.ts';
import { _decorator, Component, director } from 'cc';
import { PlatformSession } from '../framework/platform/session.ts';
import { NetClient, type NetClientEvents } from '../framework/net/net-client.ts';
@@ -60,7 +61,10 @@ export class LoginFlow extends Component {
(async () => {
try {
const composition=this.getComponent(GameComposition);
if(!composition)throw Error('LoginFlow requires GameComposition');
const result = await resolveBootstrap({
buildIdentity:await composition.loadBuildIdentity(),
win: window as any,
search: window.location.search,
fetcher: new HttpConfigFetcher(),
@@ -0,0 +1,94 @@
import {BlockInputEvents,Button,Label,Layout,Node,Prefab,ScrollView,Widget,instantiate,isValid} from 'cc';
import type {PlatformState} from '../../framework/platform/stores/platform-types.ts';
import {selectApplyVote,type ApplyVoteModel} from './apply-vote-model.ts';
import {requireNode} from './node-bindings.ts';
function need<T>(value:T|null,name:string):T {if(value===null)throw Error(`ApplyVotePanel missing ${name}`);return value;}
type Commands={agree():void;refuse():void;acknowledge():void;error(message:string):void};
/** Scene-owned Layer606 view. All player identity and voting state come from PlatformStore. */
export class ApplyVotePanel {
private root:Node|null=null;
private state:PlatformState|null=null;
private timer:ReturnType<typeof setInterval>|null=null;
private pending=false;
private disposed=false;
private rows=new Map<number,Node>();
private buttons:Button[]=[];
private releases:Array<()=>void>=[];
constructor(private readonly canvas:Node,private readonly prefab:Prefab,private readonly itemPrefab:Prefab,
private readonly commands:Commands){}
render(state:PlatformState):void {
if(this.disposed)return;
const previous=this.state?.dissolution;
this.state=state;
const model=selectApplyVote(state,Date.now());
if(!model){this.close();return;}
const vote=state.dissolution!;
if(vote.kind==='result'||!previous||previous.kind==='result'||previous.roomcode!==vote.roomcode
||previous.kind==='active'&&vote.kind==='active'&&previous.deadline!==vote.deadline
||state.app.phase!=='logged-in')this.pending=false;
if(!this.root)this.open();
this.text('group-29/Spirit191').string=model.message;
this.renderRows(model);
requireNode(this.root!,'group-32').active=model.result;
for(const button of this.buttons){button.node.active=!model.result&&model.canVote;button.interactable=model.canVote&&!this.pending;}
const countdown=this.text('group-29/Spirit222');countdown.node.active=model.seconds!==null;
if(model.seconds!==null)countdown.string=String(model.seconds);
if(model.result||model.seconds===0)this.stopTimer();
else if(this.timer===null)this.timer=setInterval(()=>this.tick(),200);
}
private open():void {
const root=instantiate(this.prefab);this.root=root;root.parent=this.canvas;root.setPosition(0,0,0);
const widget=root.getComponent(Widget);if(widget){widget.target=this.canvas;widget.updateAlignment();}
// Keep platform connection/error overlays above this modal.
const overlay=this.canvas.children.find(n=>['Layer614_Loading','Layer615_Reconnect','Layer612_Tips','Layer616_Kick'].includes(n.name));
if(overlay)root.setSiblingIndex(overlay.getSiblingIndex());
root.addComponent(BlockInputEvents);
requireNode(root,'group-29').active=true;
const content=need(need(requireNode(root,'group-29/PlayerVotes').getComponent(ScrollView),'ScrollView').content,'content');
for(const child of [...content.children]){child.removeFromParent();child.destroy();}
for(const [name,action] of [['Spirit201','agree'],['Spirit202','refuse']] as const){
const button=this.bind(requireNode(root,'group-29/'+name),()=>{
if(this.pending||!this.state||!selectApplyVote(this.state,Date.now())?.canVote)return;
this.pending=true;for(const b of this.buttons)b.interactable=false;
try{this.commands[action]();}catch(error){this.pending=false;this.render(this.state);throw error;}
});this.buttons.push(button);
}
this.bind(requireNode(root,'group-32/Spirit223'),()=>this.commands.acknowledge());
}
private bind(node:Node,action:()=>void):Button {
const button=node.getComponent(Button)||node.addComponent(Button);button.transition=Button.Transition.NONE;
const listener=()=>{if(this.disposed||!node.activeInHierarchy||!button.interactable)return;
try{action();}catch(error){console.error('[ApplyVotePanel]',error);}};
node.on(Button.EventType.CLICK,listener);this.releases.push(()=>{if(isValid(node))node.off(Button.EventType.CLICK,listener);});return button;
}
private renderRows(model:ApplyVoteModel):void {
const scroll=need(requireNode(this.root!,'group-29/PlayerVotes').getComponent(ScrollView),'ScrollView');
const content=need(scroll.content,'content'),layout=need(content.getComponent(Layout),'Layout');
const seats=new Set(model.rows.map(row=>row.seat));let changed=false;
for(const [seat,node] of this.rows)if(!seats.has(seat)){node.removeFromParent();node.destroy();this.rows.delete(seat);changed=true;}
model.rows.forEach((row,index)=>{
let node=this.rows.get(row.seat);
if(!node){node=instantiate(this.itemPrefab);node.name=`PlayerVoteItem_${row.seat}`;node.parent=content;node.setPosition(0,0,0);this.rows.set(row.seat,node);changed=true;}
node.setSiblingIndex(index);
need(requireNode(node,'Spirit166').getComponent(Label),'vote status').string=row.status;
need(requireNode(node,'Spirit264').getComponent(Label),'nickname').string=row.nickname;
});
if(changed){layout.updateLayout();scroll.scrollToTop(0);}
}
private text(path:string):Label{return need(requireNode(this.root!,path).getComponent(Label),path);}
private tick():void {
if(!this.root||!this.state)return;
const vote=this.state.dissolution;if(!vote||vote.kind!=='active'){this.stopTimer();return;}
const seconds=Math.max(0,Math.ceil((vote.deadline-Date.now())/1000));
this.text('group-29/Spirit222').string=String(seconds);if(seconds===0)this.stopTimer();
}
private stopTimer():void {if(this.timer!==null)clearInterval(this.timer);this.timer=null;}
private close():void {
this.stopTimer();for(const release of this.releases.splice(0))release();this.rows.clear();this.buttons=[];this.pending=false;
this.root?.destroy();this.root=null;
}
dispose():void {if(this.disposed)return;this.disposed=true;this.close();this.state=null;}
}
@@ -2,7 +2,7 @@
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "ec19e470-cf36-40e8-b420-e4314e5fbf07",
"uuid": "f6157c1a-6f9f-4c80-b3f9-3a490c7b9e22",
"files": [],
"subMetas": {},
"userData": {}
@@ -0,0 +1,51 @@
import {_decorator,Button,Component,Label} from 'cc';
import type {PlatformGameSnapshot} from '../../framework/sdk/contracts/index.ts';
import type {RoomPresentationActions,RoomPresentation} from '../../framework/sdk/contracts/game-definition.ts';
import {roomExitAction} from '../../framework/presentation/room-exit-action.ts';
import type {GameRoomProjection} from '../../framework/sdk/contracts/game-room-projection.ts';
import {composeGameRoomPresentation,gameAllowsRoomAction} from '../../framework/presentation/game-room-presentation.ts';
const {ccclass,property}=_decorator;
/** Template lobby-to-room handoff surface; concrete subgames replace this presentation. */
@ccclass('CommonRoomViewBase')
export class CommonRoomViewBase extends Component implements RoomPresentation {
@property(Label) roomLabel:Label|null=null;
@property(Label) playersLabel:Label|null=null;
@property(Button) prepareButton:Button|null=null;
@property(Button) exitButton:Button|null=null;
private actions:RoomPresentationActions|null=null;
private bound=false;
private pending:'prepare'|'exit'|null=null;
private timer:ReturnType<typeof setTimeout>|null=null;
private snapshot:PlatformGameSnapshot|null=null;
private projection:GameRoomProjection|null=null;
private readonly prepare=()=>this.invoke('prepare');
private readonly exit=()=>this.invoke('exit');
render(snapshot:PlatformGameSnapshot,actions:RoomPresentationActions,projection:GameRoomProjection|null=null):void{
const combined=composeGameRoomPresentation(snapshot,projection);
if(!this.roomLabel||!this.playersLabel||!this.prepareButton||!this.exitButton)throw Error('CommonRoomViewBase missing controls');
this.actions=actions;this.snapshot=snapshot;this.projection=projection;
if(!this.bound){this.prepareButton.node.on(Button.EventType.CLICK,this.prepare);this.exitButton.node.on(Button.EventType.CLICK,this.exit);this.bound=true;}
this.roomLabel.string=`房间号:${snapshot.room.roomcode}`;
this.playersLabel.string=snapshot.seats.map(p=>`${p.seat+1} ${p.nickname}${p.playerId===snapshot.self.playerId?'(我)':''} ${!p.online?'离线':p.ready?'已准备':'未准备'}`).join('\n');
const self=snapshot.seats.find(p=>p.playerId===snapshot.self.playerId);
if(self?.ready||snapshot.connection.phase!=='logged-in')this.clearPending();
this.prepareButton.interactable=gameAllowsRoomAction(this.pending===null&&snapshot.connection.phase==='logged-in'&&snapshot.room.stage===0&&!self?.ready,combined.game,'prepare');
this.exitButton.interactable=gameAllowsRoomAction(this.pending===null&&snapshot.connection.phase==='logged-in',combined.game,'exit');
const exitLabel=this.exitButton.getComponent(Label);
if(!exitLabel)throw Error('CommonRoomViewBase exit button missing Label');
exitLabel.string=roomExitAction(snapshot.room).label;
}
private invoke(kind:'prepare'|'exit'):void{
if(!this.actions||this.pending)return;
try{this.actions[kind]();this.pending=kind;this.prepareButton!.interactable=false;this.exitButton!.interactable=false;
this.timer=setTimeout(()=>{this.clearPending();if(this.snapshot&&this.actions)this.render(this.snapshot,this.actions,this.projection);this.node.emit('room-action-timeout');},15000);
}catch(error){this.node.emit('room-action-error',error);}
}
dissolutionReceived():void{
if(this.pending!=='exit'||!this.snapshot||roomExitAction(this.snapshot.room).command!=='room.apply-dissolution')return;
this.clearPending();if(this.actions)this.render(this.snapshot,this.actions,this.projection);
}
private clearPending():void{if(this.timer!==null)clearTimeout(this.timer);this.timer=null;this.pending=null;}
close():void{this.clearPending();if(this.bound){this.prepareButton?.node.off(Button.EventType.CLICK,this.prepare);this.exitButton?.node.off(Button.EventType.CLICK,this.exit);}this.bound=false;this.actions=null;}
onDestroy():void{this.close();}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c183b3f7-ecdb-47af-baa9-62004a7920a5",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,21 @@
import type {ChannelIdentity} from '../../framework/config/identity.ts';
import {_decorator,Component,Prefab,SceneAsset,Node,Label,Button} from 'cc';
import type {GameDefinition,RoomPresentation} from '../../framework/sdk/contracts/game-definition.ts';
const {ccclass}=_decorator;
export interface CommonRoomView extends RoomPresentation {
readonly roomLabel:Label|null;
readonly exitButton:Button|null;
dissolutionReceived():void;
}
/** Public startup depends on this injected component contract, never a game registry. */
@ccclass('GameComposition')
export abstract class GameComposition extends Component {
abstract readonly gameKey:string;
abstract readonly createRoom:Prefab|null;
abstract readonly room:Prefab|null;
abstract readonly roomScene:SceneAsset|null;
abstract get displayVersion():string;
abstract loadBuildIdentity():Promise<Readonly<ChannelIdentity>>;
abstract get definition():GameDefinition;
abstract resolveRoomView(root:Node):CommonRoomView;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "cf22a2ad-ab7a-46b2-bb0a-ffccf298983d",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -20,12 +20,12 @@ export class JoinRoomView extends Component {
this.close();const generation=this.generation;
if(this.digitButtons.length!==10||this.slots.length!==6||!this.clearButton||!this.deleteButton||!this.pasteButton||!this.submitButton||this.cancelButtons.length!==2)throw Error('JoinRoomView requires serialized controls');
const input=this.input=new JoinRoomInput(code=>context.submit(code));input.prefill(context.initialCode);
const bind=(button:Button,action:()=>void)=>{const fn=()=>{if(this.busy||this.pasting)return;try{action();}catch(error){context.reportError(error instanceof Error?error.message:String(error));}finally{this.render();}};
const bind=(button:Button,action:()=>void)=>{const fn=()=>{if(this.busy||this.pasting)return;try{action();}catch(error){console.error('[JoinRoomView]',error);}finally{this.render();}};
button.node.on(Button.EventType.CLICK,fn);this.releases.push(()=>button.node.off(Button.EventType.CLICK,fn));};
this.digitButtons.forEach((button,digit)=>bind(button,()=>input.digit(digit)));
bind(this.clearButton,()=>input.clear());bind(this.deleteButton,()=>input.remove());bind(this.submitButton,()=>input.confirm());
bind(this.pasteButton,()=>{this.pasting=true;void context.paste().then(text=>{if(this.generation===generation){input.paste(text);this.render();}})
.catch(error=>{if(this.generation===generation)context.reportError(error instanceof Error?error.message:String(error));})
.catch(error=>{if(this.generation===generation)console.error('[JoinRoomView]',error);})
.finally(()=>{if(this.generation===generation)this.pasting=false;});});
this.cancelButtons.forEach(button=>{const fn=()=>context.cancel();button.node.on(Button.EventType.CLICK,fn);this.releases.push(()=>button.node.off(Button.EventType.CLICK,fn));});
this.setBusy(false);this.render();
@@ -0,0 +1,127 @@
import {BlockInputEvents,Label,Node,Prefab,UITransform} from 'cc';
import type {NumericToolsPort} from '../../framework/sdk/contracts/game-capabilities.ts';
import {NumericInputModel} from '../../framework/presentation/numeric-input-model.ts';
import {MultiplierModel} from '../../framework/presentation/multiplier-model.ts';
import {requireNode} from './node-bindings.ts';
import {RoomUi,roomNeed} from './RoomUi.ts';
export interface NumericToolsPanelOptions {
readonly canvas:Node;
readonly inputPrefab:Prefab;
readonly calPrefab:Prefab;
readonly storage:Pick<Storage,'getItem'|'setItem'>;
readonly gameid:string|number;
readonly agentid:string|number;
readonly readClipboard:()=>Promise<string>;
readonly error:(message:string)=>void;
}
/** Room-scoped presentation; subgames supply both input callbacks and calculator originals. */
export class NumericToolsPanel implements NumericToolsPort {
private readonly ui:RoomUi;
private readonly inputRoot:Node;
private readonly calRoot:Node;
private readonly calEntry:Node;
private readonly calDialog:Node;
private input:NumericInputModel|null=null;
private calculator:MultiplierModel|null=null;
private inputGeneration=0;
private pasting=false;
private cancelInput:(()=>void)|null=null;
private cancelCalculator:(()=>void)|null=null;
private readonly inputDisplayWidth:number;
constructor(private readonly options:NumericToolsPanelOptions){
this.ui=new RoomUi(options.error);
this.inputRoot=this.ui.mount(options.inputPrefab,options.canvas);
this.calRoot=this.ui.mount(options.calPrefab,options.canvas);
this.calEntry=requireNode(this.calRoot,'group-78');
this.calDialog=requireNode(this.calRoot,'group-79');
// Label dimensions can be changed by its font assembler; the backdrop owns the input bounds.
this.inputDisplayWidth=roomNeed(this.inputNode(3002).getComponent(UITransform),'input backdrop bounds').width-36;
this.inputRoot.addComponent(BlockInputEvents);
this.calDialog.addComponent(BlockInputEvents);
for(let digit=0;digit<10;digit++){
this.ui.bind(this.inputNode(661+digit),()=>{const model=this.needInput();this.tip(model.inputDigit(digit));this.renderInput();});
this.ui.bind(this.calNode(392+digit),()=>{const model=this.needCalculator();this.tip(model.inputDigit(digit));this.renderCalculator();});
}
this.ui.bind(this.inputNode(671),()=>{this.needInput().backspace();this.renderInput();});
this.ui.bind(this.inputNode(3000),()=>this.needInput().confirm());
this.ui.bind(this.inputNode(3004),()=>this.closeNumberInput());
this.ui.bind(this.inputNode(3217),()=>this.paste());
this.ui.bind(requireNode(this.calEntry,'Spirit283'),()=>{
this.needCalculator();this.calDialog.active=true;this.raise(this.calRoot);this.renderCalculator();
});
this.ui.bind(this.calNode(402),()=>{this.tip(this.needCalculator().inputDecimal());this.renderCalculator();});
this.ui.bind(this.calNode(391),()=>{this.needCalculator().backspace();this.renderCalculator();});
this.ui.bind(this.calNode(403),()=>{
const model=this.needCalculator();model.confirm();
// A result callback may synchronously replace or dismiss this presentation.
if(this.calculator===model){this.calDialog.active=false;this.renderCalculator();}
});
this.ui.bind(this.calNode(375),()=>{if(this.cancelCalculator)this.hideMultiplier();else this.calDialog.active=false;});
this.close();
}
openNumberInput(request:Parameters<NumericToolsPort['openNumberInput']>[0]):void {
this.requireActive();this.closeNumberInput();this.input=new NumericInputModel(request);this.cancelInput=request.onCancel??null;
this.ui.text(this.inputRoot,'group-87/Spirit3001').string=this.input.message;
this.renderInput();this.inputRoot.active=true;this.raise(this.inputRoot);
}
closeNumberInput():void {
const cancel=this.cancelInput;this.cancelInput=null;
this.inputGeneration++;this.pasting=false;this.input=null;this.inputRoot.active=false;cancel?.();
}
showMultiplier(request:Parameters<NumericToolsPort['showMultiplier']>[0]):void {
this.requireActive();this.hideMultiplier();this.cancelCalculator=request.onCancel??null;
this.calculator=new MultiplierModel({originalValues:request.values,onConfirm:request.onConfirm,
storage:this.options.storage,gameid:this.options.gameid,agentid:this.options.agentid});
this.calRoot.active=true;this.calEntry.active=true;this.calDialog.active=false;this.renderCalculator();
}
hideMultiplier():void {
const cancel=this.cancelCalculator;this.cancelCalculator=null;
this.calculator=null;this.calDialog.active=false;this.calEntry.active=false;this.calRoot.active=false;cancel?.();
}
close():void {this.closeNumberInput();this.hideMultiplier();}
dispose():void {this.close();this.ui.dispose();}
private inputNode(id:number):Node{return requireNode(this.inputRoot,`group-87/Spirit${id}`);}
private calNode(id:number):Node{return requireNode(this.calDialog,`Spirit${id}`);}
private needInput():NumericInputModel{return roomNeed(this.input,'numeric input context');}
private needCalculator():MultiplierModel{return roomNeed(this.calculator,'calculator context');}
private requireActive():void{if(this.ui.disposed)throw Error('Numeric tools panel is disposed');}
private tip(message:string|null):void {if(message!==null)this.options.error(message);}
private renderInput():void {
const model=this.needInput();const label=this.ui.text(this.inputRoot,'group-87/Spirit3003');
this.numberLabel(label,model.text,51,90);
const transform=roomNeed(label.node.getComponent(UITransform),'input display transform');
transform.width=Math.min(transform.width,this.inputDisplayWidth);
}
private renderCalculator():void {
const model=this.needCalculator();
this.numberLabel(this.ui.text(this.calRoot,'group-78/Spirit370'),model.text,28,72);
this.numberLabel(this.ui.text(this.calRoot,'group-79/Spirit373'),model.text,36,90);
const entry=requireNode(this.calEntry,'Spirit283');
roomNeed(entry.getComponent(UITransform),'calculator entry transform').width=203+model.text.length*28;
}
private numberLabel(label:Label,text:string,charWidth:number,height:number):void {
label.string=text.replace('b','.');label.enableWrapText=false;label.overflow=Label.Overflow.SHRINK;
roomNeed(label.node.getComponent(UITransform),'numeric label transform').setContentSize(Math.max(1,text.length*charWidth),height);
}
private paste():void {
if(this.pasting)return;
const input=this.needInput(),generation=this.inputGeneration;this.pasting=true;
void Promise.resolve().then(()=>this.options.readClipboard()).then(text=>{
if(this.ui.disposed||generation!==this.inputGeneration||this.input!==input)return;
this.tip(input.paste(text));this.renderInput();
}).catch(error=>{
if(!this.ui.disposed&&generation===this.inputGeneration)console.error('[NumericToolsPanel]',error);
}).finally(()=>{if(generation===this.inputGeneration)this.pasting=false;});
}
private raise(root:Node):void {
const parent=this.options.canvas;
root.setSiblingIndex(parent.children.length-1);
const overlay=parent.children.find(node=>['Layer614_Loading','Layer615_Reconnect','Layer612_Tips','Layer616_Kick'].includes(node.name));
root.setSiblingIndex(overlay?overlay.getSiblingIndex():parent.children.length-1);
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "1c145198-d199-4ac7-bb93-6be164d2fe00",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,6 +1,4 @@
import {createErqiwangGame} from '../../games/erqiwang/erqiwang-game.ts';
import {ErqiwangRoomView} from '../../games/erqiwang/ErqiwangRoomView.ts';
import { _decorator, assetManager, Component, Director, Label, Node, Prefab, ProgressBar, SceneAsset,
import { _decorator, assetManager, BitmapFont, SpriteFrame, sys, Component, Director, Label, Node, Prefab, ProgressBar, SceneAsset,
UITransform, Widget, director, instantiate, isValid } from 'cc';
import { resolveRuntimeConfig, type RuntimeConfig } from '../../framework/config/runtime-config.ts';
import { CocosLocalConfigFetcher } from './local-config-fetcher.ts';
@@ -16,9 +14,9 @@ import { PlatformRuntime } from '../../framework/platform/runtime.ts';
import { WireClient } from '../../framework/net/wire-client.ts';
import { CocosWebSocketTransport } from '../../framework/net/cocos-transport.ts';
import type { DeviceLoginMode, LoginAccountIdentity, LoginDeviceSnapshot } from '../../framework/protocol/contracts/login-contract.ts';
import {createTemplateGame} from '../../games/template/template-game.ts';
import {TemplateRoomView} from '../../games/template/TemplateRoomView.ts';
import {SubgameAssets} from './SubgameAssets.ts';
import type {GameBinding} from '../../framework/sdk/contracts/game-definition.ts';
import {GameComposition,type CommonRoomView} from './GameComposition.ts';
import type {RoomScope} from '../../framework/platform/room/room-scope.ts';
import { CocosLogoCountdownView, createLogoCountdown } from './logo-countdown.ts';
import { requireNode } from './node-bindings.ts';
import { SceneFlow } from './scene-flow.ts';
@@ -26,7 +24,22 @@ import { LoginPanel } from './login-panel.ts';
import { LobbyAssets } from './LobbyAssets.ts';
import { LobbyPanel } from './lobby-panel.ts';
import { logNativeCall } from '../../framework/core/native-call-log.ts';
import type { NativeCallName } from '../../framework/adapters/native/native-contracts.ts';
import type { NativeCallName } from '../../framework/adapters/native/native-contracts.ts';
import {startPlatformLocation,type PlatformNativeHost} from '../../framework/adapters/native/platform-native.ts';
import {PlatformSettings} from '../../framework/presentation/platform-settings.ts';
import {ApplyVotePanel} from './ApplyVotePanel.ts';
import {RoomPlatformPanels} from './RoomPlatformPanels.ts';
import {RoomChatPanel} from './RoomChatPanel.ts';
import {NumericToolsPanel} from './NumericToolsPanel.ts';
import {DeferredNumericToolsPort} from '../../framework/presentation/deferred-numeric-tools.ts';
import {RoomHud} from './RoomHud.ts';
import {RoomNoticePanel} from './RoomNoticePanel.ts';
import {RoomDeviceStatusAdapter,createRoomDeviceStatusSubscriptions} from '../../framework/adapters/native/room-device-status.ts';
import {RoomVoiceAdapter,createRoomVoiceSubscriptions} from '../../framework/adapters/native/room-voice.ts';
import {resolveRoomChatPolicy} from '../../framework/presentation/room-chat-config.ts';
import {resolveRoomMenuContext} from '../../framework/presentation/room-menu-context.ts';
import {shouldShowOwnerNotice} from '../../framework/presentation/room-owner-notice.ts';
import type {PlatformState} from '../../framework/platform/stores/platform-types.ts';
import { resolveLobbyPolicy } from '../../framework/presentation/lobby-model.ts';
import { openLobbyUrl, resolveLobbyLaunchMode } from '../../framework/adapters/native/lobby-links.ts';
import { resolveLobbyMenuConfig, LOBBY_UNAVAILABLE, type LobbyContext } from '../../framework/presentation/lobby-menu.ts';
@@ -50,12 +63,45 @@ export class PlatformStartup extends Component {
@property(Prefab) tipsPrefab:Prefab|null=null;
@property(Prefab) waitPrefab:Prefab|null=null;
@property(Prefab) reconnectPrefab:Prefab|null=null;
@property(Prefab) kickPrefab:Prefab|null=null;
@property(Prefab) kickPrefab:Prefab|null=null;
@property(Prefab) applyPrefab:Prefab|null=null;
@property(Prefab) playerVoteItemPrefab:Prefab|null=null;
@property(Prefab) roomMainPrefab:Prefab|null=null;
@property(Prefab) roomMenuPrefab:Prefab|null=null;
@property(Prefab) roomCheckPrefab:Prefab|null=null;
@property(Prefab) roomChatPrefab:Prefab|null=null;
@property(Prefab) chatPhrasePrefab:Prefab|null=null;
@property(Prefab) chatHistoryPrefab:Prefab|null=null;
@property(Prefab) roomHelpPrefab:Prefab|null=null;
@property(Prefab) roomScenePrefab:Prefab|null=null;
@property(Prefab) roomNoticePrefab:Prefab|null=null;
@property(Prefab) numberInputPrefab:Prefab|null=null;
@property(Prefab) multiplierPrefab:Prefab|null=null;
@property([SpriteFrame]) roomNetworkFrames:SpriteFrame[]=[];
@property([SpriteFrame]) roomBatteryFrames:SpriteFrame[]=[];
@property(BitmapFont) roomClockFont:BitmapFont|null=null;
@property(BitmapFont) roomAppleClockFont:BitmapFont|null=null;
@property(SpriteFrame) roomClockSeparator:SpriteFrame|null=null;
@property(SpriteFrame) roomAppleClockSeparator:SpriteFrame|null=null;
private numericTools:NumericToolsPanel|null=null;
private readonly numericToolsPort=new DeferredNumericToolsPort();
private roomHud:RoomHud|null=null;
private roomNotice:RoomNoticePanel|null=null;
private roomDevice:RoomDeviceStatusAdapter|null=null;
private roomPanels:RoomPlatformPanels|null=null;
private roomChat:RoomChatPanel|null=null;
private roomVoice:RoomVoiceAdapter|null=null;
private nativeHost:PlatformNativeHost|null=null;
private announcementsRead=0;
private lastAnnouncements:readonly string[]|undefined;
private voiceMessageCount=0;
private applyPanel:ApplyVotePanel|null=null;
private dissolutionReceipt:PlatformState['dissolution']=undefined;
private feedback:FeedbackPanels|null=null;
private waitingReply=false;
private reconnecting=false;
private runtime:PlatformRuntime|null=null;
private game:ReturnType<typeof createTemplateGame>|null=null;
private game:GameBinding|null=null;
private roomPending:'create_room'|'self_join_room'|null=null;
private roomTimer:ReturnType<typeof setTimeout>|null=null;
private roomReceipt:{rpc:string;data:Record<string,unknown>}|null=null;
@@ -69,14 +115,16 @@ export class PlatformStartup extends Component {
private bridge:WebViewJavascriptBridgeAdapter|null=null;
private restoreShareLogin:(()=>void)|null=null;
private restoreLocation:(()=>void)|null=null;
private startLocation:(()=>void)|null=null;
private startLocation:(()=>void)|null=null;
private settings:PlatformSettings|null=null;
private location:unknown=null;
private loginResponse:unknown=null;
private unsubscribe:(()=>void)|null=null;
private countdown:ReturnType<typeof createLogoCountdown>|null=null;
private countdownView:CocosLogoCountdownView|null=null;
private progress:ProgressBar|null=null;
private status:Label|null=null;
private status:Label|null=null;
private roomView:CommonRoomView|null=null;
private device:LoginDeviceSnapshot|null=null;
private busy=false;
private terminal=false;
@@ -103,7 +151,8 @@ export class PlatformStartup extends Component {
// uAgent_3 passes the original argument, not its unused local tp variable.
accreditlogin:(provider:number)=>required(this.bridge,'native bridge').call('accreditlogin',provider===2?undefined:provider,()=>{}),
}:injected.settings;
this.config=await resolveRuntimeConfig({hostKind:bridgeHost?'uAgent_3':native?'native-settings':'h5',win:window as any,
this.config=await resolveRuntimeConfig({
buildIdentity: await required(this.getComponent(GameComposition),'GameComposition').loadBuildIdentity(),hostKind:bridgeHost?'uAgent_3':native?'native-settings':'h5',win:window as any,
search:window.location.search,fetcher:new HttpConfigFetcher(),localFetcher:new CocosLocalConfigFetcher()});
if(this.terminal)return;
this.policy=resolveLoginPolicy(this.config.remoteConfig);
@@ -132,7 +181,7 @@ export class PlatformStartup extends Component {
const value=typeof data==='string'?JSON.parse(data):data;
if(value===null||typeof value!=='object'||Array.isArray(value))throw Error('Invalid getlocationinfo payload');
this.location=value;
}catch(error){this.tip(error instanceof Error?error.message:String(error));}
}catch(error){console.error('[PlatformStartup]',error);}
};
(window as any).getlocationinfo=locationCallback;
this.restoreLocation=()=>{if((window as any).getlocationinfo===locationCallback){if(oldLocation===undefined)delete (window as any).getlocationinfo;else (window as any).getlocationinfo=oldLocation;}};
@@ -140,11 +189,13 @@ export class PlatformStartup extends Component {
this.bridge=new WebViewJavascriptBridgeAdapter(window as unknown as WebViewJavascriptBridgeEnvironment);
this.bridge.register('sharelogin',data=>this.acceptNative(data,'bridge'));
this.bridge.register('getlocationinfo',locationCallback);
this.startLocation=()=>{if(bridgeHost)required(this.bridge,'native bridge').call('startlocation',2,()=>{});
else {logNativeCall('settings','startlocation',[2],true);required(injected.settings,'settings').startlocation(2);}};
}else{
this.startLocation=()=>logNativeCall('settings','startlocation',[2],false);
}
}
const platformHost:PlatformNativeHost=bridgeHost?
{kind:'bridge',call:(name,data)=>required(this.bridge,'native bridge').call(name,data,()=>{})}:
injected.settings?{kind:'settings',settings:injected.settings}:{kind:'browser'};
this.nativeHost=platformHost;
this.startLocation=()=>startPlatformLocation(platformHost,locationCallback);
this.settings=new PlatformSettings(window.localStorage,this.config.identity.gameid,this.config.identity.agentid,platformHost);
required(this.countdown,'countdown').start(resolveLaunchWaitTime(this.config.remoteConfig));
await this.startSession(true);
}catch(error){this.fail(error);}
@@ -152,10 +203,12 @@ export class PlatformStartup extends Component {
/** 退出只重建账号会话;配置、原生桥和已加载资源由常驻组件保留。 */
private async startSession(firstStart:boolean):Promise<void>{
const identity=required(this.config,'config').identity;
const gameKey=required(this.getComponent(SubgameAssets),'SubgameAssets').gameKey;
if(gameKey!=='erqiwang'&&gameKey!=='Game_Surface_3')throw Error('Unsupported subgame: '+gameKey);
this.game=gameKey==='erqiwang'?createErqiwangGame(identity.gameid):createTemplateGame(identity.gameid);
const definition=required(this.getComponent(GameComposition),'GameComposition').definition;
this.game=definition.createGame(identity.gameid);
if(this.game.entry.key!==definition.key||this.game.entry.route!==definition.route)throw Error('Game definition identity mismatch');
this.runtime=new PlatformRuntime({gameEntry:this.game.entry,
feedback:{beginLoading:()=>required(this.feedback,'feedback').beginLoading(),showBusinessTip:(message,time)=>required(this.feedback,'feedback').showBusinessTip(message,time)},
numericTools:this.numericToolsPort,
roomHistory:new OwnedRoomHistory(window.localStorage,identity.gameid,identity.agentid),
resolveRuntimeConfig:async()=>required(this.config,'config'),
createWireClient:config=>{
@@ -187,13 +240,13 @@ export class PlatformStartup extends Component {
},
scene:{showLoading:()=>{},showLogin:()=>{void this.showLogin().catch(error=>this.fail(error));},
showLobby:()=>{void this.showLobby().catch(error=>this.fail(error));},
showRoom:()=>{void this.showRoom().catch(error=>this.fail(error));},
showRoom:scope=>this.showRoom(scope),
showReconnect:()=>{this.waitingReply=false;this.feedback?.loading(false);this.reconnecting=true;this.feedback?.reconnect(true);},
showTips:(message,time,source)=>{if(source==='show_message'){this.endRoomRequest();this.lobby?.cancelTaskRequest();this.lobby?.cancelInviteRequest();this.lobby?.cancelRankRequest();}this.tip(message,time);},
showTips:(message,time,source)=>{if(source==='show_message'){this.roomPanels?.requestFailed();this.endRoomRequest();this.lobby?.cancelTaskRequest();this.lobby?.cancelInviteRequest();this.lobby?.cancelRankRequest();}this.tip(message,time);},
showKicked:data=>{
const packet=data as {msg?:string;fromOther?:unknown;gameid?:unknown};
const text=packet.msg||`网络出现异常!${packet.fromOther?(packet.gameid==='G2hw0ubng0zcoI0r4mx3H2yr4GejidwO'?2:1):0}#(请重启游戏)`;
this.fail(Error(text));
try{this.feedback?.kick(text);}finally{this.release();}
},
showServerDenial:outcome=>{
this.busy=false;this.panel?.setBusy(false);
@@ -218,12 +271,29 @@ export class PlatformStartup extends Component {
});
this.unsubscribe=this.runtime.subscribeState(state=>{
if(firstStart)this.countdown?.socketReady(state.app.phase==='connected'||state.app.phase==='logged-in');
this.lobby?.render(state);
this.renderPlatformState(state);
},error=>this.fail(error));
await this.runtime.start();
}
await this.runtime.start();
}
private renderPlatformState(state:PlatformState):void {
// A dissolution switch ends with free_room, not player_login. Only an accepted
// result receipt clears connection overlays; malformed packets never reach here.
if(state.dissolution!==this.dissolutionReceipt){
this.dissolutionReceipt=state.dissolution;
if(state.dissolution)this.roomView?.dissolutionReceived();
if(state.dissolution?.kind==='result'){
this.waitingReply=false;this.reconnecting=false;
this.feedback?.loading(false);this.feedback?.reconnect(false);
}
}
this.lobby?.render(state);this.applyPanel?.render(state);
this.renderRoomPanels(state);
}
private canvas():Node{return required(director.getScene(),'scene').getChildByName('Canvas') ?? (()=>{throw Error('Scene requires Canvas');})();}
private createStatus():void{
private createStatus():void{
this.disposeRoomPanels();
this.roomView?.close();this.roomView=null;
this.applyPanel?.dispose();this.applyPanel=null;
const canvas=this.canvas();const node=new Node('Status');node.parent=canvas;node.layer=canvas.layer;
node.setPosition(0,315,0);node.addComponent(UITransform).setContentSize(1200,60);
this.status=node.addComponent(Label);this.status.string='';this.status.fontSize=24;this.status.lineHeight=28;
@@ -262,8 +332,8 @@ export class PlatformStartup extends Component {
required(this.accounts,'accounts'),required(this.policy,'policy'),message=>this.tip(message),
()=>!this.busy&&!this.terminal&&this.runtime?.state.app.phase==='connected');
const version=required(requireNode(root,'group-2/版本显示').getComponent(Label),'version Label');
// Beta 展示格式使用当前渠道版本号;只改变显示,不改变登录协议中的数值版本。
version.string=`v${required(this.config,'config').identity.version}.0.0 Beta`;
// 展示版本来自 XML name;登录协议使用 XML value 的数值版本。
version.string=`v${required(this.getComponent(GameComposition),'GameComposition').displayVersion}`;
// Label 自动尺寸更新后重新计算左上角 Widget,避免长版本文字向屏幕外扩展。
required(version.node.getComponent(UITransform),'version UITransform').setAnchorPoint(0,1);
version.updateRenderData(true);
@@ -271,15 +341,15 @@ export class PlatformStartup extends Component {
versionWidget.target=this.canvas();
versionWidget.updateAlignment();
this.events.push({kind:'scene',data:'Login'});
// Legacy location acquisition is independent of login. Keep the source null
// until a real callback arrives; report host failure without stopping the socket.
try{this.startLocation?.();}catch(error){this.tip(`定位接口调用失败:${error instanceof Error?error.message:String(error)}`);}
// Location acquisition is independent of login; the browser adapter supplies
// the legacy test callback, while native hosts own their real callbacks.
try{this.startLocation?.();}catch(error){console.error('[PlatformStartup] location',error);}
if(this.accounts!.cached())this.accounts!.autoLogin();
});
}
private acceptNative(data:unknown,channel:'global'|'bridge'):void{
if(this.terminal)return;
try{required(this.accounts,'accounts').acceptNative(data,channel);}catch(error){this.tip(error instanceof Error?error.message:String(error));}
try{required(this.accounts,'accounts').acceptNative(data,channel);}catch(error){console.error('[PlatformStartup]',error);}
}
private saveAcceptedLogin():void{
if(this.loginResponse!==null){
@@ -292,8 +362,10 @@ export class PlatformStartup extends Component {
if(this.terminal)return;
this.saveAcceptedLogin();
this.panel?.dispose();this.panel=null;
await this.flow.enterOnce(required(this.lobbyScene,'lobbyScene').name,()=>{
this.createStatus();
await this.flow.enterOnce(required(this.lobbyScene,'lobbyScene').name,()=>{
this.createStatus();
try{required(this.settings,'platform settings').synchronizeVoice();}
catch(error){console.error('[PlatformStartup]',error);}
const popup=new Node('PopupRoot');popup.parent=this.canvas();popup.layer=this.canvas().layer;
popup.addComponent(UITransform).setContentSize(1600,720);
this.lobby=new LobbyPanel(this.canvas(),popup,required(this.getComponent(LobbyAssets),'LobbyAssets'),
@@ -313,13 +385,14 @@ export class PlatformStartup extends Component {
bind:code=>required(this.runtime,'runtime').bindInvitecode(code),
paste:()=>this.pasteInvitecode(),
},{
settings:required(this.settings,'platform settings'),
rank:()=>required(this.runtime,'runtime').getTreasureList(),
loading:show=>required(this.feedback,'feedback').loading(show),
vipRank:limit=>required(this.runtime,'runtime').getVipRankList(limit),
joinOwned:code=>this.requestRoom('self_join_room',()=>required(this.runtime,'runtime').joinOwnedRoom(code,this.location)),
redeem:cardno=>required(this.runtime,'runtime').redeemCard(cardno),
paste:()=>this.pasteInvitecode(true),
room:{createPrefab:required(required(this.getComponent(SubgameAssets),'SubgameAssets').createRoom,'subgame create prefab'),
room:{createPrefab:required(required(this.getComponent(GameComposition),'GameComposition').createRoom,'subgame create prefab'),
join:roomcode=>this.requestRoom('self_join_room',()=>required(this.runtime,'runtime').joinRoom({roomcode,...this.roomEnvironment()})),
create:roomtype=>this.requestRoom('create_room',()=>required(this.runtime,'runtime').createRoom({roomtype,...this.roomEnvironment()})),
busy:()=>this.roomPending!==null,close:()=>this.feedback?.loading(false)},
@@ -348,8 +421,8 @@ export class PlatformStartup extends Component {
if(this.roomTimer!==null)clearTimeout(this.roomTimer);this.roomTimer=null;
if(this.roomPending!==null){this.roomPending=null;this.lobby?.setRoomBusy(false);this.feedback?.loading(false);}
}
private async showRoom():Promise<void>{
if(this.terminal)return;
private async showRoom(scope:RoomScope):Promise<void>{
if(this.terminal||!scope.active)return;
this.saveAcceptedLogin();this.endRoomRequest();
// This callback runs only after the runtime accepted and committed the packet.
const receipt=this.roomReceipt;this.roomReceipt=null;
@@ -359,14 +432,27 @@ export class PlatformStartup extends Component {
if(receipt.rpc==='self_join_room'&&receipt.data.shortcode)window.localStorage.setItem('tsgame_shortcode_'+identity.gameid+identity.agentid,String(receipt.data.shortcode));
}
this.panel?.dispose();this.panel=null;this.lobby?.dispose();this.lobby=null;
const assets=required(this.getComponent(SubgameAssets),'SubgameAssets');
const assets=required(this.getComponent(GameComposition),'GameComposition');
await this.flow.enter(required(assets.roomScene,'subgame room scene').name,()=>{
if(required(this.runtime,'runtime').state.room.kind!=='inside')return;
if(!scope.active||required(this.runtime,'runtime').state.room.kind!=='inside')return;
const root=instantiate(required(assets.room,'subgame room prefab'));root.parent=this.canvas();root.setPosition(0,0,0);
const view=assets.gameKey==='erqiwang'?required(root.getComponent(ErqiwangRoomView),'ErqiwangRoomView'):required(root.getComponent(TemplateRoomView),'TemplateRoomView');
root.on('room-action-error',(error:unknown)=>this.tip(error instanceof Error?error.message:String(error)));
scope.own(()=>{if(isValid(root))root.destroy();});
const view=assets.resolveRoomView(root);
scope.own(()=>{if(this.roomView===view){this.roomView=null;view.close();}});
scope.own(()=>this.disposeRoomPanels());
scope.own(()=>{this.applyPanel?.dispose();this.applyPanel=null;});
root.on('room-action-error',(error:unknown)=>console.error('[PlatformStartup]',error));
root.on('room-action-timeout',()=>this.tip('房间操作超时,请重试'));
this.createStatus();required(this.game,'template game').mount(view);this.events.push({kind:'scene',data:'Room'});
this.createStatus();this.roomView=view;required(this.game,'template game').mount(view);
this.mountRoomPanels();
required(view.exitButton,'room exit button').node.active=false;
required(view.roomLabel,'room label').node.on(Node.EventType.TOUCH_END,()=>this.roomPanels?.openRoomInfo());
this.applyPanel=new ApplyVotePanel(this.canvas(),required(this.applyPrefab,'applyPrefab'),required(this.playerVoteItemPrefab,'playerVoteItemPrefab'),{
agree:()=>required(this.runtime,'runtime').agreeDissolution(),refuse:()=>required(this.runtime,'runtime').refuseDissolution(),
acknowledge:()=>required(this.runtime,'runtime').acknowledgeDissolution(),error:text=>console.error('[ApplyVotePanel]',text),
});
this.applyPanel.render(required(this.runtime,'runtime').state);
this.events.push({kind:'scene',data:'Room'});
});
}
private async pasteInvitecode(raw=false):Promise<string>{
@@ -431,17 +517,82 @@ export class PlatformStartup extends Component {
if(text)this.feedback?.showTips(text,time);
if(this.status&&isValid(this.status))this.status.string='';
}
private fail(error:unknown):void{
console.error('[PlatformStartup]',error);
try{this.feedback?.kick(error instanceof Error?error.message:String(error));}
finally{this.release();}
private fail(error:unknown):void{
console.error('[PlatformStartup]',error);
this.release();
}
/** 幂等释放计时器、订阅和宿主回调,阻止销毁后的迟到消息重新激活流程。 */
private release():void{
if(this.terminal)return;this.terminal=true;this.flow.dispose();this.panel?.dispose();this.lobby?.dispose();
this.disposeRoomPanels();
this.roomView?.close();this.roomView=null;
if(this.terminal)return;this.terminal=true;this.flow.dispose();this.panel?.dispose();this.lobby?.dispose();
this.applyPanel?.dispose();this.applyPanel=null;
this.endRoomRequest();this.countdown?.cancel();this.countdownView?.dispose();this.unsubscribe?.();this.bridge?.dispose();
this.restoreShareLogin?.();this.restoreLocation?.();this.runtime?.stop();
}
onDestroy():void{this.release();this.feedback?.dispose();}
getDiagnostics(){return {events:[...this.events],ready:this.runtime?.ready,busy:this.busy,terminal:this.terminal};}
}
getDiagnostics(){return {events:[...this.events],ready:this.runtime?.ready,busy:this.busy,terminal:this.terminal};}
update(deltaSeconds:number):void{this.roomHud?.update(deltaSeconds);this.roomNotice?.update(deltaSeconds);}
private ensureNumericTools():NumericToolsPanel{
if(!this.numericTools){const identity=required(this.config,'config').identity;this.numericTools=new NumericToolsPanel({canvas:this.canvas(),inputPrefab:required(this.numberInputPrefab,'numberInputPrefab'),calPrefab:required(this.multiplierPrefab,'multiplierPrefab'),storage:window.localStorage,gameid:identity.gameid,agentid:identity.agentid,readClipboard:()=>this.pasteInvitecode(true),error:message=>this.tip(message)});}
return this.numericTools;
}
private mountRoomPanels():void{
const config=required(this.config,'config'),assets=required(this.getComponent(LobbyAssets),'LobbyAssets');
const definition=required(this.getComponent(GameComposition),'GameComposition').definition;
const chatConfig=definition.chat;
const runtime=required(this.runtime,'runtime'),host=required(this.nativeHost,'native host');
this.roomDevice=new RoomDeviceStatusAdapter({host,subscribe:createRoomDeviceStatusSubscriptions(host,window as unknown as Record<string,unknown>,this.bridge||undefined)});
const apple=sys.os===sys.OS.IOS;
this.roomHud=new RoomHud({canvas:this.canvas(),mainScene:required(this.roomScenePrefab,'roomScenePrefab'),device:this.roomDevice,networkFrames:this.roomNetworkFrames,batteryFrames:this.roomBatteryFrames,clockFont:required(apple?this.roomAppleClockFont:this.roomClockFont,'room clock font'),clockSeparator:required(apple?this.roomAppleClockSeparator:this.roomClockSeparator,'room clock separator'),openRoomInfo:()=>this.roomPanels?.openRoomInfo(),reportError:message=>console.error('[RoomHud]',message)});
this.roomHud.root.setSiblingIndex(0);
this.roomNotice=new RoomNoticePanel(this.canvas(),required(this.roomNoticePrefab,'roomNoticePrefab'),message=>console.error('[RoomNoticePanel]',message));
this.voiceMessageCount=runtime.state.roomSocial?.history.length||0;
this.roomVoice=new RoomVoiceAdapter({host,subscribe:createRoomVoiceSubscriptions(host,window as unknown as Record<string,unknown>,this.bridge||undefined),
sendVoice:(url,time)=>runtime.sendRoomVoice(url,time),onPlayback:(user,playing)=>this.roomChat?.setPlayback(user-chatConfig.voiceHistoryTag,playing)});
this.roomChat=new RoomChatPanel(this.canvas(),required(this.roomChatPrefab,'roomChatPrefab'),required(this.chatPhrasePrefab,'chatPhrasePrefab'),required(this.chatHistoryPrefab,'chatHistoryPrefab'),chatConfig,{
text:(text,announcement)=>runtime.sendRoomText(text,announcement),phrase:text=>runtime.sendRoomPhrase(text),expression:id=>runtime.sendRoomExpression(id),
voice:(url,index)=>required(this.roomVoice,'room voice').playVoice(url,2,chatConfig.voiceHistoryTag+index),error:message=>this.tip(message),
readAnnouncements:()=>{this.announcementsRead=runtime.state.roomSocial?.announcements.length||0;this.roomPanels?.renderNotice(0,null);},
});
this.roomPanels=new RoomPlatformPanels(this.canvas(),{main:required(this.roomMainPrefab,'roomMainPrefab'),menu:required(this.roomMenuPrefab,'roomMenuPrefab'),check:required(this.roomCheckPrefab,'roomCheckPrefab'),help:required(this.roomHelpPrefab,'roomHelpPrefab'),settings:required(assets.settings,'settings prefab'),switches:assets.switches},required(this.settings,'settings'),{
context:()=>resolveRoomMenuContext(runtime.state,config,definition.roomMenu,{h5Version:host.kind==='browser'?1:0,versionState:required(this.lobbyContext,'lobby context').versionState}),
command:(command,count)=>{switch(command){case 'room.exit':runtime.exitRoom();break;case 'room.break':runtime.breakRoom();break;case 'room.apply-dissolution':runtime.applyDissolution();break;case 'room.change':runtime.changeRoom();break;case 'room.escape':runtime.surrenderRoom(required(count,'surrender count'));break;default:throw Error('Unknown room action '+command);}},
chat:()=>required(this.roomChat,'room chat').open(),record:()=>required(this.roomVoice,'room voice').startRecording(),error:message=>this.tip(message),external:url=>required(this.openExternal,'external browser')(url),
copyRoom:()=>{const room=runtime.state.room;if(room.kind!=='inside')throw Error('No room');
if(host.kind==='bridge'){host.call('gameCopytext',room.roomcode);this.tip('复制成功!');}
else if(host.kind==='settings'){const method=(host.settings as Record<string,unknown>).gameCopytext;if(typeof method!=='function')throw Error('Native settings missing gameCopytext');Reflect.apply(method,host.settings,[room.roomcode]);this.tip('复制成功!');}
else {if(!navigator.clipboard)throw Error('当前浏览器无法访问剪贴板');void navigator.clipboard.writeText(room.roomcode).then(()=>this.tip('复制成功!'),error=>console.error('[PlatformStartup]',error));}},
describeRoom:()=>{const room=runtime.state.room;if(room.kind!=='inside')throw Error('No room');return room.matchId?'比赛房':room.infinite===1?'系统房':'房号:'+room.roomcode;},
autoOwnerNotice:()=>shouldShowOwnerNotice(window.localStorage,String(config.identity.gameid),String(config.identity.agentid),Date.now()),
numberInput:(message,onConfirm)=>this.ensureNumericTools().openNumberInput({message,onConfirm:value=>{this.numericTools?.closeNumberInput();if(typeof value!=='number')throw Error('Surrender input must be numeric');onConfirm(value);}}),
});
this.renderRoomPanels(runtime.state);
this.numericToolsPort.bind(this.ensureNumericTools());
}
private renderRoomPanels(state:PlatformState):void{
if(!this.roomPanels||!this.roomChat)return;if(state.room.kind!=='inside'){this.disposeRoomPanels();return;}this.roomPanels.render(state);
this.roomHud?.render(state);this.roomNotice?.render(state);
const policy=resolveRoomChatPolicy(required(this.config,'config').remoteConfig);
const self=required(state.players.entities[required(state.players.selfPlayerId,'self player')],'self player');
this.roomChat.render(state,policy.noticeLimit>=0&&self.bean>=policy.noticeLimit,`发送系统公告将消耗${resolveLobbyPolicy(required(this.config,'config').remoteConfig).starName}数量:${policy.noticeCost}`);
const history=state.roomSocial?.history;
if(history){const from=this.voiceMessageCount;this.voiceMessageCount=history.length;
for(const message of history.slice(from))if(message.kind==='voice'&&message.autoPlay){
if(state.room.kind!=='inside')throw Error('Voice playback requires room');
const seat=(message.sender.seat-state.room.selfSeat+state.room.seatPlayerIds.length)%state.room.seatPlayerIds.length;
try{required(this.roomVoice,'room voice').playVoice(required(message.voiceurl,'voice URL'),1,seat);}catch(error){console.error('[PlatformStartup]',error);}
}
}
const news=state.roomSocial?.announcements;
if(news){const changed=this.lastAnnouncements!==news;this.lastAnnouncements=news;if(this.announcementsRead>news.length)this.announcementsRead=0;this.roomPanels.renderNotice(Math.max(0,news.length-this.announcementsRead),changed&&news.length>0&&!this.roomChat.root.active?news[news.length-1]:null);}
}
private disposeRoomPanels():void{
const owned=[this.numericTools,this.roomNotice,this.roomHud,this.roomDevice,this.roomVoice,this.roomChat,this.roomPanels];
this.numericTools=null;this.roomNotice=null;this.roomHud=null;this.roomDevice=null;this.roomVoice=null;this.roomChat=null;this.roomPanels=null;
this.announcementsRead=0;this.lastAnnouncements=undefined;this.voiceMessageCount=0;
const errors:unknown[]=[];try{this.numericToolsPort.unbind();}catch(error){errors.push(error);}
for(const resource of owned)try{resource?.dispose();}catch(error){errors.push(error);}
if(errors.length)throw Object.assign(Error('Room panels cleanup failed'),{errors});
}
}
@@ -0,0 +1,108 @@
import {Director,director,BlockInputEvents,Button,EditBox,Label,Layout,Node,Prefab,ScrollView,Sprite,UITransform,isValid} from 'cc';
import type {PlatformState,RoomSocialMessage} from '../../framework/platform/stores/platform-types.ts';
import {resolveRoomChatPhrase,type RoomChatConfig,validateRoomChatText} from '../../framework/presentation/room-chat-config.ts';
import {KeyedRows} from '../../framework/presentation/keyed-rows.ts';
import {requireNode} from './node-bindings.ts';
import {RoomUi,roomNeed} from './RoomUi.ts';
export interface RoomChatActions {
text(text:string,announcement:boolean):void;phrase(text:string):void;expression(id:number):void;
voice(url:string,index:number):void;error(message:string):void;readAnnouncements():void;
}
/** The platform store owns messages; this view only owns its instantiated rows. */
export class RoomChatPanel {
readonly root:Node;
private readonly ui:RoomUi;
private readonly historyRows:KeyedRows<RoomSocialMessage,{node:Node;ui:RoomUi;index:number;message:RoomSocialMessage}>;
private pendingRender:(()=>void)|null=null;
private readonly flush=()=>{const render=this.pendingRender;this.pendingRender=null;render?.();};
private readonly visited=new Set<string>();
private readonly input:EditBox;
private page:'Phrases'|'Expressions'|'History'|'Announcements'='Phrases';
private announcement=false;
private allowed=false;
private history:readonly RoomSocialMessage[]|undefined;
private announcements:readonly string[]|undefined;
private connected=false;
private readonly playback=new Set<number>();
constructor(canvas:Node,prefab:Prefab,private readonly phrasePrefab:Prefab,private readonly historyPrefab:Prefab,
private readonly config:RoomChatConfig,private readonly actions:RoomChatActions){
this.ui=new RoomUi(actions.error);this.root=this.ui.mount(prefab,canvas);
this.historyRows=new KeyedRows({key:message=>message.id,create:(message,index)=>this.createHistoryRow(message,index),
update:(row,message,index)=>{if(row.message!==message){row.ui.dispose();Object.assign(row,this.createHistoryRow(message,index));}row.index=index;},remove:row=>{row.node.removeFromParent();row.ui.dispose();}});
this.root.addComponent(BlockInputEvents);this.root.active=false;
this.input=roomNeed(this.node('聊天输入框').getComponent(EditBox),'chat input');
this.ui.bind(this.node('聊天遮罩'),()=>this.close());
for(const name of ['聊天背景','聊天底'])this.node(name).addComponent(BlockInputEvents);
for(const [name,page] of [['常用语','Phrases'],['Spirit699','Phrases'],['表情','Expressions'],['Spirit161','Expressions'],['Spirit698','History'],['Spirit162','History'],['Spirit3107','Announcements'],['Spirit3108','Announcements']] as const)this.ui.bind(this.node(name),()=>this.select(page));
this.ui.bind(this.node('发送'),()=>{if(!this.connected||this.input.string===''||/^[ ]+$/.test(this.input.string))return;validateRoomChatText(this.config,this.input.string,this.announcement);actions.text(this.input.string,this.announcement);this.input.string='';});
this.ui.bind(this.node('Spirit3111'),()=>{if(!this.allowed)return;this.announcement=!this.announcement;this.renderAnnouncement();});
const phrases=this.content('Phrases');this.clear(phrases);
config.phrases.forEach((phrase,index)=>{const row=this.ui.mount(phrasePrefab,phrases);row.name=`ChatPhraseItem_${index}`;this.ui.text(row,'Text').string=phrase.text;this.ui.bind(row,()=>{if(this.connected)actions.phrase(phrase.text);});});
this.layout('Phrases');
for(const node of this.content('Expressions').children){const id=Number(node.name.replace('Expression',''));if(!Number.isInteger(id)||id<1)throw Error('Invalid expression item');this.ui.bind(node,()=>{if(this.connected)actions.expression(id);});}
this.layout('Expressions');this.clear(this.content('History'));this.clear(this.content('Announcements'));
this.select('Phrases');
}
private node(name:string):Node{return requireNode(this.root,'group-30/'+name);}
private scroll(page:string):ScrollView{return roomNeed(this.node(page).getComponent(ScrollView),page);}
private content(page:string):Node{return roomNeed(this.scroll(page).content,page+' content');}
private clear(parent:Node):void{for(const n of [...parent.children]){n.removeFromParent();n.destroy();}}
private layout(page:string):void{roomNeed(this.content(page).getComponent(Layout),page+' layout').updateLayout();}
open():void{this.root.active=true;const parent=this.root.parent!;const overlay=parent.children.find(n=>['Layer614_Loading','Layer615_Reconnect','Layer612_Tips','Layer616_Kick'].includes(n.name));this.root.setSiblingIndex(overlay?overlay.getSiblingIndex():parent.children.length-1);this.select(this.page);}
close():void{this.root.active=false;this.input.blur();}
private select(page:typeof this.page):void{this.page=page;for(const name of ['Phrases','Expressions','History','Announcements'])this.node(name).active=name===page;this.layout(page);if(!this.visited.has(page)&&(page==='History'||page==='Announcements'))this.scroll(page).scrollToBottom(0);this.visited.add(page);if(page==='Announcements'&&this.root.active)this.actions.readAnnouncements();}
render(state:PlatformState,announcementAllowed:boolean,announcementCost:string):void{
if(this.pendingRender===null)director.once(Director.EVENT_BEFORE_DRAW,this.flush);
this.pendingRender=()=>this.renderState(state,announcementAllowed,announcementCost);
}
private renderState(state:PlatformState,announcementAllowed:boolean,announcementCost:string):void{
this.connected=state.app.phase==='logged-in'&&state.room.kind==='inside';this.allowed=announcementAllowed;
if(!this.allowed)this.announcement=false;this.renderAnnouncement();
const cost=this.ui.text(this.root,'group-30/Spirit3117');cost.string=announcementCost;cost.node.active=this.allowed;
const social=state.roomSocial;if(!social)return;
if(this.history!==social.history){this.history=social.history;this.renderHistory(social.history);}
if(this.announcements!==social.announcements){this.announcements=social.announcements;this.renderNews(social.announcements);if(this.page==='Announcements'&&this.root.active)this.actions.readAnnouncements();}
}
private renderAnnouncement():void{
this.node('Spirit3111').active=this.allowed;this.node('Spirit3113').active=this.allowed;
this.node('Spirit3111').setScale(this.announcement?1.1:1,this.announcement?1.1:1,1);
this.input.maxLength=this.announcement?this.config.announcementLimit:this.config.limitLength;
}
private renderHistory(messages:readonly RoomSocialMessage[]):void{
const scroll=this.scroll('History'),offset=scroll.getScrollOffset(),follow=offset.y>=scroll.getMaxScrollOffset().y-2;
this.historyRows.render(messages);this.layout('History');
if(follow)scroll.scrollToBottom(0);else scroll.scrollToOffset(offset,0);
}
private createHistoryRow(message:RoomSocialMessage,index:number):{node:Node;ui:RoomUi;index:number;message:RoomSocialMessage}{
const ui=new RoomUi(this.actions.error),row=ui.mount(this.historyPrefab,this.content('History'));row.name=`ChatHistoryItem_${message.id}`;
const holder={node:row,ui,index,message};
ui.text(row,'Spirit3110').string=message.sender.nickname;
ui.avatar(requireNode(row,'Spirit697'),message.sender.avatar);
const label=ui.text(row,'Spirit164'),duration=ui.text(row,'Spirit480');label.enableWrapText=true;
requireNode(row,'Spirit165').active=message.kind==='voice';duration.node.active=message.kind==='voice';
label.string=message.kind==='text'?roomNeed(message.text,'chat text'):message.kind==='phrase'?resolveRoomChatPhrase(this.config,roomNeed(message.index,'phrase index')).text:message.kind==='expression'?'': '点击播放语音';
if(message.kind==='voice'){duration.string=String(roomNeed(message.time,'voice duration'))+'秒';ui.bind(row,()=>this.actions.voice(roomNeed(message.voiceurl,'voice URL'),holder.index));if(this.playback.has(index))requireNode(row,'Spirit165').setScale(1.25,1.25,1);}
if(message.kind==='expression'){
const expressions=this.content('Expressions'),count=expressions.children.length;if(count===0)throw Error('Missing expression assets');
const source=requireNode(expressions,'Expression'+((roomNeed(message.index,'expression index')-1)%count+1));
const emoji=new Node('Expression');emoji.parent=row;emoji.layer=row.layer;emoji.setPosition(118,-65);emoji.addComponent(UITransform).setContentSize(48,48);emoji.addComponent(Sprite).spriteFrame=roomNeed(source.getComponent(Sprite),'expression sprite').spriteFrame;
}
const lines=Math.max(1,Math.ceil(label.string.replace(/[^\x00-\xff]/g,'**').length/40));const height=Math.max(105,48+lines*24);
roomNeed(row.getComponent(UITransform),'history row').height=height;
roomNeed(label.node.getComponent(UITransform),'history text').height=height-48;label.lineHeight=24;
label.node.setPosition(290,-(height+25)/2);const bubble=requireNode(row,'Spirit163');roomNeed(bubble.getComponent(UITransform),'bubble').height=height-40;bubble.setPosition(290,-(height+25)/2);
return holder;
}
private renderNews(messages:readonly string[]):void{
const scroll=this.scroll('Announcements'),offset=scroll.getScrollOffset(),follow=offset.y>=scroll.getMaxScrollOffset().y-2;
const content=this.content('Announcements');
for(const row of [...content.children])if(Number(row.name.replace('Announcement_',''))>=messages.length){row.removeFromParent();row.destroy();}
messages.forEach((message,index)=>{let row=content.getChildByName(`Announcement_${index}`);if(!row){row=new Node(`Announcement_${index}`);row.parent=content;row.layer=content.layer;row.addComponent(UITransform).setAnchorPoint(0,1);const label=row.addComponent(Label);label.fontSize=20;label.lineHeight=26;label.overflow=Label.Overflow.RESIZE_HEIGHT;label.horizontalAlign=Label.HorizontalAlign.LEFT;label.color=this.ui.text(this.root,'group-30/Spirit3117').color;}
roomNeed(row.getComponent(UITransform),'announcement row').setContentSize(500,Math.max(60,Math.ceil(message.length/22)*26+20));roomNeed(row.getComponent(Label),'announcement label').string=message.replace(/#/g,'\n');});
this.layout('Announcements');if(follow)scroll.scrollToBottom(0);else scroll.scrollToOffset(offset,0);
}
setPlayback(index:number,playing:boolean):void{if(playing)this.playback.add(index);else this.playback.delete(index);const message=this.history?.[index],row=message?this.historyRows.get(message.id)?.node:undefined;if(row&&isValid(row))requireNode(row,'Spirit165').setScale(playing?1.25:1,playing?1.25:1,1);}
dispose():void{director.off(Director.EVENT_BEFORE_DRAW,this.flush);this.pendingRender=null;try{this.historyRows.dispose();}finally{this.ui.dispose();}}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "0bda0501-4d0e-42c6-90ef-e185e491dc4a",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,120 @@
import {BitmapFont,Label,Node,Prefab,Sprite,SpriteFrame,UIOpacity,UITransform,Widget,isValid} from 'cc';
import type {RoomDeviceStatusAdapter} from '../../framework/adapters/native/room-device-status.ts';
import type {PlatformState} from '../../framework/platform/stores/platform-types.ts';
import {RoomHudClock,roomHudCycles,roomHudTime} from '../../framework/presentation/room-hud-model.ts';
import {RoomUi,roomNeed} from './RoomUi.ts';
import {requireNode} from './node-bindings.ts';
export interface RoomHudOptions {
canvas:Node;
mainScene:Prefab;
device:RoomDeviceStatusAdapter;
/** Zero-indexed arrays contain legacy frames 1..4 and 1..3 respectively. */
networkFrames:readonly SpriteFrame[];
batteryFrames:readonly SpriteFrame[];
/** Caller selects original 00069/00070 or Apple 00174/00173 from authoritative OS. */
clockFont:BitmapFont;
clockSeparator:SpriteFrame;
openRoomInfo():void;
reportError(message:string):void;
/** Date source is optional for deterministic tests; production uses local Date. */
now?:()=>Date;
}
/** Layer50 status binding. The caller owns sibling ordering and native adapter lifetime. */
export class RoomHud {
readonly root:Node;
private readonly overlay:Node;
private readonly ui:RoomUi;
private readonly gate:Node;
private readonly hours:Label;
private readonly minutes:Label;
private readonly roomCode:Label;
private readonly codeTitle:Node;
private readonly wifi:Sprite;
private readonly battery:Sprite;
private readonly batteryBackground:Sprite;
private readonly colon:Sprite;
private readonly colonOpacity:UIOpacity;
private readonly clock=new RoomHudClock();
private roomKey:string|null=null;
private cycles=false;
private readonly now:()=>Date;
constructor(private readonly options:RoomHudOptions) {
this.ui=new RoomUi(options.reportError);
this.now=options.now===undefined?()=>new Date():options.now;
try {
if(options.networkFrames.length!==4||options.networkFrames.some(frame=>!frame))throw Error('RoomHud requires network frames 00113_1..4');
if(options.batteryFrames.length!==3||options.batteryFrames.some(frame=>!frame))throw Error('RoomHud requires battery frames 00112_1..3');
roomNeed(options.clockFont,'clockFont');roomNeed(options.clockSeparator,'clockSeparator');
this.root=this.ui.mount(options.mainScene,options.canvas);
const node=(id:number)=>requireNode(this.root,'group-21/Spirit'+id);
const sprite=(id:number)=>roomNeed(node(id).getComponent(Sprite),'Spirit'+id+' Sprite');
this.gate=node(149);
this.hours=roomNeed(node(154).getComponent(Label),'Spirit154 Label');
this.minutes=roomNeed(node(155).getComponent(Label),'Spirit155 Label');
this.roomCode=roomNeed(node(3141).getComponent(Label),'Spirit3141 Label');
this.codeTitle=node(3142);
this.wifi=sprite(336);this.batteryBackground=sprite(337);this.battery=sprite(338);this.colon=sprite(156);
this.colonOpacity=roomNeed(this.colon.getComponent(UIOpacity)||this.colon.addComponent(UIOpacity),'Spirit156 UIOpacity');
this.hours.font=options.clockFont;this.minutes.font=options.clockFont;this.colon.spriteFrame=options.clockSeparator;
this.batteryBackground.spriteFrame=options.batteryFrames[0];
// Horizontal fill clips the source texture from the left, matching legacy set_clip.
this.battery.type=Sprite.Type.FILLED;this.battery.fillType=Sprite.FillType.HORIZONTAL;this.battery.fillStart=0;
this.ui.bind(node(284),()=>options.openRoomInfo());
this.ui.bind(node(3141),()=>options.openRoomInfo());
node(291).active=false;
// The platform background stays below the subgame; status remains above it.
this.overlay=new Node('RoomStatus');this.overlay.layer=this.root.layer;this.overlay.parent=options.canvas;
this.overlay.addComponent(UITransform);
const align=roomNeed(this.overlay.addComponent(Widget),'RoomStatus Widget');align.target=options.canvas;
align.isAlignTop=align.isAlignBottom=align.isAlignLeft=align.isAlignRight=true;
align.top=align.bottom=align.left=align.right=0;align.updateAlignment();
for(const id of [284,154,155,156,336,337,338,3141,3142,291])node(id).parent=this.overlay;
const feedback=options.canvas.children.find(n=>['Layer614_Loading','Layer615_Reconnect','Layer612_Tips','Layer616_Kick'].includes(n.name));
if(feedback)this.overlay.setSiblingIndex(feedback.getSiblingIndex());
this.overlay.active=false;this.ui.releases.push(()=>{if(isValid(this.overlay))this.overlay.destroy();});
this.ui.releases.push(options.device.subscribe(()=>this.ui.run(()=>this.draw())));
this.root.active=false;
}catch(error){this.ui.dispose();throw error;}
}
render(state:PlatformState):void {
if(this.ui.disposed)return;
const inside=state.app.phase==='logged-in'&&state.room.kind==='inside';
this.root.active=inside;
this.overlay.active=inside;
if(state.room.kind!=='inside'||!inside){this.roomKey=null;this.clock.reset();return;}
if(this.roomKey!==state.room.roomcode){this.roomKey=state.room.roomcode;this.clock.reset();}
this.cycles=roomHudCycles(state.room);
this.roomCode.string=state.room.roomcode;
this.clock.update(0,this.gate.activeInHierarchy,this.cycles);
this.draw();
}
update(dt:number):void {
if(this.ui.disposed||!this.root.activeInHierarchy)return;
this.clock.update(dt,this.gate.activeInHierarchy,this.cycles);
this.draw();
}
dispose():void {this.ui.dispose();this.clock.reset();this.roomKey=null;}
private draw():void {
if(this.ui.disposed||!this.root.activeInHierarchy)return;
const visible=this.gate.activeInHierarchy;
const showStatus=visible&&!this.clock.showRoomCode;
const time=roomHudTime(this.now());
if(visible){this.hours.string=time.hours;this.minutes.string=time.minutes;this.colonOpacity.opacity=this.clock.colonOpacity;}
this.hours.node.active=showStatus;this.minutes.node.active=showStatus;this.colon.node.active=showStatus;
this.roomCode.node.active=visible&&this.clock.showRoomCode;this.codeTitle.active=visible&&this.clock.showRoomCode;
const status=this.options.device.snapshot();
this.wifi.node.active=showStatus&&status.wifiFrame!==null;
if(status.wifiFrame!==null)this.wifi.spriteFrame=this.options.networkFrames[status.wifiFrame-1];
this.batteryBackground.node.active=showStatus;
this.battery.node.active=showStatus&&status.battery!==null&&status.batteryFrame!==null;
if(status.batteryFrame!==null)this.battery.spriteFrame=this.options.batteryFrames[status.batteryFrame-1];
if(status.battery!==null)this.battery.fillRange=status.battery;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "5fec65ea-08a0-4560-a1dd-a17ed777fbe7",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,56 @@
import {Label,Mask,Node,Prefab,UITransform} from 'cc';
import type {PlatformState} from '../../framework/platform/stores/platform-types.ts';
import {RoomBroadcastQueue} from '../../framework/presentation/room-broadcast-queue.ts';
import {requireNode} from './node-bindings.ts';
import {RoomUi,roomNeed} from './RoomUi.ts';
/** Layer402: broadcast/msgtype=1 only; chat announcements have their own list. */
export class RoomNoticePanel {
readonly root:Node;
private readonly ui:RoomUi;
private readonly queue:RoomBroadcastQueue;
private readonly label:Label;
private readonly textTransform:UITransform;
private readonly clipWidth:number;
private roomcode:string|null=null;
private receipts:readonly string[]|undefined;
constructor(canvas:Node,prefab:Prefab,error:(message:string)=>void){
this.ui=new RoomUi(error);this.root=this.ui.mount(prefab,canvas);this.root.active=false;
const viewport=requireNode(this.root,'group-56/Viewport');
roomNeed(viewport.getComponent(Mask),'broadcast clip mask');
this.clipWidth=roomNeed(viewport.getComponent(UITransform),'broadcast viewport').width;
this.queue=new RoomBroadcastQueue(this.clipWidth);
this.label=this.ui.text(this.root,'group-56/Viewport/Spirit390');
this.textTransform=roomNeed(this.label.node.getComponent(UITransform),'broadcast text');
this.label.enableWrapText=false;this.label.overflow=Label.Overflow.NONE;
// Legacy closeBroadcastfromplayer is empty: no click handler consumes/skips a message.
}
render(state:PlatformState):void {
if(this.ui.disposed)return;
const roomcode=state.room.kind==='inside'?state.room.roomcode:null;
const receipts=roomcode===null?undefined:state.roomBroadcasts;
if(roomcode!==this.roomcode||receipts===undefined){
this.queue.clear();this.receipts=undefined;this.roomcode=roomcode;
}
if(receipts!==undefined&&receipts!==this.receipts){
const previous=this.receipts;
const appended=previous!==undefined&&receipts.length>=previous.length&&previous.every((text,index)=>text===receipts[index]);
if(previous!==undefined&&!appended)this.queue.clear();
const start=appended?previous.length:0;
for(let index=start;index<receipts.length;index++)this.queue.enqueue(receipts[index]);
this.receipts=receipts;
}
this.paint();
}
update(deltaSeconds:number):void {
if(this.ui.disposed)return;
this.queue.advance(deltaSeconds);this.paint();
}
private paint():void {
const frame=this.queue.current;this.root.active=frame!==null;
if(frame===null)return;
this.label.string=frame.text;this.textTransform.width=frame.textWidth;
this.label.node.setPosition(frame.offsetX-this.clipWidth/2,0,0);
}
dispose():void {this.queue.clear();this.receipts=undefined;this.roomcode=null;this.ui.dispose();}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "7bd9fc1d-809d-47fd-afc5-a80bb6ca3797",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,117 @@
import {BlockInputEvents,Label,Layout,Node,Prefab,Sprite,SpriteFrame,UITransform,Widget,isValid} from 'cc';
import type {PlatformState} from '../../framework/platform/stores/platform-types.ts';
import {buildRoomMenuModel,roomMenuAction,roomEscapeConfirmation,type RoomMenuContext,type RoomMenuAction,type RoomMenuSpiritId} from '../../framework/presentation/room-menu-model.ts';
import type {PlatformSettings} from '../../framework/presentation/platform-settings.ts';
import {OptionalSwitchAnimation} from './OptionalSwitchAnimation.ts';
import {RoomUi,roomNeed} from './RoomUi.ts';
import {requireNode} from './node-bindings.ts';
export interface RoomPanelAssets {main:Prefab;menu:Prefab;check:Prefab;settings:Prefab;help:Prefab;switches:SpriteFrame[];}
export interface RoomPanelActions {
context():RoomMenuContext;command(command:string,count?:number):void;chat():void;record():void;
numberInput(message:string,onConfirm:(value:number)=>void):void;
error(message:string):void;external(url:string):void;copyRoom():void;describeRoom():string;autoOwnerNotice():boolean;
}
const BUTTON_NAMES:Readonly<Record<RoomMenuSpiritId,string>>={151:'游戏界面设置',150:'游戏界面帮助',185:'解散',233:'退出按钮',3014:'更换房间',513:'逃跑',656:'下载'};
/** Platform-owned room toolbar, menu and confirmations. */
export class RoomPlatformPanels {
private readonly ui:RoomUi;
readonly main:Node;
readonly menu:Node;
private modal:RoomUi|null=null;
private state:PlatformState|null=null;
private pending=false;
private timer:ReturnType<typeof setTimeout>|null=null;
private pendingCommand:string|null=null;
private noticeTimer:ReturnType<typeof setTimeout>|null=null;
private ownerNotice:string|null=null;
constructor(private readonly canvas:Node,private readonly assets:RoomPanelAssets,private readonly settings:PlatformSettings,private readonly actions:RoomPanelActions){
this.ui=new RoomUi(actions.error);this.main=this.ui.mount(assets.main,canvas);this.menu=this.ui.mount(assets.menu,canvas);
this.ui.bind(requireNode(this.main,'group-21/Spirit153'),()=>actions.chat());
for(const name of ['Spirit3115','Spirit3118','Spirit3119'])this.ui.bind(requireNode(this.main,'group-21/'+name),()=>actions.chat());
const record=requireNode(this.main,'group-21/Spirit152');const fn=()=>this.ui.run(()=>{if(this.state?.app.phase==='logged-in')actions.record();});record.on(Node.EventType.TOUCH_START,fn);this.ui.releases.push(()=>{if(isValid(record))record.off(Node.EventType.TOUCH_START,fn);});
this.ui.bind(requireNode(this.menu,'group-21/Spirit107'),()=>{const group=requireNode(this.menu,'group-99');if(group.active)group.active=false;else this.openMenu();});
this.ui.bind(requireNode(this.menu,'group-99/Spirit3123'),()=>this.closeMenu());
requireNode(this.menu,'group-99/Spirit108').addComponent(BlockInputEvents);
for(const [id,name] of Object.entries(BUTTON_NAMES))this.ui.bind(requireNode(this.menu,'group-99/Buttons/'+name),()=>this.perform(roomMenuAction(Number(id) as RoomMenuSpiritId,actions.context())));
for(const name of ['Spirit109','Spirit3124'])this.ui.bind(requireNode(this.menu,'group-100/'+name),()=>{requireNode(this.menu,'group-100').active=false;});
this.ui.bind(requireNode(this.menu,'group-100/Spirit512'),()=>actions.copyRoom());
this.ui.bind(requireNode(this.menu,'group-81/Spirit3149'),()=>this.showOwnerNotice(!requireNode(this.menu,'group-81/Spirit3148').active));
for(const name of ['Spirit3148','Spirit3150'])this.ui.bind(requireNode(this.menu,'group-81/'+name),()=>this.showOwnerNotice(false));
for(const name of ['Spirit3115','Spirit3116','Spirit3118','Spirit3119','Spirit3117']){const n=this.main.getChildByPath('group-21/'+name);if(n)n.active=false;}
}
private closeMenu():void{requireNode(this.menu,'group-99').active=false;}
private openMenu():void{
if(this.pending||this.state?.app.phase!=='logged-in')return;
const visible=new Set(buildRoomMenuModel(this.actions.context()).map(x=>x.spiritId));
for(const [id,name] of Object.entries(BUTTON_NAMES))requireNode(this.menu,'group-99/Buttons/'+name).active=visible.has(Number(id) as RoomMenuSpiritId);
requireNode(this.menu,'group-100').active=false;requireNode(this.menu,'group-99').active=true;
roomNeed(requireNode(this.menu,'group-99/Buttons').getComponent(Layout),'menu Layout').updateLayout();
}
openRoomInfo():void{this.closeMenu();const g=requireNode(this.menu,'group-100');this.ui.text(g,'房间号').string=this.actions.describeRoom();this.ui.text(g,'Spirit111').string='';g.active=true;}
private perform(action:RoomMenuAction):void{
this.closeMenu();
switch(action.kind){
case 'tip':this.actions.error(action.message);return;
case 'download':this.actions.external(action.url);return;
case 'panel':if(action.panel==='settings')this.openSettings();else this.openHelp();return;
case 'command':this.send(action.command);return;
case 'confirm':this.confirm(action);return;
case 'input':this.escapeInput(action.message);return;
}
}
private send(command:string,count?:number):void{
if(this.pending||this.state?.app.phase!=='logged-in')return;
this.pending=true;this.pendingCommand=command;
this.timer=setTimeout(()=>{this.clearPending();this.actions.error('房间操作超时,请重试');},15000);
try{this.actions.command(command,count);}catch(error){this.clearPending();throw error;}
}
requestFailed():void{this.clearPending();}
private clearPending():void{this.pending=false;this.pendingCommand=null;if(this.timer!==null)clearTimeout(this.timer);this.timer=null;}
render(state:PlatformState):void{
const old=this.state;this.state=state;
if(state.app.phase!=='logged-in'||state.room.kind!=='inside'||old&&(
this.pendingCommand==='room.apply-dissolution'&&old.dissolution!==state.dissolution||
this.pendingCommand==='room.change'&&old.room.kind==='inside'&&old.room.roomcode!==state.room.roomcode))this.clearPending();
if(state.app.phase!=='logged-in'){this.closeMenu();this.closeModal();}
const notice=state.room.kind==='inside'?state.room.ownerNotice:null;
const noticeGroup=requireNode(this.menu,'group-81');noticeGroup.active=!!notice;
if(notice!==this.ownerNotice){this.ownerNotice=notice||null;this.showOwnerNotice(!!notice&&this.actions.autoOwnerNotice());}
}
private showOwnerNotice(show:boolean):void{
const g=requireNode(this.menu,'group-81'),bg=requireNode(g,'Spirit3148'),label=this.ui.text(g,'Spirit3150');
bg.active=label.node.active=show&&!!this.ownerNotice;if(!bg.active)return;
label.string='房主公告:\n'+this.ownerNotice;label.enableWrapText=true;label.overflow=Label.Overflow.CLAMP;label.horizontalAlign=Label.HorizontalAlign.LEFT;label.fontSize=20;label.lineHeight=28;
const height=Math.max(90,Math.ceil(label.string.length/20)*28+30);
for(const n of [bg,label.node]){const w=n.getComponent(Widget);if(w)w.enabled=false;}
roomNeed(bg.getComponent(Sprite),'owner notice background').sizeMode=Sprite.SizeMode.CUSTOM;
roomNeed(bg.getComponent(UITransform),'notice background').setContentSize(420,height);bg.setPosition(210,350-height/2);
roomNeed(label.node.getComponent(UITransform),'notice label').setContentSize(390,height-30);label.node.setPosition(210,350-height/2);
}
private closeModal():void{this.modal?.dispose();this.modal=null;}
private show(prefab:Prefab):{ui:RoomUi;root:Node}{this.closeModal();const ui=new RoomUi(this.actions.error);this.modal=ui;const root=ui.mount(prefab,this.canvas);root.addComponent(BlockInputEvents);return {ui,root};}
private confirm(action:Extract<RoomMenuAction,{kind:'confirm'}>):void{
const {ui,root}=this.show(this.assets.check);ui.text(root,'group-36/Spirit361').string=action.message;
for(const name of ['Spirit242','Spirit246','Spirit247'])ui.bind(requireNode(root,'group-36/'+name),()=>this.closeModal());
for(const name of ['Spirit243','Spirit244'])requireNode(root,'group-36/'+name).addComponent(BlockInputEvents);
ui.bind(requireNode(root,'group-36/Spirit245'),()=>{this.closeModal();this.send(action.command,action.count);});
}
private escapeInput(message:string):void{
this.closeModal();this.actions.numberInput(message,value=>this.perform(roomEscapeConfirmation(this.actions.context(),value)));
}
private openSettings():void{
const {ui,root}=this.show(this.assets.settings);const g=requireNode(root,'group-6');g.active=true;
ui.bind(requireNode(g,'设置遮罩'),()=>this.closeModal());
requireNode(g,'邀请好友').active=false;
ui.bind(requireNode(g,'退出登录'),()=>this.actions.error('请返回游戏大厅退出登录'));
const dissolve=requireNode(g,'申请解散');dissolve.active=buildRoomMenuModel(this.actions.context()).some(x=>x.spiritId===185);
ui.bind(dissolve,()=>{this.closeModal();this.perform(roomMenuAction(185,this.actions.context()));});
for(const [name,key] of [['音乐底','bgm'],['音效底','sound'],['Spirit357','shake'],['Spirit388','voice']] as const){const n=requireNode(g,name),animation=roomNeed(n.getComponent(OptionalSwitchAnimation),name);const render=(animate:boolean)=>{const on=this.settings.read(key);animation.setState(on,roomNeed(this.assets.switches[on?0:1],'switch sprite'),animate);};render(false);ui.bind(n,()=>{try{this.settings.toggle(key);}finally{render(true);}});}
}
private openHelp():void{const {ui,root}=this.show(this.assets.help);requireNode(root,'group-19').active=true;for(const name of ['帮助遮罩','关闭帮助'])ui.bind(requireNode(root,'group-19/'+name),()=>this.closeModal());requireNode(root,'group-19/帮助背景').addComponent(BlockInputEvents);}
renderNotice(unread:number,preview:string|null):void{
const badge=requireNode(this.main,'group-21/Spirit3118'),count=this.ui.text(this.main,'group-21/Spirit3119');badge.active=count.node.active=unread>0;count.string=unread>99?'99+':String(unread);roomNeed(badge.getComponent(UITransform),'notice badge').width=unread<10?27:unread<100?37:47;
if(preview!==null){const node=requireNode(this.main,'group-21/Spirit3115');node.active=true;const label=this.ui.text(node,'NoticeText');label.string=preview.replace(/#/g,'\n');if(this.noticeTimer!==null)clearTimeout(this.noticeTimer);this.noticeTimer=setTimeout(()=>{if(isValid(node))node.active=false;this.noticeTimer=null;},1800);}
}
dispose():void{this.clearPending();if(this.noticeTimer!==null)clearTimeout(this.noticeTimer);this.closeModal();this.ui.dispose();}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "97b778ff-e788-45e9-b18a-e377683013ed",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,27 @@
import {assetManager,Button,ImageAsset,Label,Node,Prefab,Sprite,SpriteFrame,Texture2D,UITransform,Widget,instantiate,isValid} from 'cc';
import {requireNode} from './node-bindings.ts';
import {roomAvatarCache} from './avatar-cache.ts';
export function roomNeed<T>(value:T|null|undefined,name:string):T{if(value==null)throw Error(`Room UI missing ${name}`);return value;}
/** Owns listeners and remote avatar allocations for one room UI lifetime. */
export class RoomUi {
readonly releases:Array<()=>void>=[];
disposed=false;
private readonly avatarOwners=new Map<Node,()=>void>();
constructor(readonly error:(message:string)=>void){}
run(action:()=>void):void{try{action();}catch(error){console.error('[RoomUi]',error);}}
mount(prefab:Prefab,parent:Node):Node{const n=instantiate(prefab);n.parent=parent;n.setPosition(0,0,0);const w=n.getComponent(Widget);if(w){w.target=parent;w.updateAlignment();}const overlay=parent.children.find(c=>['Layer614_Loading','Layer615_Reconnect','Layer612_Tips','Layer616_Kick'].includes(c.name));if(overlay)n.setSiblingIndex(overlay.getSiblingIndex());this.releases.push(()=>{if(isValid(n))n.destroy();});return n;}
bind(n:Node,action:()=>void):Button{const b=n.getComponent(Button)||n.addComponent(Button);b.transition=Button.Transition.NONE;const fn=()=>{if(!this.disposed&&n.activeInHierarchy&&b.interactable)this.run(action);};n.on(Button.EventType.CLICK,fn);this.releases.push(()=>{if(isValid(n))n.off(Button.EventType.CLICK,fn);});return b;}
text(root:Node,path:string):Label{return roomNeed(requireNode(root,path).getComponent(Label),path);}
avatar(node:Node,url:string):void{
this.avatarOwners.get(node)?.();this.avatarOwners.delete(node);
const sprite=roomNeed(node.getComponent(Sprite),'avatar');if(url==='')return;
const original=sprite.spriteFrame,lease=roomAvatarCache.acquire(url);let released=false;
const release=()=>{if(released)return;released=true;if(isValid(node))sprite.spriteFrame=original;lease.release();};this.avatarOwners.set(node,release);
void lease.value.then(({frame})=>{if(released||this.disposed||!isValid(node))return;sprite.sizeMode=Sprite.SizeMode.CUSTOM;sprite.spriteFrame=frame;},error=>{if(!released&&!this.disposed)console.error('[RoomUi]',error);});
}
dispose():void{
if(this.disposed)return;this.disposed=true;const errors:unknown[]=[];
for(const release of [...this.avatarOwners.values(),...this.releases.splice(0).reverse()])try{release();}catch(error){errors.push(error);}
this.avatarOwners.clear();if(errors.length)throw Object.assign(Error('Room UI cleanup failed'),{errors});
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "18fe9a15-6004-4b15-a4bf-ad99253dc8a7",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,10 +0,0 @@
import {_decorator,Component,Prefab,SceneAsset} from 'cc';
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 Component {
@property gameKey:string='Game_Surface_3';
@property(Prefab) createRoom:Prefab|null=null;
@property(Prefab) room:Prefab|null=null;
@property(SceneAsset) roomScene:SceneAsset|null=null;
}
@@ -0,0 +1,28 @@
import type {PlatformState} from '../../framework/platform/stores/platform-types.ts';
export interface ApplyVoteModel {
readonly message:string;
readonly rows:readonly {seat:number;nickname:string;status:string}[];
readonly seconds:number|null;
readonly canVote:boolean;
readonly result:boolean;
}
/** GameUI.OpenApply/OpenApplyResult. Server snapshots alone determine the result. */
export function selectApplyVote(state:PlatformState,now:number):ApplyVoteModel|null {
const vote=state.dissolution;
if(!vote||vote.kind==='result'&&vote.freeNow)return null;
if(vote.kind==='result'&&!vote.passed){
const refused=vote.participants.find(p=>p.seat===vote.refusedSeat);
if(!refused)throw Error('Dissolution result missing refusing player');
return {message:`投票结果:\n\n拒绝解散玩家:【${refused.nickname}】`,rows:[],seconds:null,canVote:false,result:true};
}
if(!vote.initiator)throw Error('Dissolution missing initiator');
const rows=vote.participants.filter(p=>p.seat!==vote.initiator!.seat&&(vote.kind==='active'||p.state===1))
.map(p=>({seat:p.seat,nickname:`【${p.nickname}】`,status:p.state===1?'同意':p.state===2?'拒绝':'等待'}));
if(vote.kind==='result')return {message:`投票结果:\n\n投票发起:【${vote.initiator.nickname}】`,rows,seconds:null,canVote:false,result:true};
const self=vote.participants.find(p=>p.seat===vote.selfSeat);
if(!self)throw Error('Dissolution missing self participant');
return {message:`发起投票:【${vote.initiator.nickname}】\n请问是否同意?(倒计时结束未选择,则默认同意)`,rows,
seconds:Math.max(0,Math.ceil((vote.deadline-now)/1000)),
canVote:state.app.phase==='logged-in'&&self.state===-1,result:false};
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e1dec3fb-6d05-4242-9e7c-b718a168c3f3",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,11 @@
import {assetManager,ImageAsset,SpriteFrame,Texture2D} from 'cc';
import {ResourceLeaseCache} from '../../framework/presentation/resource-lease-cache.ts';
/** One bounded application cache; only referenced textures are leased by views. */
export const roomAvatarCache=new ResourceLeaseCache<{frame:SpriteFrame;texture:Texture2D}>({
idleCapacity:32,
load:url=>new Promise((resolve,reject)=>assetManager.loadRemote<ImageAsset>(url,{ext:'.png'},(error,image)=>{
if(error){reject(error);return;}
const texture=new Texture2D();texture.image=image;const frame=new SpriteFrame();frame.texture=texture;resolve({frame,texture});
})),
destroy:({frame,texture})=>{frame.destroy();texture.destroy();},
});
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "34b523da-476f-4081-be46-ca91fdd90342",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,3 +1,5 @@
import {OwnedFeedback} from '../../framework/presentation/owned-feedback.ts';
import type {OwnerHandle} from '../../framework/sdk/contracts/game-capabilities.ts';
import {Animation, BlockInputEvents, Label, Node, Prefab, UITransform, Widget, instantiate, isValid} from 'cc';
import {requireNode} from './node-bindings.ts';
@@ -10,6 +12,7 @@ export class FeedbackPanels {
private reconnectNode:Node;
private kickNode:Node;
private locked=false;
private readonly owned:OwnedFeedback;
constructor(canvas:Node,assets:{tips:Prefab;loading:Prefab;reconnect:Prefab;kick:Prefab}){
const mount=(asset:Prefab)=>{
const n=instantiate(asset);n.parent=canvas;n.setPosition(0,0,0);
@@ -19,6 +22,7 @@ export class FeedbackPanels {
};
this.loadingNode=mount(assets.loading);this.reconnectNode=mount(assets.reconnect);
this.tipNode=mount(assets.tips);this.kickNode=mount(assets.kick);
this.owned=new OwnedFeedback({loading:show=>this.renderLoading(show),tip:(message,time)=>this.renderTips(message,time),closeTip:()=>this.hideTip()});
requireNode(this.tipNode,'group-89').active=false;
this.tipNode.on(Node.EventType.TOUCH_END,()=>this.closeTips());
}
@@ -36,20 +40,27 @@ export class FeedbackPanels {
bg.getComponent(UITransform)!.setContentSize(Math.max(570,size.width+100),height);
bg.setPosition(0,18,0);n.setPosition(0,18,0);
}
showTips(message:string,time=3000):void{
if(this.locked||!message)return;this.closeTips();
showTips(message:string,time=3000):void{if(this.locked||!message)return;this.owned.showPlatformTip(message,time);}
showBusinessTip(message:string,time:number):OwnerHandle{if(this.locked)throw Error('Feedback is locked by server kick');return this.owned.showBusinessTip(message,time);}
beginLoading():OwnerHandle{if(this.locked)throw Error('Feedback is locked by server kick');return this.owned.beginLoading();}
private renderTips(message:string,time:number):void{
if(this.locked||!message)return;this.hideTip();
this.text(this.tipNode,'group-32/提示文字','group-32/提示背景',message);
this.tipNode.active=true;this.front(this.tipNode);
if(time>0)this.timer=setTimeout(()=>this.closeTips(),time);
}
closeTips():void{if(this.timer!==null)clearTimeout(this.timer);this.timer=null;if(isValid(this.tipNode))this.tipNode.active=false;}
loading(show:boolean):void{
const animation=requireNode(this.loadingNode,'group-40/载入动画').getComponent(Animation);
if(!animation||!animation.defaultClip)throw Error('Loading requires serialized AnimationClip');
this.loadingNode.active=show&&!this.locked;
if(this.loadingNode.active){this.front(this.loadingNode);if(!animation.getState(animation.defaultClip.name)?.isPlaying)animation.play();}
else animation.stop();
}
closeTips():void{this.owned.dismissTip();}
private hideTip():void{if(this.timer!==null)clearTimeout(this.timer);this.timer=null;if(isValid(this.tipNode))this.tipNode.active=false;}
loading(show:boolean):void{this.owned.setPlatformLoading(show);}
private renderLoading(show:boolean):void{
// Scene replacement may destroy this panel before its owner is disposed.
if(!show&&!isValid(this.loadingNode))return;
const animation=requireNode(this.loadingNode,'group-40/载入动画').getComponent(Animation);
if(!animation||!animation.defaultClip)throw Error('Loading requires serialized AnimationClip');
this.loadingNode.active=show&&!this.locked;
if(this.loadingNode.active){this.front(this.loadingNode);if(!animation.getState(animation.defaultClip.name)?.isPlaying)animation.play();}
else animation.stop();
}
reconnect(show:boolean):void{
const animation=requireNode(this.reconnectNode,'group-35/重连动画').getComponent(Animation);
if(!animation||!animation.defaultClip)throw Error('Reconnect requires serialized AnimationClip');
@@ -57,12 +68,15 @@ export class FeedbackPanels {
if(this.reconnectNode.active){this.front(this.reconnectNode);animation.play();}else animation.stop();
}
kick(message:string):void{
this.loading(false);this.reconnect(false);this.closeTips();this.locked=true;
this.locked=true;this.owned.clear();this.reconnect(false);
this.text(this.kickNode,'group-33/强制退出提示文字','group-33/强制下线提示底',message);
this.kickNode.active=true;this.front(this.kickNode);
}
dispose():void{
this.closeTips();for(const n of this.nodes){if(!isValid(n))continue;for(const animation of n.getComponentsInChildren(Animation))animation.stop();n.destroy();}
const errors:unknown[]=[];
try{this.owned.dispose();}catch(error){errors.push(error);}
for(const n of this.nodes){if(!isValid(n))continue;try{for(const animation of n.getComponentsInChildren(Animation))animation.stop();}catch(error){errors.push(error);}finally{try{n.destroy();}catch(error){errors.push(error);}}}
this.nodes=[];
if(errors.length)throw Object.assign(new Error('Feedback panel cleanup failed'),{errors:Object.freeze(errors)});
}
}
@@ -1,5 +1,6 @@
import type {Roomtype} from '../../framework/sdk/contracts/roomtype.ts';
import { assetManager, BlockInputEvents, Button, EditBox, ImageAsset, Label, Mask, Node, Prefab, ScrollView, Sprite, SpriteFrame, Texture2D, UITransform, Widget, instantiate, isValid } from 'cc';
import {requireRoomtype} from '../../framework/protocol/contracts/validation.ts';
import { assetManager, BlockInputEvents, Button, EditBox, ImageAsset, Label, Layout, Mask, Node, Prefab, ScrollView, Sprite, SpriteFrame, Texture2D, UITransform, Widget, instantiate, isValid } from 'cc';
import type { RuntimeConfig } from '../../framework/config/runtime-config.ts';
import { selectLobbyModel, shouldShowNotice, type LobbyModel, type LobbyPolicy } from '../../framework/presentation/lobby-model.ts';
import type { PlatformState } from '../../framework/platform/stores/platform-types.ts';
@@ -9,6 +10,7 @@ import { AnnouncementView } from './AnnouncementView.ts';
import { NoticeTabsView } from './NoticeTabsView.ts';
import { OwnerRoomListView } from './OwnerRoomListView.ts';
import { OptionalSwitchAnimation } from './OptionalSwitchAnimation.ts';
import type {PlatformSettings} from '../../framework/presentation/platform-settings.ts';
import { ProfileInfoListView } from './ProfileInfoListView.ts';
import { validateProfileAuth } from '../../framework/presentation/profile-auth.ts';
import {RequestLoading} from '../../framework/presentation/request-loading.ts';
@@ -46,7 +48,8 @@ export class LobbyPanel {
private cardValue:number|null=null;
private beanValue:number|null=null;
private readonly menuNodes=new Map<number,Node>();
private readonly taskTip:Node;
private readonly taskTip:Node;
private readonly buttonBar:Node;
private taskData:readonly TaskData[]|undefined;
private readonly requestLoading:RequestLoading;
private taskPending:'list'|'award'|null=null;
@@ -70,12 +73,18 @@ export class LobbyPanel {
private readonly nativeCall:(name:string,data:Record<string,unknown>)=>void,
private readonly vip?:{request:(codes:readonly VipCode[])=>void;join:(code:VipCode)=>void;describe:(roomtype: Roomtype)=>string},
private readonly invite?:{query:()=>void;bind:(code:string)=>void;paste:()=>Promise<string>},
private readonly extras?:{rank:()=>void;redeem:(cardno:string)=>void;paste:()=>Promise<string>;vipRank?:(limit:number)=>void;joinOwned?:(code:number)=>void;loading?:(show:boolean)=>void;
private readonly extras?:{settings?:PlatformSettings;rank:()=>void;redeem:(cardno:string)=>void;paste:()=>Promise<string>;vipRank?:(limit:number)=>void;joinOwned?:(code:number)=>void;loading?:(show:boolean)=>void;
room?:{createPrefab:Prefab;join(code:number):void;create(roomtype: Roomtype):void;busy():boolean;close():void}}){
this.requestLoading=new RequestLoading(show=>need(this.extras?.loading,'request Loading UI')(show));
this.model=selectLobbyModel(state);
if(assets.switches.length!==2)throw Error('LobbyAssets requires 2 switch frames');
this.menu=this.add(assets.menu,canvas);this.header=this.add(assets.header,canvas);this.noticeMenu=this.add(assets.noticeMenu,canvas);
this.menu=this.add(assets.menu,canvas);this.header=this.add(assets.header,canvas);this.noticeMenu=this.add(assets.noticeMenu,canvas);
this.buttonBar=requireNode(this.menu,'group-3/ButtonBar');
need(this.buttonBar.getComponent(Layout),'ButtonBar Layout');
for(const node of this.buttonBar.children){
const widget=node.getComponent(Widget);
if(widget)widget.isAlignLeft=widget.isAlignRight=widget.isAlignHorizontalCenter=false;
}
requireNode(this.header,'group-103').active=false;
requireNode(this.noticeMenu,'group-95').active=menuConfig.template.isGameHall;
for(const root of [this.menu,this.header,this.noticeMenu]){
@@ -89,7 +98,7 @@ export class LobbyPanel {
// Original JumpMenuScene hides these transient/obsolete fields.
for(const id of [157,497])requireNode(this.menu,`group-3/Spirit${id}`).active=false;
// updateNoticeBoard clears all old contact samples before rendering source values.
const vipRank=requireNode(this.menu,'group-3/VipRank/Spirit679');
const vipRank=requireNode(this.menu,'group-3/MiddleButton/VipRank/Spirit679');
for(const id of [180,193,194,682,178,179]){label(vipRank,`Spirit${id}`).string='';requireNode(vipRank,`Spirit${id}`).active=false;}
label(this.menu,'group-3/Spirit340').string='';requireNode(this.menu,'group-3/Spirit340').active=false;
label(this.menu,'group-3/Spirit681').string='客服信息';
@@ -104,10 +113,10 @@ export class LobbyPanel {
this.applyMenuState(lobbyMenuState(menuConfig,context));
need(requireNode(this.menu,'group-3/Announcement').getComponent(AnnouncementView),'AnnouncementView').show(policy.scroll);
for(const [id,locked] of Object.entries(menuConfig.locked)){
requireNode(this.menu,`group-3/Spirit${id}/Locked`).active=locked;
requireNode(this.menu,`group-3/MiddleButton/Spirit${id}/Locked`).active=locked;
}
if(policy.contacts.length>0)requireNode(vipRank,'Spirit16').active=false;
this.taskTip=requireNode(this.menu,'group-3/Spirit17/Tip');
this.taskTip=requireNode(this.menu,'group-3/ButtonBar/Spirit17/Tip');
need(this.taskTip.getComponent(Sprite),'Spirit17/Tip Sprite');
this.render(state);
const key=`tsgame_gameNoteTime_${config.identity.gameid}${config.identity.agentid}`;
@@ -160,7 +169,10 @@ export class LobbyPanel {
// A projection includes nodes from the adjacent notice layer. Missing IDs belonging to other layers are intentional.
for(const [id,active] of Object.entries(state.visible)){const node=this.menuNodes.get(Number(id));if(node)node.active=active;}
for(const id of new Set([...Object.keys(state.left),...Object.keys(state.top)])){
const node=this.menuNodes.get(Number(id));if(!node)continue;
const node=this.menuNodes.get(Number(id));if(!node)continue;
// ButtonBar now owns horizontal placement. Legacy hideSpid uses an offscreen
// top coordinate; remove that entry from the layout instead of placing it back.
if(node.parent===this.buttonBar){if(state.top[Number(id)]!==undefined)node.active=false;continue;}
const ui=need(node.getComponent(UITransform),`menu ${id} transform`);
const widget=node.getComponent(Widget);
// Original coordinates are top-left in a centered 1280x720 layout; retain the 1600x720 Cocos canvas.
@@ -175,8 +187,9 @@ export class LobbyPanel {
if(widget){widget.isAlignTop=false;widget.isAlignBottom=false;widget.isAlignVerticalCenter=true;widget.verticalCenter=y;}
node.setPosition(node.position.x,y,0);
}
if(widget)widget.updateAlignment();
}
if(widget)widget.updateAlignment();
}
need(this.buttonBar.getComponent(Layout),'ButtonBar Layout').updateLayout();
}
private add(prefab:Prefab|null,parent:Node):Node {
const root=instantiate(need(prefab,'prefab'));root.parent=parent;root.setPosition(0,0,0);root.active=true;
@@ -189,7 +202,7 @@ export class LobbyPanel {
const button=node.getComponent(Button)??node.addComponent(Button);
button.transition=Button.Transition.NONE;
const listener=()=>{if(this.disposed||!node.activeInHierarchy||!button.interactable||(!modal&&this.modal!==null)||!this.model.canAct)return;
try{action();}catch(error){this.tip(error instanceof Error?error.message:String(error));}};
try{action();}catch(error){console.error('[lobby-panel]',error);}};
node.on(Button.EventType.CLICK,listener);
(modal?this.modalReleases:this.releases).push(()=>{if(isValid(node))node.off(Button.EventType.CLICK,listener);});
}
@@ -256,7 +269,7 @@ export class LobbyPanel {
if(this.model.avatar==='')return;
assetManager.loadRemote<ImageAsset>(this.model.avatar,{ext:'.png'},(error,image)=>{
if(this.disposed||generation!==this.avatarGeneration)return;
if(error){this.tip(`头像加载失败:${error.message}`);return;}
if(error){console.error('[lobby-panel]',error);return;}
const texture=new Texture2D();texture.image=image;const frame=new SpriteFrame();frame.texture=texture;
this.avatarTexture=texture;this.avatarFrame=frame;sprite.spriteFrame=frame;
if(this.modalKind==='profile')this.renderProfile();
@@ -317,7 +330,7 @@ export class LobbyPanel {
need(requireNode(group,'Spirit639').getComponent(Sprite),'rank symbol').spriteFrame=this.assets.rankSymbols[frame-1];
this.bind(requireNode(group,'Spirit605'),()=>this.close(),true);
requireNode(group,'Spirit605/Spirit606').addComponent(BlockInputEvents);
const onError=(message:string)=>this.tip(`排行榜头像加载失败:${message}`);group.on('rank-image-error',onError);
const onError=(message:string)=>console.error('[lobby-panel] rank image',message);group.on('rank-image-error',onError);
const view=new RankListView(group,entries,this.policy.starName,this.assets.rankMedals,need(vip?this.assets.vipRankTitle:this.assets.rankTitle,'rank title'),need(this.assets.rankItem,'rankItem'),vip);
this.modalReleases.push(()=>{group.off('rank-image-error',onError);view.dispose();});
}
@@ -339,7 +352,7 @@ export class LobbyPanel {
this.bind(requireNode(group,'Spirit3265'),()=>{
if(pasting)return;pasting=true;
void need(this.extras,'card clipboard').paste().then(value=>{if(this.modal===modal&&this.model.canAct&&value)edit.string=value;})
.catch(error=>{if(this.modal===modal)this.tip(error instanceof Error?error.message:String(error));}).finally(()=>{pasting=false;});
.catch(error=>{if(this.modal===modal)console.error('[lobby-panel]',error);}).finally(()=>{pasting=false;});
},true);
this.bind(requireNode(group,'Spirit3266'),()=>{
const cardno=edit.string;if(cardno===''||/^[ ]+$/.test(cardno))return;
@@ -356,7 +369,7 @@ export class LobbyPanel {
sprite.sizeMode=Sprite.SizeMode.CUSTOM;sprite.spriteFrame=null;
assetManager.loadRemote<ImageAsset>(policy.url,{ext:'.png'},(error,image)=>{
if(this.disposed||this.modal!==modal||!isValid(sprite.node))return;
if(error){this.tip(`二维码图片加载失败:${error.message}`);return;}
if(error){console.error('[lobby-panel]',error);return;}
const texture=new Texture2D();texture.image=image;
const frame=new SpriteFrame();frame.texture=texture;sprite.spriteFrame=frame;
this.modalReleases.push(()=>{sprite.spriteFrame=null;frame.destroy();texture.destroy();});
@@ -399,15 +412,13 @@ export class LobbyPanel {
for(const name of ['申请解散','邀请好友'])requireNode(group,name).active=false;
this.bind(requireNode(group,'设置遮罩'),()=>this.close(),true);
this.bind(requireNode(group,'退出登录'),()=>{if(this.context.launchMode===1)this.tip('请返回游戏大厅退出登录');else this.logout();},true);
for(const [name,key] of [['音乐底',`tsgame_bgm_${this.config.identity.gameid}`],['音效底',`tsgame_sound_${this.config.identity.gameid}${this.config.identity.agentid}`]] as const){
const settings=need(this.extras?.settings,'platform settings');
for(const [name,key] of [['音乐底','bgm'],['音效底','sound'],['Spirit357','shake'],['Spirit388','voice']] as const){
const node=requireNode(group,name);
const animation=need(node.getComponent(OptionalSwitchAnimation),`${name} OptionalSwitchAnimation`);
const read=()=>{const raw=window.localStorage.getItem(key);if(raw===null){window.localStorage.setItem(key,'1');return true;}
if(raw!=='0'&&raw!=='1')throw Error(`Invalid ${key}`);return raw==='1';};
const render=(animate:boolean)=>{const on=read();animation.setState(on,this.assets.switches[on?0:1],animate);};render(false);
this.bind(node,()=>{window.localStorage.setItem(key,read()?'0':'1');render(true);},true);
}
for(const name of ['Spirit357','Spirit388'])this.bind(requireNode(group,name),()=>this.tip('原生震动与语音开关尚未接入'),true);
const render=(animate:boolean)=>{const on=settings.read(key);animation.setState(on,this.assets.switches[on?0:1],animate);};render(false);
this.bind(node,()=>{try{settings.toggle(key);}finally{render(true);}},true);
}
}
openNotice():void {
const modal=this.open(this.assets.noticeBoard,'notice','group-12'),group=requireNode(modal,'group-12');
@@ -453,7 +464,7 @@ export class LobbyPanel {
const sprite=need(imageNode.getComponent(Sprite),'notice image');sprite.spriteFrame=null;
assetManager.loadRemote<ImageAsset>(notice.image,{ext:'.png'},(error,image)=>{
if(this.disposed||this.modal!==modal||generation!==pageGeneration||!isValid(imageNode))return;
if(error){this.tip(`公告图片加载失败:${error.message}`);return;}
if(error){console.error('[lobby-panel]',error);return;}
const texture=new Texture2D();texture.image=image;const frame=new SpriteFrame();frame.texture=texture;sprite.sizeMode=Sprite.SizeMode.CUSTOM;sprite.spriteFrame=frame;
need(imageNode.getComponent(UITransform),'notice image transform').setContentSize(725,notice.height);contentUI.height=Math.max(450,notice.height);scroll.scrollToTop(0);
releaseImage=()=>{sprite.spriteFrame=null;frame.destroy();texture.destroy();};
@@ -470,7 +481,7 @@ export class LobbyPanel {
const saved=window.localStorage.getItem('tsgame_shortcode_'+this.config.identity.gameid+this.config.identity.agentid);
const current=()=>this.modal===modal&&this.roomPage===view&&this.model.canAct;
view.open({initialCode:saved===null?'':saved,submit:code=>{if(current())commands.join(code);},cancel:()=>{if(this.modal===modal)this.close();},
paste:()=>need(this.extras,'clipboard').paste(),reportError:text=>this.tip(text)});
paste:()=>need(this.extras,'clipboard').paste(),reportError:text=>console.error('[JoinRoomView]',text)});
view.setBusy(commands.busy());
}
openCreate():void {
@@ -480,10 +491,10 @@ export class LobbyPanel {
const text=window.localStorage.getItem('tsgame_roomtype_'+this.config.identity.gameid+this.config.identity.agentid);
let previous:unknown=null;
if(text!==null&&text!==''){
const saved=JSON.parse(text);if(!saved||!(typeof saved.data==='string'||Array.isArray(saved.data)))throw Error('Invalid saved roomtype');previous=saved.data;
const saved=JSON.parse(text);previous=requireRoomtype(saved?.data,'saved.roomtype');
}
const current=()=>this.modal===modal&&this.roomPage===page&&this.model.canAct;
page.open({previousRoomtype:previous,submit:roomtype=>{if(current())commands.create(roomtype);},cancel:()=>{if(this.modal===modal)this.close();},reportError:text=>{if(current())this.tip(text);}});
page.open({previousRoomtype:previous,submit:roomtype=>{if(current())commands.create(roomtype);},cancel:()=>{if(this.modal===modal)this.close();},reportError:text=>{if(current())console.error('[CreateRoomPage]',text);}});
page.setBusy(commands.busy());
}
setRoomBusy(busy:boolean):void{this.roomPage?.setBusy(busy);}
@@ -499,7 +510,7 @@ export class LobbyPanel {
const node=requireNode(modal,'group-11/Spirit345'),sprite=need(node.getComponent(Sprite),'share background');
assetManager.loadRemote<ImageAsset>(policy.background,{ext:'.png'},(error,image)=>{
if(this.disposed||this.modal!==modal||!isValid(node))return;
if(error){this.tip(`分享背景加载失败:${error.message}`);return;}
if(error){console.error('[lobby-panel]',error);return;}
const texture=new Texture2D();texture.image=image;const frame=new SpriteFrame();frame.texture=texture;sprite.spriteFrame=frame;
this.modalReleases.push(()=>{sprite.spriteFrame=null;frame.destroy();texture.destroy();});
});
@@ -518,21 +529,21 @@ export class LobbyPanel {
this.bind(requireNode(group,'Spirit3184'),()=>this.openCreate(),true);
this.bind(requireNode(group,'Spirit3240'),()=>this.openJoin(),true);
this.bind(requireNode(group,'Spirit3231'),()=>this.requestVipRank(),true);
this.bind(requireNode(group,'Spirit3199'),()=>this.tip(`房间 ${view.selectedCode}:短房号排行模块尚未接入`),true);
for(const id of [3194,3195,3214])this.bind(requireNode(group,`Spirit${id}`),()=>this.tip('黑名单管理模块尚未接入'),true);
for(const id of [3196,3197,3215])this.bind(requireNode(group,`Spirit${id}`),()=>this.tip('统一魅力值设置模块尚未接入'),true);
this.bind(requireNode(group,'Spirit3235'),()=>this.tip('好友管理模块尚未接入'),true);
this.bind(requireNode(group,'Spirit3199'),()=>console.error('[lobby-panel]',`房间 ${view.selectedCode}:短房号排行模块尚未接入`),true);
for(const id of [3194,3195,3214])this.bind(requireNode(group,`Spirit${id}`),()=>console.error('[lobby-panel] 黑名单管理模块尚未接入'),true);
for(const id of [3196,3197,3215])this.bind(requireNode(group,`Spirit${id}`),()=>console.error('[lobby-panel] 统一魅力值设置模块尚未接入'),true);
this.bind(requireNode(group,'Spirit3235'),()=>console.error('[lobby-panel] 好友管理模块尚未接入'),true);
if(advanced){
label(group,'Spirit3198').string='(黑名单内玩家临时禁入房间,请尽快到后台处理好友关系)';
requireNode(group,'Spirit3235').active=!!this.config.remoteConfig.getValue('managerUrl');
}
const join=(code:VipCode)=>{if(this.model.canAct&&this.modal===modal){try{port.join(code);}catch(e){this.tip(e instanceof Error?e.message:String(e));}}};
const join=(code:VipCode)=>{if(this.model.canAct&&this.modal===modal){try{port.join(code);}catch(e){console.error('[lobby-panel]',e);}}};
modal.on('vip-join',join);this.modalReleases.push(()=>modal.off('vip-join',join));
const refresh=()=>{if(this.model.canAct&&this.modal===modal)port.request(this.vipHistory().read());};
const raw=this.config.remoteConfig.getValue('vipFlushTime');
// GameData.sysConfig defines the optional refresh setting as 10000 ms.
const delay=raw==null?10000:Number(raw);if(!Number.isFinite(delay)||delay<=0)throw Error('Invalid vipFlushTime');
const timer=setInterval(()=>{try{if(this.vipHistory().read().length>0)refresh();}catch(e){this.tip(e instanceof Error?e.message:String(e));}},delay);
const timer=setInterval(()=>{try{if(this.vipHistory().read().length>0)refresh();}catch(e){console.error('[lobby-panel]',e);}},delay);
this.modalReleases.push(()=>clearInterval(timer));
if(this.vipData)this.renderVipRooms(this.vipData);
refresh();
@@ -563,7 +574,7 @@ export class LobbyPanel {
if(this.modalReleases.length===0){
for(const id of [112,115])this.bind(requireNode(modal,`group-10/Spirit${id}`),()=>this.close(),true);
const claim=(taskid:number|string)=>{if(this.model.canAct&&!this.disposed&&this.modal===modal){
try{this.requestTask('award',()=>this.claimTask(taskid));}catch(error){this.tip(error instanceof Error?error.message:String(error));}
try{this.requestTask('award',()=>this.claimTask(taskid));}catch(error){console.error('[lobby-panel]',error);}
}};
const perform=()=>{if(this.model.canAct&&!this.taskPending&&this.modal===modal){this.close();this.openShare();}};
modal.on('task-claim',claim);modal.on('task-do',perform);
@@ -600,7 +611,7 @@ export class LobbyPanel {
if(pasting)return;pasting=true;
void need(this.invite,'invite commands').paste().then(text=>{
if(!this.disposed&&this.model.canAct&&this.modal===modal)edit.string=text;
}).catch(error=>{if(!this.disposed&&this.modal===modal)this.tip(error instanceof Error?error.message:String(error));}).finally(()=>{pasting=false;});
}).catch(error=>{if(!this.disposed&&this.modal===modal)console.error('[lobby-panel]',error);}).finally(()=>{pasting=false;});
},true);
this.bind(requireNode(group,'Spirit367'),()=>{
const text=edit.string;edit.blur();this.close();
@@ -45,7 +45,7 @@ export class LoginPanel {
const sprite=requireNode(logining,'登录中头像').getComponent(Sprite);if(!sprite)throw Error('登录中头像 requires Sprite');
assetManager.loadRemote<ImageAsset>(cached.avatar,{ext:'.png'},(error,image)=>{
if(this.disposed||!isValid(sprite))return;
if(error){this.tip(`登录头像加载失败:${error.message}`);return;}
if(error){console.error('[login-panel]',error);return;}
const texture=new Texture2D();texture.image=image;const frame=new SpriteFrame();frame.texture=texture;sprite.spriteFrame=frame;
this.releases.push(()=>{if(isValid(sprite))sprite.spriteFrame=null;frame.destroy();texture.destroy();});
});
@@ -62,7 +62,7 @@ export class LoginPanel {
this.bind(requireNode(group,'隐藏快速登录-2'),()=>accounts.hiddenSecond());
}
private bind(node:Node,action:()=>void,modal=false){
const listener=()=>{if(this.disposed||!this.canAct())return;try{action();}catch(error){this.tip(error instanceof Error?error.message:String(error));}};
const listener=()=>{if(this.disposed||!this.canAct())return;try{action();}catch(error){console.error('[login-panel]',error);}};
node.on(Node.EventType.TOUCH_END,listener);
(modal?this.modalReleases:this.releases).push(()=>node.off(Node.EventType.TOUCH_END,listener));
}
@@ -13,7 +13,8 @@ export class SceneFlow {
if(this.disposed)throw Error('SceneFlow disposed');
await bind();
});
this.pending=next;
// A failed destination must not permanently poison later, explicitly requested transitions.
this.pending=next.catch(()=>{});
return next;
}
enterOnce(name:string,bind:()=>void|Promise<void>):Promise<void>{