Files
d1kt/server/websocket/minesweeperSocket.ts

417 lines
16 KiB
TypeScript
Raw Permalink 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.
// 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();
}