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:
+50
@@ -0,0 +1,50 @@
|
||||
import type {NumericToolsPort} from '../sdk/contracts/game-capabilities.ts';
|
||||
|
||||
type InputRequest=Parameters<NumericToolsPort['openNumberInput']>[0];
|
||||
type MultiplierRequest=Parameters<NumericToolsPort['showMultiplier']>[0];
|
||||
type PendingRequest={kind:'input';request:InputRequest}|{kind:'multiplier';request:MultiplierRequest};
|
||||
|
||||
/** Defers game attach/restore requests until the destination room Canvas is ready. */
|
||||
export class DeferredNumericToolsPort implements NumericToolsPort {
|
||||
private target:NumericToolsPort|null=null;
|
||||
private readonly pending=new Map<PendingRequest['kind'],PendingRequest>();
|
||||
|
||||
bind(target:NumericToolsPort):void {
|
||||
this.target=target;
|
||||
while(this.target===target&&this.pending.size>0){
|
||||
const next=this.pending.values().next().value as PendingRequest;
|
||||
this.pending.delete(next.kind);
|
||||
if(next.kind==='input')target.openNumberInput(next.request);
|
||||
else target.showMultiplier(next.request);
|
||||
}
|
||||
}
|
||||
/** A scene transition drops the old view, retaining requests queued for its replacement. */
|
||||
unbind():void {this.target=null;}
|
||||
openNumberInput(request:InputRequest):void {
|
||||
if(this.target!==null){this.target.openNumberInput(request);return;}
|
||||
this.enqueue({kind:'input',request});
|
||||
}
|
||||
closeNumberInput():void {
|
||||
const pending=this.pending.get('input');this.pending.delete('input');pending?.request.onCancel?.();this.target?.closeNumberInput();
|
||||
}
|
||||
showMultiplier(request:MultiplierRequest):void {
|
||||
if(this.target!==null){this.target.showMultiplier(request);return;}
|
||||
this.enqueue({kind:'multiplier',request});
|
||||
}
|
||||
hideMultiplier():void {
|
||||
const pending=this.pending.get('multiplier');this.pending.delete('multiplier');pending?.request.onCancel?.();this.target?.hideMultiplier();
|
||||
}
|
||||
/** Lease invalidation also releases queued callbacks and prevents new work reaching an old view. */
|
||||
close():void {
|
||||
const pending=[...this.pending.values()];this.pending.clear();
|
||||
const target=this.target;this.unbind();const errors:unknown[]=[];
|
||||
for(const entry of pending){try{entry.request.onCancel?.();}catch(error){errors.push(error);}}
|
||||
try{target?.close();}catch(error){errors.push(error);}
|
||||
if(errors.length===1)throw errors[0];
|
||||
if(errors.length>1)throw Object.assign(new Error('Numeric tools cleanup failed'),{errors:Object.freeze(errors)});
|
||||
}
|
||||
private enqueue(pending:PendingRequest):void {
|
||||
// Replacing a request moves it to the end, preserving which tool should be on top.
|
||||
const previous=this.pending.get(pending.kind);this.pending.delete(pending.kind);previous?.request.onCancel?.();this.pending.set(pending.kind,pending);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "0c3a8ee5-c1b7-4694-a926-26fae90f5e55",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import type {PlatformGameSnapshot} from '../sdk/contracts/snapshots.ts';
|
||||
import type {GameRoomProjection} from '../sdk/contracts/game-room-projection.ts';
|
||||
|
||||
/** Keep authority and game facts separate; composition never mutates either owner. */
|
||||
export function composeGameRoomPresentation(platform:PlatformGameSnapshot,game:GameRoomProjection|null){
|
||||
return Object.freeze({platform,game});
|
||||
}
|
||||
|
||||
export {gameAllowsRoomAction} from '../sdk/room-action-policy.ts';
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d30b033d-a7fc-4e44-8d16-b342d0b3e3af",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface KeyedRowPort<T,R>{key(item:T):number;create(item:T,index:number):R;update(row:R,item:T,index:number):void;remove(row:R):void;}
|
||||
/** Stable identity belongs to the source; this reconciler never derives keys from content. */
|
||||
export class KeyedRows<T,R>{
|
||||
private readonly rows=new Map<number,{item:T;row:R;index:number}>();
|
||||
constructor(private readonly port:KeyedRowPort<T,R>){}
|
||||
get(key:number):R|undefined{return this.rows.get(key)?.row;}
|
||||
render(items:readonly T[]):void{
|
||||
const keys=new Set<number>();
|
||||
for(const item of items){const key=this.port.key(item);if(keys.has(key))throw Error('Duplicate row ID '+key);keys.add(key);}
|
||||
for(const [key,value]of this.rows)if(!keys.has(key)){this.rows.delete(key);this.port.remove(value.row);}
|
||||
items.forEach((item,index)=>{const key=this.port.key(item),prior=this.rows.get(key);
|
||||
if(!prior)this.rows.set(key,{item,index,row:this.port.create(item,index)});
|
||||
else if(prior.item!==item||prior.index!==index){this.port.update(prior.row,item,index);prior.item=item;prior.index=index;}
|
||||
});
|
||||
}
|
||||
dispose():void{const errors:unknown[]=[];for(const value of this.rows.values())try{this.port.remove(value.row);}catch(error){errors.push(error);}this.rows.clear();if(errors.length)throw Object.assign(Error('Row cleanup failed'),{errors});}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d565fcd4-07ea-4269-a7d3-6fae0c3f4266",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface MultiplierOptions {
|
||||
readonly originalValues:readonly number[];
|
||||
readonly onConfirm:(values:number[])=>void;
|
||||
readonly storage:Pick<Storage,'getItem'|'setItem'>;
|
||||
readonly gameid:string|number;
|
||||
readonly agentid:string|number;
|
||||
}
|
||||
|
||||
/** Layer607 calculator owns the legacy saved preference's default/repair semantics. */
|
||||
export class MultiplierModel {
|
||||
private value:string;
|
||||
private readonly originalValues:readonly number[];
|
||||
private readonly key:string;
|
||||
constructor(private readonly options:MultiplierOptions){
|
||||
if(!Array.isArray(options.originalValues)||!options.originalValues.every(Number.isFinite))throw TypeError('Calculator requires finite original values');
|
||||
if(typeof options.onConfirm!=='function')throw TypeError('Calculator requires a result callback');
|
||||
this.originalValues=[...options.originalValues];
|
||||
this.key=`tsgame_multiple_${options.gameid}${options.agentid}`;
|
||||
const saved=options.storage.getItem(this.key);
|
||||
if(saved!==null&&/^\d+(?:\.\d*)?$/.test(saved)&&Number.isFinite(Number(saved))&&Number(saved)>0){
|
||||
this.value=String(Number(saved)).replace('.','b');
|
||||
}else{
|
||||
this.value='1';options.storage.setItem(this.key,'1');
|
||||
}
|
||||
}
|
||||
get text():string{return this.value;}
|
||||
get multiplier():number{return Number(this.value.replace('b','.'));}
|
||||
inputDigit(digit:number):string|null {
|
||||
if(!Number.isInteger(digit)||digit<0||digit>9)throw RangeError('Invalid multiplier digit');
|
||||
return this.append(String(digit));
|
||||
}
|
||||
inputDecimal():string|null{return this.append('b');}
|
||||
backspace():void{this.value=this.value.length>1?this.value.slice(0,-1):'0';}
|
||||
confirm():number[] {
|
||||
const multiplier=this.multiplier===0?1:this.multiplier;
|
||||
const result=this.originalValues.map(value=>decimalMultiply(value,multiplier));
|
||||
this.options.storage.setItem(this.key,String(multiplier));
|
||||
this.options.onConfirm(result);
|
||||
this.value=String(multiplier).replace('.','b');
|
||||
return result;
|
||||
}
|
||||
private append(input:string):string|null {
|
||||
const next=this.value==='0'&&input!=='b'?input:this.value+input;
|
||||
if(next.length>8)return '输入长度超过限制!';
|
||||
if(!/^\d+(?:b\d*)?$/.test(next))return '输入不正确!';
|
||||
this.value=next;return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Integer coefficient arithmetic retains Func.accMul's decimal precision, including exponent notation. */
|
||||
function decimalMultiply(left:number,right:number):number {
|
||||
const parts=(value:number):{coefficient:number;exponent:number}=>{
|
||||
const [mantissa,exponentText]=String(value).split('e');
|
||||
const decimals=mantissa.includes('.')?mantissa.length-mantissa.indexOf('.')-1:0;
|
||||
return {coefficient:Number(mantissa.replace('.','')),exponent:(exponentText===undefined?0:Number(exponentText))-decimals};
|
||||
};
|
||||
const a=parts(left),b=parts(right);
|
||||
const result=Number(`${a.coefficient*b.coefficient}e${a.exponent+b.exponent}`);
|
||||
if(!Number.isFinite(result))throw RangeError('Calculator result exceeds finite number range');
|
||||
return result;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "104f8058-384f-4221-bfa0-aff820f7ebe6",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface NumericInputOptions {
|
||||
readonly message:string;
|
||||
/** Legacy openInputPanel omitting begin0 selects numeric mode. */
|
||||
readonly begin0?:boolean;
|
||||
readonly onConfirm:(value:string|number)=>void;
|
||||
}
|
||||
|
||||
/** Layer602 input state. Confirmation intentionally leaves dismissal to its caller. */
|
||||
export class NumericInputModel {
|
||||
private value:string;
|
||||
constructor(private readonly options:NumericInputOptions){
|
||||
if(typeof options.message!=='string'||typeof options.onConfirm!=='function')throw TypeError('Invalid numeric input request');
|
||||
this.value=options.begin0?'':'0';
|
||||
}
|
||||
get message():string{return this.options.message;}
|
||||
get text():string{return this.value;}
|
||||
inputDigit(digit:number):string|null {
|
||||
if(!Number.isInteger(digit)||digit<0||digit>9)throw RangeError('Invalid input digit');
|
||||
if(this.value.length>=18)return '输入超出范围';
|
||||
this.value=!this.options.begin0&&this.value==='0'?String(digit):this.value+digit;
|
||||
return null;
|
||||
}
|
||||
backspace():void {
|
||||
this.value=this.value.length===1?(this.options.begin0?'':'0'):this.value.slice(0,-1);
|
||||
}
|
||||
paste(text:string):string|null {
|
||||
if(!text)return null;
|
||||
const digits=text.replace(/[^0-9]/g,'');
|
||||
if(digits.length>18)return '输入超出范围';
|
||||
this.value=digits;return null;
|
||||
}
|
||||
confirm():string|number {
|
||||
const result=this.options.begin0?this.value:Number(this.value);
|
||||
this.options.onConfirm(result);return result;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "70c4afff-ffe3-4598-899f-f07c4d80e1b8",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {FeedbackOwnerPort,OwnerHandle} from '../sdk/contracts/game-capabilities.ts';
|
||||
|
||||
/** One arbiter for the real loading/tip surface, including existing platform callers. */
|
||||
export class OwnedFeedback implements FeedbackOwnerPort {
|
||||
private disposed=false;
|
||||
private platformLoading=false;
|
||||
private readonly loadingOwners=new Set<object>();
|
||||
private tipOwner:object|null=null;
|
||||
constructor(private readonly render:{loading(show:boolean):void;tip(message:string,time:number):void;closeTip():void}){}
|
||||
setPlatformLoading(show:boolean):void{this.active();this.platformLoading=show;this.render.loading(show||this.loadingOwners.size>0);}
|
||||
beginLoading():OwnerHandle {
|
||||
this.active();const owner={};this.loadingOwners.add(owner);
|
||||
try{this.render.loading(true);}catch(error){this.loadingOwners.delete(owner);throw error;}
|
||||
return Object.freeze({close:()=>{if(!this.loadingOwners.delete(owner)||this.disposed)return;this.render.loading(this.platformLoading||this.loadingOwners.size>0);}});
|
||||
}
|
||||
showBusinessTip(message:string,time:number):OwnerHandle {
|
||||
if(!Number.isFinite(time))throw TypeError('Business tip requires finite display time');
|
||||
return this.showPlatformTip(message,time);
|
||||
}
|
||||
/** Legacy show_message uses Number(time): non-positive/NaN means no automatic dismissal. */
|
||||
showPlatformTip(message:string,time:number):OwnerHandle {
|
||||
this.active();if(typeof message!=='string'||message.length===0||typeof time!=='number')throw TypeError('Business tip requires text and display time');
|
||||
const owner={};this.tipOwner=owner;
|
||||
try{this.render.tip(message,time);}catch(error){if(this.tipOwner===owner)this.tipOwner=null;throw error;}
|
||||
return Object.freeze({close:()=>{if(this.disposed||this.tipOwner!==owner)return;this.tipOwner=null;this.render.closeTip();}});
|
||||
}
|
||||
dismissTip():void{if(this.disposed)return;this.tipOwner=null;this.render.closeTip();}
|
||||
clear():void {
|
||||
this.platformLoading=false;this.loadingOwners.clear();this.tipOwner=null;const errors:unknown[]=[];
|
||||
try{this.render.loading(false);}catch(error){errors.push(error);}
|
||||
try{this.render.closeTip();}catch(error){errors.push(error);}
|
||||
if(errors.length)throw Object.assign(new Error('Feedback cleanup failed'),{errors:Object.freeze(errors)});
|
||||
}
|
||||
dispose():void{if(this.disposed)return;this.disposed=true;this.clear();}
|
||||
private active():void{if(this.disposed)throw Error('Feedback owner is disposed');}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4579cc6c-b016-4b55-902c-35887b3dce31",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {callPlatformSetting,type PlatformNativeHost} from '../adapters/native/platform-native.ts';
|
||||
|
||||
export type PlatformSetting='bgm'|'sound'|'shake'|'voice';
|
||||
|
||||
/** Utl.Config + Logic setting actions. The source defines absent preferences as on. */
|
||||
export class PlatformSettings {
|
||||
private readonly keys:Record<PlatformSetting,string>;
|
||||
constructor(private readonly storage:Pick<Storage,'getItem'|'setItem'>,gameid:string|number,agentid:string|number,
|
||||
private readonly host:PlatformNativeHost){
|
||||
this.keys={bgm:`tsgame_bgm_${gameid}`,sound:`tsgame_sound_${gameid}${agentid}`,
|
||||
shake:`tsgame_shake_${gameid}${agentid}`,voice:`tsgame_voice_${gameid}${agentid}`};
|
||||
}
|
||||
read(setting:PlatformSetting):boolean {
|
||||
const key=this.keys[setting],raw=this.storage.getItem(key);
|
||||
if(raw===null){this.storage.setItem(key,'1');return true;}
|
||||
if(raw!=='0'&&raw!=='1')throw Error(`Invalid ${key}`);
|
||||
return raw==='1';
|
||||
}
|
||||
/** Logic initialization reapplies the saved voice preference to each native session. */
|
||||
synchronizeVoice():void {callPlatformSetting(this.host,'voicePlaying',this.read('voice')?1:0);}
|
||||
toggle(setting:PlatformSetting):boolean {
|
||||
const on=!this.read(setting);
|
||||
this.storage.setItem(this.keys[setting],on?'1':'0');
|
||||
if(setting==='voice')callPlatformSetting(this.host,'voicePlaying',on?1:0);
|
||||
if(setting==='sound')callPlatformSetting(this.host,'SwitchShake',on?1:0);
|
||||
if(setting==='shake'){
|
||||
// The lobby has no active game shake action (legacy shakeID == nil).
|
||||
if(on)callPlatformSetting(this.host,'SwitchShake',this.read('sound')?1:0);
|
||||
else callPlatformSetting(this.host,'stopshake');
|
||||
}
|
||||
return on;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "26177acb-fa14-427a-abfe-716837c7bba0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
export interface ResourceLease<T>{readonly value:Promise<T>;release():void;}
|
||||
interface Entry<T>{promise:Promise<T>;refs:number;value?:T;loaded:boolean;}
|
||||
export class ResourceLeaseCache<T>{
|
||||
private readonly entries=new Map<string,Entry<T>>();
|
||||
constructor(private readonly options:{idleCapacity:number;load:(key:string)=>Promise<T>;destroy:(value:T)=>void}){
|
||||
if(!Number.isSafeInteger(options.idleCapacity)||options.idleCapacity<0)throw Error('Invalid idle resource capacity');
|
||||
}
|
||||
get size():number{return this.entries.size;}
|
||||
get leases():number{let count=0;for(const e of this.entries.values())count+=e.refs;return count;}
|
||||
acquire(key:string):ResourceLease<T>{
|
||||
let entry=this.entries.get(key);
|
||||
if(!entry){
|
||||
const fresh:Entry<T>={refs:0,loaded:false,promise:undefined as unknown as Promise<T>};entry=fresh;this.entries.set(key,fresh);
|
||||
try{fresh.promise=this.options.load(key).then(value=>{fresh.loaded=true;fresh.value=value;this.trim();return value;},error=>{if(this.entries.get(key)===fresh)this.entries.delete(key);throw error;});}
|
||||
catch(error){this.entries.delete(key);throw error;}
|
||||
}
|
||||
entry.refs++;let released=false;const owned=entry;
|
||||
return {value:entry.promise,release:()=>{if(released)return;released=true;owned.refs--;this.trim();}};
|
||||
}
|
||||
clear():void{for(const [key,entry]of this.entries)if(entry.refs===0&&entry.loaded){this.entries.delete(key);this.options.destroy(entry.value as T);}}
|
||||
private trim():void{
|
||||
const idle=[...this.entries].filter(([,e])=>e.refs===0&&e.loaded);
|
||||
for(let i=0;i<idle.length-this.options.idleCapacity;i++){const [key,entry]=idle[i];this.entries.delete(key);this.options.destroy(entry.value as T);}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "01f41cce-b7ad-4441-b4fa-128811f7f70f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import {roomChatByteLength} from './room-chat-config.ts';
|
||||
|
||||
/** Game_Surface_3 02_Const.Broadcast: pixels per byte and pixels per millisecond. */
|
||||
export const ROOM_BROADCAST = Object.freeze({textWidth:18,speed:100});
|
||||
export interface RoomBroadcastFrame {
|
||||
readonly text:string;
|
||||
readonly textWidth:number;
|
||||
readonly offsetX:number;
|
||||
readonly durationSeconds:number;
|
||||
readonly elapsedSeconds:number;
|
||||
}
|
||||
|
||||
/** Layer402/390 marquee. Its owner admits messages only while in the room. */
|
||||
export class RoomBroadcastQueue {
|
||||
private readonly pending:string[]=[];
|
||||
private frame:RoomBroadcastFrame|null=null;
|
||||
constructor(private readonly clipWidth:number){
|
||||
if(!Number.isFinite(clipWidth)||clipWidth<=0)throw Error('Broadcast clip width must be positive');
|
||||
}
|
||||
get current():RoomBroadcastFrame|null{return this.frame;}
|
||||
enqueue(text:string):void {
|
||||
if(typeof text!=='string')throw Error('Broadcast text must be a string');
|
||||
this.pending.push(text);
|
||||
if(this.frame===null)this.startNext();
|
||||
}
|
||||
advance(deltaSeconds:number):void {
|
||||
if(!Number.isFinite(deltaSeconds)||deltaSeconds<0)throw Error('Broadcast elapsed time must be nonnegative');
|
||||
while(this.frame!==null){
|
||||
const remaining=this.frame.durationSeconds-this.frame.elapsedSeconds;
|
||||
if(deltaSeconds<remaining){
|
||||
const elapsedSeconds=this.frame.elapsedSeconds+deltaSeconds;
|
||||
this.frame=Object.freeze({...this.frame,elapsedSeconds,offsetX:this.clipWidth-elapsedSeconds*ROOM_BROADCAST.speed});
|
||||
return;
|
||||
}
|
||||
deltaSeconds-=remaining;
|
||||
this.startNext();
|
||||
}
|
||||
}
|
||||
clear():void {this.pending.length=0;this.frame=null;}
|
||||
private startNext():void {
|
||||
const text=this.pending.shift();
|
||||
if(text===undefined){this.frame=null;return;}
|
||||
const textWidth=roomChatByteLength(text)*ROOM_BROADCAST.textWidth;
|
||||
this.frame=Object.freeze({text,textWidth,offsetX:this.clipWidth,durationSeconds:(textWidth+this.clipWidth)/ROOM_BROADCAST.speed,elapsedSeconds:0});
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c22a2ea0-b39e-4b44-b771-70700994da31",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {RemoteConfig} from '../config/remote-config.ts';
|
||||
import type {RoomChatConfig,RoomChatPhrase} from '../sdk/contracts/room-chat.ts';
|
||||
export type {RoomChatConfig,RoomChatPhrase} from '../sdk/contracts/room-chat.ts';
|
||||
/** Incoming send_text type 3 uses a 1-based phrase index; outgoing quick chat uses text. */
|
||||
export function resolveRoomChatPhrase(config:RoomChatConfig,index:number):RoomChatPhrase {
|
||||
if(!Number.isInteger(index)||index<1||config.phrases.length===0)throw Error(`Invalid room chat phrase index: ${index}`);
|
||||
return config.phrases[(index-1)%config.phrases.length];
|
||||
}
|
||||
|
||||
/** Game_Surface_3 Func.gblen, for visual text measurement only. */
|
||||
export function roomChatByteLength(text:string):number {
|
||||
let length=0;
|
||||
for(let index=0;index<text.length;index++)length+=text.charCodeAt(index)>127||text.charCodeAt(index)===94?2:1;
|
||||
return length;
|
||||
}
|
||||
export function validateRoomChatText(config:RoomChatConfig,text:string,announcement=false):void {
|
||||
if(typeof text!=='string')throw Error('Invalid chat text');
|
||||
if(text===''||/^[ ]+$/.test(text))throw Error('请输入聊天内容');
|
||||
const limit=announcement?config.announcementLimit:config.limitLength;
|
||||
if(text.length>limit)throw Error(`聊天内容不能超过${limit}个字符`);
|
||||
if(config.bannedWords.some(word=>word!==''&&text.indexOf(word)>=0))throw Error('内容含有敏感词汇请重新输入!');
|
||||
}
|
||||
/** Source defaults from 12_Logic.getConfig_Succ, not consumer fallbacks. */
|
||||
export function resolveRoomChatPolicy(remote:RemoteConfig):{noticeLimit:number;noticeCost:number} {
|
||||
const limit=remote.getValue('noticelimit'),cost=remote.getValue('noticecost');
|
||||
const noticeLimit=limit==null?-1:limit,noticeCost=cost?cost:0;
|
||||
if(typeof noticeLimit!=='number'||!Number.isFinite(noticeLimit))throw Error('Invalid noticelimit');
|
||||
if(typeof noticeCost!=='number'||!Number.isFinite(noticeCost))throw Error('Invalid noticecost');
|
||||
return {noticeLimit,noticeCost};
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "86b1833e-8e0d-490d-9431-aa99238ecb78",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {roomExitAction} from '../sdk/room-exit-action.ts';
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c70435c2-745c-48aa-9615-8bc7bfc6ee3d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type {InsideRoomState} from '../platform/stores/platform-types.ts';
|
||||
|
||||
export function roomHudCycles(room:Pick<InsideRoomState,'infinite'|'matchId'>):boolean {
|
||||
return room.infinite!==1&&!room.matchId;
|
||||
}
|
||||
export function roomHudTime(now:Date):{hours:string;minutes:string} {
|
||||
if(!Number.isFinite(now.getTime()))throw Error('Invalid room HUD clock date');
|
||||
return {hours:String(now.getHours()).padStart(2,'0'),minutes:String(now.getMinutes()).padStart(2,'0')};
|
||||
}
|
||||
/** 11_GameUI playTopInfoAnimation and timer 3, driven by the Cocos update clock. */
|
||||
export class RoomHudClock {
|
||||
showRoomCode=false;
|
||||
colonOpacity:255|100=255;
|
||||
private seconds=0;
|
||||
private cycleSeconds=0;
|
||||
reset():void{this.seconds=0;this.cycleSeconds=0;this.showRoomCode=false;this.colonOpacity=255;}
|
||||
update(dt:number,visible:boolean,cycles:boolean):void {
|
||||
if(!Number.isFinite(dt)||dt<0)throw Error('Invalid room HUD elapsed time');
|
||||
if(!visible)return;
|
||||
this.seconds+=dt;
|
||||
const ticks=Math.floor(this.seconds);this.seconds-=ticks;
|
||||
if(ticks%2!==0)this.colonOpacity=this.colonOpacity===255?100:255;
|
||||
if(!cycles){this.showRoomCode=false;this.cycleSeconds=0;return;}
|
||||
this.cycleSeconds+=dt;
|
||||
const cycleTicks=Math.floor(this.cycleSeconds/5);this.cycleSeconds-=cycleTicks*5;
|
||||
if(cycleTicks%2!==0)this.showRoomCode=!this.showRoomCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "15fa0855-2809-472f-b634-b8daada8ac90",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {RuntimeConfig} from '../config/runtime-config.ts';
|
||||
import type {PlatformState} from '../platform/stores/platform-types.ts';
|
||||
import {selectSelfPlayer} from '../platform/stores/selectors.ts';
|
||||
import {resolveLobbyPolicy} from './lobby-model.ts';
|
||||
import {resolveSharePolicy} from './share-model.ts';
|
||||
import {buildRoomMenuModel,type RoomMenuContext} from './room-menu-model.ts';
|
||||
|
||||
import type {GameDefinition} from '../sdk/contracts/game-definition.ts';
|
||||
|
||||
/** Call when opening Layer413; missing protocol metadata must not stop the HUD. */
|
||||
export function resolveRoomMenuContext(state:PlatformState,config:RuntimeConfig,policy:GameDefinition['roomMenu'],
|
||||
host:{h5Version:number;versionState:number}):RoomMenuContext {
|
||||
if(state.room.kind!=='inside')throw Error('Room menu requires inside room');
|
||||
if(!policy||typeof policy.mainSceneButton!=='boolean'||typeof policy.vipInfinite!=='boolean')throw Error('Invalid game room menu policy');
|
||||
const room=state.room,self=selectSelfPlayer(state);
|
||||
if(!self)throw Error('Room menu requires self player');
|
||||
for(const key of ['status','canexit','roomMode','shortcode','matchId'] as const){
|
||||
if(room[key]===undefined)throw Error(`Missing authoritative room source: ${key}`);
|
||||
}
|
||||
const rawHideEscape=config.remoteConfig.getValue('hideEscape');
|
||||
// 04_Data.sysConfig.hideEscape starts at zero; only non-null remote overrides.
|
||||
const hideEscape=rawHideEscape==null?false:Boolean(rawHideEscape);
|
||||
const lobby=resolveLobbyPolicy(config.remoteConfig),share=resolveSharePolicy(config);
|
||||
const context:RoomMenuContext={
|
||||
status:room.status!,canexit:room.canexit!,roomMode:room.roomMode!,shortcode:room.shortcode!,matchId:room.matchId!,
|
||||
stage:room.stage,infinite:room.infinite,playercnt:room.seatPlayerIds.filter(id=>id!==null).length,
|
||||
...host,appdownload:share.url,hideEscape,...policy,
|
||||
roomCardName:lobby.roomCardName,starName:lobby.starName,bean:self.bean,
|
||||
};
|
||||
buildRoomMenuModel(context);
|
||||
return context;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "9e2e7f5f-9a36-42ff-9ba1-7e25e62f06f7",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Pure projection of Game_Surface_3/11_GameUI.js openMainSceneBtnPanel.
|
||||
* All legacy source fields must be resolved before opening the panel. */
|
||||
export interface RoomMenuContext {
|
||||
readonly status:number; readonly canexit:number; readonly stage:number;
|
||||
readonly playercnt:number; readonly roomMode:number; readonly infinite:number;
|
||||
readonly shortcode:string|number|null; readonly matchId:string|number|null;
|
||||
readonly h5Version:number; readonly appdownload:string;
|
||||
readonly hideEscape:boolean; readonly mainSceneButton:boolean; readonly vipInfinite:boolean;
|
||||
readonly versionState:number; readonly roomCardName:string; readonly bean:number; readonly starName:string;
|
||||
}
|
||||
export type RoomMenuSpiritId=151|150|185|233|3014|513|656;
|
||||
export interface RoomMenuButton {readonly spiritId:RoomMenuSpiritId;readonly label:string;readonly y:number;}
|
||||
export type RoomMenuAction =
|
||||
| {readonly kind:'command';readonly command:'room.exit'|'room.apply-dissolution'|'room.change';readonly message?:never}
|
||||
| {readonly kind:'confirm';readonly checkType:1|2|3;readonly message:string;readonly command:'room.exit'|'room.break'|'room.escape';readonly count?:number}
|
||||
| {readonly kind:'input'|'tip';readonly message:string}
|
||||
| {readonly kind:'panel';readonly panel:'settings'|'help';readonly message?:never}
|
||||
| {readonly kind:'download';readonly url:string;readonly message?:never};
|
||||
|
||||
export function buildRoomMenuModel(context:RoomMenuContext):readonly RoomMenuButton[] {
|
||||
for(const field of ['status','canexit','stage','playercnt','roomMode','infinite','h5Version','versionState','bean'] as const){
|
||||
if(!Number.isFinite(context[field]))throw Error(`Missing or invalid room menu source: ${field}`);
|
||||
}
|
||||
for(const field of ['hideEscape','mainSceneButton','vipInfinite'] as const){
|
||||
if(typeof context[field]!=='boolean')throw Error(`Missing or invalid room menu source: ${field}`);
|
||||
}
|
||||
for(const field of ['appdownload','roomCardName','starName'] as const){
|
||||
if(typeof context[field]!=='string')throw Error(`Missing or invalid room menu source: ${field}`);
|
||||
}
|
||||
for(const field of ['shortcode','matchId'] as const){
|
||||
if(context[field]!==null&&typeof context[field]!=='string'&&typeof context[field]!=='number')throw Error(`Missing or invalid room menu source: ${field}`);
|
||||
}
|
||||
if(context.canexit!==0&&context.canexit!==1)throw Error('Invalid room menu source: canexit');
|
||||
// The original first hides dissolution for matchId/infinite, then overwrites
|
||||
// it in the stage branch; infinite is the final override. Preserve that order.
|
||||
const exit=context.infinite===1||context.stage!==1||context.canexit===1;
|
||||
const dissolve=context.infinite!==1&&(context.stage===1?context.canexit===0:context.status===1);
|
||||
const escape=context.status===1&&context.playercnt===2&&context.roomMode===1&&context.infinite===0&&context.stage===0&&!context.mainSceneButton&&!context.hideEscape;
|
||||
const buttons:readonly [RoomMenuSpiritId,string,boolean][]=[
|
||||
[151,'设置',true],[150,'帮助',true],[185,'解散',dissolve],[233,'退出',exit],
|
||||
[3014,'换房',context.infinite===1&&!context.shortcode&&!context.vipInfinite],
|
||||
[513,'逃跑',escape],[656,'下载',context.h5Version===1&&Boolean(context.appdownload)],
|
||||
];
|
||||
return buttons.filter(([, ,visible])=>visible).map(([spiritId,label],index)=>({spiritId,label,y:75+93*index}));
|
||||
}
|
||||
|
||||
export function roomMenuAction(spiritId:RoomMenuSpiritId,context:RoomMenuContext):RoomMenuAction {
|
||||
if(!buildRoomMenuModel(context).some(button=>button.spiritId===spiritId))throw Error(`Room menu button ${spiritId} is not visible`);
|
||||
switch(spiritId){
|
||||
case 151:return {kind:'panel',panel:'settings'};
|
||||
case 150:return {kind:'panel',panel:'help'};
|
||||
case 233:return context.infinite===0&&context.status===1
|
||||
?{kind:'confirm',checkType:2,message:'房间仍存在,是否退出?',command:'room.exit'}
|
||||
:{kind:'command',command:'room.exit'};
|
||||
case 185:return context.stage===0
|
||||
?{kind:'confirm',checkType:1,message:context.versionState===1?' 是否解散房间?':`解散房间不扣${context.roomCardName},是否解散?`,command:'room.break'}
|
||||
:{kind:'command',command:'room.apply-dissolution'};
|
||||
case 3014:return {kind:'command',command:'room.change'};
|
||||
case 513:return {kind:'input',message:'请输入投降扣除数量(底翻)'};
|
||||
case 656:return {kind:'download',url:context.appdownload};
|
||||
}
|
||||
}
|
||||
|
||||
export function roomEscapeConfirmation(context:RoomMenuContext,count:number):RoomMenuAction {
|
||||
if(!Number.isFinite(count)||count<0)throw Error('Invalid surrender count');
|
||||
if(count>context.bean)return {kind:'tip',message:`输入大于您拥有的${count}${context.starName}数:${context.bean}!`};
|
||||
return {kind:'confirm',checkType:3,message:`将扣除${count}${context.starName},确定逃跑?`,command:'room.escape',count};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "087800ef-8c3f-4a0f-b81f-630fcbd69a4c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Logic.getVisibleNote + Utl.Config.ownerNoteTime from Game_Surface_3.
|
||||
* Call only for a nonempty room owner notice. Manual opening bypasses this check. */
|
||||
export function shouldShowOwnerNotice(
|
||||
storage:Pick<Storage,'getItem'|'setItem'>,gameid:string,agentid:string,now:number,
|
||||
):boolean {
|
||||
if(!Number.isSafeInteger(now)||now<0)throw Error('Invalid owner notice now');
|
||||
if(typeof gameid!=='string'||gameid===''||typeof agentid!=='string'||agentid==='')throw Error('Invalid owner notice identity');
|
||||
const key=`tsgame_ownerNoteTime_${gameid}${agentid}`;
|
||||
const stored=storage.getItem(key);
|
||||
let expiry=0;
|
||||
// Missing/empty storage and zero have the source's explicit first-show meaning.
|
||||
if(stored!==null&&stored!==''){
|
||||
if(!/^\d+$/.test(stored))throw Error('Invalid owner notice expiry');
|
||||
expiry=Number(stored);
|
||||
if(!Number.isSafeInteger(expiry))throw Error('Invalid owner notice expiry');
|
||||
}
|
||||
if(expiry!==0&&now<expiry)return false;
|
||||
const next=now+24*60*60*1000;
|
||||
if(!Number.isSafeInteger(next))throw Error('Invalid owner notice next expiry');
|
||||
storage.setItem(key,String(next));
|
||||
return true;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "8ff34190-44a3-4c8f-8638-8fca68bdc940",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Reference in New Issue
Block a user