1618 lines
52 KiB
JavaScript
1618 lines
52 KiB
JavaScript
/**
|
||
* 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);
|