/** * Net-Games 斗地主客户端 * Socket.IO 对接服务端 8900 端口 */ // ==================== 全局状态 ==================== let socket = null; let currentRoom = null; let currentPlayer = null; let gameState = null; let myCards = []; // 我的手牌 let selectedCards = new Set(); // 选中的牌 let userInfo = null; // 当前登录用户信息 // 连接管理 let connectionState = 'disconnected'; // disconnected, connecting, connected, reconnecting, failed let reconnectAttempts = 0; const MAX_RECONNECT_ATTEMPTS = 5; const RECONNECT_DELAY = 2000; // 2秒 let reconnectTimer = null; let isManualDisconnect = false; // 标记是否是手动断开 let isReconnectingToRoom = false; // 标记是否正在重连到房间 // 断线重连的存储键 const STORAGE_KEY_ROOM = 'netgames_room_code'; const STORAGE_KEY_PLAYER = 'netgames_player_id'; const STORAGE_KEY_PLAYER_NAME = 'netgames_player_name'; // 倒计时相关 let countdownTimer = null; // 倒计时定时器 let countdownSeconds = 30; // 倒计时秒数 const TURN_TIMEOUT = 30; // 每轮出牌时间(秒) let currentTurnPlayerId = null; // 当前轮到出牌的玩家ID // 托管状态跟踪 let trusteePlayers = new Set(); // 托管中的玩家ID集合 let isSelfTrustee = false; // 自己是否处于托管状态 // 牌面映射 const SUIT_SYMBOLS = { 'spade': '♠', 'heart': '♥', 'diamond': '♦', 'club': '♣', 'joker': '' }; const RANK_MAP = { '3': '3', '4': '4', '5': '5', '6': '6', '7': '7', '8': '8', '9': '9', '10': '10', 'J': 'J', 'Q': 'Q', 'K': 'K', 'A': 'A', '2': '2', 'small_joker': '🃏', 'big_joker': '🃏' }; // 牌值映射 (用于比较大小) const VALUE_MAP = { '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, '10': 10, 'J': 11, 'Q': 12, 'K': 13, 'A': 14, '2': 15, 'small_joker': 16, 'big_joker': 17 }; // ==================== 牌型识别工具 ==================== function getCardValue(card) { return VALUE_MAP[card.rank] || 0; } function identifyCardType(cards) { if (!cards || cards.length === 0) return null; const n = cards.length; const values = cards.map(c => getCardValue(c)).sort((a, b) => a - b); const uniqueValues = [...new Set(values)]; // 统计每个值的数量 const valueCount = {}; values.forEach(v => valueCount[v] = (valueCount[v] || 0) + 1); const counts = Object.values(valueCount).sort((a, b) => b - a); // 王炸 if (n === 2 && values.includes(16) && values.includes(17)) { return { type: 'joker_bomb', value: 17, length: 2, name: '王炸' }; } // 单张 if (n === 1) { return { type: 'single', value: values[0], length: 1, name: '单张' }; } // 对子 if (n === 2 && counts[0] === 2) { return { type: 'pair', value: values[0], length: 1, name: '对子' }; } // 三张 if (n === 3 && counts[0] === 3) { return { type: 'triple', value: values[0], length: 1, name: '三张' }; } // 三带一 if (n === 4 && counts[0] === 3 && counts[1] === 1) { const tripleValue = parseInt(Object.keys(valueCount).find(k => valueCount[k] === 3)); return { type: 'triple_with_single', value: tripleValue, length: 1, name: '三带一' }; } // 三带二 if (n === 5 && counts[0] === 3 && counts[1] === 2) { const tripleValue = parseInt(Object.keys(valueCount).find(k => valueCount[k] === 3)); return { type: 'triple_with_pair', value: tripleValue, length: 1, name: '三带二' }; } // 炸弹 if (n === 4 && counts[0] === 4) { return { type: 'bomb', value: values[0], length: 1, name: '炸弹' }; } // 顺子 (>=5张连续,不能含2和王) if (n >= 5 && Math.max(...values) <= 14) { if (uniqueValues.length === n && uniqueValues[n-1] - uniqueValues[0] === n - 1 && counts.every(c => c === 1)) { return { type: 'straight', value: uniqueValues[0], length: n, name: `${n}连顺` }; } } // 连对 (>=3对连续) if (n >= 6 && n % 2 === 0 && counts.every(c => c === 2)) { const pairValues = Object.keys(valueCount).map(Number).sort((a, b) => a - b); if (pairValues.length === n / 2 && pairValues[pairValues.length-1] - pairValues[0] === pairValues.length - 1 && Math.max(...pairValues) <= 14) { return { type: 'double_straight', value: pairValues[0], length: n / 2, name: `${n/2}连对` }; } } // 飞机 (>=2组连续三张) if (n >= 6 && n % 3 === 0 && counts.every(c => c === 3)) { const tripleValues = Object.keys(valueCount).map(Number).sort((a, b) => a - b); if (tripleValues.length === n / 3 && tripleValues[tripleValues.length-1] - tripleValues[0] === tripleValues.length - 1 && Math.max(...tripleValues) <= 14) { return { type: 'triple_straight', value: tripleValues[0], length: n / 3, name: `${n/3}连三张` }; } } return null; } function canBeat(desktopType, newType) { if (!desktopType) return true; // 王炸最大 if (newType.type === 'joker_bomb') return true; if (desktopType.type === 'joker_bomb') return false; // 炸弹 > 非炸弹 if (newType.type === 'bomb' && desktopType.type !== 'bomb') return true; if (desktopType.type === 'bomb' && newType.type !== 'bomb') return false; // 同类型比较 if (newType.type === desktopType.type && newType.length === desktopType.length) { return newType.value > desktopType.value; } return false; } // 获取所有可能的牌型组合 function findAllValidPlays(handCards, desktopType) { const validPlays = []; const n = handCards.length; // 辅助函数:生成组合 function getCombinations(arr, k) { const result = []; function combine(start, path) { if (path.length === k) { result.push([...path]); return; } for (let i = start; i < arr.length; i++) { path.push(arr[i]); combine(i + 1, path); path.pop(); } } combine(0, []); return result; } // 尝试不同数量的牌 for (let k = 1; k <= Math.min(n, 8); k++) { const combos = getCombinations(handCards, k); for (const combo of combos) { const cardType = identifyCardType(combo); if (cardType && canBeat(desktopType, cardType)) { validPlays.push({ cards: combo, type: cardType, indices: combo.map(c => handCards.indexOf(c)) }); } } } // 按牌值排序,优先提示较小的 validPlays.sort((a, b) => { if (a.type.value !== b.type.value) return a.type.value - b.type.value; return a.cards.length - b.cards.length; }); return validPlays; } // ==================== 连接管理 ==================== function connectServer(isAuto = false) { if (socket && socket.connected) { log('已经连接了', 'system'); return; } const url = document.getElementById('serverUrl').value; connectionState = 'connecting'; updateConnectionUI(); if (!isAuto) { log('正在连接服务器...', 'system'); } // 清理旧的socket if (socket) { socket.removeAllListeners(); socket.disconnect(); socket = null; } socket = io(url, { transports: ['polling', 'websocket'], // 优先使用polling,更稳定 reconnection: false, // 我们自己管理重连 timeout: 10000, forceNew: true, // 确保新连接 withCredentials: true // 关键:带上登录凭证(cookies) }); // 连接成功 socket.on('connect', () => { connectionState = 'connected'; reconnectAttempts = 0; isManualDisconnect = false; updateConnectionUI(); log('✅ 连接成功!', 'success'); document.getElementById('btn-quickstart').disabled = false; document.getElementById('btn-join').disabled = false; }); // 服务器返回的初始连接信息(包含是否有活跃游戏) socket.on('connected', (data) => { console.log('Server connected response:', data); // 如果服务器告诉我们有活跃游戏,自动重连 if (data.has_active_game && data.room_code) { log(`检测到进行中的游戏,正在重连到房间 ${data.room_code}...`, 'info'); // 保存房间信息 saveRoomToStorage(data.room_code, null, getPlayerName()); // 延迟一下再重连,确保连接完全建立 setTimeout(() => { isReconnectingToRoom = true; socket.emit('reconnect_room', { room_code: data.room_code, player_id: null, // 让服务器通过用户名查找 player_name: getPlayerName() }); }, 500); } else { // 没有活跃游戏,尝试从 localStorage 重连 attemptRoomReconnect(); } }); // 连接断开 socket.on('disconnect', (reason) => { log(`连接断开: ${reason}`, 'error'); if (!isManualDisconnect && connectionState !== 'failed') { // 非手动断开,尝试重连 startReconnect(); } else { connectionState = 'disconnected'; updateConnectionUI(); resetGame(); } }); // 连接错误 socket.on('connect_error', (error) => { console.error('连接错误:', error); if (connectionState === 'connecting') { // 首次连接失败,尝试重连 startReconnect(); } }); socket.on('error', (data) => { log(`错误: ${data.message || JSON.stringify(data)}`, 'error'); // 账号/昵称已被占用 if (data.code === 'ALREADY_ONLINE') { alert(data.message || '该账号/昵称已在游戏中,请更换昵称或等待30秒后重试'); // 未登录用户可以修改昵称 if (!userInfo) { const nameInput = document.getElementById('playerName'); if (nameInput) { nameInput.focus(); nameInput.select(); } } } // 房间不存在(服务器重启后房间丢失) if (data.code === 'ROOM_NOT_FOUND') { // 清除本地存储的房间信息,避免重复尝试 clearRoomStorage(); log('已清除本地房间缓存,请重新加入或创建房间', 'info'); } }); // 被踢下线(其他页面登录) socket.on('kicked', (data) => { isManualDisconnect = true; // 标记为手动断开,不重连 connectionState = 'disconnected'; log(`⚠️ ${data.message || '已在其他页面登录'}`, 'error'); alert('您的账号已在其他页面登录,此页面已断开。'); // 清理本地存储的房间信息 clearRoomStorage(); if (socket) { socket.disconnect(); socket = null; } updateConnectionUI(); resetGame(); }); // 房间事件 socket.on('room_created', onRoomCreated); socket.on('room_joined', onRoomJoined); socket.on('room_updated', onRoomUpdated); socket.on('player_joined', (data) => log(`玩家加入: ${data.player?.name}`, 'action')); socket.on('player_left', (data) => log(`玩家离开: ${data.player_id}`, 'action')); // 断线重连事件 socket.on('reconnect_success', onReconnectSuccess); socket.on('player_reconnected', (data) => log(`玩家重连: ${data.player_name}`, 'success')); socket.on('player_disconnected', (data) => { const msg = data.will_ai_control ? `${data.player_name} 断开连接,30秒后AI接管` : `${data.player_name} 断开连接`; log(msg, 'warning'); }); socket.on('player_ai_controlled', (data) => { log(data.message, 'system'); showToast(data.message, 'warning'); // 更新托管状态显示 if (data.player_id) { trusteePlayers.add(data.player_id); if (data.player_id === currentPlayer?.id) { isSelfTrustee = true; } updateTrusteeDisplay(data.player_id, true); } }); // 托管状态变更(手动切换) socket.on('trustee_changed', (data) => { log(data.message, 'system'); showToast(data.message, data.is_trustee ? 'warning' : 'success'); if (data.player_id) { if (data.is_trustee) { trusteePlayers.add(data.player_id); } else { trusteePlayers.delete(data.player_id); } if (data.player_id === currentPlayer?.id) { isSelfTrustee = data.is_trustee; updateTrusteeButton(); } updateTrusteeDisplay(data.player_id, data.is_trustee); } }); // 回合超时自动操作 socket.on('turn_timeout_auto', (data) => { log(data.message, 'warning'); // 更新游戏状态 if (data.game_state) { gameState = data.game_state; updateGameView(); } // 显示警告 if (data.player_id === currentPlayer?.id) { showToast(`您已超时${data.timeout_count}次,${data.timeout_count >= 3 ? '已进入托管模式' : '下次将自动托管'}`, 'warning'); } }); // 游戏事件 socket.on('game_started', onGameStarted); socket.on('state_update', onStateUpdate); socket.on('game_ended', onGameEnded); socket.on('action_failed', (data) => log(`操作失败: ${data.message}`, 'error')); } // 开始重连 function startReconnect() { if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { connectionState = 'failed'; updateConnectionUI(); log('❌ 连接失败,请检查网络或手动重试', 'error'); return; } connectionState = 'reconnecting'; reconnectAttempts++; updateConnectionUI(); log(`🔄 ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}秒后重试...`, 'system'); if (reconnectTimer) { clearTimeout(reconnectTimer); } reconnectTimer = setTimeout(() => { if (connectionState === 'reconnecting') { connectServer(true); } }, RECONNECT_DELAY); } // 手动断开 function disconnectServer() { isManualDisconnect = true; connectionState = 'disconnected'; reconnectAttempts = 0; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (socket) { socket.removeAllListeners(); socket.disconnect(); socket = null; } // 手动断开时清除房间信息 clearRoomStorage(); updateConnectionUI(); resetGame(); log('已手动断开连接', 'system'); } // 更新连接UI function updateConnectionUI() { const statusEl = document.getElementById('connectionStatus'); const dotEl = statusEl.querySelector('.status-dot'); const textEl = statusEl.querySelector('.status-text'); const connectBtn = document.getElementById('btn-connect'); const disconnectBtn = document.getElementById('btn-disconnect'); const quickstartBtn = document.getElementById('btn-quickstart'); const joinBtn = document.getElementById('btn-join'); // 重置类名 statusEl.className = 'connection-status'; switch (connectionState) { case 'connected': statusEl.classList.add('connected'); textEl.textContent = '已连接'; connectBtn.disabled = true; connectBtn.textContent = '已连接'; disconnectBtn.disabled = false; if (quickstartBtn) quickstartBtn.disabled = false; if (joinBtn) joinBtn.disabled = false; break; case 'connecting': statusEl.classList.add('connecting'); textEl.textContent = '连接中...'; connectBtn.disabled = true; connectBtn.textContent = '连接中...'; disconnectBtn.disabled = true; if (quickstartBtn) quickstartBtn.disabled = true; if (joinBtn) joinBtn.disabled = true; break; case 'reconnecting': statusEl.classList.add('reconnecting'); textEl.textContent = `重连 ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`; connectBtn.disabled = true; connectBtn.textContent = `重连中...`; disconnectBtn.disabled = true; if (quickstartBtn) quickstartBtn.disabled = true; if (joinBtn) joinBtn.disabled = true; break; case 'failed': statusEl.classList.add('failed'); textEl.textContent = '连接失败'; connectBtn.disabled = false; connectBtn.textContent = '重试连接'; disconnectBtn.disabled = true; if (quickstartBtn) quickstartBtn.disabled = true; if (joinBtn) joinBtn.disabled = true; break; case 'disconnected': default: statusEl.classList.add('disconnected'); textEl.textContent = '未连接'; connectBtn.disabled = false; connectBtn.textContent = '连接'; disconnectBtn.disabled = true; if (quickstartBtn) quickstartBtn.disabled = true; if (joinBtn) joinBtn.disabled = true; } } // 页面关闭/刷新时清理 window.addEventListener('beforeunload', () => { if (socket) { socket.disconnect(); } }); // ==================== 游戏流程 ==================== function quickStart() { // 隐藏游戏结束弹窗 document.getElementById('gameOverModal').style.display = 'none'; // 检查连接状态 if (!socket || !socket.connected) { log('❌ 未连接服务器,无法开始游戏', 'error'); alert('请先连接服务器'); return; } const playerName = document.getElementById('playerName').value || '蔡叔叔'; const aiCount = parseInt(document.getElementById('aiCount').value); const aiType1 = document.getElementById('aiType1').value; const aiType2 = document.getElementById('aiType2').value; const ai_players = []; if (aiCount >= 1) { ai_players.push({ name: `AI-1(${aiType1})`, ai_type: aiType1 }); } if (aiCount >= 2) { ai_players.push({ name: `AI-2(${aiType2})`, ai_type: aiType2 }); } log(`创建房间: 斗地主 (${aiCount}个AI)`, 'action'); socket.emit('create_room', { game_type: 'doudizhu', player_name: playerName, config: { max_players: 3 }, ai_players: ai_players }); } function joinRoom() { const roomCode = document.getElementById('roomCodeInput').value.toUpperCase(); const playerName = document.getElementById('playerName').value || '蔡叔叔'; if (!roomCode) { alert('请输入房间号'); return; } log(`加入房间: ${roomCode}`, 'action'); socket.emit('join_room', { room_code: roomCode, player_name: playerName }); } function onRoomCreated(data) { if (data.success) { currentRoom = data.room; currentPlayer = data.player; log(`房间创建成功: ${data.room.code}`, 'success'); // 保存房间信息到本地存储(用于断线重连) saveRoomToStorage(data.room.code, data.player.id, data.player.name); // 切换到游戏界面 showGameScreen(); updateRoomInfo(); updatePlayers(data.room.players); // 自动准备并开始 setTimeout(() => { socket.emit('set_ready', { ready: true }); setTimeout(() => { socket.emit('start_game'); }, 500); }, 300); } } function onRoomJoined(data) { if (data.success) { currentRoom = data.room; currentPlayer = data.player; log(`加入房间成功: ${data.room.code}`, 'success'); // 保存房间信息到本地存储(用于断线重连) saveRoomToStorage(data.room.code, data.player.id, data.player.name); showGameScreen(); updateRoomInfo(); updatePlayers(data.room.players); } } function onRoomUpdated(data) { if (data.room) { currentRoom = data.room; updatePlayers(data.room.players); } } function onGameStarted(data) { log('游戏开始!', 'success'); // 服务端返回的是 {room: {game: {...}}} gameState = data.game_state || data.room?.game || data.game; document.querySelector('.game-status-text').textContent = '游戏中'; // 初始化我的手牌 - 数据结构: game.state.player_states[player_id].cards if (gameState && currentPlayer) { const state = gameState.state || gameState; const playerStates = state.player_states || {}; const myPlayerState = playerStates[currentPlayer.id]; if (myPlayerState && myPlayerState.cards) { myCards = myPlayerState.cards; renderHandCards(); } else { // 尝试从 doudizhu 数据中获取 const doudizhuData = gameState.doudizhu || {}; log(`游戏阶段: ${doudizhuData.phase || state.phase}, 等待叫地主...`, 'system'); } } updateGameView(); } function onStateUpdate(data) { // 服务端返回: {action, player_id, result, game_state} log(`状态更新: action=${data.action}, player=${data.player_id?.slice(0,8)}`, 'system'); gameState = data.game_state || data.game || data; // 打印关键状态信息 const state = gameState.state || gameState; const doudizhu = gameState.doudizhu || state.data || {}; log(` 阶段: ${doudizhu.phase}, 当前回合: ${state.current_turn?.slice(0,8)}, 地主: ${doudizhu.lord_id?.slice(0,8)}`, 'system'); // 打印各玩家牌数 const cardCounts = doudizhu.player_cards_count || {}; Object.entries(cardCounts).forEach(([pid, count]) => { const name = pid === currentPlayer?.id ? '我' : (pid.slice(0,8)); log(` ${name}: ${count}张`, 'system'); }); // 处理 pass 动作 - 显示"不要"提示 if (data.action === 'pass' && data.player_id) { showPassPopup(data.player_id); } // 更新我的手牌(如果有) if (gameState && currentPlayer) { const playerStates = state.player_states || {}; const myPlayerState = playerStates[currentPlayer.id]; if (myPlayerState && myPlayerState.cards) { myCards = myPlayerState.cards; renderHandCards(); log(` 我的手牌已更新: ${myCards.length}张`, 'system'); } } updateGameView(); } function onGameEnded(data) { log('游戏结束!', 'card'); // 停止倒计时 stopCountdown(); const winner = data.winner; const isWin = winner && (winner.id === currentPlayer?.id || winner.player_id === currentPlayer?.id); let resultHtml = ''; if (winner) { const winnerName = winner.name || winner.player_id?.slice(0, 8) || '未知'; const identityText = winner.is_lord ? '👑 地主' : '🌾 农民'; resultHtml = `
🏆 获胜者: ${winnerName}
`; resultHtml += `
${identityText}
`; resultHtml += `
${isWin ? '🎉 恭喜你赢了!' : '😔 再接再厉'}
`; } // 显示积分变动 if (data.scores) { resultHtml += '
'; resultHtml += '
📊 积分变动
'; for (const [pid, scoreInfo] of Object.entries(data.scores)) { if (scoreInfo.is_ai) continue; // 跳过AI玩家 const isMe = pid === currentPlayer?.id; const changeClass = scoreInfo.change > 0 ? 'score-up' : (scoreInfo.change < 0 ? 'score-down' : 'score-zero'); const changeSymbol = scoreInfo.change > 0 ? '+' : ''; const identityIcon = scoreInfo.is_lord ? '👑' : '🌾'; resultHtml += `
`; resultHtml += `${identityIcon}`; resultHtml += `${scoreInfo.name}`; resultHtml += `${changeSymbol}${scoreInfo.change}`; if (scoreInfo.guest) { resultHtml += `(游客)`; } else { resultHtml += `总积分: ${scoreInfo.total}`; } resultHtml += '
'; } resultHtml += '
'; } document.getElementById('gameResult').innerHTML = resultHtml; document.getElementById('gameOverModal').style.display = 'flex'; // 游戏结束,清除房间信息 clearRoomStorage(); // 刷新积分显示 setTimeout(fetchUserScore, 500); } // ==================== 游戏操作 ==================== function callLord(want) { // 停止倒计时 stopCountdown(); socket.emit('game_action', { action: want ? 'call' : 'pass', data: { want: want } }); document.getElementById('callLordModal').style.display = 'none'; log(want ? '叫地主' : '不叫', 'action'); } function playCards() { const cards = myCards.filter((_, idx) => selectedCards.has(idx)); if (cards.length === 0) { alert('请先选择要出的牌'); return; } // 服务端期望 card_ids (整数数组) const cardIds = cards.map(c => parseInt(c.id, 10)); log(`尝试出牌: ${formatCards(cards)}, IDs: ${cardIds.join(',')}`, 'card'); // 停止倒计时 stopCountdown(); socket.emit('game_action', { action: 'play', data: { card_ids: cardIds } }); selectedCards.clear(); renderHandCards(); } function passTurn() { // 清空已选中的牌 selectedCards.clear(); renderHandCards(); // 停止倒计时 stopCountdown(); socket.emit('game_action', { action: 'pass', data: {} }); log('不出', 'action'); } function showHint() { if (myCards.length === 0) return; // 获取当前桌面牌型 const state = gameState?.state || gameState; const doudizhu = gameState?.doudizhu || state?.data || {}; const desktopCards = doudizhu.desktop_cards; const desktopType = desktopCards || null; const lastPlayedId = doudizhu.last_played_id; // 判断是否需要管牌 const needToBeat = desktopType && desktopType.cards && desktopType.cards.length > 0 && lastPlayedId !== currentPlayer?.id; if (needToBeat) { // 需要管牌 - 找能管上的最小牌型 const validPlays = findAllValidPlays(myCards, desktopType); if (validPlays.length === 0) { // 管不起 showToast('😅 管不起,点击"不出"跳过', 'warning'); return; } // 选择最小的能管上的牌型 const bestPlay = validPlays[0]; selectedCards.clear(); bestPlay.indices.forEach(idx => selectedCards.add(idx)); renderHandCards(); showToast(`💡 提示: ${bestPlay.type.name} (${bestPlay.cards.length}张)`, 'info'); } else { // 自由出牌 - 提示最小的牌 const singleCards = myCards.filter(c => c.rank !== '2' && !c.is_joker) .sort((a, b) => getCardValue(a) - getCardValue(b)); if (singleCards.length > 0) { const idx = myCards.indexOf(singleCards[0]); selectedCards.clear(); selectedCards.add(idx); renderHandCards(); showToast('💡 提示: 最小单张', 'info'); } else { // 只剩2或王,提示最小的 selectedCards.clear(); selectedCards.add(0); renderHandCards(); showToast('💡 提示: 最小牌', 'info'); } } } // 切换托管状态 function toggleTrustee() { if (!socket || !currentRoom) { showToast('请先连接服务器并加入房间', 'warning'); return; } socket.emit('game_action', { action: 'toggle_trustee', data: {} }); log(isSelfTrustee ? '请求取消托管...' : '请求进入托管...', 'action'); } // 更新托管按钮显示 function updateTrusteeButton() { const btn = document.getElementById('btn-trustee'); if (!btn) return; if (isSelfTrustee) { btn.textContent = '✋ 取消托管'; btn.classList.add('active'); } else { btn.textContent = '🤖 托管'; btn.classList.remove('active'); } } // 更新玩家托管状态显示 function updateTrusteeDisplay(playerId, isTrustee) { if (!currentPlayer || !currentRoom) return; let badgeEl = null; if (playerId === currentPlayer.id) { badgeEl = document.getElementById('myTrustee'); } else { const player = currentRoom.players.find(p => p.id === playerId); if (!player) return; if (player.seat_number === 1) { badgeEl = document.getElementById('leftTrustee'); } else if (player.seat_number === 2) { badgeEl = document.getElementById('rightTrustee'); } } if (badgeEl) { if (isTrustee) { badgeEl.classList.add('active'); } else { badgeEl.classList.remove('active'); } } } // 显示提示消息 function showToast(message, type = 'info') { // 创建或获取提示元素 let toast = document.getElementById('gameToast'); if (!toast) { toast = document.createElement('div'); toast.id = 'gameToast'; toast.style.cssText = ` position: fixed; top: 80px; left: 50%; transform: translateX(-50%); padding: 12px 24px; border-radius: 8px; font-size: 14px; font-weight: bold; z-index: 10001; transition: all 0.3s; opacity: 0; pointer-events: none; `; document.body.appendChild(toast); } // 设置样式 const colors = { info: 'background: linear-gradient(135deg, #3498db, #2980b9)', warning: 'background: linear-gradient(135deg, #f39c12, #e67e22)', success: 'background: linear-gradient(135deg, #2ecc71, #27ae60)' }; toast.style.cssText += colors[type] || colors.info; toast.style.color = 'white'; toast.textContent = message; toast.style.opacity = '1'; toast.style.top = '100px'; // 2秒后消失 setTimeout(() => { toast.style.opacity = '0'; toast.style.top = '80px'; }, 2000); } // ==================== 界面更新 ==================== function showGameScreen() { document.getElementById('welcomeScreen').style.display = 'none'; document.getElementById('gameContainer').style.display = 'block'; } function updateRoomInfo() { if (currentRoom) { document.querySelector('.room-code').textContent = `房间: ${currentRoom.code}`; } } function updatePlayers(players) { if (!players || !currentPlayer) return; // 找到其他玩家位置 const others = players.filter(p => p.id !== currentPlayer.id); if (others.length > 0) { updatePlayerView('leftPlayer', others[0]); } if (others.length > 1) { updatePlayerView('rightPlayer', others[1]); } // 更新自己 document.getElementById('myName').textContent = currentPlayer.name; } function updatePlayerView(elementId, player) { const el = document.getElementById(elementId); if (!el) return; el.querySelector('.avatar-img').textContent = player.name?.slice(0, 2) || '?'; el.querySelector('.player-name').textContent = player.name; el.querySelector('.player-role').textContent = player.role || (player.is_ai ? 'AI' : '?'); const cardCount = player.cards?.length || player.card_count || 0; el.querySelector('.card-count').textContent = `${cardCount}张`; } function showPassPopup(playerId) { // 显示玩家"不要"的弹出提示 let popupId; if (playerId === currentPlayer?.id) { // 自己 popupId = 'myPassPopup'; } else { // 其他玩家 - 根据座位确定左右 const players = currentRoom?.players || []; const player = players.find(p => p.id === playerId); if (!player) return; popupId = player.seat_number === 1 ? 'leftPassPopup' : 'rightPassPopup'; } const popup = document.getElementById(popupId); if (!popup) return; // 显示提示 popup.classList.add('show'); // 2秒后自动消失 setTimeout(() => { popup.classList.remove('show'); }, 2000); } function updateGameView() { if (!gameState) return; // 获取游戏状态 - 数据结构: game.state 或 game.doudizhu const state = gameState.state || gameState; const doudizhu = gameState.doudizhu || state.data || {}; // 更新游戏阶段 - 优先使用 doudizhu.phase const phase = doudizhu.phase || state.phase || state.data?.phase; const statusText = { 'calling': '叫地主阶段', 'playing': '出牌阶段', 'dealing': '发牌中', 'ended': '游戏结束' }[phase] || '游戏中'; document.querySelector('.game-status-text').textContent = statusText; // 更新底牌 const bottomCards = doudizhu.bottom_cards || []; if (bottomCards.length > 0) { renderDeckCards(bottomCards); } // 更新桌面出牌 const desktopCards = doudizhu.desktop_cards; renderPlayedCards(desktopCards); // 如果没有桌面出牌,根据游戏阶段显示合适的提示 if (!desktopCards || !desktopCards.cards || desktopCards.cards.length === 0) { const playedBy = document.querySelector('.played-by'); const currentTurn = state.current_turn || state.current_turn_player_id; if (currentTurn && currentPlayer) { const players = currentRoom?.players || []; const turnPlayer = players.find(p => p.id === currentTurn); const turnName = turnPlayer?.name || (currentTurn === currentPlayer.id ? '你' : '其他玩家'); // 根据阶段显示不同提示 if (phase === 'calling') { playedBy.textContent = `等待 ${turnName} 叫地主...`; } else { playedBy.textContent = `等待 ${turnName} 出牌...`; } } } // 更新玩家牌数 const cardCounts = doudizhu.player_cards_count || {}; Object.entries(cardCounts).forEach(([playerId, count]) => { updatePlayerCardCount(playerId, count); }); // 更新自己的牌数 if (currentPlayer && cardCounts[currentPlayer.id] !== undefined) { const myCountEl = document.getElementById('myCardCount'); if (myCountEl) { myCountEl.textContent = `${cardCounts[currentPlayer.id]}张`; } } // 更新玩家托管状态 - 从 player_states 获取 const playerStates = state.player_states || {}; Object.entries(playerStates).forEach(([playerId, playerState]) => { if (playerState && playerState.is_ai_controlled !== undefined) { const wasTrustee = trusteePlayers.has(playerId); const isTrustee = playerState.is_ai_controlled; if (isTrustee !== wasTrustee) { if (isTrustee) { trusteePlayers.add(playerId); } else { trusteePlayers.delete(playerId); } if (playerId === currentPlayer?.id) { isSelfTrustee = isTrustee; updateTrusteeButton(); } updateTrusteeDisplay(playerId, isTrustee); } } }); // 检查是否轮到我的回合 - 从 state.current_turn 获取 const currentTurn = state.current_turn || state.current_turn_player_id; const isMyTurn = currentTurn === currentPlayer?.id; // 处理倒计时 - 轮到新玩家时启动倒计时 if (currentTurn && currentTurn !== currentTurnPlayerId) { currentTurnPlayerId = currentTurn; startCountdown(currentTurn, phase); } if (isMyTurn) { handleMyTurn(phase); } else { disableActionButtons(); } // 更新角色显示 updateRoles(); } function updatePlayerCardCount(playerId, count) { const players = currentRoom?.players || []; const player = players.find(p => p.id === playerId); if (!player || player.id === currentPlayer?.id) return; const elementId = player.seat_number === 1 ? 'leftPlayer' : 'rightPlayer'; const el = document.getElementById(elementId); if (el) { el.querySelector('.card-count').textContent = `${count}张`; } } function handleMyTurn(phase) { log('轮到你的回合!', 'action'); if (phase === 'calling') { // 显示叫地主弹窗 document.getElementById('callLordModal').style.display = 'flex'; } else if (phase === 'playing') { // 启用出牌按钮 document.getElementById('btn-play').disabled = false; document.getElementById('btn-pass').disabled = false; document.getElementById('btn-hint').disabled = false; } } function disableActionButtons() { document.getElementById('btn-play').disabled = true; document.getElementById('btn-pass').disabled = true; document.getElementById('btn-hint').disabled = true; } // ==================== 倒计时功能 ==================== function startCountdown(playerId, phase) { // 停止之前的倒计时 stopCountdown(); // 重置倒计时 countdownSeconds = TURN_TIMEOUT; // 找到对应的倒计时元素 const countdownEl = getCountdownElement(playerId); if (!countdownEl) return; // 显示并更新倒计时 updateCountdownDisplay(countdownEl, countdownSeconds); // 启动定时器 countdownTimer = setInterval(() => { countdownSeconds--; updateCountdownDisplay(countdownEl, countdownSeconds); if (countdownSeconds <= 0) { stopCountdown(); // 倒计时结束,自动处理 onCountdownTimeout(playerId, phase); } }, 1000); } function stopCountdown() { if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } // 隐藏所有倒计时 document.querySelectorAll('.countdown').forEach(el => { el.classList.remove('active', 'warning'); el.textContent = TURN_TIMEOUT; }); } function updateCountdownDisplay(el, seconds) { el.textContent = seconds; el.classList.add('active'); // 最后5秒显示警告样式 if (seconds <= 5) { el.classList.add('warning'); } else { el.classList.remove('warning'); } } function getCountdownElement(playerId) { if (!currentPlayer || !currentRoom) return null; if (playerId === currentPlayer.id) { return document.getElementById('myCountdown'); } // 找到玩家座位 const player = currentRoom.players.find(p => p.id === playerId); if (!player) return null; if (player.seat_number === 1) { return document.getElementById('leftCountdown'); } else if (player.seat_number === 2) { return document.getElementById('rightCountdown'); } return null; } function onCountdownTimeout(playerId, phase) { // 只有轮到自己时才自动处理 if (playerId !== currentPlayer?.id) return; log('⏰ 倒计时结束,自动操作', 'system'); if (phase === 'calling') { // 叫地主超时,自动不叫 callLord(false); } else if (phase === 'playing') { // 出牌超时,自动不出 passTurn(); } } function updateRoles() { const state = gameState.state || gameState; const doudizhu = gameState.doudizhu || state.data || {}; const lordId = doudizhu.lord_id || gameState.lord_id; if (!lordId) return; // 更新自己 const myRole = currentPlayer?.id === lordId ? '地主' : '农民'; document.getElementById('myRole').textContent = myRole; if (currentPlayer?.id === lordId) { document.querySelector('.player-self .avatar-img').classList.add('landlord'); } // 更新其他玩家 const players = currentRoom?.players || []; players.forEach(p => { if (p.id === currentPlayer?.id) return; const role = p.id === lordId ? '地主' : '农民'; const elementId = p.seat_number === 1 ? 'leftPlayer' : 'rightPlayer'; const el = document.getElementById(elementId); if (el) { el.querySelector('.player-role').textContent = role; if (p.id === lordId) { el.querySelector('.avatar-img').classList.add('landlord'); } } }); } // ==================== 渲染牌 ==================== function renderHandCards() { const container = document.getElementById('handCards'); container.innerHTML = ''; myCards.forEach((card, index) => { const cardEl = createCardElement(card, index); if (selectedCards.has(index)) { cardEl.classList.add('selected'); } container.appendChild(cardEl); }); } function createCardElement(card, index) { const el = document.createElement('div'); el.className = 'card'; el.dataset.index = index; const isRed = card.suit === '♥' || card.suit === '♦' || card.suit === 'heart' || card.suit === 'diamond'; const colorClass = isRed ? 'red' : 'black'; const suit = SUIT_SYMBOLS[card.suit] || card.suit; const rank = RANK_MAP[card.rank] || card.rank; if (card.suit === 'joker' || card.rank.includes('joker')) { // 大小王 el.innerHTML = `
${card.rank === 'big_joker' ? '大王' : '小王'}
🃏
${card.rank === 'big_joker' ? '大王' : '小王'}
`; } else { el.innerHTML = `
${rank}
${suit}
${suit}
${rank}
${suit}
`; } el.onclick = () => toggleCardSelection(index); return el; } function toggleCardSelection(index) { if (selectedCards.has(index)) { selectedCards.delete(index); } else { selectedCards.add(index); } renderHandCards(); } function renderDeckCards(cards) { const container = document.getElementById('deckCards'); container.innerHTML = ''; cards.forEach(card => { const el = createCardElement(card, -1); el.style.width = '50px'; el.style.height = '70px'; el.style.marginLeft = '-15px'; el.onclick = null; // 不可点击 container.appendChild(el); }); } function renderPlayedCards(desktopCards) { const container = document.getElementById('playedCards'); const playedBy = document.querySelector('.played-by'); container.innerHTML = ''; // 如果没有出牌,只清空牌区,不设置提示文字(由调用者控制提示) if (!desktopCards || !desktopCards.cards || desktopCards.cards.length === 0) { return; } // 显示出牌者 - 从多个可能的位置获取 const doudizhu = gameState.doudizhu || gameState.data || {}; const lastPlayerId = doudizhu.last_played_id || gameState.last_played_id; if (lastPlayerId) { const players = currentRoom?.players || []; const player = players.find(p => p.id === lastPlayerId); playedBy.textContent = `${player?.name || '玩家'} 出牌`; } // 显示出的牌 const cards = desktopCards.cards || []; cards.forEach(card => { const el = createCardElement(card, -1); el.onclick = null; container.appendChild(el); }); } // ==================== 工具函数 ==================== function formatCards(cards) { return cards.map(c => { const suit = SUIT_SYMBOLS[c.suit] || c.suit; return `${suit}${RANK_MAP[c.rank] || c.rank}`; }).join(' '); } function log(message, type = 'system') { const container = document.getElementById('logContent'); const entry = document.createElement('div'); entry.className = `log-entry ${type}`; const time = new Date().toLocaleTimeString(); entry.textContent = `[${time}] ${message}`; container.insertBefore(entry, container.firstChild); } function clearLog() { document.getElementById('logContent').innerHTML = ''; } function resetGame() { currentRoom = null; currentPlayer = null; gameState = null; myCards = []; selectedCards.clear(); currentTurnPlayerId = null; // 重置托管状态 trusteePlayers.clear(); isSelfTrustee = false; updateTrusteeButton(); document.querySelectorAll('.trustee-badge').forEach(el => el.classList.remove('active')); // 停止倒计时 stopCountdown(); document.getElementById('welcomeScreen').style.display = 'flex'; document.getElementById('gameContainer').style.display = 'none'; document.getElementById('callLordModal').style.display = 'none'; document.getElementById('gameOverModal').style.display = 'none'; } function getPlayerName() { // 获取当前玩家名称 const nameInput = document.getElementById('playerName'); return nameInput ? nameInput.value : (userInfo?.nickname || '蔡叔叔'); } function backToLobby() { // 停止倒计时 stopCountdown(); if (socket && currentRoom) { socket.emit('leave_room'); } // 主动返回大厅,清除房间信息 clearRoomStorage(); resetGame(); } // ==================== 用户认证 ==================== async function fetchUserInfo() { // 获取当前登录用户信息 try { const response = await fetch('/api/user/profile', { credentials: 'include' }); const data = await response.json(); if (data.success) { userInfo = data.user; // 更新昵称显示 const nameInput = document.getElementById('playerName'); if (nameInput && userInfo.nickname) { nameInput.value = userInfo.nickname; } log(`欢迎回来,${userInfo.nickname || userInfo.username}!`, 'success'); // 获取并显示斗地主积分 await fetchUserScore(); return true; } } catch (error) { console.error('获取用户信息失败:', error); } return false; } // 获取用户斗地主积分 async function fetchUserScore() { try { const response = await fetch('/api/user/scores?game=doudizhu', { credentials: 'include' }); const data = await response.json(); if (data.success) { const scoreArea = document.getElementById('userScoreArea'); const scoreValue = document.getElementById('userScoreValue'); if (scoreArea && scoreValue) { scoreValue.textContent = data.score || 0; scoreArea.style.display = 'inline-block'; } } } catch (error) { console.error('获取积分失败:', error); } } // ==================== 断线重连 ==================== function saveRoomToStorage(roomCode, playerId, playerName) { // 保存房间信息到本地存储 try { localStorage.setItem(STORAGE_KEY_ROOM, roomCode); localStorage.setItem(STORAGE_KEY_PLAYER, playerId); localStorage.setItem(STORAGE_KEY_PLAYER_NAME, playerName); console.log('房间信息已保存:', roomCode, playerId); } catch (e) { console.error('保存房间信息失败:', e); } } function clearRoomStorage() { // 清除本地存储的房间信息 try { localStorage.removeItem(STORAGE_KEY_ROOM); localStorage.removeItem(STORAGE_KEY_PLAYER); localStorage.removeItem(STORAGE_KEY_PLAYER_NAME); console.log('房间信息已清除'); } catch (e) { console.error('清除房间信息失败:', e); } } function getRoomFromStorage() { // 从本地存储获取房间信息 try { return { roomCode: localStorage.getItem(STORAGE_KEY_ROOM), playerId: localStorage.getItem(STORAGE_KEY_PLAYER), playerName: localStorage.getItem(STORAGE_KEY_PLAYER_NAME) }; } catch (e) { console.error('获取房间信息失败:', e); return {}; } } function attemptRoomReconnect() { // 尝试断线重连到房间 // 如果已经手动进入房间,不重连 if (currentRoom) { return; } const saved = getRoomFromStorage(); if (!saved.roomCode) { return; // 没有保存的房间信息 } console.log('尝试重连到房间:', saved.roomCode); isReconnectingToRoom = true; // 发送重连请求 socket.emit('reconnect_room', { room_code: saved.roomCode, player_id: saved.playerId, player_name: saved.playerName || getPlayerName() }); } function onReconnectSuccess(data) { // 重连成功处理 console.log('重连成功:', data); isReconnectingToRoom = false; // 恢复房间状态 currentRoom = data.room; currentPlayer = data.player; // 清除托管状态(重连后玩家自己控制) if (data.was_ai_controlled) { trusteePlayers.delete(currentPlayer.id); isSelfTrustee = false; updateTrusteeButton(); updateTrusteeDisplay(currentPlayer.id, false); } // 更新UI showGameScreen(); updateRoomInfo(data.room); // 显示提示 const message = data.was_ai_controlled ? '重连成功!AI已代打,现在由您接管' : data.message || '重连成功!'; log(message, 'success'); // 如果游戏进行中,更新游戏状态 if (data.room.game) { gameState = data.room.game; // 获取我的手牌 const myState = gameState.player_states?.[currentPlayer.id]; if (myState && myState.cards) { myCards = myState.cards; renderHandCards(); } // 更新游戏界面 updateGameView(); updatePlayers(data.room.players); } else { // 游戏未开始,只更新玩家列表 updatePlayers(data.room.players); } // 保存新的玩家ID(可能生成新ID) if (data.player && data.player.id) { saveRoomToStorage(currentRoom.code, data.player.id, data.player.name); } } function onReconnectFailed(error) { // 重连失败处理 console.log('重连失败:', error); isReconnectingToRoom = false; // 清除保存的房间信息 clearRoomStorage(); // 显示提示 log(`重连失败: ${error.message || '房间已关闭'}`, 'warning'); } // ==================== 初始化 ==================== function initApp() { log('斗地主客户端初始化...', 'system'); // 先获取用户信息,然后自动连接 fetchUserInfo().then((success) => { log(`获取用户信息${success ? '成功' : '失败(将使用默认昵称)'}`, success ? 'success' : 'warning'); log('正在自动连接服务器...', 'system'); connectServer(true); // 自动连接 }).catch(err => { log('初始化出错: ' + err.message, 'error'); // 即使获取用户信息失败,也尝试连接 connectServer(true); }); } // DOMContentLoaded 或 window.onload 都可以触发 document.addEventListener('DOMContentLoaded', initApp);