1119 lines
36 KiB
TypeScript
1119 lines
36 KiB
TypeScript
/**
|
||
* 聊天 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),
|
||
};
|
||
}
|
||
}
|