ColyseusRoomClient.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. // Cocos Store 中的 Colyseus SDK 扩展提供此虚拟模块。
  2. // @ts-ignore 扩展尚未导入时仍允许项目完成静态类型检查。
  3. // import Colyseus from 'db://colyseus-sdk/colyseus.js';
  4. import Colyseus from "db://assets/colyseus/colyseus-cocos-creator.js";
  5. interface ColyseusRoom {
  6. send(type: string, message?: unknown): void;
  7. leave(consented?: boolean): Promise<unknown>;
  8. removeAllListeners(): void;
  9. onMessage(type: string, callback: (type: string, message: unknown) => void): void;
  10. onError(callback: (code: number, message?: string) => void): void;
  11. onLeave(callback: (code?: number) => void): void;
  12. }
  13. interface ColyseusClient {
  14. joinOrCreate(roomName: string, options?: Record<string, unknown>): Promise<ColyseusRoom>;
  15. }
  16. interface ColyseusEndpoint {
  17. hostname: string;
  18. port: number;
  19. secure: boolean;
  20. pathname: string;
  21. }
  22. export interface RoomClientCallbacks {
  23. onConnected?: () => void;
  24. onDisconnected?: (code?: number) => void;
  25. onTimeout?: () => void;
  26. onMessage?: (type: string, message: unknown) => void;
  27. }
  28. export interface RoomClientOptions {
  29. /** Maximum time spent waiting for one joinOrCreate attempt. */
  30. connectTimeoutMs?: number;
  31. pingIntervalMs?: number;
  32. pingTimeoutMax?: number;
  33. retryDelayMs?: number;
  34. retryStepMs?: number;
  35. maxRetries?: number;
  36. }
  37. class ConnectionTimeoutError extends Error {
  38. constructor(roomName: string, timeoutMs: number) {
  39. super(`[ColyseusRoomClient] Timed out connecting to ${roomName} after ${timeoutMs}ms`);
  40. this.name = 'ConnectionTimeoutError';
  41. }
  42. }
  43. function parseColyseusEndpoint(rawUrl: string): ColyseusEndpoint {
  44. const value = rawUrl.trim();
  45. const match = value.match(
  46. /^(https?|wss?):\/\/([^/:?#]+)(?::(\d+))?(\/[^?#]*)?/i,
  47. );
  48. if (!match) {
  49. throw new Error(`[Colyseus] Invalid server URL: ${rawUrl}`);
  50. }
  51. const protocol = match[1].toLowerCase();
  52. const secure = protocol === 'https' || protocol === 'wss';
  53. return {
  54. hostname: match[2],
  55. port: match[3] ? Number(match[3]) : secure ? 443 : 80,
  56. secure,
  57. pathname: match[4] || '',
  58. };
  59. }
  60. /**
  61. * Colyseus room connection with the heartbeat and retry behaviour used by the
  62. * web hall client. One instance owns one logical room session.
  63. */
  64. export class ColyseusRoomClient {
  65. private readonly client: ColyseusClient;
  66. private readonly connectTimeoutMs: number;
  67. private readonly pingIntervalMs: number;
  68. private readonly pingTimeoutMax: number;
  69. private readonly retryDelayMs: number;
  70. private readonly retryStepMs: number;
  71. private readonly maxRetries: number;
  72. private room: ColyseusRoom | null = null;
  73. private connecting: Promise<boolean> | null = null;
  74. private pingTimer: ReturnType<typeof setInterval> | null = null;
  75. private retryTimer: ReturnType<typeof setTimeout> | null = null;
  76. private retryResolve: (() => void) | null = null;
  77. private pendingPings = 0;
  78. private generation = 0;
  79. private shouldConnect = false;
  80. private kicked = false;
  81. constructor(
  82. private readonly roomName: string,
  83. serverUrl: string,
  84. private readonly callbacks: RoomClientCallbacks = {},
  85. options: RoomClientOptions = {},
  86. ) {
  87. const endpoint = parseColyseusEndpoint(serverUrl);
  88. this.client = new Colyseus.Client(endpoint) as ColyseusClient;
  89. this.connectTimeoutMs = options.connectTimeoutMs ?? 15_000;
  90. this.pingIntervalMs = options.pingIntervalMs ?? 10_000;
  91. this.pingTimeoutMax = options.pingTimeoutMax ?? 3;
  92. this.retryDelayMs = options.retryDelayMs ?? 1_000;
  93. this.retryStepMs = options.retryStepMs ?? 2_000;
  94. this.maxRetries = options.maxRetries ?? 3;
  95. }
  96. get isConnected(): boolean {
  97. return this.room !== null;
  98. }
  99. async connect(joinOptions: Record<string, unknown> = {}): Promise<boolean> {
  100. if (this.room) return true;
  101. if (this.connecting) return this.connecting;
  102. this.shouldConnect = true;
  103. this.kicked = false;
  104. const generation = ++this.generation;
  105. const task = this.connectWithRetry(generation, joinOptions);
  106. this.connecting = task;
  107. try {
  108. return await task;
  109. } finally {
  110. if (this.connecting === task) this.connecting = null;
  111. }
  112. }
  113. async disconnect(): Promise<void> {
  114. this.shouldConnect = false;
  115. ++this.generation;
  116. this.clearTimers();
  117. const room = this.room;
  118. this.room = null;
  119. if (!room) return;
  120. room.removeAllListeners();
  121. try {
  122. await room.leave(true);
  123. } catch (error) {
  124. console.warn(`[ColyseusRoomClient] Failed to leave ${this.roomName}`, error);
  125. }
  126. }
  127. markKicked(): void {
  128. this.kicked = true;
  129. this.shouldConnect = false;
  130. this.clearTimers();
  131. }
  132. send(type: string, message: unknown = ''): boolean {
  133. if (!this.room) return false;
  134. //console.log('[Hall → Server]', type, message);
  135. this.room.send(type, message);
  136. return true;
  137. }
  138. private async connectWithRetry(
  139. generation: number,
  140. joinOptions: Record<string, unknown>,
  141. ): Promise<boolean> {
  142. for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
  143. if (!this.isCurrent(generation)) return false;
  144. if (attempt > 0) {
  145. const delay = this.retryDelayMs + (attempt - 1) * this.retryStepMs;
  146. await this.waitForRetry(delay, generation);
  147. if (!this.isCurrent(generation)) return false;
  148. }
  149. try {
  150. const room = await this.joinOrCreateWithTimeout(generation, joinOptions);
  151. if (!this.isCurrent(generation)) {
  152. this.discardRoom(room);
  153. return false;
  154. }
  155. this.attachRoom(room);
  156. return true;
  157. } catch (error) {
  158. // The SDK does not reject joinOrCreate while its underlying socket is
  159. // reconnecting. Retrying here would create more hanging connections.
  160. if (error instanceof ConnectionTimeoutError) break;
  161. console.warn(
  162. `[ColyseusRoomClient] Failed to join ${this.roomName} (${attempt + 1}/${this.maxRetries + 1})`,
  163. error,
  164. );
  165. }
  166. }
  167. if (this.isCurrent(generation)) {
  168. this.shouldConnect = false;
  169. this.callbacks.onTimeout?.();
  170. }
  171. return false;
  172. }
  173. private joinOrCreateWithTimeout(
  174. generation: number,
  175. joinOptions: Record<string, unknown>,
  176. ): Promise<ColyseusRoom> {
  177. let timedOut = false;
  178. const joinTask = this.client.joinOrCreate(this.roomName, joinOptions);
  179. // A timed-out SDK request may still resolve later. Close that stale room
  180. // immediately so it cannot become an orphaned connection.
  181. void joinTask.then(
  182. (room) => {
  183. if (timedOut || !this.isCurrent(generation)) void this.discardRoom(room);
  184. },
  185. () => undefined,
  186. );
  187. return new Promise<ColyseusRoom>((resolve, reject) => {
  188. const timeout = setTimeout(() => {
  189. timedOut = true;
  190. reject(new ConnectionTimeoutError(this.roomName, this.connectTimeoutMs));
  191. }, this.connectTimeoutMs);
  192. void joinTask.then(
  193. (room) => {
  194. if (timedOut) return;
  195. clearTimeout(timeout);
  196. resolve(room);
  197. },
  198. (error: unknown) => {
  199. if (timedOut) return;
  200. clearTimeout(timeout);
  201. reject(error);
  202. },
  203. );
  204. });
  205. }
  206. private discardRoom(room: ColyseusRoom): void {
  207. room.removeAllListeners();
  208. // A room which has not completed the join handshake never emits onLeave,
  209. // so waiting for leave() here can leave the caller stuck again.
  210. void room.leave(false).catch((error) => {
  211. console.warn(`[ColyseusRoomClient] Failed to close stale ${this.roomName}`, error);
  212. });
  213. }
  214. private attachRoom(room: ColyseusRoom): void {
  215. this.room = room;
  216. this.pendingPings = 0;
  217. room.onMessage('*', (type: string, message: unknown) => {
  218. //console.log('[Hall ← Server]', type, message);
  219. if (type === 'PONG') {
  220. this.pendingPings = Math.max(0, this.pendingPings - 1);
  221. return;
  222. }
  223. if (this.isKickMessage(message)) this.markKicked();
  224. this.callbacks.onMessage?.(type, message);
  225. });
  226. room.onError((code: number, message?: string) => {
  227. console.warn(`[ColyseusRoomClient] ${this.roomName} error ${code}: ${message || ''}`);
  228. });
  229. room.onLeave((code?: number) => {
  230. if (this.room !== room) return;
  231. this.room = null;
  232. this.stopPing();
  233. this.callbacks.onDisconnected?.(code);
  234. if (this.shouldConnect && !this.kicked) void this.reconnect();
  235. });
  236. this.callbacks.onConnected?.();
  237. this.startPing();
  238. }
  239. private async reconnect(): Promise<void> {
  240. ++this.generation;
  241. this.connecting = null;
  242. await this.connect();
  243. }
  244. private startPing(): void {
  245. this.stopPing();
  246. this.pingTimer = setInterval(() => {
  247. if (this.pendingPings > this.pingTimeoutMax) {
  248. void this.restartAfterPingTimeout();
  249. return;
  250. }
  251. this.pendingPings += 1;
  252. this.send('PING', '');
  253. }, this.pingIntervalMs);
  254. }
  255. private async restartAfterPingTimeout(): Promise<void> {
  256. const room = this.room;
  257. this.room = null;
  258. this.stopPing();
  259. if (room) {
  260. room.removeAllListeners();
  261. try {
  262. await room.leave(true);
  263. } catch (error) {
  264. console.warn(`[ColyseusRoomClient] Failed to close timed-out ${this.roomName}`, error);
  265. }
  266. }
  267. if (this.shouldConnect && !this.kicked) await this.reconnect();
  268. }
  269. private waitForRetry(delay: number, generation: number): Promise<void> {
  270. return new Promise((resolve) => {
  271. this.retryResolve = resolve;
  272. this.retryTimer = setTimeout(() => {
  273. this.retryTimer = null;
  274. this.retryResolve = null;
  275. resolve();
  276. }, delay);
  277. if (!this.isCurrent(generation)) {
  278. clearTimeout(this.retryTimer);
  279. this.retryTimer = null;
  280. this.retryResolve = null;
  281. resolve();
  282. }
  283. });
  284. }
  285. private isCurrent(generation: number): boolean {
  286. return this.shouldConnect && generation === this.generation;
  287. }
  288. private isKickMessage(message: unknown): boolean {
  289. return Boolean(message && typeof message === 'object' && (message as { cmd?: string }).cmd === 'kick');
  290. }
  291. private clearTimers(): void {
  292. if (this.retryTimer) {
  293. clearTimeout(this.retryTimer);
  294. this.retryTimer = null;
  295. const resolve = this.retryResolve;
  296. this.retryResolve = null;
  297. resolve?.();
  298. }
  299. this.stopPing();
  300. this.pendingPings = 0;
  301. }
  302. private stopPing(): void {
  303. if (!this.pingTimer) return;
  304. clearInterval(this.pingTimer);
  305. this.pingTimer = null;
  306. }
  307. }