/** * 聊天 WebSocket 网关 * * 功能描述: * - 处理 WebSocket 协议连接和消息 * - 只做协议转换,不包含业务逻辑 * - 将消息路由到 Business 层处理 * * 架构层级:Gateway Layer(网关层) * * 职责: * - WebSocket 连接管理 * - 消息协议解析 * - 路由到业务层 * - 错误转换 * * WebSocket 事件: * - connection: 客户端连接事件 * - message: 消息接收事件(login/logout/chat/position) * - close: 客户端断开事件 * - error: 错误处理事件 * * 最近修改: * - 2026-01-14: 代码规范优化 - 提取常量、替换弃用API (修改者: moyin) * - 2026-01-14: 代码规范优化 - 完善注释规范 (修改者: moyin) * * @author moyin * @version 1.0.2 * @since 2026-01-14 * @lastModified 2026-01-14 */ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import * as WebSocket from 'ws'; import { ChatService } from '../../business/chat/chat.service'; import { WorldNpcService } from '../../business/world_npc/world_npc.service'; /** WebSocket 服务器默认端口 */ const DEFAULT_WEBSOCKET_PORT = 3001; const WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000; /** 默认地图 ID */ const DEFAULT_MAP_ID = 'whale_port'; /** * 扩展的 WebSocket 接口 */ interface ExtendedWebSocket extends WebSocket { id: string; isAlive?: boolean; authenticated?: boolean; userId?: string; username?: string; sessionId?: string; currentMap?: string; worldReady?: boolean; welcomed?: boolean; messageQueue?: Promise; movementSequence?: number; guest?: boolean; lastNpcInteractionAt?: number; } interface MapPositionMessage { mapId: string; x: number; y: number; direction: 'down' | 'up' | 'right' | 'left'; movementState: 'idle' | 'walk'; sequence?: number; } const WELCOME_RECONNECT_GRACE_MS = 15_000; /** * WebSocket 网关接口 - 供业务层调用 */ export interface IChatWebSocketGateway { sendToPlayer(socketId: string, data: any): void; broadcastToMap(mapId: string, data: any, excludeId?: string): void; broadcastToAll(data: any, excludeId?: string): void; getConnectionCount(): number; getAuthenticatedConnectionCount(): number; getMapPlayerCounts(): Record; getMapPlayers(mapId: string): string[]; } @Injectable() /** * 聊天 WebSocket 网关类 * * 职责: * - 管理 WebSocket 客户端连接 * - 解析和路由 WebSocket 消息 * - 管理地图房间和玩家广播 * * 主要方法: * - sendToPlayer() - 向指定玩家发送消息 * - broadcastToMap() - 向地图内所有玩家广播 * - getConnectionCount() - 获取连接数统计 * * 使用场景: * - 游戏内实时聊天通信 * - 玩家位置同步广播 */ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, IChatWebSocketGateway { private server: WebSocket.Server; private readonly logger = new Logger(ChatWebSocketGateway.name); private clients = new Map(); private mapRooms = new Map>(); private lastWelcomeAtByUserId = new Map(); private heartbeatTimer?: NodeJS.Timeout; private npcActionTimer?: NodeJS.Timeout; private npcTickRunning = false; constructor( private readonly chatService: ChatService, private readonly worldNpcService: WorldNpcService, ) {} async onModuleInit() { const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT) : DEFAULT_WEBSOCKET_PORT; this.server = new WebSocket.Server({ port, path: '/game' }); this.server.on('connection', (ws: ExtendedWebSocket) => { ws.id = this.generateClientId(); ws.isAlive = true; ws.authenticated = false; ws.worldReady = false; ws.welcomed = false; ws.messageQueue = Promise.resolve(); this.clients.set(ws.id, ws); this.logger.log(`新的WebSocket连接: ${ws.id}`); ws.on('message', (data) => { ws.messageQueue = (ws.messageQueue || Promise.resolve()) .then(() => this.handleRawMessage(ws, data)) .catch((error) => this.logger.error(`消息处理失败: ${ws.id}`, error)); }); ws.on('pong', () => { ws.isAlive = true; }); ws.on('close', (code, reason) => this.handleClose(ws, code, reason)); ws.on('error', (error) => this.handleError(ws, error)); this.sendMessage(ws, { type: 'connected', message: '连接成功', socketId: ws.id }); }); // 设置网关引用到业务层 this.chatService.setWebSocketGateway(this); this.heartbeatTimer = setInterval(() => this.checkClientHeartbeats(), WEBSOCKET_HEARTBEAT_INTERVAL_MS); this.npcActionTimer = setInterval(() => void this.tickNpcActions(), 1_000); this.heartbeatTimer.unref(); this.npcActionTimer.unref(); this.logger.log(`WebSocket服务器启动成功,端口: ${port},路径: /game`); } async onModuleDestroy() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; } if (this.npcActionTimer) { clearInterval(this.npcActionTimer); this.npcActionTimer = undefined; } if (this.server) { this.server.close(); this.logger.log('WebSocket服务器已关闭'); } } /** * 处理原始消息 - 协议解析 * * @param ws WebSocket 连接实例 * @param data 原始消息数据 */ private async handleRawMessage(ws: ExtendedWebSocket, data: WebSocket.RawData): Promise { try { const message = JSON.parse(data.toString()); await this.routeMessage(ws, message); } catch (error) { this.logger.error('解析消息失败', error); this.sendError(ws, '消息格式错误'); } } /** * 消息路由 - 根据类型分发到业务层 * * @param ws WebSocket 连接实例 * @param message 解析后的消息对象 */ private async routeMessage(ws: ExtendedWebSocket, message: any) { const messageType = message.type || message.t; if (messageType !== 'position' && messageType !== 'ping') { this.logger.log(`收到消息: ${ws.id}, 类型: ${messageType}`); } if (ws.guest && !['ping', 'world_ready', 'logout', 'npc_session_end'].includes(messageType)) { this.sendMessage(ws, { t: 'error', code: 'GUEST_READ_ONLY', message: '游客模式只能参观' }); return; } switch (messageType) { case 'ping': ws.isAlive = true; this.sendMessage(ws, { t: 'pong', timestamp: Date.now() }); break; case 'login': await this.handleLogin(ws, message); break; case 'guest_login': await this.handleGuestLogin(ws); break; case 'logout': await this.handleLogout(ws); break; case 'chat': await this.handleChat(ws, message); break; case 'position': await this.handlePosition(ws, message); break; case 'change_map': await this.handleChangeMap(ws, message); break; case 'world_ready': await this.handleWorldReady(ws, message); break; case 'npc_interact': await this.handleNpcInteract(ws, message); break; case 'npc_session_end': await this.handleNpcSessionEnd(ws, message); break; case 'leave_world': await this.handleLeaveWorld(ws, message); break; case 'appearance_changed': await this.handleAppearanceChanged(ws); break; case 'friend_add': await this.handleFriendAdd(ws, message); break; case 'friend_request': await this.handleFriendRequest(ws, message); break; case 'friend_accept': await this.handleFriendAccept(ws, message); break; case 'friend_reject': await this.handleFriendReject(ws, message); break; case 'friend_remove': await this.handleFriendRemove(ws, message); break; case 'friend_list': await this.handleFriendList(ws); break; default: this.logger.warn(`未知消息类型: ${messageType}`); this.sendError(ws, `未知消息类型: ${messageType}`); } } private async handleGuestLogin(ws: ExtendedWebSocket): Promise { ws.authenticated = true; ws.guest = true; ws.username = '游客'; ws.currentMap = DEFAULT_MAP_ID; ws.worldReady = false; this.sendMessage(ws, { t: 'guest_login_success', currentMap: DEFAULT_MAP_ID, readOnly: true }); } private async handleNpcInteract(ws: ExtendedWebSocket, message: any): Promise { if (!ws.authenticated || ws.guest || !ws.userId || !ws.worldReady) { this.sendMessage(ws, { t: 'npc_interaction_error', code: 'AUTH_REQUIRED', message: '请登录后再与NPC交流' }); return; } const now = Date.now(); if (ws.lastNpcInteractionAt && now - ws.lastNpcInteractionAt < 1_000) { this.sendMessage(ws, { t: 'npc_interaction_error', code: 'RATE_LIMITED', message: '请稍后再交流' }); return; } const npcId = String(message.npcId || message.npc_id || '').trim(); if (!npcId) { this.sendMessage(ws, { t: 'npc_interaction_error', code: 'NPC_REQUIRED', message: 'NPC不能为空' }); return; } const session = await this.chatService.getSession(ws.id); if (!session) { this.sendMessage(ws, { t: 'npc_interaction_error', code: 'SESSION_EXPIRED', message: '会话已失效,请重新登录' }); return; } ws.lastNpcInteractionAt = now; try { const result = await this.worldNpcService.interact({ npcId, userId: String(ws.userId), username: String(ws.username || session.username || '居民'), mapId: String(session.currentMap || ws.currentMap || DEFAULT_MAP_ID), x: Number(session.position?.x), y: Number(session.position?.y), message: String(message.message || '').trim(), sessionId: String(message.sessionId || message.session_id || ''), }); this.sendMessage(ws, { t: 'npc_interaction_success', ...result }); this.broadcastToMap(session.currentMap, { t: 'npc_spoke', ...result, targetUserId: ws.userId, targetUsername: ws.username, }); } catch (error) { this.sendMessage(ws, { t: 'npc_interaction_error', code: 'INTERACTION_REJECTED', message: error instanceof Error ? error.message : 'NPC暂时无法回应', npcId, }); } } private async handleNpcSessionEnd(ws: ExtendedWebSocket, message: any): Promise { if (!ws.authenticated || ws.guest || !ws.userId) return; await this.worldNpcService.endResidentSession( String(message.npcId || message.npc_id || ''), String(ws.userId), String(ws.username || ''), String(message.sessionId || message.session_id || ''), ); } /** * 处理登录 - 协议转换后调用业务层 * * @param ws WebSocket 连接实例 * @param message 登录消息(包含 token) */ private async handleLogin(ws: ExtendedWebSocket, message: any) { if (!message.token) { this.sendError(ws, 'Token不能为空'); return; } try { const result = await this.chatService.handlePlayerLogin({ socketId: ws.id, token: message.token }); if (result.success) { this.disconnectOtherUserConnections(ws.id, String(result.userId || '')); ws.authenticated = true; ws.userId = result.userId; ws.username = result.username; ws.sessionId = result.sessionId; ws.currentMap = result.currentMap || DEFAULT_MAP_ID; this.sendMessage(ws, { t: 'login_success', sessionId: result.sessionId, userId: result.userId, username: result.username, currentMap: ws.currentMap }); this.logger.log(`用户登录成功: ${result.username} (${ws.id})`); } else { this.sendMessage(ws, { t: 'login_error', message: result.error || '登录失败' }); } } catch (error) { this.logger.error('登录处理失败', error); this.sendError(ws, '登录处理失败'); } } /** * 处理登出 * * @param ws WebSocket 连接实例 */ private async handleLogout(ws: ExtendedWebSocket) { if (!ws.authenticated) { this.sendError(ws, '用户未登录'); return; } try { if (!ws.guest) await this.chatService.handlePlayerLogout(ws.id, 'manual'); this.cleanupClient(ws); this.sendMessage(ws, { t: 'logout_success', message: '登出成功' }); ws.close(1000, '用户主动登出'); } catch (error) { this.logger.error('登出处理失败', error); this.sendError(ws, '登出处理失败'); } } /** * 处理聊天消息 * * @param ws WebSocket 连接实例 * @param message 聊天消息(包含 content, scope, mapId) */ private async handleChat(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } if (!message.content) { this.sendError(ws, '消息内容不能为空'); return; } try { const result = await this.chatService.sendChatMessage({ socketId: ws.id, content: message.content, scope: message.scope || 'local', mapId: message.mapId || ws.currentMap, // 支持指定目标地图 targetUserId: message.targetUserId || message.target_user_id, targetUsername: message.targetUsername || message.target_username, privateContext: message.privateContext || message.private_context, bubble: Boolean(message.bubble ?? message.showBubble ?? message.show_bubble), worldBulletin: message.worldBulletin === true || message.world_bulletin === true, }); if (result.success) { this.sendMessage(ws, { t: 'chat_sent', messageId: result.messageId, charged: result.charged, balance: result.balance, worldBulletin: message.worldBulletin === true || message.world_bulletin === true, message: '消息发送成功' }); } else { this.sendMessage(ws, { t: 'chat_error', code: this.toClientErrorCode(result.error), worldBulletin: message.worldBulletin === true || message.world_bulletin === true, message: result.error || '消息发送失败' }); } } catch (error) { this.logger.error('聊天处理失败', error); this.sendError(ws, '聊天处理失败'); } } private async handleFriendAdd(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const friendUserId = message.friendUserId || message.friend_user_id || message.userId || message.user_id; if (!friendUserId) { this.sendMessage(ws, { t: 'friend_error', message: '好友用户ID不能为空' }); return; } const result = await this.chatService.addFriend({ socketId: ws.id, friendUserId, friendUsername: message.friendUsername || message.friend_username || message.username, }); if (result.success) { this.sendMessage(ws, { t: 'friend_added', friend: result.friend, }); await this.sendFriendList(ws); return; } this.sendMessage(ws, { t: 'friend_error', code: this.toClientErrorCode(result.error), message: result.error || '添加好友失败', }); } private async handleFriendRequest(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const friendUserId = message.friendUserId || message.friend_user_id || message.userId || message.user_id; if (!friendUserId) { this.sendMessage(ws, { t: 'friend_error', message: '好友用户ID不能为空' }); return; } const result = await this.chatService.requestFriend({ socketId: ws.id, friendUserId, friendUsername: message.friendUsername || message.friend_username || message.username, }); if (result.success) { this.sendMessage(ws, { t: 'friend_request_sent', request: result.friendRequest, }); return; } this.sendMessage(ws, { t: 'friend_error', code: this.toClientErrorCode(result.error), message: result.error || '好友请求发送失败', }); } private async handleFriendAccept(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const friendUserId = message.friendUserId || message.friend_user_id || message.userId || message.user_id; if (!friendUserId) { this.sendMessage(ws, { t: 'friend_error', message: '好友用户ID不能为空' }); return; } const result = await this.chatService.acceptFriendRequest({ socketId: ws.id, friendUserId, friendUsername: message.friendUsername || message.friend_username || message.username, }); if (result.success) { this.sendMessage(ws, { t: 'friend_added', friend: result.friend, }); await this.sendFriendList(ws); return; } this.sendMessage(ws, { t: 'friend_error', code: this.toClientErrorCode(result.error), message: result.error || '接受好友请求失败', }); } private async handleFriendReject(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const friendUserId = message.friendUserId || message.friend_user_id || message.userId || message.user_id; if (!friendUserId) { this.sendMessage(ws, { t: 'friend_error', message: '好友用户ID不能为空' }); return; } const result = await this.chatService.rejectFriendRequest({ socketId: ws.id, friendUserId, friendUsername: message.friendUsername || message.friend_username || message.username, }); if (result.success) { this.sendMessage(ws, { t: 'friend_request_rejected', userId: friendUserId, }); await this.sendFriendList(ws); return; } this.sendMessage(ws, { t: 'friend_error', code: this.toClientErrorCode(result.error), message: result.error || '拒绝好友请求失败', }); } private async handleFriendRemove(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const friendUserId = message.friendUserId || message.friend_user_id || message.userId || message.user_id; if (!friendUserId) { this.sendMessage(ws, { t: 'friend_error', message: '好友用户ID不能为空' }); return; } const result = await this.chatService.removeFriend({ socketId: ws.id, friendUserId, }); if (result.success) { this.sendMessage(ws, { t: 'friend_removed', friendUserId, }); await this.sendFriendList(ws); return; } this.sendMessage(ws, { t: 'friend_error', code: this.toClientErrorCode(result.error), message: result.error || '移除好友失败', }); } private async handleFriendList(ws: ExtendedWebSocket) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } await this.sendFriendList(ws); } private async sendFriendList(ws: ExtendedWebSocket) { const result = await this.chatService.getFriends(ws.id); if (result.success) { this.sendMessage(ws, { t: 'friend_list', friends: result.friends || [], requests: result.requests || [], }); return; } this.sendMessage(ws, { t: 'friend_error', code: this.toClientErrorCode(result.error), message: result.error || '获取好友列表失败', }); } /** * 处理位置更新 * * @param ws WebSocket 连接实例 * @param message 位置消息(包含 x, y, mapId) */ private async handlePosition(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } if (!ws.worldReady) { this.sendError(ws, '请先进入世界'); return; } const positionMessage = this.normalizePositionMessage(message, ws.currentMap); if (!positionMessage) { this.sendError(ws, '位置消息无效'); return; } const nextSequence = positionMessage.sequence ?? Number(ws.movementSequence ?? 0) + 1; if (ws.movementSequence !== undefined && nextSequence <= ws.movementSequence) { return; } try { const oldMapId = ws.currentMap || DEFAULT_MAP_ID; const mapChanged = oldMapId !== positionMessage.mapId; // 如果切换地图,更新房间 if (mapChanged) { this.leaveMapRoom(ws.id, oldMapId); this.joinMapRoom(ws.id, positionMessage.mapId); ws.currentMap = positionMessage.mapId; } const updatedPresence = await this.chatService.updatePlayerPositionAndGetPresence({ socketId: ws.id, x: positionMessage.x, y: positionMessage.y, mapId: positionMessage.mapId, direction: positionMessage.direction, movementState: positionMessage.movementState, sequence: nextSequence, }); if (!updatedPresence) { this.sendMessage(ws, { type: 'error', code: 'SESSION_EXPIRED', message: '会话不存在,请重新登录' }); return; } ws.movementSequence = nextSequence; if (mapChanged) { this.broadcastToMap(oldMapId, { t: 'player_left', userId: ws.userId, username: ws.username, mapId: oldMapId }, ws.id); await this.sendMapPlayersSnapshot(ws, positionMessage.mapId); this.sendMapNpcSnapshot(ws, positionMessage.mapId); } const presencePayload = { t: 'position_update', userId: ws.userId, username: ws.username, x: updatedPresence.x, y: updatedPresence.y, mapId: positionMessage.mapId, skinId: updatedPresence.skinId, avatarId: updatedPresence.avatarId, skinAsset: updatedPresence.skinAsset, cafeCompanion: updatedPresence.cafeCompanion ?? null, movementLocked: Boolean(updatedPresence.movementLocked), direction: updatedPresence.direction || positionMessage.direction, movementState: updatedPresence.movementState || positionMessage.movementState, sequence: Number(updatedPresence.sequence ?? nextSequence), }; this.broadcastToMap(positionMessage.mapId, mapChanged ? { ...presencePayload, t: 'player_joined' } : presencePayload, ws.id); } catch (error) { this.logger.error('位置更新处理失败', error); this.sendError(ws, '位置更新处理失败'); } } private async handleWorldReady(ws: ExtendedWebSocket, message: any): Promise { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const mapId = String(message.mapId || message.map_id || ws.currentMap || DEFAULT_MAP_ID).trim(); const x = Number(message.x ?? 400); const y = Number(message.y ?? 300); if (!mapId || !Number.isFinite(x) || !Number.isFinite(y)) { this.sendError(ws, '世界就绪消息无效'); return; } if (ws.guest) { const guestMapId = DEFAULT_MAP_ID; if (ws.currentMap) this.leaveMapRoom(ws.id, ws.currentMap); ws.currentMap = guestMapId; ws.worldReady = true; this.joinMapRoom(ws.id, guestMapId); this.sendMessage(ws, { t: 'world_ready_success', mapId: guestMapId, readOnly: true }); await this.sendMapPlayersSnapshot(ws, guestMapId); this.sendMapNpcSnapshot(ws, guestMapId); return; } const direction = this.normalizeDirection(message.direction); const movementState = this.normalizeMovementState(message.movementState ?? message.movement_state, 'idle'); const sequence = this.normalizeSequence(message.sequence) ?? 0; const wasWorldReady = Boolean(ws.worldReady); const oldMapId = ws.currentMap || DEFAULT_MAP_ID; const mapChanged = wasWorldReady && oldMapId !== mapId; if (mapChanged) { this.broadcastToMap(oldMapId, { t: 'player_left', userId: ws.userId, username: ws.username, mapId: oldMapId, }, ws.id); this.leaveMapRoom(ws.id, oldMapId); } await this.chatService.updatePlayerPosition({ socketId: ws.id, mapId, x, y, direction, movementState, sequence, }); const refreshedPresence = await this.chatService.refreshPlayerAppearance(ws.id); if (!refreshedPresence) { this.sendError(ws, '外观刷新失败'); return; } ws.currentMap = mapId; ws.worldReady = true; ws.movementSequence = sequence; this.joinMapRoom(ws.id, mapId); this.sendMessage(ws, { t: 'world_ready_success', mapId }); await this.sendMapPlayersSnapshot(ws, mapId); this.sendMapNpcSnapshot(ws, mapId); const appearance = refreshedPresence.appearance; this.broadcastToMap(mapId, { t: 'player_joined', userId: ws.userId, username: ws.username, x: Number(refreshedPresence.x ?? x), y: Number(refreshedPresence.y ?? y), mapId, skinId: appearance?.skinId, avatarId: appearance?.avatarId, skinAsset: appearance?.skinAsset, cafeCompanion: refreshedPresence.cafeCompanion ?? null, movementLocked: Boolean(refreshedPresence.movementLocked), direction: refreshedPresence.direction || direction, movementState: refreshedPresence.movementState || movementState, sequence: Number(refreshedPresence.sequence ?? sequence), }, ws.id); if (!wasWorldReady && !ws.welcomed) { if (this.shouldBroadcastWelcome(String(ws.userId || ''))) { this.broadcastToWorldReady({ t: 'system_presence', scope: 'global', event: 'player_online', userId: ws.userId, username: ws.username, content: `欢迎 ${ws.username || '新朋友'} 来到鲸鱼镇!`, timestamp: new Date().toISOString(), }); } ws.welcomed = true; } } private async handleLeaveWorld(ws: ExtendedWebSocket, message: any): Promise { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const oldMapId = ws.currentMap || DEFAULT_MAP_ID; if (ws.worldReady) { this.broadcastToMap(oldMapId, { t: 'player_left', userId: ws.userId, username: ws.username, mapId: oldMapId, }, ws.id); this.leaveMapRoom(ws.id, oldMapId); } const sceneId = String(message.sceneId || message.scene_id || 'private_space') .trim() .toLowerCase() .replace(/[^a-z0-9_-]/g, '_'); const privateMapId = `private:${ws.userId || 'unknown'}:${sceneId || 'private_space'}`; const session = await this.chatService.getSession(ws.id); await this.chatService.updatePlayerPosition({ socketId: ws.id, mapId: privateMapId, x: Number(session?.position?.x ?? 0), y: Number(session?.position?.y ?? 0), }); ws.currentMap = privateMapId; ws.worldReady = false; this.sendMessage(ws, { t: 'world_left', mapId: oldMapId, sceneId }); } private async handleAppearanceChanged(ws: ExtendedWebSocket): Promise { if (!ws.authenticated || !ws.worldReady) { this.sendError(ws, '请先进入世界'); return; } const presence = await this.chatService.refreshPlayerAppearance(ws.id); if (!presence) { this.sendError(ws, '外观刷新失败'); return; } this.broadcastToMap(presence.mapId, { t: 'appearance_changed', ...presence, }, ws.id); this.sendMessage(ws, { t: 'appearance_changed_success', mapId: presence.mapId, skinId: presence.skinId ?? presence.appearance?.skinId ?? '', }); } /** * 处理切换地图 * * @param ws WebSocket 连接实例 * @param message 切换地图消息(包含 mapId) */ private async handleChangeMap(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } if (!message.mapId) { this.sendError(ws, '地图ID不能为空'); return; } try { const oldMapId = ws.currentMap; const newMapId = message.mapId; const x = Number(message.x ?? 400); const y = Number(message.y ?? 300); // 如果地图相同,直接返回成功 if (oldMapId === newMapId) { this.sendMessage(ws, { t: 'map_changed', mapId: newMapId, message: '已在当前地图' }); return; } // 更新房间 this.leaveMapRoom(ws.id, oldMapId); this.joinMapRoom(ws.id, newMapId); ws.currentMap = newMapId; // 更新会话中的地图信息(使用默认位置) await this.chatService.updatePlayerPosition({ socketId: ws.id, x: Number.isFinite(x) ? x : 400, y: Number.isFinite(y) ? y : 300, mapId: newMapId, }); const updatedSession = await this.chatService.getSession(ws.id); // 通知客户端切换成功 this.sendMessage(ws, { t: 'map_changed', mapId: newMapId, oldMapId: oldMapId, message: '地图切换成功' }); // 向旧地图广播玩家离开 this.broadcastToMap(oldMapId, { t: 'player_left', userId: ws.userId, username: ws.username, mapId: oldMapId }); // 向新地图广播玩家加入 this.broadcastToMap(newMapId, { t: 'player_joined', userId: ws.userId, username: ws.username, x: Number.isFinite(x) ? x : 400, y: Number.isFinite(y) ? y : 300, mapId: newMapId, skinId: updatedSession?.appearance?.skinId, avatarId: updatedSession?.appearance?.avatarId, skinAsset: updatedSession?.appearance?.skinAsset, }, ws.id); await this.sendMapPlayersSnapshot(ws, newMapId); this.sendMapNpcSnapshot(ws, newMapId); this.logger.log(`用户切换地图: ${ws.username} (${oldMapId} -> ${newMapId})`); } catch (error) { this.logger.error('切换地图处理失败', error); this.sendError(ws, '切换地图处理失败'); } } /** * 处理连接关闭 * * @param ws WebSocket 连接实例 * @param code 关闭状态码 * @param reason 关闭原因 */ private handleClose(ws: ExtendedWebSocket, code: number, reason: Buffer) { this.logger.log(`WebSocket连接关闭: ${ws.id}`, { code, reason: reason?.toString() }); let logoutReason: 'manual' | 'timeout' | 'disconnect' = 'disconnect'; if (code === 1000) logoutReason = 'manual'; this.cleanupClient(ws, logoutReason); } /** * 处理错误 * * @param ws WebSocket 连接实例 * @param error 错误对象 */ private handleError(ws: ExtendedWebSocket, error: Error) { this.logger.error(`WebSocket错误: ${ws.id}`, error); } // ========== IChatWebSocketGateway 接口实现 ========== public sendToPlayer(socketId: string, data: any): void { const client = this.clients.get(socketId); if (client && client.readyState === WebSocket.OPEN) { this.sendMessage(client, data); } } public broadcastToMap(mapId: string, data: any, excludeId?: string): void { const room = this.mapRooms.get(mapId); if (!room) return; room.forEach(clientId => { if (clientId !== excludeId) { const client = this.clients.get(clientId); if (client && client.authenticated && client.readyState === WebSocket.OPEN) { this.sendMessage(client, data); } } }); } public broadcastToAll(data: any, excludeId?: string): void { this.clients.forEach((client, clientId) => { if (clientId === excludeId) return; if (client.authenticated && client.readyState === WebSocket.OPEN) { this.sendMessage(client, data); } }); } public getConnectionCount(): number { return this.clients.size; } public getAuthenticatedConnectionCount(): number { return Array.from(this.clients.values()).filter(c => c.authenticated).length; } public getMapPlayerCounts(): Record { const counts: Record = {}; this.mapRooms.forEach((clients, mapId) => { counts[mapId] = clients.size; }); return counts; } public getMapPlayers(mapId: string): string[] { const room = this.mapRooms.get(mapId); if (!room) return []; const players: string[] = []; room.forEach(clientId => { const client = this.clients.get(clientId); if (client?.authenticated && client.username) { players.push(client.username); } }); return players; } // ========== 私有辅助方法 ========== private sendMessage(ws: ExtendedWebSocket, data: any) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } } private sendError(ws: ExtendedWebSocket, message: string) { this.sendMessage(ws, { type: 'error', code: this.toClientErrorCode(message), message }); } private checkClientHeartbeats(): void { this.clients.forEach((client) => { if (client.isAlive === false) { this.logger.warn(`WebSocket心跳超时: ${client.id}`); client.terminate(); return; } client.isAlive = false; if (client.readyState === WebSocket.OPEN) { client.ping(); } }); } private async tickNpcActions(): Promise { if (this.npcTickRunning) return; this.npcTickRunning = true; try { const result = await this.worldNpcService.tick(); result.completed.forEach((completed) => this.broadcastToMap(completed.mapId, { t: 'npc_action_completed', ...completed, x: completed.action.toX, y: completed.action.toY, })); result.started.forEach((started) => this.broadcastToMap(started.mapId, { t: 'npc_action_started', ...started, })); result.conversations.forEach((conversation) => this.broadcastToMap(conversation.mapId, { t: 'npc_conversation', ...conversation, })); result.changedMaps.forEach((mapId) => this.broadcastMapNpcSnapshot(mapId)); } catch (error) { this.logger.error(`NPC 运行时 tick 失败: ${error instanceof Error ? error.message : error}`); } finally { this.npcTickRunning = false; } } private toClientErrorCode(message?: string): string { const normalizedMessage = String(message || ''); if (normalizedMessage.includes('会话不存在') || normalizedMessage.includes('重新登录')) { return 'SESSION_EXPIRED'; } if (normalizedMessage.includes('请先登录') || normalizedMessage.includes('Token')) { return 'AUTH_FAILED'; } if (normalizedMessage.includes('余额不足')) { return 'INSUFFICIENT_BALANCE'; } if (normalizedMessage.includes('钱包服务')) { return 'WALLET_UNAVAILABLE'; } return 'CHAT_ERROR'; } private joinMapRoom(clientId: string, mapId: string) { if (!this.mapRooms.has(mapId)) { this.mapRooms.set(mapId, new Set()); } this.mapRooms.get(mapId).add(clientId); } private leaveMapRoom(clientId: string, mapId: string) { const room = this.mapRooms.get(mapId); if (room) { room.delete(clientId); if (room.size === 0) this.mapRooms.delete(mapId); } } private normalizePositionMessage(message: any, currentMap?: string): MapPositionMessage | null { const mapId = String(message.mapId || message.map_id || currentMap || DEFAULT_MAP_ID).trim(); const x = Number(message.x); const y = Number(message.y); if (!mapId || !Number.isFinite(x) || !Number.isFinite(y)) { return null; } return { mapId, x, y, direction: this.normalizeDirection(message.direction), movementState: this.normalizeMovementState(message.movementState ?? message.movement_state, 'walk'), sequence: this.normalizeSequence(message.sequence), }; } private normalizeDirection(value: unknown): 'down' | 'up' | 'right' | 'left' { const normalized = String(value || '').trim().toLowerCase(); return normalized === 'up' || normalized === 'right' || normalized === 'left' ? normalized : 'down'; } private normalizeMovementState(value: unknown, fallback: 'idle' | 'walk'): 'idle' | 'walk' { const normalized = String(value || '').trim().toLowerCase(); return normalized === 'idle' || normalized === 'walk' ? normalized : fallback; } private normalizeSequence(value: unknown): number | undefined { if (value === undefined || value === null || value === '') return undefined; const sequence = Number(value); return Number.isSafeInteger(sequence) && sequence >= 0 ? sequence : undefined; } private async sendMapPlayersSnapshot(ws: ExtendedWebSocket, mapId?: string): Promise { const normalizedMapId = String(mapId || DEFAULT_MAP_ID).trim(); if (!normalizedMapId) return; const activeUserIds = new Set(); this.mapRooms.get(normalizedMapId)?.forEach((socketId) => { const client = this.clients.get(socketId); if (client?.worldReady && client.userId) activeUserIds.add(String(client.userId)); }); const players = (await this.chatService.getMapPlayerSnapshot(normalizedMapId, ws.id)) .filter((player) => activeUserIds.has(String(player.userId))); this.sendMessage(ws, { t: 'map_players_snapshot', mapId: normalizedMapId, players, }); } private sendMapNpcSnapshot(ws: ExtendedWebSocket, mapId?: string): void { const normalizedMapId = String(mapId || DEFAULT_MAP_ID).trim(); if (!normalizedMapId) return; const snapshot = this.worldNpcService.getMapSnapshot(normalizedMapId); this.sendMessage(ws, { t: 'npc_snapshot', ...snapshot, }); } private broadcastMapNpcSnapshot(mapId: string): void { const snapshot = this.worldNpcService.getMapSnapshot(mapId); this.broadcastToMap(mapId, { t: 'npc_snapshot', ...snapshot }); } private async cleanupClient(ws: ExtendedWebSocket, reason: 'manual' | 'timeout' | 'disconnect' = 'disconnect') { try { if (ws.authenticated && !ws.guest && ws.worldReady && ws.currentMap) { this.broadcastToMap(ws.currentMap, { t: 'player_left', userId: ws.userId, username: ws.username, mapId: ws.currentMap, }, ws.id); } if (ws.authenticated && !ws.guest && ws.id) { await this.chatService.handlePlayerLogout(ws.id, reason); } if (ws.currentMap) { this.leaveMapRoom(ws.id, ws.currentMap); } this.clients.delete(ws.id); } catch (error) { this.logger.error(`清理客户端失败: ${ws.id}`, error); } } private broadcastToWorldReady(data: any): void { this.clients.forEach((client) => { if (client.authenticated && client.worldReady && client.readyState === WebSocket.OPEN) { this.sendMessage(client, data); } }); } private shouldBroadcastWelcome(userId: string): boolean { if (!userId) return false; const now = Date.now(); const lastWelcomeAt = this.lastWelcomeAtByUserId.get(userId) || 0; if (now - lastWelcomeAt < WELCOME_RECONNECT_GRACE_MS) return false; this.lastWelcomeAtByUserId.set(userId, now); return true; } private disconnectOtherUserConnections(currentSocketId: string, userId: string): void { if (!userId) return; this.clients.forEach((client, socketId) => { if (socketId !== currentSocketId && String(client.userId || '') === userId) { client.close(4001, '账号已在新连接中登录'); } }); } private generateClientId(): string { return `ws_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } }