| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354 |
- // 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<unknown>;
- 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<string, unknown>): Promise<ColyseusRoom>;
- }
- 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 {
- /** Maximum time spent waiting for one joinOrCreate attempt. */
- connectTimeoutMs?: number;
- pingIntervalMs?: number;
- pingTimeoutMax?: number;
- retryDelayMs?: number;
- retryStepMs?: number;
- maxRetries?: number;
- }
- class ConnectionTimeoutError extends Error {
- constructor(roomName: string, timeoutMs: number) {
- super(`[ColyseusRoomClient] Timed out connecting to ${roomName} after ${timeoutMs}ms`);
- this.name = 'ConnectionTimeoutError';
- }
- }
- 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 connectTimeoutMs: number;
- 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<boolean> | null = null;
- private pingTimer: ReturnType<typeof setInterval> | null = null;
- private retryTimer: ReturnType<typeof setTimeout> | 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.connectTimeoutMs = options.connectTimeoutMs ?? 15_000;
- 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 ?? 3;
- }
- get isConnected(): boolean {
- return this.room !== null;
- }
- async connect(joinOptions: Record<string, unknown> = {}): Promise<boolean> {
- 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<void> {
- 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<string, unknown>,
- ): Promise<boolean> {
- 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.joinOrCreateWithTimeout(generation, joinOptions);
- if (!this.isCurrent(generation)) {
- this.discardRoom(room);
- return false;
- }
- this.attachRoom(room);
- return true;
- } catch (error) {
- // The SDK does not reject joinOrCreate while its underlying socket is
- // reconnecting. Retrying here would create more hanging connections.
- if (error instanceof ConnectionTimeoutError) break;
- 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 joinOrCreateWithTimeout(
- generation: number,
- joinOptions: Record<string, unknown>,
- ): Promise<ColyseusRoom> {
- let timedOut = false;
- const joinTask = this.client.joinOrCreate(this.roomName, joinOptions);
- // A timed-out SDK request may still resolve later. Close that stale room
- // immediately so it cannot become an orphaned connection.
- void joinTask.then(
- (room) => {
- if (timedOut || !this.isCurrent(generation)) void this.discardRoom(room);
- },
- () => undefined,
- );
- return new Promise<ColyseusRoom>((resolve, reject) => {
- const timeout = setTimeout(() => {
- timedOut = true;
- reject(new ConnectionTimeoutError(this.roomName, this.connectTimeoutMs));
- }, this.connectTimeoutMs);
- void joinTask.then(
- (room) => {
- if (timedOut) return;
- clearTimeout(timeout);
- resolve(room);
- },
- (error: unknown) => {
- if (timedOut) return;
- clearTimeout(timeout);
- reject(error);
- },
- );
- });
- }
- private discardRoom(room: ColyseusRoom): void {
- room.removeAllListeners();
- // A room which has not completed the join handshake never emits onLeave,
- // so waiting for leave() here can leave the caller stuck again.
- void room.leave(false).catch((error) => {
- console.warn(`[ColyseusRoomClient] Failed to close stale ${this.roomName}`, error);
- });
- }
- 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<void> {
- ++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<void> {
- 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<void> {
- 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;
- }
- }
|