Files
whale-town-end-v2/src/gateway/chat/chat.gateway.ts
2026-07-22 13:41:28 +08:00

1119 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 聊天 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 { SocialService } from '../../business/social/social.service';
import {
getTestLabPresence,
getTestLabPresences,
removeTestLabPresence,
TestLabPresence,
upsertTestLabPresence,
} from '../../business/admin/test_lab_presence.registry';
/** WebSocket 服务器默认端口 */
const DEFAULT_WEBSOCKET_PORT = 3001;
/** 默认地图 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<void>;
}
interface MapPositionMessage {
mapId: string;
x: number;
y: 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<string, number>;
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<string, ExtendedWebSocket>();
private mapRooms = new Map<string, Set<string>>();
private lastWelcomeAtByUserId = new Map<string, number>();
constructor(
private readonly chatService: ChatService,
private readonly socialService: SocialService,
) {}
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('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.socialService.setRealtimeGateway(this);
this.logger.log(`WebSocket服务器启动成功端口: ${port},路径: /game`);
}
async onModuleDestroy() {
if (this.server) {
this.server.close();
this.logger.log('WebSocket服务器已关闭');
}
}
/**
* 处理原始消息 - 协议解析
*
* @param ws WebSocket 连接实例
* @param data 原始消息数据
*/
private async handleRawMessage(ws: ExtendedWebSocket, data: WebSocket.RawData): Promise<void> {
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;
this.logger.log(`收到消息: ${ws.id}, 类型: ${messageType}`);
switch (messageType) {
case 'login':
await this.handleLogin(ws, message);
break;
case 'logout':
await this.handleLogout(ws);
break;
case 'chat':
await this.handleChat(ws, message);
break;
case 'dm_send':
await this.handleDirectMessage(ws, message);
break;
case 'dm_read':
await this.handleDirectMessageRead(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 '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}`);
}
}
/**
* 处理登录 - 协议转换后调用业务层
*
* @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})`);
await this.socialService.notifyPresenceChanged(String(result.userId), true);
} 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 {
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;
}
const scope = String(message.scope || 'local').trim().toLowerCase();
if (scope === 'private' || scope === 'whisper' || scope === 'dm') {
await this.handleDirectMessage(ws, message);
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),
});
if (result.success) {
this.sendMessage(ws, {
t: 'chat_sent',
messageId: result.messageId,
message: '消息发送成功'
});
} else {
this.sendMessage(ws, {
t: 'chat_error',
code: this.toClientErrorCode(result.error),
message: result.error || '消息发送失败'
});
}
} catch (error) {
this.logger.error('聊天处理失败', error);
this.sendError(ws, '聊天处理失败');
}
}
private async handleDirectMessage(ws: ExtendedWebSocket, message: any) {
if (!ws.authenticated || !ws.userId) {
this.sendError(ws, '请先登录');
return;
}
const targetUserId = String(message.targetUserId || message.target_user_id || message.userId || message.user_id || '').trim();
const content = String(message.content || message.txt || '').trim();
if (!/^\d+$/.test(targetUserId) || !content) {
this.sendMessage(ws, { t: 'chat_error', code: 'CHAT_ERROR', message: '私聊目标或内容无效' });
return;
}
try {
const result = await this.socialService.sendDirectMessage(BigInt(ws.userId), BigInt(targetUserId), content);
this.sendMessage(ws, { t: 'chat_sent', messageId: result.id, message: '消息发送成功' });
} catch (error) {
this.sendMessage(ws, { t: 'chat_error', code: this.toClientErrorCode((error as Error).message), message: (error as Error).message || '私聊发送失败' });
}
}
private async handleDirectMessageRead(ws: ExtendedWebSocket, message: any) {
if (!ws.authenticated || !ws.userId) {
this.sendError(ws, '请先登录');
return;
}
const targetUserId = String(message.userId || message.user_id || message.targetUserId || '').trim();
if (!/^\d+$/.test(targetUserId)) {
this.sendError(ws, '私聊对象无效');
return;
}
try {
const result = await this.socialService.markConversationRead(BigInt(ws.userId), BigInt(targetUserId));
this.sendMessage(ws, { t: 'dm_read_success', ...result, userId: targetUserId });
} catch (error) {
this.sendError(ws, (error as Error).message || '标记私聊已读失败');
}
}
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;
}
try {
const request = await this.socialService.createFriendRequest(BigInt(String(ws.userId)), BigInt(String(friendUserId)));
this.sendMessage(ws, { t: 'friend_request_sent', request });
} catch (error) {
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '好友请求发送失败' });
}
}
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;
}
try {
const request = await this.socialService.createFriendRequest(BigInt(String(ws.userId)), BigInt(String(friendUserId)));
this.sendMessage(ws, { t: 'friend_request_sent', request });
} catch (error) {
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '好友请求发送失败' });
}
}
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;
}
try {
const requests = await this.socialService.getFriendRequests(BigInt(String(ws.userId)));
const request = requests.find((item) => item.requester.id === String(friendUserId));
if (!request) throw new Error('好友请求不存在或已过期');
const result = await this.socialService.acceptFriendRequest(BigInt(String(ws.userId)), BigInt(request.id));
this.sendMessage(ws, { t: 'friend_added', friend: this.legacyFriend(result.friend) });
await this.sendFriendList(ws);
} catch (error) {
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '接受好友请求失败' });
}
}
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;
}
try {
const requests = await this.socialService.getFriendRequests(BigInt(String(ws.userId)));
const request = requests.find((item) => item.requester.id === String(friendUserId));
if (!request) throw new Error('好友请求不存在或已过期');
await this.socialService.rejectFriendRequest(BigInt(String(ws.userId)), BigInt(request.id));
this.sendMessage(ws, { t: 'friend_request_rejected', userId: String(friendUserId) });
await this.sendFriendList(ws);
} catch (error) {
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '拒绝好友请求失败' });
}
}
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;
}
try {
await this.socialService.removeFriend(BigInt(String(ws.userId)), BigInt(String(friendUserId)));
this.sendMessage(ws, { t: 'friend_removed', friendUserId: String(friendUserId) });
await this.sendFriendList(ws);
} catch (error) {
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '移除好友失败' });
}
}
private async handleFriendList(ws: ExtendedWebSocket) {
if (!ws.authenticated) {
this.sendError(ws, '请先登录');
return;
}
await this.sendFriendList(ws);
}
private async sendFriendList(ws: ExtendedWebSocket) {
try {
const userId = BigInt(String(ws.userId));
const [friends, requests] = await Promise.all([this.socialService.getFriends(userId), this.socialService.getFriendRequests(userId)]);
this.sendMessage(ws, { t: 'friend_list', friends: friends.map((friend) => this.legacyFriend(friend)), requests: requests.map((request) => ({ userId: request.requester.id, username: request.requester.nickname, createdAt: request.createdAt, requestId: request.id })) });
} catch (error) {
this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '获取好友列表失败' });
}
}
/**
* 处理位置更新
*
* @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;
}
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;
}
await this.chatService.updatePlayerPosition({
socketId: ws.id,
x: positionMessage.x,
y: positionMessage.y,
mapId: positionMessage.mapId,
});
const updatedSession = await this.chatService.getSession(ws.id);
const broadcastX = Number(updatedSession?.position?.x ?? positionMessage.x);
const broadcastY = Number(updatedSession?.position?.y ?? positionMessage.y);
const broadcastAppearance = updatedSession?.appearance;
if (mapChanged) {
this.broadcastToMap(oldMapId, {
t: 'player_left',
userId: ws.userId,
username: ws.username,
mapId: oldMapId
}, ws.id);
await this.sendMapPlayersSnapshot(ws, positionMessage.mapId);
}
const presencePayload = {
t: 'position_update',
userId: ws.userId,
username: ws.username,
x: broadcastX,
y: broadcastY,
mapId: positionMessage.mapId,
skinId: broadcastAppearance?.skinId,
avatarId: broadcastAppearance?.avatarId,
skinAsset: broadcastAppearance?.skinAsset,
cafeCompanion: updatedSession?.cafeCompanion ?? null,
movementLocked: Boolean(updatedSession?.movementLocked),
};
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<void> {
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;
}
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 });
const refreshedPresence = await this.chatService.refreshPlayerAppearance(ws.id);
if (!refreshedPresence) {
this.sendError(ws, '外观刷新失败');
return;
}
ws.currentMap = mapId;
ws.worldReady = true;
this.joinMapRoom(ws.id, mapId);
this.sendMessage(ws, { t: 'world_ready_success', mapId });
await this.sendMapPlayersSnapshot(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),
}, 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<void> {
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<void> {
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);
}
/**
* 处理切换地图
*
* @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.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 async broadcastToMap(mapId: string, data: any, excludeId?: string): Promise<void> {
const room = this.mapRooms.get(mapId);
if (!room) return;
for (const clientId of room) {
if (clientId !== excludeId) {
const client = this.clients.get(clientId);
if (client && client.authenticated && client.readyState === WebSocket.OPEN) {
if (data?.t === 'chat_render' && data?.fromUserId && client.userId && !(await this.socialService.canSeeChat(String(data.fromUserId), String(client.userId)))) continue;
this.sendMessage(client, data);
}
}
}
}
public async broadcastToAll(data: any, excludeId?: string): Promise<void> {
for (const [clientId, client] of this.clients) {
if (clientId === excludeId) continue;
if (client.authenticated && client.readyState === WebSocket.OPEN) {
if (data?.t === 'chat_render' && data?.fromUserId && client.userId && !(await this.socialService.canSeeChat(String(data.fromUserId), String(client.userId)))) continue;
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<string, number> {
const counts: Record<string, number> = {};
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;
}
/** 管理端测试实验室使用:返回真实在线玩家,绝不包含合成假人。 */
public getOnlineWorldPlayers(): Array<{ userId: string; username: string; mapId: string }> {
return [...this.clients.values()]
.filter((client) => client.authenticated && client.worldReady && client.userId && client.currentMap)
.map((client) => ({ userId: String(client.userId), username: String(client.username || ''), mapId: String(client.currentMap) }));
}
public setTestLabPresence(presence: TestLabPresence): void {
const previous = getTestLabPresence(presence.userId);
const current = upsertTestLabPresence(presence);
if (previous?.online && (!current.online || previous.mapId !== current.mapId)) {
this.broadcastToMap(previous.mapId, { t: 'player_left', userId: previous.userId, username: previous.nickname, mapId: previous.mapId });
}
if (!current.online) return;
const payload = {
t: previous?.online && previous.mapId === current.mapId ? 'position_update' : 'player_joined',
userId: current.userId,
username: current.nickname,
mapId: current.mapId,
x: current.x,
y: current.y,
skinId: current.skinId,
avatarId: current.avatarId,
appearance: { skinId: current.skinId, avatarId: current.avatarId },
};
this.broadcastToMap(current.mapId, payload);
}
public removeTestLabActor(userId: string): void {
const previous = removeTestLabPresence(userId);
if (previous?.online) {
this.broadcastToMap(previous.mapId, { t: 'player_left', userId: previous.userId, username: previous.nickname, mapId: previous.mapId });
}
}
public broadcastTestLabChat(actor: TestLabPresence, content: string, scope: 'local' | 'global' = 'local'): void {
const payload = {
t: 'chat_render',
from: actor.nickname,
fromUserId: actor.userId,
txt: content,
bubble: true,
timestamp: new Date().toISOString(),
messageId: `test_${Date.now()}_${actor.userId}`,
mapId: actor.mapId,
scope,
testLab: true,
};
if (scope === 'global') this.broadcastToAll(payload);
else this.broadcastToMap(actor.mapId, payload);
}
// ========== 私有辅助方法 ==========
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 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';
}
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,
};
}
private async sendMapPlayersSnapshot(ws: ExtendedWebSocket, mapId?: string): Promise<void> {
const normalizedMapId = String(mapId || DEFAULT_MAP_ID).trim();
if (!normalizedMapId) return;
const activeUserIds = new Set<string>();
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)));
const testPlayers = getTestLabPresences(normalizedMapId).map((player) => ({
userId: player.userId,
username: player.nickname,
mapId: player.mapId,
x: player.x,
y: player.y,
skinId: player.skinId,
avatarId: player.avatarId,
appearance: { skinId: player.skinId, avatarId: player.avatarId },
cafeCompanion: null,
movementLocked: false,
}));
this.sendMessage(ws, {
t: 'map_players_snapshot',
mapId: normalizedMapId,
players: [...players, ...testPlayers].filter((player) => String(player.userId) !== String(ws.userId)),
});
}
private async cleanupClient(ws: ExtendedWebSocket, reason: 'manual' | 'timeout' | 'disconnect' = 'disconnect') {
try {
if (ws.authenticated && 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.id) {
await this.chatService.handlePlayerLogout(ws.id, reason);
if (ws.userId) await this.socialService.notifyPresenceChanged(String(ws.userId), false);
}
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)}`;
}
private legacyFriend(profile: any) {
return {
userId: String(profile.id),
username: String(profile.nickname || profile.username || '玩家'),
online: Boolean(profile.online),
room_visitable: Boolean(profile.room_visitable),
};
}
}