417 lines
16 KiB
TypeScript
417 lines
16 KiB
TypeScript
// server/websocket/minesweeperSocket.ts
|
||
import { Server as SocketIOServer } from 'socket.io';
|
||
import { Server as HTTPServer } from 'http';
|
||
import dotenv from 'dotenv';
|
||
|
||
// 加载环境变量
|
||
dotenv.config();
|
||
|
||
interface Cell {
|
||
isMine: boolean;
|
||
isRevealed: boolean;
|
||
isFlagged: boolean;
|
||
neighborMines: number;
|
||
isExploded?: boolean;
|
||
}
|
||
|
||
interface GameRoom {
|
||
roomId: string;
|
||
playerId: string;
|
||
board: Cell[][];
|
||
difficulty: string;
|
||
spectators: Set<string>;
|
||
highlightedCells: Set<string>; // 存储需要闪烁的格子坐标 "row,col"
|
||
invitePlayMode: boolean; // 是否邀请同玩模式
|
||
}
|
||
|
||
// 存储所有游戏房间
|
||
const gameRooms = new Map<string, GameRoom>();
|
||
|
||
// 获取 WebSocket 路径前缀 - 与客户端保持一致
|
||
function getSocketIoPath() {
|
||
const envApiUrl = process.env.REACT_APP_API_BASE_URL;
|
||
|
||
console.log('[Socket.IO Path Config] REACT_APP_API_BASE_URL:', envApiUrl);
|
||
console.log('[Socket.IO Path Config] NODE_ENV:', process.env.NODE_ENV);
|
||
|
||
if (!envApiUrl || envApiUrl.trim() === '') {
|
||
// 如果没有设置环境变量,根据 NODE_ENV 决定
|
||
const path = process.env.NODE_ENV === 'production' ? '/api/socket.io' : '/socket.io';
|
||
console.log('[Socket.IO Path Config] Using default path:', path);
|
||
return path;
|
||
}
|
||
|
||
// 根据Nginx转发规则调整生产环境路径
|
||
// Nginx转发到 http://localhost:5001/api/socket.io/,所以后端应该接受 /api/socket.io
|
||
if (process.env.NODE_ENV === 'production') {
|
||
console.log('[Socket.IO Path Config] Production mode, Nginx配置');
|
||
console.log('[Socket.IO Path Config] Frontend sends: /api/api/socket.io');
|
||
console.log('[Socket.IO Path Config] Nginx location = /api/api/socket.io/');
|
||
console.log('[Socket.IO Path Config] Nginx proxy_pass: http://localhost:5001/api/socket.io/');
|
||
console.log('[Socket.IO Path Config] Backend should accept: /api/socket.io');
|
||
// 使用 /api/socket.io 路径匹配Nginx转发结果
|
||
return '/api/socket.io';
|
||
}
|
||
|
||
// 如果是完整 URL,检查是否包含路径
|
||
if (envApiUrl.startsWith('http://') || envApiUrl.startsWith('https://')) {
|
||
const urlObj = new URL(envApiUrl);
|
||
// 如果原始 URL 包含路径,将其作为前缀
|
||
if (urlObj.pathname && urlObj.pathname !== '/') {
|
||
const path = `${urlObj.pathname}/socket.io`;
|
||
console.log('[Socket.IO Path Config] From URL pathname:', path);
|
||
return path;
|
||
}
|
||
}
|
||
|
||
// 如果是相对路径(如 /api),将其作为前缀
|
||
if (envApiUrl.startsWith('/')) {
|
||
const path = `${envApiUrl}/socket.io`;
|
||
console.log('[Socket.IO Path Config] From relative path:', path);
|
||
return path;
|
||
}
|
||
|
||
const path = '/socket.io';
|
||
console.log('[Socket.IO Path Config] Using fallback path:', path);
|
||
return path;
|
||
}
|
||
|
||
export function setupMinesweeperSocket(httpServer: HTTPServer) {
|
||
// 生产环境支持多种可能的路径,通过请求检测来确定
|
||
const possiblePaths = ['/api/socket.io', '/socket.io'];
|
||
const socketIoPath = getSocketIoPath();
|
||
console.log('[Socket.IO] Socket.IO 路径配置:', socketIoPath);
|
||
console.log('[Socket.IO] 支持的可能路径:', possiblePaths);
|
||
console.log('[Socket.IO] HTTP Server listening on port:', httpServer.address());
|
||
|
||
const io = new SocketIOServer(httpServer, {
|
||
path: socketIoPath, // 使用最常见的路径作为配置
|
||
cors: {
|
||
origin: "*",
|
||
methods: ["GET", "POST"]
|
||
},
|
||
allowEIO3: true,
|
||
transports: ['polling', 'websocket']
|
||
});
|
||
|
||
// 添加底层HTTP请求日志中间件
|
||
io.engine.on('initial_headers', (headers, req) => {
|
||
console.log('[Socket.IO Engine] === INITIAL HEADERS ===');
|
||
console.log('[Socket.IO Engine] Method:', req.method);
|
||
console.log('[Socket.IO Engine] URL:', req.url);
|
||
console.log('[Socket.IO Engine] Headers:', JSON.stringify(req.headers, null, 2));
|
||
console.log('[Socket.IO Engine] Query:', req.url ? req.url.split('?')[1] : 'N/A');
|
||
console.log('[Socket.IO Engine] ========================');
|
||
});
|
||
|
||
io.engine.on('headers', (headers, req) => {
|
||
console.log('[Socket.IO Engine] === RESPONSE HEADERS ===');
|
||
console.log('[Socket.IO Engine] Status:', headers.status || 200);
|
||
console.log('[Socket.IO Engine] Headers:', JSON.stringify(headers, null, 2));
|
||
console.log('[Socket.IO Engine] ========================');
|
||
});
|
||
|
||
io.on('connection', (socket) => {
|
||
console.log('[Socket.IO] >>> CONNECTION ESTABLISHED <<<');
|
||
console.log('[Socket.IO] 用户连接:', socket.id);
|
||
console.log('[Socket.IO] 握手耗时:', Date.now() - (socket.handshake.time || Date.now()), 'ms');
|
||
console.log('[Socket.IO] 握手 URL:', socket.handshake.url);
|
||
console.log('[Socket.IO] 握手查询参数:', socket.handshake.query);
|
||
console.log('[Socket.IO] 握手 Origin:', socket.handshake.headers.origin);
|
||
console.log('[Socket.IO] 握手 Referer:', socket.handshake.headers.referer);
|
||
console.log('[Socket.IO] 握手 User-Agent:', socket.handshake.headers['user-agent']);
|
||
console.log('[Socket.IO] 握手 Host:', socket.handshake.headers.host);
|
||
console.log('[Socket.IO] 配置的 Socket.IO 路径:', socketIoPath);
|
||
|
||
// 详细路径分析 - 规范化路径比较(处理末尾斜杠差异)
|
||
const reqPath = socket.handshake.url ? socket.handshake.url.split('?')[0] : 'N/A';
|
||
const normalizedReqPath = reqPath.replace(/\/$/, ''); // 移除末尾斜杠
|
||
const normalizedConfigPath = socketIoPath.replace(/\/$/, ''); // 移除末尾斜杠
|
||
|
||
console.log('[Socket.IO] 路径分析:');
|
||
console.log(' - 原始请求路径:', reqPath);
|
||
console.log(' - 规范化请求路径:', normalizedReqPath);
|
||
console.log(' - 配置路径:', socketIoPath);
|
||
console.log(' - 规范化配置路径:', normalizedConfigPath);
|
||
console.log(' - 严格相等:', reqPath === socketIoPath);
|
||
console.log(' - 规范化后相等:', normalizedReqPath === normalizedConfigPath);
|
||
console.log(' - 请求路径.endsWith(配置路径):', reqPath.endsWith(socketIoPath));
|
||
console.log(' - 规范化请求.endsWith(规范化配置):', normalizedReqPath.endsWith(normalizedConfigPath));
|
||
|
||
// 使用规范化路径进行比较
|
||
if (normalizedReqPath !== normalizedConfigPath) {
|
||
console.warn(`[Socket.IO] ⚠️ 警告: 请求路径与配置路径不匹配!`);
|
||
console.warn(`[Socket.IO] 原始请求: ${reqPath}`);
|
||
console.warn(`[Socket.IO] 规范化请求: ${normalizedReqPath}`);
|
||
console.warn(`[Socket.IO] 配置路径: ${socketIoPath}`);
|
||
console.warn(`[Socket.IO] 规范化配置: ${normalizedConfigPath}`);
|
||
console.warn(`[Socket.IO] 差异: ${reqPath.replace(socketIoPath, '【配置路径】')}`);
|
||
}
|
||
|
||
// 创建游戏房间(玩家创建)
|
||
socket.on('create-room', (data: { difficulty: string; roomId?: string; invitePlayMode?: boolean }) => {
|
||
const roomId = data.roomId || generateRoomId();
|
||
const room: GameRoom = {
|
||
roomId,
|
||
playerId: socket.id,
|
||
board: [],
|
||
difficulty: data.difficulty,
|
||
spectators: new Set(),
|
||
highlightedCells: new Set(),
|
||
invitePlayMode: data.invitePlayMode || false
|
||
};
|
||
|
||
gameRooms.set(roomId, room);
|
||
socket.join(roomId);
|
||
|
||
console.log(`[Socket.IO] 房间创建: ${roomId}, 玩家: ${socket.id}, 邀请同玩模式: ${room.invitePlayMode}`);
|
||
socket.emit('room-created', { roomId });
|
||
});
|
||
|
||
// 旁观者加入房间
|
||
socket.on('join-spectate', (data: { roomId: string }) => {
|
||
const room = gameRooms.get(data.roomId);
|
||
|
||
if (!room) {
|
||
socket.emit('error', { message: '房间不存在' });
|
||
return;
|
||
}
|
||
|
||
room.spectators.add(socket.id);
|
||
socket.join(data.roomId);
|
||
|
||
console.log(`旁观者加入: ${socket.id} -> 房间: ${data.roomId}`);
|
||
|
||
// 发送当前游戏状态给旁观者
|
||
socket.emit('game-state', {
|
||
board: room.board,
|
||
difficulty: room.difficulty
|
||
});
|
||
});
|
||
|
||
// 获取房间信息
|
||
socket.on('get-room-info', (data: { roomId: string }) => {
|
||
console.log('[服务器日志] === 获取房间信息开始 ===');
|
||
console.log('[服务器日志] 收到 get-room-info 事件:');
|
||
console.log('[服务器日志] - 房间ID:', data.roomId);
|
||
console.log('[服务器日志] - 请求者ID:', socket.id);
|
||
|
||
const room = gameRooms.get(data.roomId);
|
||
|
||
if (!room) {
|
||
console.log('[服务器日志] ❌ 房间不存在');
|
||
socket.emit('error', { message: '房间不存在' });
|
||
console.log('[服务器日志] === 获取房间信息结束 ===');
|
||
return;
|
||
}
|
||
|
||
// 构建房间信息
|
||
const roomInfo = {
|
||
roomId: room.roomId,
|
||
playerCount: 1, // 玩家数量固定为1
|
||
spectatorCount: room.spectators.size,
|
||
difficulty: room.difficulty,
|
||
invitePlayMode: room.invitePlayMode,
|
||
gameState: 'playing' // 默认游戏状态为进行中
|
||
};
|
||
|
||
console.log('[服务器日志] ✅ 发送房间信息:');
|
||
console.log('[服务器日志] - 同玩模式:', roomInfo.invitePlayMode ? '开启' : '关闭');
|
||
console.log('[服务器日志] - 旁观者数量:', roomInfo.spectatorCount);
|
||
|
||
socket.emit('room-info', roomInfo);
|
||
console.log('[服务器日志] === 获取房间信息完成 ===');
|
||
});
|
||
|
||
// 玩家更新游戏状态
|
||
socket.on('update-game', (data: { roomId: string; board: Cell[][] }) => {
|
||
const room = gameRooms.get(data.roomId);
|
||
|
||
if (!room || room.playerId !== socket.id) {
|
||
return;
|
||
}
|
||
|
||
room.board = data.board;
|
||
|
||
// 广播给该房间的所有旁观者
|
||
socket.to(data.roomId).emit('game-state', {
|
||
board: room.board,
|
||
difficulty: room.difficulty,
|
||
highlightedCells: Array.from(room.highlightedCells)
|
||
});
|
||
|
||
// 清除闪烁的格子
|
||
if (room.highlightedCells.size > 0) {
|
||
setTimeout(() => {
|
||
room.highlightedCells.clear();
|
||
}, 100);
|
||
}
|
||
});
|
||
|
||
// 旁观者清除所有高亮
|
||
socket.on('clear-all-highlights', (data: { roomId: string }) => {
|
||
const room = gameRooms.get(data.roomId);
|
||
|
||
if (!room || !room.spectators.has(socket.id)) {
|
||
return;
|
||
}
|
||
|
||
// 清除所有高亮格子
|
||
room.highlightedCells.clear();
|
||
|
||
// 通知玩家清除所有高亮
|
||
io.to(room.playerId).emit('clear-all-highlights');
|
||
|
||
// 通知所有旁观者清除所有高亮
|
||
io.to(data.roomId).emit('clear-all-highlights');
|
||
|
||
console.log(`旁观者清除所有高亮: 房间 ${data.roomId}`);
|
||
});
|
||
|
||
// 旁观者点击格子
|
||
socket.on('spectator-click', (data: { roomId: string; row: number; col: number }) => {
|
||
const room = gameRooms.get(data.roomId);
|
||
|
||
if (!room || !room.spectators.has(socket.id)) {
|
||
return;
|
||
}
|
||
|
||
const cellKey = `${data.row},${data.col}`;
|
||
room.highlightedCells.add(cellKey);
|
||
|
||
console.log(`旁观者点击: 房间 ${data.roomId}, 格子 (${data.row}, ${data.col})`);
|
||
|
||
// 通知玩家该格子被旁观者点击
|
||
io.to(room.playerId).emit('spectator-suggest', {
|
||
row: data.row,
|
||
col: data.col
|
||
});
|
||
|
||
// 3秒后移除闪烁
|
||
setTimeout(() => {
|
||
room.highlightedCells.delete(cellKey);
|
||
io.to(data.roomId).emit('clear-highlight', { row: data.row, col: data.col });
|
||
}, 3000);
|
||
});
|
||
|
||
// 旁观者操作格子(同玩模式)
|
||
socket.on('spectator-action', (data: { roomId: string; action: 'reveal' | 'flag' | 'chord'; row: number; col: number }) => {
|
||
console.log('[服务器日志] === 旁观者操作开始 ===');
|
||
console.log('[服务器日志] 收到 spectator-action 事件:');
|
||
console.log('[服务器日志] - 房间ID:', data.roomId);
|
||
console.log('[服务器日志] - 动作类型:', data.action);
|
||
console.log('[服务器日志] - 格子坐标:', `(${data.row}, ${data.col})`);
|
||
console.log('[服务器日志] - 旁观者ID:', socket.id);
|
||
|
||
const room = gameRooms.get(data.roomId);
|
||
|
||
if (!room) {
|
||
console.log('[服务器日志] ❌ 房间不存在,操作失败');
|
||
console.log('[服务器日志] 当前存在的房间:', Array.from(gameRooms.keys()));
|
||
return;
|
||
}
|
||
|
||
console.log('[服务器日志] 房间信息:');
|
||
console.log('[服务器日志] - 玩家ID:', room.playerId);
|
||
console.log('[服务器日志] - 旁观者数量:', room.spectators.size);
|
||
console.log('[服务器日志] - 同玩模式:', room.invitePlayMode);
|
||
console.log('[服务器日志] - 当前旁观者:', Array.from(room.spectators));
|
||
|
||
if (!room.spectators.has(socket.id)) {
|
||
console.log('[服务器日志] ❌ 旁观者不在房间中,操作失败');
|
||
return;
|
||
}
|
||
|
||
if (!room.invitePlayMode) {
|
||
console.log('[服务器日志] ❌ 房间未开启同玩模式,操作失败');
|
||
return;
|
||
}
|
||
|
||
console.log(`[服务器日志] ✅ 旁观者操作有效: 房间 ${data.roomId}, 动作: ${data.action}, 格子 (${data.row}, ${data.col})`);
|
||
|
||
// 转发操作给玩家
|
||
console.log('[服务器日志] 转发操作给玩家:', room.playerId);
|
||
io.to(room.playerId).emit('spectator-action', {
|
||
action: data.action,
|
||
row: data.row,
|
||
col: data.col
|
||
});
|
||
console.log('[服务器日志] === 旁观者操作完成 ===');
|
||
});
|
||
|
||
// 玩家更新难度
|
||
socket.on('update-difficulty', (data: { roomId: string; difficulty: string; config?: any }) => {
|
||
const room = gameRooms.get(data.roomId);
|
||
|
||
if (!room || room.playerId !== socket.id) {
|
||
return;
|
||
}
|
||
|
||
console.log(`玩家更新难度: 房间 ${data.roomId}, 从 ${room.difficulty} 变更为 ${data.difficulty}`);
|
||
console.log('配置信息:', data.config);
|
||
room.difficulty = data.difficulty;
|
||
|
||
// 广播难度更新给房间内所有旁观者
|
||
io.to(data.roomId).emit('difficulty-updated', {
|
||
difficulty: data.difficulty,
|
||
config: data.config // 传递配置信息给旁观者
|
||
});
|
||
});
|
||
|
||
// 玩家切换同玩模式
|
||
socket.on('toggle-invite-play-mode', (data: { roomId: string; invitePlayMode: boolean }) => {
|
||
console.log('[服务器日志] === 切换同玩模式开始 ===');
|
||
console.log('[服务器日志] 收到 toggle-invite-play-mode 事件:');
|
||
console.log('[服务器日志] - 房间ID:', data.roomId);
|
||
console.log('[服务器日志] - 同玩模式:', data.invitePlayMode ? '开启' : '关闭');
|
||
console.log('[服务器日志] - 玩家ID:', socket.id);
|
||
|
||
const room = gameRooms.get(data.roomId);
|
||
|
||
if (!room || room.playerId !== socket.id) {
|
||
console.log('[服务器日志] ❌ 房间不存在或玩家不匹配,操作失败');
|
||
console.log('[服务器日志] === 切换同玩模式结束 ===');
|
||
return;
|
||
}
|
||
|
||
// 更新房间的同玩模式状态
|
||
room.invitePlayMode = data.invitePlayMode;
|
||
|
||
console.log(`[服务器日志] ✅ 同玩模式已更新: ${data.invitePlayMode ? '开启' : '关闭'}`);
|
||
|
||
// 广播同玩模式更新给房间内所有旁观者
|
||
console.log('[服务器日志] 广播同玩模式更新给旁观者');
|
||
io.to(data.roomId).emit('invite-play-mode-updated', {
|
||
invitePlayMode: data.invitePlayMode
|
||
});
|
||
|
||
console.log('[服务器日志] === 切换同玩模式完成 ===');
|
||
});
|
||
|
||
// 断开连接
|
||
socket.on('disconnect', () => {
|
||
console.log('用户断开连接:', socket.id);
|
||
|
||
// 清理房间数据
|
||
gameRooms.forEach((room, roomId) => {
|
||
if (room.playerId === socket.id) {
|
||
// 玩家离开,关闭房间
|
||
io.to(roomId).emit('room-closed');
|
||
gameRooms.delete(roomId);
|
||
console.log(`房间关闭: ${roomId}`);
|
||
} else if (room.spectators.has(socket.id)) {
|
||
// 旁观者离开
|
||
room.spectators.delete(socket.id);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
return io;
|
||
}
|
||
|
||
// 生成唯一房间ID
|
||
function generateRoomId(): string {
|
||
return Math.random().toString(36).substring(2, 10).toUpperCase();
|
||
}
|