// Cocos Store 中的 Colyseus SDK 扩展提供此虚拟模块。 // @ts-ignore 扩展尚未导入时仍允许项目完成静态类型检查。 // import Colyseus from 'db://colyseus-sdk/colyseus.js'; import Colyseus from "db://assets/colyseus/colyseus-cocos-creator.js"; interface ColyseusRoom { send(type: string, message?: unknown): void; leave(consented?: boolean): Promise; removeAllListeners(): void; onMessage(type: string, callback: (type: string, message: unknown) => void): void; onError(callback: (code: number, message?: string) => void): void; onLeave(callback: (code?: number) => void): void; } interface ColyseusClient { joinOrCreate(roomName: string, options?: Record): Promise; } interface ColyseusEndpoint { hostname: string; port: number; secure: boolean; pathname: string; } export interface RoomClientCallbacks { onConnected?: () => void; onDisconnected?: (code?: number) => void; onTimeout?: () => void; onMessage?: (type: string, message: unknown) => void; } export interface RoomClientOptions { pingIntervalMs?: number; pingTimeoutMax?: number; retryDelayMs?: number; retryStepMs?: number; maxRetries?: number; } function parseColyseusEndpoint(rawUrl: string): ColyseusEndpoint { const value = rawUrl.trim(); const match = value.match( /^(https?|wss?):\/\/([^/:?#]+)(?::(\d+))?(\/[^?#]*)?/i, ); if (!match) { throw new Error(`[Colyseus] Invalid server URL: ${rawUrl}`); } const protocol = match[1].toLowerCase(); const secure = protocol === 'https' || protocol === 'wss'; return { hostname: match[2], port: match[3] ? Number(match[3]) : secure ? 443 : 80, secure, pathname: match[4] || '', }; } /** * Colyseus room connection with the heartbeat and retry behaviour used by the * web hall client. One instance owns one logical room session. */ export class ColyseusRoomClient { private readonly client: ColyseusClient; private readonly pingIntervalMs: number; private readonly pingTimeoutMax: number; private readonly retryDelayMs: number; private readonly retryStepMs: number; private readonly maxRetries: number; private room: ColyseusRoom | null = null; private connecting: Promise | null = null; private pingTimer: ReturnType | null = null; private retryTimer: ReturnType | null = null; private retryResolve: (() => void) | null = null; private pendingPings = 0; private generation = 0; private shouldConnect = false; private kicked = false; constructor( private readonly roomName: string, serverUrl: string, private readonly callbacks: RoomClientCallbacks = {}, options: RoomClientOptions = {}, ) { const endpoint = parseColyseusEndpoint(serverUrl); this.client = new Colyseus.Client(endpoint) as ColyseusClient; this.pingIntervalMs = options.pingIntervalMs ?? 10_000; this.pingTimeoutMax = options.pingTimeoutMax ?? 3; this.retryDelayMs = options.retryDelayMs ?? 1_000; this.retryStepMs = options.retryStepMs ?? 2_000; this.maxRetries = options.maxRetries ?? 5; } get isConnected(): boolean { return this.room !== null; } async connect(joinOptions: Record = {}): Promise { if (this.room) return true; if (this.connecting) return this.connecting; this.shouldConnect = true; this.kicked = false; const generation = ++this.generation; const task = this.connectWithRetry(generation, joinOptions); this.connecting = task; try { return await task; } finally { if (this.connecting === task) this.connecting = null; } } async disconnect(): Promise { this.shouldConnect = false; ++this.generation; this.clearTimers(); const room = this.room; this.room = null; if (!room) return; room.removeAllListeners(); try { await room.leave(true); } catch (error) { console.warn(`[ColyseusRoomClient] Failed to leave ${this.roomName}`, error); } } markKicked(): void { this.kicked = true; this.shouldConnect = false; this.clearTimers(); } send(type: string, message: unknown = ''): boolean { if (!this.room) return false; //console.log('[Hall → Server]', type, message); this.room.send(type, message); return true; } private async connectWithRetry( generation: number, joinOptions: Record, ): Promise { for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) { if (!this.isCurrent(generation)) return false; if (attempt > 0) { const delay = this.retryDelayMs + (attempt - 1) * this.retryStepMs; await this.waitForRetry(delay, generation); if (!this.isCurrent(generation)) return false; } try { const room = await this.client.joinOrCreate(this.roomName, joinOptions); if (!this.isCurrent(generation)) { room.removeAllListeners(); await room.leave(true); return false; } this.attachRoom(room); return true; } catch (error) { console.warn( `[ColyseusRoomClient] Failed to join ${this.roomName} (${attempt + 1}/${this.maxRetries + 1})`, error, ); } } if (this.isCurrent(generation)) { this.shouldConnect = false; this.callbacks.onTimeout?.(); } return false; } private attachRoom(room: ColyseusRoom): void { this.room = room; this.pendingPings = 0; room.onMessage('*', (type: string, message: unknown) => { //console.log('[Hall ← Server]', type, message); if (type === 'PONG') { this.pendingPings = Math.max(0, this.pendingPings - 1); return; } if (this.isKickMessage(message)) this.markKicked(); this.callbacks.onMessage?.(type, message); }); room.onError((code: number, message?: string) => { console.warn(`[ColyseusRoomClient] ${this.roomName} error ${code}: ${message || ''}`); }); room.onLeave((code?: number) => { if (this.room !== room) return; this.room = null; this.stopPing(); this.callbacks.onDisconnected?.(code); if (this.shouldConnect && !this.kicked) void this.reconnect(); }); this.callbacks.onConnected?.(); this.startPing(); } private async reconnect(): Promise { ++this.generation; this.connecting = null; await this.connect(); } private startPing(): void { this.stopPing(); this.pingTimer = setInterval(() => { if (this.pendingPings > this.pingTimeoutMax) { void this.restartAfterPingTimeout(); return; } this.pendingPings += 1; this.send('PING', ''); }, this.pingIntervalMs); } private async restartAfterPingTimeout(): Promise { const room = this.room; this.room = null; this.stopPing(); if (room) { room.removeAllListeners(); try { await room.leave(true); } catch (error) { console.warn(`[ColyseusRoomClient] Failed to close timed-out ${this.roomName}`, error); } } if (this.shouldConnect && !this.kicked) await this.reconnect(); } private waitForRetry(delay: number, generation: number): Promise { return new Promise((resolve) => { this.retryResolve = resolve; this.retryTimer = setTimeout(() => { this.retryTimer = null; this.retryResolve = null; resolve(); }, delay); if (!this.isCurrent(generation)) { clearTimeout(this.retryTimer); this.retryTimer = null; this.retryResolve = null; resolve(); } }); } private isCurrent(generation: number): boolean { return this.shouldConnect && generation === this.generation; } private isKickMessage(message: unknown): boolean { return Boolean(message && typeof message === 'object' && (message as { cmd?: string }).cmd === 'kick'); } private clearTimers(): void { if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; const resolve = this.retryResolve; this.retryResolve = null; resolve?.(); } this.stopPing(); this.pendingPings = 0; } private stopPing(): void { if (!this.pingTimer) return; clearInterval(this.pingTimer); this.pingTimer = null; } }