/** * NetworkManager.js * Manages WebSocket connection for PVP online multiplayer. * Handles connection lifecycle, heartbeat, reconnection, and message routing. */ const { NET_MSG } = require('../base/GameGlobal'); class NetworkManager { constructor() { /** @type {WebSocket|null} */ this._ws = null; /** @type {string} Server URL */ this._serverUrl = ''; /** @type {boolean} */ this._connected = false; /** @type {boolean} */ this._connecting = false; /** @type {string|null} Current room ID */ this._roomId = null; /** @type {number} Player slot (1 or 2) */ this._playerSlot = 0; /** @type {string} Unique player ID */ this._playerId = ''; // Heartbeat this._heartbeatInterval = null; this._heartbeatTimeout = null; this._heartbeatMs = 5000; this._heartbeatTimeoutMs = 10000; // Reconnection this._reconnectAttempts = 0; this._maxReconnectAttempts = 3; this._reconnectDelay = 2000; this._reconnectTimer = null; this._shouldReconnect = false; // Message handlers /** @type {Map>} */ this._handlers = new Map(); // Latency tracking this._lastPingTime = 0; this._latency = 0; // Generate a unique player ID this._playerId = this._generatePlayerId(); } /** * Connect to the WebSocket server. * @param {string} serverUrl - WebSocket server URL (e.g., 'wss://example.com/ws'). * @returns {Promise} Whether connection succeeded. */ connect(serverUrl) { return new Promise((resolve) => { if (this._connected || this._connecting) { resolve(this._connected); return; } this._serverUrl = serverUrl; this._connecting = true; this._shouldReconnect = true; try { this._ws = wx.connectSocket({ url: serverUrl, header: { 'content-type': 'application/json' }, }); this._ws.onOpen(() => { console.log('[NetworkManager] Connected to server'); this._connected = true; this._connecting = false; this._reconnectAttempts = 0; this._startHeartbeat(); this._emit('connected'); resolve(true); }); this._ws.onMessage((res) => { this._handleMessage(res.data); }); this._ws.onError((err) => { console.error('[NetworkManager] WebSocket error:', err); this._connecting = false; this._emit('error', err); resolve(false); }); this._ws.onClose((res) => { console.log('[NetworkManager] Connection closed:', res.code, res.reason); this._connected = false; this._connecting = false; this._stopHeartbeat(); this._emit('disconnected', { code: res.code, reason: res.reason }); // Auto-reconnect if needed if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) { this._attemptReconnect(); } }); } catch (e) { console.error('[NetworkManager] Failed to create WebSocket:', e); this._connecting = false; resolve(false); } }); } /** * Disconnect from the server. */ disconnect() { this._shouldReconnect = false; this._stopHeartbeat(); this._clearReconnectTimer(); if (this._ws) { try { this._ws.close({}); } catch (e) { // Ignore close errors } this._ws = null; } this._connected = false; this._connecting = false; this._roomId = null; this._playerSlot = 0; } /** * Send a message to the server. * @param {string} type - Message type from NET_MSG. * @param {object} [data={}] - Message payload. */ send(type, data = {}) { if (!this._connected || !this._ws) { console.warn('[NetworkManager] Cannot send, not connected'); return; } const message = JSON.stringify({ type, data, playerId: this._playerId, roomId: this._roomId, timestamp: Date.now(), }); try { this._ws.send({ data: message }); } catch (e) { console.error('[NetworkManager] Send error:', e); } } /** * Create a new room on the server. */ createRoom() { this.send(NET_MSG.CREATE_ROOM, { playerId: this._playerId, }); } /** * Join an existing room. * @param {string} roomId - Room ID to join. */ joinRoom(roomId) { this.send(NET_MSG.JOIN_ROOM, { playerId: this._playerId, roomId: roomId, }); } /** * Send player input to the server. * @param {object} input - { direction, firing, x, y } */ sendInput(input) { this.send(NET_MSG.PLAYER_INPUT, input); } /** * Send player state for synchronization. * @param {object} state - { x, y, direction, hp, alive } */ sendState(state) { this.send(NET_MSG.PLAYER_STATE, state); } /** * Send bullet fire event. * @param {object} bulletData - { x, y, direction } */ sendBulletFire(bulletData) { this.send(NET_MSG.BULLET_FIRE, bulletData); } // ============================================================ // 3v3 Team Methods // ============================================================ /** * Create a new team for 3v3 mode. */ createTeam() { this.send(NET_MSG.CREATE_TEAM, { playerId: this._playerId, }); } /** * Join an existing team by teamId. * @param {string} teamId - Team ID to join. */ joinTeam(teamId) { this.send(NET_MSG.JOIN_TEAM, { playerId: this._playerId, teamId, }); } /** * Leave the current team. */ leaveTeam() { this.send(NET_MSG.LEAVE_TEAM, { playerId: this._playerId, }); } /** * Toggle ready state in team room. * @param {boolean} ready - Whether the player is ready. */ teamReady(ready) { this.send(NET_MSG.TEAM_READY, { playerId: this._playerId, ready, }); } /** * Start matchmaking (leader only). */ startMatch() { this.send(NET_MSG.MATCH_START, { playerId: this._playerId, }); } /** * Cancel matchmaking (leader only). */ cancelMatch() { this.send(NET_MSG.MATCH_CANCEL, { playerId: this._playerId, }); } /** * Kick a player from the team (leader only). * @param {string} targetPlayerId - Player ID to kick. */ kickPlayer(targetPlayerId) { this.send(NET_MSG.TEAM_KICK, { playerId: this._playerId, targetPlayerId, }); } /** * Disband the team (leader only). */ disbandTeam() { this.send(NET_MSG.TEAM_DISBAND, { playerId: this._playerId, }); } /** * Start solo matchmaking for 3v3. */ soloMatch() { this.send(NET_MSG.SOLO_MATCH, { playerId: this._playerId, }); } /** * Attempt to reconnect to an ongoing team game. * @param {string} teamId - Team room ID. */ reconnectToTeam(teamId) { this.send(NET_MSG.RECONNECT, { teamId, playerId: this._playerId, }); } /** * Register a handler for a message type. * @param {string} type - Message type. * @param {Function} handler - Callback function(data). * @returns {Function} Unsubscribe function. */ on(type, handler) { if (!this._handlers.has(type)) { this._handlers.set(type, []); } this._handlers.get(type).push(handler); return () => { const list = this._handlers.get(type); if (list) { const idx = list.indexOf(handler); if (idx !== -1) list.splice(idx, 1); } }; } /** * Remove a handler for a message type. * @param {string} type * @param {Function} handler */ off(type, handler) { const list = this._handlers.get(type); if (list) { const idx = list.indexOf(handler); if (idx !== -1) list.splice(idx, 1); } } /** * Remove all handlers. */ clearHandlers() { this._handlers.clear(); } // ============================================================ // Private Methods // ============================================================ /** * Handle incoming WebSocket message. * @private */ _handleMessage(rawData) { try { const msg = JSON.parse(rawData); const { type, data } = msg; // Handle system messages if (type === NET_MSG.PONG) { this._latency = Date.now() - this._lastPingTime; this._resetHeartbeatTimeout(); return; } if (type === NET_MSG.ROOM_CREATED) { this._roomId = data.roomId; this._playerSlot = 1; // Creator is player 1 } else if (type === NET_MSG.ROOM_JOINED) { this._roomId = data.roomId; this._playerSlot = data.playerSlot || 2; } // Emit to registered handlers this._emit(type, data); } catch (e) { console.error('[NetworkManager] Failed to parse message:', e, rawData); } } /** * Emit an event to registered handlers. * @private */ _emit(type, data) { const list = this._handlers.get(type); if (list) { for (const handler of list) { try { handler(data); } catch (e) { console.error(`[NetworkManager] Handler error for "${type}":`, e); } } } } /** * Start heartbeat ping/pong. * @private */ _startHeartbeat() { this._stopHeartbeat(); this._heartbeatInterval = setInterval(() => { if (this._connected) { this._lastPingTime = Date.now(); this.send(NET_MSG.PING); this._startHeartbeatTimeout(); } }, this._heartbeatMs); } /** * Stop heartbeat. * @private */ _stopHeartbeat() { if (this._heartbeatInterval) { clearInterval(this._heartbeatInterval); this._heartbeatInterval = null; } this._resetHeartbeatTimeout(); } /** * Start heartbeat timeout (disconnect if no pong received). * @private */ _startHeartbeatTimeout() { this._resetHeartbeatTimeout(); this._heartbeatTimeout = setTimeout(() => { console.warn('[NetworkManager] Heartbeat timeout, disconnecting'); this.disconnect(); }, this._heartbeatTimeoutMs); } /** * Reset heartbeat timeout. * @private */ _resetHeartbeatTimeout() { if (this._heartbeatTimeout) { clearTimeout(this._heartbeatTimeout); this._heartbeatTimeout = null; } } /** * Attempt to reconnect to the server. * @private */ _attemptReconnect() { this._clearReconnectTimer(); this._reconnectAttempts++; console.log(`[NetworkManager] Reconnect attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts}`); this._emit('reconnecting', { attempt: this._reconnectAttempts }); this._reconnectTimer = setTimeout(() => { this.connect(this._serverUrl); }, this._reconnectDelay * this._reconnectAttempts); } /** * Clear reconnect timer. * @private */ _clearReconnectTimer() { if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } } /** * Generate a unique player ID. * @private * @returns {string} */ _generatePlayerId() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let id = 'p_'; for (let i = 0; i < 8; i++) { id += chars.charAt(Math.floor(Math.random() * chars.length)); } return id + '_' + Date.now().toString(36); } // ============================================================ // Getters // ============================================================ /** Whether currently connected. */ get connected() { return this._connected; } /** Current room ID. */ get roomId() { return this._roomId; } /** Player slot (1 or 2). */ get playerSlot() { return this._playerSlot; } /** Player unique ID. */ get playerId() { return this._playerId; } /** Current latency in ms. */ get latency() { return this._latency; } /** Whether currently connecting. */ get connecting() { return this._connecting; } } module.exports = NetworkManager;