Files
nanocode/net-games/client/game.js
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

1618 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `<div class="winner">🏆 获胜者: ${winnerName}</div>`;
resultHtml += `<div class="winner-identity">${identityText}</div>`;
resultHtml += `<div class="my-result">${isWin ? '🎉 恭喜你赢了!' : '😔 再接再厉'}</div>`;
}
// 显示积分变动
if (data.scores) {
resultHtml += '<div class="score-changes">';
resultHtml += '<div class="score-title">📊 积分变动</div>';
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 += `<div class="score-row ${isMe ? 'score-me' : ''}">`;
resultHtml += `<span class="score-identity">${identityIcon}</span>`;
resultHtml += `<span class="score-name">${scoreInfo.name}</span>`;
resultHtml += `<span class="score-change ${changeClass}">${changeSymbol}${scoreInfo.change}</span>`;
if (scoreInfo.guest) {
resultHtml += `<span class="score-guest">(游客)</span>`;
} else {
resultHtml += `<span class="score-total">总积分: ${scoreInfo.total}</span>`;
}
resultHtml += '</div>';
}
resultHtml += '</div>';
}
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 = `
<div class="card-top ${colorClass}">${card.rank === 'big_joker' ? '大王' : '小王'}</div>
<div class="card-center">🃏</div>
<div class="card-bottom ${colorClass}">${card.rank === 'big_joker' ? '大王' : '小王'}</div>
`;
} else {
el.innerHTML = `
<div class="card-top ${colorClass}">${rank}<br>${suit}</div>
<div class="card-center ${colorClass}">${suit}</div>
<div class="card-bottom ${colorClass}">${rank}<br>${suit}</div>
`;
}
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);