Files
czech-fool/client/game.js
T
2025-11-11 02:54:15 +03:00

2267 lines
97 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.
class CardGame {
constructor() {
this.ws = null;
this.playerId = null;
this.roomId = null;
this.currentRoom = null;
this.hand = [];
this.pendingCardToPlay = null;
this.eightDrawnCards = []; // ID карт взятых из колоды на восьмёрку
this.lastShakeTime = 0; // Время последней тряски для кулдауна
this.lastReactionTime = 0; // Время последней реакции для кулдауна
// Загружаем состояние звука из localStorage
this.soundEnabled = localStorage.getItem('soundEnabled') !== 'false';
// Маппинг звуков для реакций
this.reactionSounds = {
'🙃': 'chat',
'🙁': 'chat',
'😡': 'chat',
'😈': 'chat',
'😇': 'chat',
'😠': 'chat',
'😄': 'chat',
'😎': 'chat',
'🤔': 'chat',
'😤': 'chat',
'😐': 'chat',
'👍': 'chat',
'👎': 'chat',
'🤝': 'chat',
'🫰': 'chat',
'🪬': 'chat',
'🤯': 'chat',
'🤡': 'chat',
'🤨': 'chat',
'😑': 'chat',
'😌': 'chat',
'😴': 'chat',
'🌚': 'chat',
'🐱': 'cat',
'🐸': 'frog',
'🐺': 'chat',
'🐊': 'chat',
'🐠': 'chat',
'🌹': 'chat',
'🗿': 'huh',
'👁️': 'chat',
'💩': 'chat',
'🔩': 'pipe',
'🔪': 'okay',
'⚔️': 'sword',
'🎺': 'chat',
'🔮': 'chat',
'🎲': 'chat',
'🎯': 'chat',
'♥️': 'chat',
'♦️': 'chat',
'♣️': 'chat',
'♠️': 'chat'
};
// Инициализация звуков
this.sounds = {
playcard: new Audio('/sounds/playcard.aac'),
drawcard: new Audio('/sounds/drawcard.aac'),
eight: new Audio('/sounds/eight.aac'),
change: new Audio('/sounds/change.aac'),
skip: new Audio('/sounds/skip.aac'),
alert: new Audio('/sounds/alert.aac'),
chat: new Audio('/sounds/chat.aac'),
frog: new Audio('/sounds/frog.aac'),
win: new Audio('/sounds/win.aac'),
winqueen: new Audio('/sounds/winqueen.aac'),
lose: new Audio('/sounds/lose.aac'),
two: new Audio('/sounds/two.aac'),
six: new Audio('/sounds/six.aac'),
seven: new Audio('/sounds/seven.aac'),
shuffle: new Audio('/sounds/shuffle.aac'),
ace: new Audio('/sounds/ace.aac'),
eightplace: new Audio('/sounds/eightplace.aac'),
cat: new Audio('/sounds/cat.aac'),
okay: new Audio('/sounds/okay.aac'),
sword: new Audio('/sounds/sword.aac'),
pipe: new Audio('/sounds/pipe.aac'),
huh: new Audio('/sounds/huh.aac')
};
this.initElements();
this.initEventListeners();
// Загружаем правила игры
this.loadRules();
// Отслеживаем видимость страницы
this.pageVisible = !document.hidden;
this.setupVisibilityHandling();
// Проверяем URL и localStorage для переподключения
this.checkReconnect();
// Инициализируем PWA
this.initPWA();
}
setupVisibilityHandling() {
// Обработка видимости страницы
document.addEventListener('visibilitychange', () => {
this.pageVisible = !document.hidden;
// Проверяем на каком экране мы находимся
const lobbyScreen = document.getElementById('lobby-screen');
const isOnLobby = lobbyScreen && lobbyScreen.classList.contains('active');
if (document.hidden) {
// Страница скрыта - закрываем WebSocket ТОЛЬКО на главной странице
if (isOnLobby) {
console.log('Page hidden on lobby, closing WebSocket');
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.close();
}
} else {
console.log('Page hidden during game, keeping connection');
}
} else {
// Страница видима - переподключаемся если нужно
console.log('Page visible, checking connection');
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.connect(true);
}
}
});
}
playSound(soundName) {
if (!this.soundEnabled || !this.sounds[soundName]) {
return;
}
// Проверяем на каком экране мы находимся
const isOnLobby = this.lobbyScreen && this.lobbyScreen.classList.contains('active');
// Если на главной странице и вкладка скрыта - не воспроизводим звук
if (isOnLobby && !this.pageVisible) {
return;
}
// Во время игры (room или game экран) звуки работают даже в фоне
// Используем существующий объект вместо клонирования
const sound = this.sounds[soundName];
sound.currentTime = 0; // Сбрасываем на начало для повторного воспроизведения
sound.volume = 0.5;
sound.play().catch(err => {}); // Убираем console.log для производительности
}
toggleSound() {
this.soundEnabled = this.soundToggle.checked;
localStorage.setItem('soundEnabled', this.soundEnabled);
}
toggleAnimations() {
this.animationsEnabled = this.animationsToggle.checked;
localStorage.setItem('animationsEnabled', this.animationsEnabled);
}
toggleProMode() {
this.proModeEnabled = this.proModeToggle.checked;
localStorage.setItem('proModeEnabled', this.proModeEnabled);
// Обновляем отображение карт в руке если игра идёт
if (this.hand && this.hand.length > 0 && this.topCard) {
this.updateHand(this.topCard, this.chosenSuit);
}
}
toggleNightMode() {
this.nightModeEnabled = this.nightModeToggle.checked;
localStorage.setItem('nightModeEnabled', this.nightModeEnabled);
if (this.nightModeEnabled) {
document.body.classList.add('night-mode');
} else {
document.body.classList.remove('night-mode');
}
this.updateNightModeButton();
}
toggleNightModeFromButton() {
this.nightModeEnabled = !this.nightModeEnabled;
localStorage.setItem('nightModeEnabled', this.nightModeEnabled);
if (this.nightModeEnabled) {
document.body.classList.add('night-mode');
} else {
document.body.classList.remove('night-mode');
}
// Синхронизируем с переключателем в настройках
if (this.nightModeToggle) {
this.nightModeToggle.checked = this.nightModeEnabled;
}
this.updateNightModeButton();
}
updateNightModeButton() {
if (!this.nightModeToggleBtn) return;
const sunIcon = this.nightModeToggleBtn.querySelector('.sun-icon');
const moonIcon = this.nightModeToggleBtn.querySelector('.moon-icon');
if (this.nightModeEnabled) {
// Ночной режим включен - показываем солнце (переключит на дневной)
if (sunIcon) sunIcon.style.display = 'inline';
if (moonIcon) moonIcon.style.display = 'none';
} else {
// Дневной режим - показываем луну (переключит на ночной)
if (sunIcon) sunIcon.style.display = 'none';
if (moonIcon) moonIcon.style.display = 'inline';
}
}
showAlert(message) {
if (!this.alertModal || !this.alertText) return;
this.playSound('alert');
this.alertText.textContent = message;
this.alertModal.classList.add('active');
}
checkReconnect() {
// Получаем room_id из URL
const path = window.location.pathname;
// Проверяем формат /room/join/{room_id} (приглашение в приватную комнату)
let match = path.match(/\/room\/join\/([a-f0-9-]+)/);
if (match) {
this.roomId = match[1];
this.isJoiningViaLink = true;
// Не проверяем player_id, показываем модалку для ввода никнейма
this.connect(false);
return;
}
// Проверяем формат /room/{room_id} (обычное переподключение)
match = path.match(/\/room\/([a-f0-9-]+)/);
if (match) {
this.roomId = match[1];
// Получаем player_id из localStorage
this.playerId = localStorage.getItem(`player_${this.roomId}`);
// Пытаемся переподключиться (даже без player_id, чтобы получить ошибку)
this.connect(true);
return;
}
// Обычное подключение
this.connect(false);
}
saveToLocalStorage() {
if (this.roomId && this.playerId) {
localStorage.setItem(`player_${this.roomId}`, this.playerId);
}
}
clearLocalStorage() {
if (this.roomId) {
localStorage.removeItem(`player_${this.roomId}`);
}
}
updateURL(roomId) {
const newUrl = `/room/${roomId}`;
window.history.pushState({roomId}, '', newUrl);
}
goToLobby() {
this.clearLocalStorage();
window.history.pushState({}, '', '/');
location.reload();
}
initElements() {
// Screens
this.lobbyScreen = document.getElementById('lobby-screen');
this.roomScreen = document.getElementById('room-screen');
this.errorScreen = document.getElementById('error-screen');
this.gameScreen = document.getElementById('game-screen');
// Lobby elements
this.nicknameInput = document.getElementById('nickname-input');
this.createRoomBtn = document.getElementById('create-room-btn');
this.roomsList = document.getElementById('rooms-list');
this.privateRoomToggle = document.getElementById('private-room-toggle');
this.privateRoomSettings = document.getElementById('private-room-settings');
this.inviteLinkBlock = document.getElementById('invite-link-block');
this.inviteLink = document.getElementById('invite-link');
this.copyLinkBtn = document.getElementById('copy-link-btn');
this.shareLinkBtn = document.getElementById('share-link-btn');
this.nightModeToggleBtn = document.getElementById('night-mode-toggle-btn');
// Room elements
this.playersList = document.getElementById('players-list');
this.readyToggleBtn = document.getElementById('ready-toggle-btn');
this.leaveRoomBtn = document.getElementById('leave-room-btn');
this.roomSettings = document.getElementById('room-settings');
this.deckSizeToggle = document.getElementById('deck-size-toggle');
// Game elements
this.deckCount = document.getElementById('deck-count');
this.discardPile = document.getElementById('discard-pile');
this.chosenSuitIndicator = document.getElementById('chosen-suit-indicator');
this.handCards = document.getElementById('hand-cards');
this.drawCardBtn = document.getElementById('draw-card-btn');
this.skipTurnBtn = document.getElementById('skip-turn-btn');
// Game state
this.cardDrawnThisTurn = false;
this.waitingForEight = false;
this.eightDrawUsed = false;
// Countdown elements
this.countdownDisplay = document.getElementById('countdown-display');
this.countdownNumber = document.getElementById('countdown-number');
// Game log
this.gameLog = document.getElementById('game-log');
// Modals
this.suitModal = document.getElementById('suit-modal');
this.resultsModal = document.getElementById('results-modal');
this.joinModal = document.getElementById('join-modal');
this.joinNicknameInput = document.getElementById('join-nickname-input');
this.joinConfirmBtn = document.getElementById('join-confirm-btn');
this.joinCancelBtn = document.getElementById('join-cancel-btn');
this.closeResultsBtn = document.getElementById('close-results-btn');
this.cancelSuitBtn = document.getElementById('cancel-suit-btn');
this.alertModal = document.getElementById('alert-modal');
this.alertText = document.getElementById('alert-text');
this.alertOkBtn = document.getElementById('alert-ok-btn');
// Chat elements
this.chatBtn = document.getElementById('chat-btn');
this.chatModal = document.getElementById('chat-modal');
this.chatInput = document.getElementById('chat-input');
this.sendChatBtn = document.getElementById('send-chat-btn');
this.closeChatBtn = document.getElementById('close-chat-btn');
// Reaction elements
this.reactionPicker = document.getElementById('reaction-picker');
this.playerInfo = document.querySelector('.player-info');
// Rules elements
this.rulesBtn = document.getElementById('rules-btn');
this.rulesModal = document.getElementById('rules-modal');
this.rulesContent = document.getElementById('rules-content'); // Модалка
this.lobbyRulesContent = document.getElementById('lobby-rules-content'); // Главная страница
this.closeRulesBtn = document.getElementById('close-rules-btn');
// Settings elements
this.settingsBtn = document.getElementById('settings-btn');
this.settingsModal = document.getElementById('settings-modal');
this.closeSettingsBtn = document.getElementById('close-settings-btn');
this.fullscreenToggle = document.getElementById('fullscreen-toggle');
this.soundToggle = document.getElementById('sound-toggle');
this.animationsToggle = document.getElementById('animations-toggle');
this.proModeToggle = document.getElementById('pro-mode-toggle');
this.nightModeToggle = document.getElementById('night-mode-toggle');
this.logToggle = document.getElementById('log-toggle');
// Leave game button and modal
this.leaveGameBtn = document.getElementById('leave-game-btn');
this.leaveConfirmModal = document.getElementById('leave-confirm-modal');
this.leaveConfirmBtn = document.getElementById('leave-confirm-btn');
this.leaveCancelBtn = document.getElementById('leave-cancel-btn');
// Bot game buttons
this.playWith1BotBtn = document.getElementById('play-with-1-bot-btn');
this.playWith2BotsBtn = document.getElementById('play-with-2-bots-btn');
this.playWith3BotsBtn = document.getElementById('play-with-3-bots-btn');
// Инициализируем состояние анимаций
const savedAnimations = localStorage.getItem('animationsEnabled');
this.animationsEnabled = savedAnimations !== null ? savedAnimations === 'true' : true;
// Инициализируем режим Про
const savedProMode = localStorage.getItem('proModeEnabled');
this.proModeEnabled = savedProMode === 'true';
// Инициализируем ночной режим
const savedNightMode = localStorage.getItem('nightModeEnabled');
this.nightModeEnabled = savedNightMode === 'true';
if (this.nightModeEnabled) {
document.body.classList.add('night-mode');
}
// Обновляем иконку кнопки ночного режима
this.updateNightModeButton();
}
initEventListeners() {
this.createRoomBtn.addEventListener('click', () => this.createRoom());
this.nicknameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.createRoom();
});
// Private room toggle
this.privateRoomToggle.addEventListener('change', () => this.togglePrivateRoom());
// Copy and share link buttons
this.copyLinkBtn.addEventListener('click', () => this.copyInviteLink());
this.shareLinkBtn.addEventListener('click', () => this.shareInviteLink());
// Show share button if Web Share API is available
if (navigator.share) {
this.shareLinkBtn.style.display = 'block';
}
// Night mode toggle button on lobby
if (this.nightModeToggleBtn) {
this.nightModeToggleBtn.addEventListener('click', () => this.toggleNightModeFromButton());
}
this.readyToggleBtn.addEventListener('click', () => this.toggleReady());
this.leaveRoomBtn.addEventListener('click', () => this.leaveRoom());
// Deck size toggle
this.deckSizeToggle.addEventListener('change', () => this.changeDeckSize());
// Bot game buttons
this.playWith1BotBtn.addEventListener('click', () => this.createBotGame(1));
this.playWith2BotsBtn.addEventListener('click', () => this.createBotGame(2));
this.playWith3BotsBtn.addEventListener('click', () => this.createBotGame(3));
this.drawCardBtn.addEventListener('click', () => this.drawCard());
this.skipTurnBtn.addEventListener('click', () => this.skipTurn());
// Settings modal
this.settingsBtn.addEventListener('click', () => this.openSettings());
this.closeSettingsBtn.addEventListener('click', () => this.closeSettings());
this.fullscreenToggle.addEventListener('change', () => this.toggleFullscreen());
this.soundToggle.addEventListener('change', () => this.toggleSound());
this.animationsToggle.addEventListener('change', () => this.toggleAnimations());
this.proModeToggle.addEventListener('change', () => this.toggleProMode());
this.nightModeToggle.addEventListener('change', () => this.toggleNightMode());
this.logToggle.addEventListener('change', () => this.toggleLog());
// Leave game button
this.leaveGameBtn.addEventListener('click', () => this.showLeaveConfirm());
this.leaveConfirmBtn.addEventListener('click', () => this.confirmLeaveGame());
this.leaveCancelBtn.addEventListener('click', () => this.cancelLeaveGame());
// Suit selection
document.querySelectorAll('.suit-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const suit = e.currentTarget.dataset.suit;
this.selectSuit(suit);
});
});
this.joinConfirmBtn.addEventListener('click', () => this.confirmJoin());
this.joinCancelBtn.addEventListener('click', () => this.cancelJoin());
this.joinNicknameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.confirmJoin();
});
this.closeResultsBtn.addEventListener('click', () => {
this.closeResultsModal();
});
// Chat handlers
this.chatBtn.addEventListener('click', () => this.openChat());
this.sendChatBtn.addEventListener('click', () => this.sendChatMessage());
this.closeChatBtn.addEventListener('click', () => this.closeChat());
this.chatInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendChatMessage();
}
});
// Reaction handlers - клик по блоку с никнеймом
if (this.playerInfo) {
this.playerInfo.addEventListener('click', (e) => {
e.stopPropagation();
this.showReactionPicker(e);
});
}
document.querySelectorAll('.reaction-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const reaction = e.currentTarget.dataset.reaction;
this.sendReaction(reaction);
this.hideReactionPicker();
});
});
// Закрываем пикер при клике вне его
document.addEventListener('click', (e) => {
if (this.reactionPicker &&
this.reactionPicker.classList.contains('active') &&
!this.reactionPicker.contains(e.target) &&
(!this.playerInfo || !this.playerInfo.contains(e.target))) {
this.hideReactionPicker();
}
});
// Rules handlers
this.rulesBtn.addEventListener('click', () => this.openRules());
this.closeRulesBtn.addEventListener('click', () => this.closeRules());
const goHomeBtn = document.getElementById('go-home-btn');
if (goHomeBtn) {
goHomeBtn.addEventListener('click', () => this.goToLobby());
}
// Горячие клавиши
document.addEventListener('keydown', (e) => this.handleKeyPress(e));
if (this.alertOkBtn && this.alertModal) {
this.alertOkBtn.addEventListener('click', () => {
this.alertModal.classList.remove('active');
});
}
if (this.cancelSuitBtn) {
this.cancelSuitBtn.addEventListener('click', () => {
this.suitModal.classList.remove('active');
this.pendingCardToPlay = null;
});
}
// Закрытие модалок по клику на фон
this.setupModalBackdropClose();
}
setupModalBackdropClose() {
// Список всех модалок
const modals = [
{ element: this.settingsModal, closeMethod: () => this.closeSettings() },
{ element: this.resultsModal, closeMethod: () => this.closeResultsModal() },
{ element: this.rulesModal, closeMethod: () => this.closeRules() },
{ element: this.alertModal, closeMethod: () => this.alertModal.classList.remove('active') },
{ element: this.suitModal, closeMethod: () => {
this.suitModal.classList.remove('active');
this.pendingCardToPlay = null;
}},
{ element: this.chatModal, closeMethod: () => this.chatModal.classList.remove('active') },
{ element: this.joinModal, closeMethod: () => this.joinModal.classList.remove('active') },
{ element: this.leaveConfirmModal, closeMethod: () => this.leaveConfirmModal.classList.remove('active') }
];
// Добавляем обработчик для каждой модалки
modals.forEach(modal => {
if (modal.element) {
modal.element.addEventListener('click', (e) => {
// Закрываем только если клик был по самой модалке (фону), а не по её содержимому
if (e.target === modal.element) {
modal.closeMethod();
}
});
}
});
}
connect(reconnect = false) {
// Определяем протокол WebSocket (ws или wss)
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Проверяем, есть ли кастомный WebSocket домен в meta теге
const wsHostMeta = document.querySelector('meta[name="ws-host"]');
let wsUrl;
if (wsHostMeta && wsHostMeta.content) {
// Используем кастомный домен для WebSocket
wsUrl = `${wsProtocol}//${wsHostMeta.content}`;
} else {
// Используем текущий хост с портом 8765
wsUrl = `${wsProtocol}//${window.location.hostname}:8765`;
}
console.log('Connecting to:', wsUrl);
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('Connected to server');
if (reconnect && this.roomId) {
// Переподключаемся к существующей игре
// Если нет playerId - сервер вернёт ошибку и покажется экран ошибки
this.send({
type: 'reconnect',
player_id: this.playerId || 'temp_' + Date.now(),
room_id: this.roomId
});
} else if (this.isJoiningViaLink && this.roomId) {
// Переход по ссылке приглашения - показываем модалку для ввода никнейма
this.joinModal.classList.add('active');
this.joinNicknameInput.focus();
} else {
// Обычное подключение
this.send({ type: 'get_rooms' });
}
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleMessage(data);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
// Показываем алерт только если страница видима и мы не в игре
const lobbyScreen = document.getElementById('lobby-screen');
const isOnLobby = lobbyScreen && lobbyScreen.classList.contains('active');
if (this.pageVisible && isOnLobby) {
this.showAlert('Ошибка подключения к серверу');
}
};
this.ws.onclose = () => {
console.log('Disconnected from server');
// Переподключаемся всегда если страница видима, или если мы в игре (даже если скрыта)
const lobbyScreen = document.getElementById('lobby-screen');
const isOnLobby = lobbyScreen && lobbyScreen.classList.contains('active');
if (this.pageVisible || !isOnLobby) {
// Переподключаемся если: страница видима ИЛИ мы в игре (не на лобби)
setTimeout(() => this.connect(), 3000);
}
};
}
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
handleMessage(data) {
// console.log('Received:', data); // Закомментировано для производительности
switch (data.type) {
case 'rooms_list':
this.updateRoomsList(data.rooms);
break;
case 'room_created':
this.playerId = data.player_id;
this.roomId = data.room_id;
this.currentRoom = data.room;
this.saveToLocalStorage();
this.updateURL(this.roomId);
this.showScreen('room');
this.updatePlayersInRoom(data.room.players);
this.updateRoomSettings(data.room);
break;
case 'room_joined':
this.playerId = data.player_id;
this.roomId = data.room_id;
this.currentRoom = data.room;
this.saveToLocalStorage();
this.updateURL(this.roomId);
this.showScreen('room');
this.updatePlayersInRoom(data.room.players);
this.updateRoomSettings(data.room);
break;
case 'player_joined':
if (this.currentRoom) {
this.currentRoom.players.push(data.player);
this.updatePlayersInRoom(this.currentRoom.players);
}
break;
case 'player_left':
if (this.currentRoom) {
this.currentRoom.players = this.currentRoom.players.filter(
p => p.id !== data.player_id
);
this.updatePlayersInRoom(this.currentRoom.players);
}
break;
case 'player_ready_changed':
if (this.currentRoom) {
const player = this.currentRoom.players.find(p => p.id === data.player_id);
if (player) {
player.ready = data.ready;
this.updatePlayersInRoom(this.currentRoom.players);
}
}
break;
case 'room_updated':
this.currentRoom = data.room;
this.updatePlayersInRoom(data.room.players);
break;
case 'game_started':
// Проверяем это переподключение или новая игра
// Если мы на экране комнаты или лобби - это новая игра
// Если мы на экране игры - это переподключение
const isReconnect = this.gameScreen.classList.contains('active') ||
(this.hand && this.hand.length > 0 && !this.roomScreen.classList.contains('active'));
this.hand = data.hand;
this.currentPlayerId = data.current_player;
this.dealerId = data.dealer;
this.deckCount.textContent = data.deck_count;
this.waitingForEight = data.waiting_for_eight || false;
this.eightDrawUsed = data.eight_draw_used || false;
this.eightDrawnCards = data.eight_drawn_cards || [];
this.cardDrawnThisTurn = data.card_drawn_this_turn || false;
if (!isReconnect) {
// Новая игра - очищаем лог и добавляем сообщение
this.clearLog();
this.addLogEntry('Игра началась!');
// Воспроизводим звук если игра началась со специальной карты
if (data.top_card) {
const rank = data.top_card.rank;
const deckSize = data.deck_size || 52;
if (rank === 'A') {
this.playSound('ace');
} else if (rank === '8' && deckSize === 52) {
// Восьмёрка только в режиме 52 карт
this.playSound('eightplace');
} else if (rank === '6') {
this.playSound('six');
} else if (rank === '7') {
this.playSound('seven');
} else if (rank === 'Q') {
this.playSound('change');
}
}
// Если кто-то принудительно взял карты при старте (6 или 7)
if (data.forced_draw_player_id && data.forced_draw_count > 0) {
// Анимация принудительного взятия карт при старте игры
// Задержка чтобы элементы успели отрендериться
setTimeout(() => {
this.animateDrawCards(data.forced_draw_player_id, data.forced_draw_count);
}, 300);
const cardName = `${data.top_card.rank}${this.getSuitSymbolForLog(data.top_card.suit)}`;
const cardsText = data.forced_draw_count === 1 ? '1 карту' : `${data.forced_draw_count} карты`;
this.addLogEntry(`${data.forced_draw_player_nickname} взял ${cardsText} от ${cardName}`);
}
} else {
// Переподключение - просто добавляем сообщение в лог
this.addLogEntry('Вы переподключились');
}
this.hideCountdown();
this.showScreen('game');
this.updateGameState(data);
// Скрываем настройки комнаты когда игра началась
if (this.roomSettings) {
this.roomSettings.style.display = 'none';
}
break;
case 'card_played':
// Сохраняем старое значение waiting_for_eight перед обновлением
const wasWaitingForEight = this.waitingForEight;
// Анимация полета карты от игрока к столу
if (data.player_id) {
this.animatePlayCard(data.player_id);
}
this.hand = data.hand;
this.currentPlayerId = data.current_player;
this.deckCount.textContent = data.deck_count;
this.cardDrawnThisTurn = data.card_drawn_this_turn || false;
this.waitingForEight = data.waiting_for_eight || false;
this.eightDrawUsed = data.eight_draw_used || false;
this.eightDrawnCards = data.eight_drawn_cards || [];
// Звук игры карты
// Специальные карты - звук для всех игроков
const deckSize = data.deck_size || 52;
if (data.card.rank === 'A') {
this.playSound('playcard');
setTimeout(() => this.playSound('ace'), 60);
} else if (data.card.rank === '8' && deckSize === 52) {
// Восьмёрка только в режиме 52 карт
this.playSound('eightplace');
} else if (data.card.rank === '6') {
this.playSound('playcard');
setTimeout(() => this.playSound('six'), 40);
} else if (data.card.rank === '7') {
this.playSound('playcard');
setTimeout(() => this.playSound('seven'), 40);
} else if (data.card.rank === '2' && data.waiting_for_eight === false && wasWaitingForEight === true) {
this.playSound('two');
} else {
this.playSound('playcard');
}
// Логируем событие
const cardName = `${data.card.rank} ${this.getSuitSymbolForLog(data.card.suit)}`;
this.addLogEntry(`${data.player_nickname} сыграл ${cardName}`);
// Если выбрана масть дамой
if (data.card.rank === 'Q' && data.chosen_suit) {
// Звук смены масти для всех игроков
this.playSound('change');
this.addLogEntry(`${data.player_nickname} назвал ${this.getSuitName(data.chosen_suit)}`);
}
// Если кто-то принудительно взял карты (6 или 7)
if (data.forced_draw_player_id && data.forced_draw_count > 0) {
// Анимация принудительного взятия карт
this.animateDrawCards(data.forced_draw_player_id, data.forced_draw_count);
const cardsText = data.forced_draw_count === 1 ? '1 карту' : `${data.forced_draw_count} карты`;
this.addLogEntry(`${data.forced_draw_player_nickname} взял ${cardsText} от ${cardName}`);
}
this.updateGameState(data);
break;
case 'card_drawn':
// Анимация взятия карт из колоды
if (data.player_id && data.cards_count) {
this.animateDrawCards(data.player_id, data.cards_count);
}
this.hand = data.hand;
this.currentPlayerId = data.current_player;
this.deckCount.textContent = data.deck_count;
this.cardDrawnThisTurn = data.card_drawn_this_turn || false;
this.waitingForEight = data.waiting_for_eight || false;
this.eightDrawUsed = data.eight_draw_used || false;
this.eightDrawnCards = data.eight_drawn_cards || [];
// Звук взятия карты
if (data.waiting_for_eight) {
this.playSound('eight');
} else {
this.playSound('drawcard');
}
// Логируем событие
const cardsText = data.cards_count === 1 ? '1 карту' : `${data.cards_count} карты`;
if (data.waiting_for_eight) {
const topCard = data.top_card;
this.addLogEntry(`${data.player_nickname} взял ${cardsText} от ${topCard.rank} ${this.getSuitSymbolForLog(topCard.suit)}`);
} else {
const cardsText = data.cards_count === 1 ? 'карту' : 'карты';
this.addLogEntry(`${data.player_nickname} взял ${data.cards_count} ${cardsText}`);
}
this.updateGameState(data);
break;
case 'turn_skipped':
this.hand = data.hand;
this.currentPlayerId = data.current_player;
this.cardDrawnThisTurn = false;
this.waitingForEight = data.waiting_for_eight || false;
this.eightDrawUsed = data.eight_draw_used || false;
this.eightDrawnCards = data.eight_drawn_cards || [];
// Звук пропуска хода (для всех игроков)
this.playSound('skip');
// Логируем пропуск хода
if (data.log_message) {
this.addLogEntry(data.log_message);
}
this.updateGameState(data);
break;
case 'deck_shuffled':
// Анимация перемешивания колоды
this.animateDeckShuffle();
// Звук перемешивания колоды (для всех игроков)
this.playSound('shuffle');
// Логируем перемешивание колоды
if (data.log_message) {
this.addLogEntry(data.log_message);
}
break;
case 'chat_message':
// Отображаем сообщение в логе с выделением
if (data.message) {
// Звук чата для всех
this.playSound('chat');
// Определяем класс для сообщения
let chatClass = 'chat-other';
if (data.player_id === this.playerId) {
chatClass = 'chat-player';
} else {
// Для других игроков назначаем цвет по их ID
const colorIndex = this.getPlayerColorIndex(data.player_id);
chatClass = `chat-other chat-color-${colorIndex}`;
}
this.addLogEntry(data.message, chatClass);
}
break;
case 'game_ended':
this.showResults(data);
break;
case 'player_kicked':
// Игрок вылетел из комнаты (набрал >101)
if (data.player_id === this.playerId) {
// Это мы вылетели
this.showAlert(`Вы набрали больше 101 очка и выбыли из игры`);
setTimeout(() => this.goToLobby(), 6000);
} else {
// Другой игрок вылетел
this.addLogEntry(`${data.player_nickname} выбыл (>101 очка)`);
if (this.currentRoom) {
this.currentRoom.players = this.currentRoom.players.filter(
p => p.id !== data.player_id
);
this.updatePlayersInRoom(this.currentRoom.players);
}
}
break;
case 'final_winner':
// Финальная победа - комната закрывается
const winnerText = data.winner_id === this.playerId
? 'Поздравляем! Вы победили!'
: `Победитель: ${data.winner_nickname}`;
this.showAlert(winnerText);
setTimeout(() => this.goToLobby(), 6000);
break;
case 'countdown_tick':
this.showCountdown(data.seconds);
break;
case 'countdown_cancelled':
this.hideCountdown();
break;
case 'player_disconnected':
// Игрок временно отключился
this.addLogEntry(`${data.nickname} отключился`);
break;
case 'player_reconnected':
// Игрок переподключился
this.addLogEntry(`${data.nickname} переподключился`);
break;
case 'shake_discard':
// Тряска карты сброса
this.animateShakeDiscard();
this.playSound('alert');
break;
case 'reaction':
// Быстрая реакция от игрока
// Используем маппинг звуков для реакций
const soundName = this.reactionSounds[data.emoji] || 'chat';
this.playSound(soundName);
this.showReactionBubble(data.player_id, data.emoji);
break;
case 'deck_size_changed':
// Размер колоды изменён
if (this.currentRoom) {
this.currentRoom.deck_size = data.deck_size;
}
this.deckSizeToggle.checked = data.deck_size === 52;
this.addLogEntry(`Размер колоды карт: ${data.deck_size}`);
break;
case 'room_closed':
// Комната закрыта создателем
this.showAlert(data.message);
setTimeout(() => this.goToLobby(), 3000);
break;
case 'error':
// Обрабатываем ошибки по кодам
if (data.error_code === 'room_not_found' ||
data.error_code === 'game_started' ||
data.error_code === 'player_not_found') {
// Ссылка недействительна или комната не существует - показываем экран ошибки
this.showError(data.message);
// Очищаем данные
this.isJoiningViaLink = false;
this.roomId = null;
this.clearLocalStorage();
} else if (data.message.includes('not found') || data.message.includes('не найден')) {
this.showError(data.message);
} else {
this.showAlert(data.message);
}
break;
}
}
showScreen(screen) {
this.lobbyScreen.classList.remove('active');
this.roomScreen.classList.remove('active');
this.errorScreen.classList.remove('active');
this.gameScreen.classList.remove('active');
if (screen === 'lobby') {
this.lobbyScreen.classList.add('active');
} else if (screen === 'room') {
this.roomScreen.classList.add('active');
} else if (screen === 'error') {
this.errorScreen.classList.add('active');
} else if (screen === 'game') {
this.gameScreen.classList.add('active');
}
}
showError(message) {
const errorMessage = document.getElementById('error-message');
if (errorMessage) {
errorMessage.textContent = message;
}
this.showScreen('error');
}
createRoom() {
const nickname = this.nicknameInput.value.trim();
if (!nickname) {
this.showAlert('Введите никнейм');
return;
}
this.send({
type: 'create_room',
nickname: nickname,
is_private: false // По умолчанию не приватная
});
}
togglePrivateRoom() {
const isChecked = this.privateRoomToggle.checked;
// Отправляем на сервер изменение приватности
this.send({
type: 'toggle_private',
is_private: isChecked
});
// Показываем/скрываем блок со ссылкой
if (isChecked) {
this.inviteLinkBlock.style.display = 'block';
// Генерируем ссылку если её ещё нет
if (!this.inviteLink.textContent && this.roomId) {
const inviteUrl = `${window.location.origin}/room/join/${this.roomId}`;
this.inviteLink.textContent = inviteUrl;
}
} else {
this.inviteLinkBlock.style.display = 'none';
}
}
copyInviteLink() {
const link = this.inviteLink.textContent;
navigator.clipboard.writeText(link).then(() => {
// Временно меняем иконку на галочку
const originalHTML = this.copyLinkBtn.innerHTML;
this.copyLinkBtn.textContent = '✓';
setTimeout(() => {
this.copyLinkBtn.innerHTML = originalHTML;
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
this.showAlert('Не удалось скопировать ссылку');
});
}
async shareInviteLink() {
const link = this.inviteLink.textContent;
try {
await navigator.share({
title: 'Погнали в чешского!',
text: 'Присоединяйся к комнате:',
url: link
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Failed to share:', err);
}
}
}
createBotGame(botCount) {
const nickname = this.nicknameInput.value.trim();
if (!nickname) {
this.showAlert('Введите никнейм');
return;
}
this.send({
type: 'create_bot_game',
nickname: nickname,
bot_count: botCount
});
}
updateRoomsList(rooms) {
this.roomsList.innerHTML = '';
if (rooms.length === 0) {
this.roomsList.innerHTML = '<p style="color: #666;">Нет доступных комнат</p>';
return;
}
rooms.forEach(room => {
const roomCard = document.createElement('div');
const isFull = room.player_count >= 4;
roomCard.className = 'room-card' + (isFull ? ' room-full' : '');
roomCard.innerHTML = `
<h4>Комната</h4>
<p>Игроков: ${room.player_count}/4</p>
<p>Статус: ${isFull ? 'Полная' : 'Ожидание'}</p>
`;
if (!isFull) {
roomCard.addEventListener('click', () => this.showJoinModal(room.id));
}
this.roomsList.appendChild(roomCard);
});
}
showJoinModal(roomId) {
this.pendingRoomId = roomId;
this.joinModal.classList.add('active');
this.joinNicknameInput.value = '';
this.joinNicknameInput.focus();
}
confirmJoin() {
const nickname = this.joinNicknameInput.value.trim();
if (!nickname) {
this.showAlert('Введите никнейм');
return;
}
// Используем roomId если присоединяемся по ссылке, иначе pendingRoomId
const roomId = this.isJoiningViaLink ? this.roomId : this.pendingRoomId;
this.send({
type: 'join_room',
room_id: roomId,
nickname: nickname
});
this.joinModal.classList.remove('active');
// Сбрасываем флаг после присоединения
this.isJoiningViaLink = false;
}
cancelJoin() {
this.joinModal.classList.remove('active');
}
updatePlayersInRoom(players) {
this.playersList.innerHTML = '';
players.forEach(player => {
const playerCard = document.createElement('div');
playerCard.className = 'player-card';
if (player.ready) {
playerCard.classList.add('ready');
}
playerCard.innerHTML = `
<h4>${player.nickname} ${player.id === this.playerId ? '(Вы)' : ''}</h4>
<p class="score">Очки: ${player.score || 0}</p>
<p class="status">${player.ready ? '✓ Готов' : 'Не готов'}</p>
`;
this.playersList.appendChild(playerCard);
});
}
toggleReady() {
this.send({ type: 'toggle_ready' });
}
leaveRoom() {
// Скрываем блок с ссылкой если он был показан
if (this.inviteLinkBlock) {
this.inviteLinkBlock.style.display = 'none';
}
// Сбрасываем переключатель приватной комнаты
if (this.privateRoomToggle) {
this.privateRoomToggle.checked = false;
}
this.goToLobby();
}
changeDeckSize() {
const deckSize = this.deckSizeToggle.checked ? 52 : 36;
this.send({
type: 'change_deck_size',
deck_size: deckSize
});
}
updateRoomSettings(room) {
// Показываем настройки только создателю комнаты и только до начала первого раунда
const isCreator = room.creator_id === this.playerId;
const gameNotStarted = !room.game_started;
// Проверяем что ни у кого нет очков (игра ещё не начиналась ни разу)
// Это ключевая проверка - между раундами game_started=false, но очки уже есть
const noScores = room.players.every(p => p.score === 0);
// Проверяем есть ли боты в комнате
const hasBot = room.players.some(p => p.is_bot);
// Настройки комнаты (переключатель 36/52) показываем только до первого раунда
const shouldShow = isCreator && gameNotStarted && noScores;
this.roomSettings.style.display = shouldShow ? 'block' : 'none';
// Блок приватной комнаты показываем ТОЛЬКО при создании комнаты (noScores) и без ботов
// Между раундами он не должен показываться, даже если game_started=false
if (this.privateRoomSettings) {
this.privateRoomSettings.style.display = (shouldShow && !hasBot) ? 'block' : 'none';
}
// Устанавливаем текущий размер колоды
if (room.deck_size) {
this.deckSizeToggle.checked = room.deck_size === 52;
}
}
showLeaveConfirm() {
this.leaveConfirmModal.classList.add('active');
}
confirmLeaveGame() {
this.leaveConfirmModal.classList.remove('active');
this.goToLobby();
}
cancelLeaveGame() {
this.leaveConfirmModal.classList.remove('active');
}
updateGameState(data) {
// Update top card
this.discardPile.innerHTML = '';
if (data.top_card) {
const cardElement = this.createCardElement(data.top_card, false);
cardElement.style.cursor = 'pointer';
cardElement.addEventListener('click', () => this.shakeDiscardPile());
this.discardPile.appendChild(cardElement);
}
// Update chosen suit indicator
// Показываем индикатор всегда когда есть выбранная масть (после дамы)
if (data.chosen_suit) {
this.chosenSuitIndicator.style.display = 'block';
const suitEmoji = this.getSuitSymbol(data.chosen_suit);
const suitClass = data.chosen_suit; // hearts, diamonds, clubs, spades
this.chosenSuitIndicator.innerHTML = `<span class="suit-emoji-indicator ${suitClass}">${suitEmoji}</span>`;
} else {
this.chosenSuitIndicator.style.display = 'none';
}
// Update player info
const currentPlayer = data.players.find(p => p.id === this.playerId);
if (currentPlayer) {
document.getElementById('player-name').textContent = currentPlayer.nickname;
document.getElementById('player-score').textContent = `Очки: ${currentPlayer.score}`;
}
// Update opponents around the table
this.updateOpponents(data.players, this.currentPlayerId);
// Update hand
this.updateHand(data.top_card, data.chosen_suit);
// Управление кнопками
const isMyTurn = this.currentPlayerId === this.playerId;
if (isMyTurn) {
if (this.waitingForEight) {
// При восьмёрке - только кнопка взятия карты
// Пропуск хода невозможен - нужно брать карты
this.drawCardBtn.style.display = 'block';
// Отключаем кнопку если взятие уже использовано
this.drawCardBtn.disabled = this.eightDrawUsed;
this.skipTurnBtn.style.display = 'none';
} else if (this.cardDrawnThisTurn) {
// Карта уже взята - скрываем кнопку взятия, показываем пропуск
// Игрок может играть карты или пропустить ход
this.drawCardBtn.style.display = 'none';
this.skipTurnBtn.style.display = 'block';
this.skipTurnBtn.disabled = false;
} else {
// Карта не взята - показываем только кнопку взятия
this.drawCardBtn.style.display = 'block';
this.drawCardBtn.disabled = false;
this.skipTurnBtn.style.display = 'none';
}
} else {
// Не наш ход - отключаем все кнопки
this.drawCardBtn.style.display = 'block';
this.drawCardBtn.disabled = true;
this.skipTurnBtn.style.display = 'none';
}
}
updateOpponents(players, currentPlayerId) {
// Скрываем все позиции
const opponentTop = document.getElementById('opponent-top');
const opponentLeft = document.getElementById('opponent-left');
const opponentRight = document.getElementById('opponent-right');
opponentTop.style.display = 'none';
opponentLeft.style.display = 'none';
opponentRight.style.display = 'none';
// Убираем подсветку со всех
opponentTop.classList.remove('current-turn');
opponentLeft.classList.remove('current-turn');
opponentRight.classList.remove('current-turn');
// Получаем противников (все кроме нас)
// Сортируем их в порядке хода (по часовой стрелке от нас)
const myIndex = players.findIndex(p => p.id === this.playerId);
const opponents = [];
for (let i = 1; i < players.length; i++) {
const opponentIndex = (myIndex + i) % players.length;
opponents.push(players[opponentIndex]);
}
if (opponents.length === 0) return;
// Получаем контейнер для противников
const opponentsContainer = document.getElementById('opponents-container');
// Выбираем позиции в зависимости от количества противников
let positions;
if (opponents.length === 1) {
// Один противник - только центр
positions = [opponentTop];
opponentsContainer.className = 'opponents-1';
} else if (opponents.length === 2) {
// Два противника - слева и справа
positions = [opponentLeft, opponentRight];
opponentsContainer.className = 'opponents-2';
} else {
// Три противника - слева, центр, справа
positions = [opponentLeft, opponentTop, opponentRight];
opponentsContainer.className = 'opponents-3';
}
opponents.forEach((opponent, index) => {
if (index >= positions.length) return;
const position = positions[index];
position.style.display = 'flex';
position.dataset.playerId = opponent.id; // Сохраняем ID игрока для анимаций
const info = position.querySelector('.opponent-info');
const cardsContainer = position.querySelector('.opponent-cards');
// Обновляем информацию
info.querySelector('.opponent-name').textContent = opponent.nickname;
info.querySelector('.opponent-score').textContent = `Очки: ${opponent.score}`;
// Подсвечиваем текущего игрока
if (opponent.id === currentPlayerId) {
position.classList.add('current-turn');
} else {
position.classList.remove('current-turn');
}
// Отображаем карты-рубашки
cardsContainer.innerHTML = '';
// Если игроков больше 2 (т.е. противников больше 1), показываем только одну рубашку с цифрой
if (opponents.length > 1) {
const cardBack = document.createElement('div');
cardBack.className = 'opponent-card single-card';
cardBack.innerHTML = `
<div class="card-back-icon">🎴</div>
<div class="card-count">${opponent.hand_count}</div>
`;
cardsContainer.appendChild(cardBack);
} else {
// Если только 1 противник, показываем все его карты
for (let i = 0; i < opponent.hand_count; i++) {
const cardBack = document.createElement('div');
cardBack.className = 'opponent-card';
cardBack.textContent = '🎴';
cardsContainer.appendChild(cardBack);
}
}
});
// Подсвечиваем наши карты и инфо, если наш ход
const playerHand = document.querySelector('.player-hand');
const playerInfo = document.querySelector('.player-info');
if (currentPlayerId === this.playerId) {
playerHand.classList.add('current-turn');
playerInfo.classList.add('current-turn');
} else {
playerHand.classList.remove('current-turn');
playerInfo.classList.remove('current-turn');
}
}
updateHand(topCard, chosenSuit) {
// Сохраняем для использования при переключении режима Про
this.topCard = topCard;
this.chosenSuit = chosenSuit;
this.handCards.innerHTML = '';
this.hand.forEach(card => {
const cardElement = this.createCardElement(card, true);
// Check if card can be played
const canPlay = this.currentPlayerId === this.playerId &&
this.canPlayCard(card, topCard, chosenSuit, this.waitingForEight, this.eightDrawnCards);
if (!canPlay) {
// В режиме Про карты остаются яркими (не добавляем disabled)
// В обычном режиме затемняем неподходящие карты
if (this.proModeEnabled) {
cardElement.classList.remove('disabled');
// В режиме Про добавляем обработчик клика даже для неподходящих карт
cardElement.addEventListener('click', () => this.playCard(card));
} else {
cardElement.classList.add('disabled');
}
} else {
cardElement.classList.remove('disabled');
cardElement.addEventListener('click', () => this.playCard(card));
}
this.handCards.appendChild(cardElement);
});
}
canPlayCard(card, topCard, chosenSuit, waitingForEight, eightDrawnCards = []) {
// На восьмёрку можно класть:
// - Любую двойку (из руки)
// - Карты взятые из колоды, которые подходят (двойка, дама, восьмёрка или та же масть)
if (waitingForEight) {
// Двойка из руки всегда разрешена
if (card.rank === '2') return true;
// Карты взятые из колоды разрешены, только если они подходят
if (eightDrawnCards.includes(card.id)) {
// Проверяем что карта действительно подходит для восьмёрки
return card.rank === '2' ||
card.rank === 'Q' ||
card.rank === '8' ||
card.suit === topCard.suit;
}
// Остальные карты заблокированы
return false;
}
// Дама может быть положена на любую карту
if (card.rank === 'Q') return true;
// Если была выбрана масть дамой
if (chosenSuit) {
return card.suit === chosenSuit || card.rank === topCard.rank;
}
// Обычная проверка: масть или номинал
return card.suit === topCard.suit || card.rank === topCard.rank;
}
createCardElement(card, clickable) {
const cardDiv = document.createElement('div');
cardDiv.className = `card ${card.suit}`;
cardDiv.innerHTML = `
<div class="card-rank">${card.rank}</div>
<div class="card-suit">${this.getSuitSymbol(card.suit)}</div>
`;
return cardDiv;
}
getSuitSymbol(suit) {
const symbols = {
'hearts': '♥️',
'diamonds': '♦️',
'clubs': '♣️',
'spades': '♠️'
};
return symbols[suit] || '';
}
getSuitSymbolForLog(suit) {
const symbols = {
'hearts': '<span class="suit-emoji-log hearts">♥️</span>',
'diamonds': '<span class="suit-emoji-log diamonds">♦️</span>',
'clubs': '<span class="suit-emoji-log clubs">♣️</span>',
'spades': '<span class="suit-emoji-log spades">♠️</span>'
};
return symbols[suit] || '';
}
getSuitName(suit) {
const names = {
'hearts': 'черви',
'diamonds': 'бубны',
'clubs': 'трефы',
'spades': 'пики'
};
return names[suit] || suit;
}
playCard(card) {
if (this.currentPlayerId !== this.playerId) {
return;
}
// If it's a Queen, check if it's the last card
if (card.rank === 'Q') {
// Если это последняя карта - автоматически выбираем пики
if (this.hand.length === 1) {
// Отправляем карту с автоматическим выбором пик (игрок выигрывает)
this.send({
type: 'play_card',
card_id: card.id,
chosen_suit: 'spades'
});
} else {
// Не последняя карта - показываем модалку выбора масти
this.pendingCardToPlay = card;
this.suitModal.classList.add('active');
}
} else {
// Отправляем карту без воспроизведения звука
// Звук будет воспроизведен когда придет событие card_played
this.send({
type: 'play_card',
card_id: card.id
});
}
}
selectSuit(suit) {
this.suitModal.classList.remove('active');
if (this.pendingCardToPlay) {
// Отправляем карту без воспроизведения звука
// Звук будет воспроизведен когда придет событие card_played
this.send({
type: 'play_card',
card_id: this.pendingCardToPlay.id,
chosen_suit: suit
});
this.pendingCardToPlay = null;
}
}
drawCard() {
if (this.currentPlayerId !== this.playerId) {
return;
}
this.send({ type: 'draw_card' });
}
skipTurn() {
if (this.currentPlayerId !== this.playerId) {
return;
}
this.send({
type: 'skip_turn'
});
}
shakeDiscardPile() {
// Проверяем кулдаун (5 секунд)
const now = Date.now();
const cooldown = 5000; // 5 секунд в миллисекундах
if (now - this.lastShakeTime < cooldown) {
// Просто игнорируем клик если кулдаун активен
return;
}
// Обновляем время последней тряски
this.lastShakeTime = now;
// Отправляем событие тряски на сервер
this.send({
type: 'shake_discard'
});
}
showCountdown(seconds) {
this.countdownNumber.textContent = seconds;
this.countdownDisplay.style.display = 'block';
}
hideCountdown() {
this.countdownDisplay.style.display = 'none';
}
toggleFullscreen() {
if (this.fullscreenToggle.checked) {
// Входим в полноэкранный режим
document.documentElement.requestFullscreen().catch(err => {
console.log('Error attempting to enable fullscreen:', err);
this.fullscreenToggle.checked = false;
});
} else {
// Выходим из полноэкранного режима
if (document.fullscreenElement) {
document.exitFullscreen();
}
}
}
openSettings() {
// Синхронизируем состояние переключателей с текущими настройками
this.soundToggle.checked = this.soundEnabled;
this.animationsToggle.checked = this.animationsEnabled;
this.proModeToggle.checked = this.proModeEnabled;
this.nightModeToggle.checked = this.nightModeEnabled;
this.fullscreenToggle.checked = !!document.fullscreenElement;
// Синхронизируем состояние лога (проверяем есть ли класс hidden)
if (this.gameLog) {
this.logToggle.checked = !this.gameLog.classList.contains('hidden');
}
this.settingsModal.classList.add('active');
}
closeSettings() {
this.settingsModal.classList.remove('active');
}
toggleLog() {
if (!this.gameLog) return;
if (this.gameLog.classList.contains('hidden')) {
this.gameLog.classList.remove('hidden');
} else {
this.gameLog.classList.add('hidden');
}
}
addLogEntry(message, extraClass = '') {
if (!this.gameLog) return;
const entry = document.createElement('div');
entry.className = extraClass ? `log-entry ${extraClass}` : 'log-entry';
entry.innerHTML = message;
// Добавляем в начало (новые сверху)
this.gameLog.insertBefore(entry, this.gameLog.firstChild);
// Обновляем прозрачность для всех записей
const entries = this.gameLog.querySelectorAll('.log-entry');
entries.forEach((e, index) => {
if (index === 0) {
e.style.opacity = '1';
} else {
// Каждая следующая запись на 15% прозрачнее
e.style.opacity = Math.max(0.1, 1 - (index * 0.15)).toString();
}
});
// Ограничиваем количество записей (максимум 20)
if (entries.length > 20) {
this.gameLog.removeChild(entries[entries.length - 1]);
}
}
clearLog() {
if (this.gameLog) {
this.gameLog.innerHTML = '';
}
}
showResults(data) {
const resultsContent = document.getElementById('results-content');
resultsContent.innerHTML = '';
// Воспроизводим звук win или lose
if (data.winner_id === this.playerId) {
// Проверяем есть ли бонус за даму у победителя
const winnerResult = data.results.find(r => r.player_id === data.winner_id);
const hasQueenBonus = winnerResult && winnerResult.queen_bonus && winnerResult.queen_bonus < 0;
// Если победа на даму (есть бонус) - особый звук
if (hasQueenBonus) {
this.playSound('winqueen');
setTimeout(() => this.playSound('win'), 60);
} else {
this.playSound('win');
}
} else {
this.playSound('lose');
}
data.results.forEach(result => {
const resultItem = document.createElement('div');
resultItem.className = 'result-item';
if (result.player_id === data.winner_id) {
resultItem.classList.add('winner');
// Отображаем последнюю карту победителя
const winningCardHtml = data.winning_card
? `<div class="result-cards">
<div class="result-card ${data.winning_card.suit}">
<span class="card-rank">${data.winning_card.rank}</span>
<span class="card-suit">${this.getSuitSymbol(data.winning_card.suit)}</span>
</div>
</div>`
: '';
// Текст для бонуса за даму
const queenBonusText = result.queen_bonus && result.queen_bonus < 0
? `<p style="color: #4caf50; font-weight: bold;">Бонус за даму: ${result.queen_bonus} 👑</p>`
: '';
resultItem.innerHTML = `
<h4>🏆 ${result.nickname} - ПОБЕДИТЕЛЬ!</h4>
<p>Очки за раунд: ${result.points}</p>
${queenBonusText}
<p>Всего очков: ${result.total_score}</p>
${winningCardHtml}
`;
} else {
const cardsHtml = result.hand && result.hand.length > 0
? `<div class="result-cards">
${result.hand.map(card => `
<div class="result-card ${card.suit}">
<span class="card-rank">${card.rank}</span>
<span class="card-suit">${this.getSuitSymbol(card.suit)}</span>
</div>
`).join('')}
</div>`
: '';
// Текст для обнуления при 101
const resetText = result.reset_to_zero
? `<p style="color: #ff9800; font-weight: bold; font-size: 1.1em;">🎯 Ровно 101! Очки обнулены!</p>`
: '';
resultItem.innerHTML = `
<h4>${result.nickname}</h4>
<p>Очки за раунд: +${result.points}</p>
${resetText}
<p>Всего очков: ${result.total_score}</p>
${cardsHtml}
`;
}
resultsContent.appendChild(resultItem);
});
this.resultsModal.classList.add('active');
// Добавляем обработчик Enter для закрытия модалки
this.resultsEnterHandler = (e) => {
// Проверяем что:
// 1. Нажат Enter
// 2. Модалка результатов активна
// 3. Фокус не на input/textarea (чтобы не мешать другим формам)
const activeElement = document.activeElement;
const isInputFocused = activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA');
if (e.key === 'Enter' && this.resultsModal.classList.contains('active') && !isInputFocused) {
this.closeResultsModal();
}
};
document.addEventListener('keydown', this.resultsEnterHandler);
}
closeResultsModal() {
this.resultsModal.classList.remove('active');
this.showScreen('room');
// Обновляем настройки комнаты чтобы скрыть блок приватной комнаты после первого раунда
if (this.currentRoom) {
this.updateRoomSettings(this.currentRoom);
}
// Удаляем обработчик Enter
if (this.resultsEnterHandler) {
document.removeEventListener('keydown', this.resultsEnterHandler);
this.resultsEnterHandler = null;
}
}
openChat() {
this.chatModal.classList.add('active');
this.chatInput.value = '';
this.chatInput.focus();
}
closeChat() {
this.chatModal.classList.remove('active');
this.chatInput.value = '';
}
sendChatMessage() {
const message = this.chatInput.value.trim();
if (!message) {
return;
}
// Отправляем сообщение на сервер
this.send({
type: 'chat_message',
message: message
});
this.closeChat();
}
showReactionPicker(e) {
e.stopPropagation();
if (!this.reactionPicker) return;
// Проверяем кулдаун (5 секунд)
const now = Date.now();
const cooldown = 5000; // 5 секунд в миллисекундах
if (now - this.lastReactionTime < cooldown) {
// Просто игнорируем клик если кулдаун активен
return;
}
// Получаем координаты блока руки (.player-hand)
const playerHand = document.querySelector('.player-hand');
if (!playerHand) return;
const rect = playerHand.getBoundingClientRect();
// Позиционируем пикер НАД блоком руки по центру
const vh = window.innerHeight / 100;
this.reactionPicker.style.left = `${rect.left + rect.width / 2}px`;
this.reactionPicker.style.transform = 'translateX(-50%)';
this.reactionPicker.style.top = `${rect.top - 70 - vh}px`; // Над рукой + 1vh выше
this.reactionPicker.classList.add('active');
// Предотвращаем конфликт свайпа с другими элементами
const scrollContainer = this.reactionPicker.querySelector('.reaction-picker-scroll');
if (scrollContainer) {
scrollContainer.addEventListener('touchstart', (e) => {
e.stopPropagation();
}, { passive: true, once: false });
scrollContainer.addEventListener('touchmove', (e) => {
e.stopPropagation();
}, { passive: true, once: false });
// Drag-скролл мышкой (как на телефоне)
let isDown = false;
let startX;
let scrollLeft;
let hasMoved = false;
scrollContainer.addEventListener('mousedown', (e) => {
isDown = true;
hasMoved = false;
scrollContainer.style.scrollBehavior = 'auto';
startX = e.pageX;
scrollLeft = scrollContainer.scrollLeft;
});
scrollContainer.addEventListener('mouseleave', () => {
isDown = false;
scrollContainer.style.scrollBehavior = 'smooth';
});
scrollContainer.addEventListener('mouseup', () => {
isDown = false;
scrollContainer.style.scrollBehavior = 'smooth';
});
scrollContainer.addEventListener('mousemove', (e) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX;
const walk = x - startX;
// Если сдвинули больше чем на 5px, считаем это скроллом
if (Math.abs(walk) > 5) {
hasMoved = true;
}
scrollContainer.scrollLeft = scrollLeft - walk;
});
// Предотвращаем клик на кнопках если был скролл
scrollContainer.addEventListener('click', (e) => {
if (hasMoved) {
e.preventDefault();
e.stopPropagation();
hasMoved = false;
}
}, true);
// Скролл колёсиком мыши по горизонтали
scrollContainer.addEventListener('wheel', (e) => {
e.preventDefault();
scrollContainer.scrollLeft += e.deltaY * 2.42; // Ускоряем скролл в 2 раза
}, { passive: false });
}
}
hideReactionPicker() {
if (this.reactionPicker) {
this.reactionPicker.classList.remove('active');
}
}
sendReaction(emoji) {
// Обновляем время последней реакции
this.lastReactionTime = Date.now();
this.send({
type: 'reaction',
emoji: emoji
});
}
showReactionBubble(playerId, emoji) {
// Находим область игрока
let targetElement;
if (playerId === this.playerId) {
// Реакция от нас самих - показываем над нашими картами
targetElement = this.handCards;
} else {
// Реакция от противника - находим блок с ником и очками
const opponentArea = this.getOpponentAreaById(playerId);
if (opponentArea) {
// Берём блок opponent-info (ник и очки) для центрирования
targetElement = opponentArea.querySelector('.opponent-info');
}
}
if (!targetElement) return;
// Создаём пузырёк
const bubble = document.createElement('div');
bubble.className = 'reaction-bubble';
bubble.textContent = emoji;
// Позиционируем пузырёк и добавляем стрелочку
const rect = targetElement.getBoundingClientRect();
bubble.style.left = `${rect.left + rect.width / 2}px`; // Центр элемента
bubble.style.transform = 'translateX(-50%)'; // Центрируем пузырёк
if (playerId === this.playerId) {
// Наш пузырёк - НАД картами, стрелка ВНИЗ на наши карты
bubble.style.top = `${rect.top - 70}px`;
bubble.classList.add('from-me');
} else {
// Пузырёк противника - ПОД блоком с ником, стрелка ВВЕРХ
bubble.style.top = `${rect.bottom + 10}px`;
bubble.classList.add('from-opponent');
}
document.body.appendChild(bubble);
// Удаляем пузырёк после анимации
setTimeout(() => {
bubble.remove();
}, 2000);
}
handleKeyPress(e) {
// Проверяем что мы не в поле ввода (кроме чата)
const isInInput = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA';
const isChatInput = e.target === this.chatInput;
// T (или Е на русской раскладке) - открыть чат
if ((e.key === 't' || e.key === 'T' || e.key === 'е' || e.key === 'Е') && !isInInput) {
e.preventDefault();
this.openChat();
return;
}
// Enter в чате - отправить сообщение (уже обработано в chatInput.addEventListener)
// Пробел - Взять/Нету (только если чат закрыт и не в других полях ввода)
if (e.key === ' ' && !isInInput && !this.chatModal.classList.contains('active')) {
e.preventDefault();
// Проверяем что мы на экране игры и это наш ход
const gameScreen = document.getElementById('game-screen');
if (!gameScreen || !gameScreen.classList.contains('active')) {
return;
}
if (this.currentPlayerId !== this.playerId) {
return;
}
// Если кнопка "Взять" видима и активна - нажимаем её
if (this.drawCardBtn.style.display !== 'none' && !this.drawCardBtn.disabled) {
this.drawCard();
}
// Если кнопка "Нету" видима и активна - нажимаем её
else if (this.skipTurnBtn.style.display !== 'none' && !this.skipTurnBtn.disabled) {
this.skipTurn();
}
}
}
async openRules() {
// Загружаем правила если еще не загружены
if (!this.rulesContent.innerHTML) {
await this.loadRules();
}
this.rulesModal.classList.add('active');
}
closeRules() {
this.rulesModal.classList.remove('active');
}
async loadRules() {
try {
const response = await fetch('/rules.md');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const markdown = await response.text();
// Рендерим markdown в HTML
let htmlContent;
if (typeof marked !== 'undefined') {
htmlContent = marked.parse(markdown);
} else {
console.error('marked.js not loaded');
// Показываем как обычный текст если marked не загружен
htmlContent = `<pre style="white-space: pre-wrap;">${markdown}</pre>`;
}
// Загружаем в оба контейнера
if (this.lobbyRulesContent) {
this.lobbyRulesContent.innerHTML = htmlContent;
}
if (this.rulesContent) {
this.rulesContent.innerHTML = htmlContent;
}
} catch (error) {
console.error('Failed to load rules:', error);
const errorHtml = `<p>Не удалось загрузить правила игры.</p><p>Ошибка: ${error.message}</p>`;
if (this.lobbyRulesContent) {
this.lobbyRulesContent.innerHTML = errorHtml;
}
if (this.rulesContent) {
this.rulesContent.innerHTML = errorHtml;
}
}
}
initPWA() {
// Слушаем событие beforeinstallprompt
window.addEventListener('beforeinstallprompt', (e) => {
// Не предотвращаем - позволяем браузеру показать стандартный баннер
console.log('PWA install prompt available');
});
// Слушаем успешную установку
window.addEventListener('appinstalled', () => {
console.log('PWA installed successfully');
});
}
// Анимация полета карты от точки A до точки B
animateFlyingCard(fromElement, toElement, count = 1, delay = 0) {
// Проверяем включены ли анимации
if (!this.animationsEnabled) return;
setTimeout(() => {
for (let i = 0; i < count; i++) {
setTimeout(() => {
const flyingCard = document.createElement('div');
flyingCard.className = 'flying-card';
flyingCard.textContent = '🎴';
// Получаем координаты начальной и конечной точек
const fromRect = fromElement.getBoundingClientRect();
const toRect = toElement.getBoundingClientRect();
// Устанавливаем начальную позицию
flyingCard.style.left = `${fromRect.left + fromRect.width / 2 - 30}px`;
flyingCard.style.top = `${fromRect.top + fromRect.height / 2 - 42.5}px`;
document.body.appendChild(flyingCard);
// Запускаем анимацию после небольшой задержки для корректного рендера
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Перемещаем к конечной точке
flyingCard.style.left = `${toRect.left + toRect.width / 2 - 30}px`;
flyingCard.style.top = `${toRect.top + toRect.height / 2 - 42.5}px`;
flyingCard.classList.add('arrived');
// Удаляем элемент после завершения анимации
setTimeout(() => {
flyingCard.remove();
}, 400);
});
});
}, i * 100); // Задержка между картами
}
}, delay);
}
// Анимация игры карты от игрока к столу
animatePlayCard(playerId) {
const discardPile = this.discardPile;
if (playerId === this.playerId) {
// От нашей руки к столу
const handCards = this.handCards;
if (handCards && handCards.children.length > 0) {
this.animateFlyingCard(handCards, discardPile);
}
} else {
// От противника к столу
const opponentArea = this.getOpponentAreaById(playerId);
if (opponentArea) {
const opponentCards = opponentArea.querySelector('.opponent-cards');
if (opponentCards) {
this.animateFlyingCard(opponentCards, discardPile);
}
}
}
}
// Анимация взятия карт из колоды
animateDrawCards(playerId, count) {
const deck = document.getElementById('deck');
if (playerId === this.playerId) {
// К нашей руке
const handCards = this.handCards;
if (handCards && deck) {
this.animateFlyingCard(deck, handCards, count);
}
} else {
// К противнику
const opponentArea = this.getOpponentAreaById(playerId);
if (opponentArea && deck) {
const opponentCards = opponentArea.querySelector('.opponent-cards');
if (opponentCards) {
this.animateFlyingCard(deck, opponentCards, count);
}
}
}
}
// Получить область противника по ID
getOpponentAreaById(playerId) {
const opponents = ['opponent-left', 'opponent-top', 'opponent-right'];
for (const opponentId of opponents) {
const area = document.getElementById(opponentId);
if (area && area.dataset.playerId === playerId) {
return area;
}
}
return null;
}
// Анимация перемешивания колоды (карты летят из сброса в колоду)
animateDeckShuffle() {
// Проверяем включены ли анимации
if (!this.animationsEnabled) return;
const discardPile = this.discardPile;
const deck = document.getElementById('deck');
if (!discardPile || !deck) return;
// Создаём несколько летящих карт для эффекта перемешивания
const cardCount = 8; // Количество визуальных карт для эффекта
for (let i = 0; i < cardCount; i++) {
setTimeout(() => {
const flyingCard = document.createElement('div');
flyingCard.className = 'flying-card';
flyingCard.textContent = '🎴';
// Получаем координаты сброса и колоды
const fromRect = discardPile.getBoundingClientRect();
const toRect = deck.getBoundingClientRect();
// Устанавливаем начальную позицию (от сброса)
flyingCard.style.left = `${fromRect.left + fromRect.width / 2 - 30}px`;
flyingCard.style.top = `${fromRect.top + fromRect.height / 2 - 42.5}px`;
document.body.appendChild(flyingCard);
// Запускаем анимацию
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Перемещаем к колоде
flyingCard.style.left = `${toRect.left + toRect.width / 2 - 30}px`;
flyingCard.style.top = `${toRect.top + toRect.height / 2 - 42.5}px`;
flyingCard.classList.add('arrived');
// Удаляем элемент после завершения анимации
setTimeout(() => {
flyingCard.remove();
}, 400);
});
});
}, i * 80); // Задержка между картами
}
// Добавляем эффект тряски колоды
setTimeout(() => {
deck.classList.add('deck-shuffling');
setTimeout(() => {
deck.classList.remove('deck-shuffling');
}, 600);
}, cardCount * 80);
}
// Анимация тряски карты сброса
animateShakeDiscard() {
if (!this.discardPile) return;
// Добавляем класс для анимации тряски
this.discardPile.classList.add('shaking');
// Убираем класс после завершения анимации
setTimeout(() => {
this.discardPile.classList.remove('shaking');
}, 500);
}
getPlayerColorIndex(playerId) {
// Создаем простой хеш из ID игрока для определения цвета
// Возвращаем число от 1 до 3 для разных цветов
if (!this.playerColorMap) {
this.playerColorMap = {};
this.nextColorIndex = 1;
}
if (!this.playerColorMap[playerId]) {
this.playerColorMap[playerId] = this.nextColorIndex;
this.nextColorIndex = (this.nextColorIndex % 3) + 1; // Циклично 1, 2, 3
}
return this.playerColorMap[playerId];
}
}
// Start the game when page loads
window.addEventListener('DOMContentLoaded', () => {
new CardGame();
});