/**
* 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);