ColyseusRoomClient.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  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. pingIntervalMs?: number;
  30. pingTimeoutMax?: number;
  31. retryDelayMs?: number;
  32. retryStepMs?: number;
  33. maxRetries?: number;
  34. }
  35. function parseColyseusEndpoint(rawUrl: string): ColyseusEndpoint {
  36. const value = rawUrl.trim();
  37. const match = value.match(
  38. /^(https?|wss?):\/\/([^/:?#]+)(?::(\d+))?(\/[^?#]*)?/i,
  39. );
  40. if (!match) {
  41. throw new Error(`[Colyseus] Invalid server URL: ${rawUrl}`);
  42. }
  43. const protocol = match[1].toLowerCase();
  44. const secure = protocol === 'https' || protocol === 'wss';
  45. return {
  46. hostname: match[2],
  47. port: match[3] ? Number(match[3]) : secure ? 443 : 80,
  48. secure,
  49. pathname: match[4] || '',
  50. };
  51. }
  52. /**
  53. * Colyseus room connection with the heartbeat and retry behaviour used by the
  54. * web hall client. One instance owns one logical room session.
  55. */
  56. export class ColyseusRoomClient {
  57. private readonly client: ColyseusClient;
  58. private readonly pingIntervalMs: number;
  59. private readonly pingTimeoutMax: number;
  60. private readonly retryDelayMs: number;
  61. private readonly retryStepMs: number;
  62. private readonly maxRetries: number;
  63. private room: ColyseusRoom | null = null;
  64. private connecting: Promise<boolean> | null = null;
  65. private pingTimer: ReturnType<typeof setInterval> | null = null;
  66. private retryTimer: ReturnType<typeof setTimeout> | null = null;
  67. private retryResolve: (() => void) | null = null;
  68. private pendingPings = 0;
  69. private generation = 0;
  70. private shouldConnect = false;
  71. private kicked = false;
  72. constructor(
  73. private readonly roomName: string,
  74. serverUrl: string,
  75. private readonly callbacks: RoomClientCallbacks = {},
  76. options: RoomClientOptions = {},
  77. ) {
  78. const endpoint = parseColyseusEndpoint(serverUrl);
  79. this.client = new Colyseus.Client(endpoint) as ColyseusClient;
  80. this.pingIntervalMs = options.pingIntervalMs ?? 10_000;
  81. this.pingTimeoutMax = options.pingTimeoutMax ?? 3;
  82. this.retryDelayMs = options.retryDelayMs ?? 1_000;
  83. this.retryStepMs = options.retryStepMs ?? 2_000;
  84. this.maxRetries = options.maxRetries ?? 5;
  85. }
  86. get isConnected(): boolean {
  87. return this.room !== null;
  88. }
  89. async connect(joinOptions: Record<string, unknown> = {}): Promise<boolean> {
  90. if (this.room) return true;
  91. if (this.connecting) return this.connecting;
  92. this.shouldConnect = true;
  93. this.kicked = false;
  94. const generation = ++this.generation;
  95. const task = this.connectWithRetry(generation, joinOptions);
  96. this.connecting = task;
  97. try {
  98. return await task;
  99. } finally {
  100. if (this.connecting === task) this.connecting = null;
  101. }
  102. }
  103. async disconnect(): Promise<void> {
  104. this.shouldConnect = false;
  105. ++this.generation;
  106. this.clearTimers();
  107. const room = this.room;
  108. this.room = null;
  109. if (!room) return;
  110. room.removeAllListeners();
  111. try {
  112. await room.leave(true);
  113. } catch (error) {
  114. console.warn(`[ColyseusRoomClient] Failed to leave ${this.roomName}`, error);
  115. }
  116. }
  117. markKicked(): void {
  118. this.kicked = true;
  119. this.shouldConnect = false;
  120. this.clearTimers();
  121. }
  122. send(type: string, message: unknown = ''): boolean {
  123. if (!this.room) return false;
  124. //console.log('[Hall → Server]', type, message);
  125. this.room.send(type, message);
  126. return true;
  127. }
  128. private async connectWithRetry(
  129. generation: number,
  130. joinOptions: Record<string, unknown>,
  131. ): Promise<boolean> {
  132. for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
  133. if (!this.isCurrent(generation)) return false;
  134. if (attempt > 0) {
  135. const delay = this.retryDelayMs + (attempt - 1) * this.retryStepMs;
  136. await this.waitForRetry(delay, generation);
  137. if (!this.isCurrent(generation)) return false;
  138. }
  139. try {
  140. const room = await this.client.joinOrCreate(this.roomName, joinOptions);
  141. if (!this.isCurrent(generation)) {
  142. room.removeAllListeners();
  143. await room.leave(true);
  144. return false;
  145. }
  146. this.attachRoom(room);
  147. return true;
  148. } catch (error) {
  149. console.warn(
  150. `[ColyseusRoomClient] Failed to join ${this.roomName} (${attempt + 1}/${this.maxRetries + 1})`,
  151. error,
  152. );
  153. }
  154. }
  155. if (this.isCurrent(generation)) {
  156. this.shouldConnect = false;
  157. this.callbacks.onTimeout?.();
  158. }
  159. return false;
  160. }
  161. private attachRoom(room: ColyseusRoom): void {
  162. this.room = room;
  163. this.pendingPings = 0;
  164. room.onMessage('*', (type: string, message: unknown) => {
  165. //console.log('[Hall ← Server]', type, message);
  166. if (type === 'PONG') {
  167. this.pendingPings = Math.max(0, this.pendingPings - 1);
  168. return;
  169. }
  170. if (this.isKickMessage(message)) this.markKicked();
  171. this.callbacks.onMessage?.(type, message);
  172. });
  173. room.onError((code: number, message?: string) => {
  174. console.warn(`[ColyseusRoomClient] ${this.roomName} error ${code}: ${message || ''}`);
  175. });
  176. room.onLeave((code?: number) => {
  177. if (this.room !== room) return;
  178. this.room = null;
  179. this.stopPing();
  180. this.callbacks.onDisconnected?.(code);
  181. if (this.shouldConnect && !this.kicked) void this.reconnect();
  182. });
  183. this.callbacks.onConnected?.();
  184. this.startPing();
  185. }
  186. private async reconnect(): Promise<void> {
  187. ++this.generation;
  188. this.connecting = null;
  189. await this.connect();
  190. }
  191. private startPing(): void {
  192. this.stopPing();
  193. this.pingTimer = setInterval(() => {
  194. if (this.pendingPings > this.pingTimeoutMax) {
  195. void this.restartAfterPingTimeout();
  196. return;
  197. }
  198. this.pendingPings += 1;
  199. this.send('PING', '');
  200. }, this.pingIntervalMs);
  201. }
  202. private async restartAfterPingTimeout(): Promise<void> {
  203. const room = this.room;
  204. this.room = null;
  205. this.stopPing();
  206. if (room) {
  207. room.removeAllListeners();
  208. try {
  209. await room.leave(true);
  210. } catch (error) {
  211. console.warn(`[ColyseusRoomClient] Failed to close timed-out ${this.roomName}`, error);
  212. }
  213. }
  214. if (this.shouldConnect && !this.kicked) await this.reconnect();
  215. }
  216. private waitForRetry(delay: number, generation: number): Promise<void> {
  217. return new Promise((resolve) => {
  218. this.retryResolve = resolve;
  219. this.retryTimer = setTimeout(() => {
  220. this.retryTimer = null;
  221. this.retryResolve = null;
  222. resolve();
  223. }, delay);
  224. if (!this.isCurrent(generation)) {
  225. clearTimeout(this.retryTimer);
  226. this.retryTimer = null;
  227. this.retryResolve = null;
  228. resolve();
  229. }
  230. });
  231. }
  232. private isCurrent(generation: number): boolean {
  233. return this.shouldConnect && generation === this.generation;
  234. }
  235. private isKickMessage(message: unknown): boolean {
  236. return Boolean(message && typeof message === 'object' && (message as { cmd?: string }).cmd === 'kick');
  237. }
  238. private clearTimers(): void {
  239. if (this.retryTimer) {
  240. clearTimeout(this.retryTimer);
  241. this.retryTimer = null;
  242. const resolve = this.retryResolve;
  243. this.retryResolve = null;
  244. resolve?.();
  245. }
  246. this.stopPing();
  247. this.pendingPings = 0;
  248. }
  249. private stopPing(): void {
  250. if (!this.pingTimer) return;
  251. clearInterval(this.pingTimer);
  252. this.pingTimer = null;
  253. }
  254. }