38 Commits

Author SHA1 Message Date
jahlib 5884ea55f9 MORE QUICK REACTIONS 2025-11-10 15:53:34 +03:00
jahlib 43f3836b5f lobby moving 2025-11-10 14:47:18 +03:00
jahlib cdb2968180 flying cards fix 2025-11-10 13:46:16 +03:00
jahlib b0f9461d9d why not frog ? 2025-11-10 13:27:57 +03:00
jahlib 5dce10149c why not frog ? 2025-11-10 13:27:05 +03:00
jahlib e35fb67cf0 fix some fun 2025-11-10 13:11:07 +03:00
jahlib 3545c91bfd some fun 2025-11-10 12:45:02 +03:00
jahlib a793aa57a3 add some fun 2025-11-10 12:44:44 +03:00
jahlib d9284f90f6 just one more night mode css fix... 2025-11-10 02:12:55 +03:00
jahlib 81feb139b9 suit change background color 2025-11-10 01:13:28 +03:00
jahlib a8e3779e07 just some night mode bug fix 2025-11-10 01:11:49 +03:00
jahlib 70d8fb8d3f just one more night mode css fix... 2025-11-09 22:24:28 +03:00
jahlib 01487026f5 just one more night mode css fix... 2025-11-09 17:51:26 +03:00
jahlib 635d0aa64c just one more night mode css fix... 2025-11-09 17:44:34 +03:00
jahlib 9e1b1642d7 modal close behavior 2025-11-09 17:37:59 +03:00
jahlib 05a8e60b26 just one more night mode css fix... 2025-11-09 17:36:28 +03:00
jahlib 342d25a93d just one more night mode css fix... 2025-11-09 17:27:41 +03:00
jahlib 6cd7efbd99 just one more night mode css fix... 2025-11-09 15:52:28 +03:00
jahlib 2b7c6e4284 just one more night mode css fix... 2025-11-09 04:36:48 +03:00
jahlib 4adbe2cf66 just one more night mode css fix... 2025-11-09 04:27:58 +03:00
jahlib b924f6a6ef just one more night mode css fix... 2025-11-09 04:22:33 +03:00
jahlib 93ac3d1c5e night mode rules css fix 2025-11-09 04:04:17 +03:00
jahlib e7c9d38df8 night mode rules fix 2025-11-09 04:03:10 +03:00
jahlib b749d0194a some other stuff 2025-11-09 03:56:08 +03:00
jahlib db975b964e fix night mode and some other stuff 2025-11-09 03:55:19 +03:00
jahlib 7e2e9dcfb8 add night mode toggle 2025-11-09 03:20:40 +03:00
jahlib 21296ab59d add Pro mode on client settings
In Pro mode cards don’t have a disabled state in CSS
2025-11-09 01:14:13 +03:00
jahlib ee15d3c1fd fix cooldown and =101 win 2025-11-07 23:33:50 +03:00
jahlib 3b04d54bcc fix cooldown and =101 win 2025-11-07 23:33:37 +03:00
jahlib 60127088c5 fix private room settings after one game 2025-11-07 11:35:35 +03:00
jahlib 11d7e5be96 bot playable cards bug fix 2025-11-07 02:43:51 +03:00
jahlib 62c2f86678 fix showing roomtoggle with bots 2025-11-06 11:30:34 +03:00
jahlib e69bdabc15 some fixes 2025-11-06 11:26:03 +03:00
jahlib 330b025107 some fixes 2025-11-06 02:20:25 +03:00
jahlib 43017b00bb move private room settings checkbox 2025-11-06 02:19:33 +03:00
jahlib d0f02ed85e add private room support with uniq url 2025-11-06 01:26:59 +03:00
jahlib dec8122c2f add private room support with uniq url 2025-11-06 01:26:43 +03:00
jahlib 93ceffd1f9 disable winqueen choose suit modal 2025-11-05 23:34:58 +03:00
8 changed files with 4002 additions and 130 deletions
+559 -26
View File
@@ -7,6 +7,8 @@ class CardGame {
this.hand = [];
this.pendingCardToPlay = null;
this.eightDrawnCards = []; // ID карт взятых из колоды на восьмёрку
this.lastShakeTime = 0; // Время последней тряски для кулдауна
this.lastReactionTime = 0; // Время последней реакции для кулдауна
// Загружаем состояние звука из localStorage
this.soundEnabled = localStorage.getItem('soundEnabled') !== 'false';
@@ -20,6 +22,7 @@ class CardGame {
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'),
@@ -78,14 +81,24 @@ class CardGame {
}
playSound(soundName) {
// Не воспроизводим звуки если страница скрыта
if (this.soundEnabled && this.sounds[soundName] && this.pageVisible) {
// Используем существующий объект вместо клонирования
const sound = this.sounds[soundName];
sound.currentTime = 0; // Сбрасываем на начало для повторного воспроизведения
sound.volume = 0.5;
sound.play().catch(err => {}); // Убираем console.log для производительности
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() {
@@ -97,6 +110,64 @@ class CardGame {
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;
@@ -108,8 +179,19 @@ class CardGame {
checkReconnect() {
// Получаем room_id из URL
const path = window.location.pathname;
const match = path.match(/\/room\/([a-f0-9-]+)/);
// Проверяем формат /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
@@ -158,6 +240,13 @@ class CardGame {
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');
@@ -206,6 +295,10 @@ class CardGame {
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');
@@ -220,6 +313,8 @@ class CardGame {
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
@@ -236,6 +331,20 @@ class CardGame {
// Инициализируем состояние анимаций
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() {
@@ -244,6 +353,23 @@ class CardGame {
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());
@@ -264,6 +390,8 @@ class CardGame {
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
@@ -274,7 +402,7 @@ class CardGame {
// Suit selection
document.querySelectorAll('.suit-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const suit = e.target.dataset.suit;
const suit = e.currentTarget.dataset.suit;
this.selectSuit(suit);
});
});
@@ -300,6 +428,33 @@ class CardGame {
}
});
// 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());
@@ -324,6 +479,38 @@ class CardGame {
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) {
@@ -356,6 +543,10 @@ class CardGame {
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' });
@@ -409,6 +600,7 @@ class CardGame {
this.currentRoom = data.room;
this.saveToLocalStorage();
this.updateURL(this.roomId);
this.showScreen('room');
this.updatePlayersInRoom(data.room.players);
this.updateRoomSettings(data.room);
@@ -497,7 +689,7 @@ class CardGame {
this.animateDrawCards(data.forced_draw_player_id, data.forced_draw_count);
}, 300);
const cardName = `${data.top_card.rank}${this.getSuitSymbol(data.top_card.suit)}`;
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}`);
}
@@ -554,7 +746,7 @@ class CardGame {
}
// Логируем событие
const cardName = `${data.card.rank}${this.getSuitSymbol(data.card.suit)}`;
const cardName = `${data.card.rank} ${this.getSuitSymbolForLog(data.card.suit)}`;
this.addLogEntry(`${data.player_nickname} сыграл ${cardName}`);
// Если выбрана масть дамой
@@ -600,7 +792,7 @@ class CardGame {
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.getSuitSymbol(topCard.suit)}`);
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}`);
@@ -699,6 +891,18 @@ class CardGame {
// Игрок переподключился
this.addLogEntry(`${data.nickname} переподключился`);
break;
case 'shake_discard':
// Тряска карты сброса
this.animateShakeDiscard();
this.playSound('alert');
break;
case 'reaction':
// Быстрая реакция от игрока
// Для лягушки играем специальный звук
const soundName = data.emoji === '🐸' ? 'frog' : 'chat';
this.playSound(soundName);
this.showReactionBubble(data.player_id, data.emoji);
break;
case 'deck_size_changed':
// Размер колоды изменён
if (this.currentRoom) {
@@ -707,9 +911,23 @@ class CardGame {
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.message.includes('not found') || data.message.includes('не найден')) {
// Обрабатываем ошибки по кодам
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);
@@ -752,10 +970,63 @@ class CardGame {
this.send({
type: 'create_room',
nickname: nickname
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) {
@@ -811,13 +1082,19 @@ class CardGame {
return;
}
// Используем roomId если присоединяемся по ссылке, иначе pendingRoomId
const roomId = this.isJoiningViaLink ? this.roomId : this.pendingRoomId;
this.send({
type: 'join_room',
room_id: this.pendingRoomId,
room_id: roomId,
nickname: nickname
});
this.joinModal.classList.remove('active');
// Сбрасываем флаг после присоединения
this.isJoiningViaLink = false;
}
cancelJoin() {
@@ -849,6 +1126,14 @@ class CardGame {
}
leaveRoom() {
// Скрываем блок с ссылкой если он был показан
if (this.inviteLinkBlock) {
this.inviteLinkBlock.style.display = 'none';
}
// Сбрасываем переключатель приватной комнаты
if (this.privateRoomToggle) {
this.privateRoomToggle.checked = false;
}
this.goToLobby();
}
@@ -861,14 +1146,27 @@ class CardGame {
}
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);
this.roomSettings.style.display = (isCreator && gameNotStarted && noScores) ? 'block' : 'none';
// Проверяем есть ли боты в комнате
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) {
@@ -894,6 +1192,8 @@ class CardGame {
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);
}
@@ -901,7 +1201,9 @@ class CardGame {
// Показываем индикатор всегда когда есть выбранная масть (после дамы)
if (data.chosen_suit) {
this.chosenSuitIndicator.style.display = 'block';
this.chosenSuitIndicator.textContent = this.getSuitSymbol(data.chosen_suit);
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';
}
@@ -1053,6 +1355,10 @@ class CardGame {
}
updateHand(topCard, chosenSuit) {
// Сохраняем для использования при переключении режима Про
this.topCard = topCard;
this.chosenSuit = chosenSuit;
this.handCards.innerHTML = '';
this.hand.forEach(card => {
@@ -1063,8 +1369,17 @@ class CardGame {
this.canPlayCard(card, topCard, chosenSuit, this.waitingForEight, this.eightDrawnCards);
if (!canPlay) {
cardElement.classList.add('disabled');
// В режиме Про карты остаются яркими (не добавляем 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));
}
@@ -1127,6 +1442,16 @@ class CardGame {
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': 'черви',
@@ -1142,10 +1467,21 @@ class CardGame {
return;
}
// If it's a Queen, show suit selector
// If it's a Queen, check if it's the last card
if (card.rank === 'Q') {
this.pendingCardToPlay = card;
this.suitModal.classList.add('active');
// Если это последняя карта - автоматически выбираем пики
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
@@ -1184,7 +1520,28 @@ class CardGame {
return;
}
this.send({ type: 'skip_turn' });
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) {
@@ -1215,6 +1572,8 @@ class CardGame {
// Синхронизируем состояние переключателей с текущими настройками
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)
@@ -1244,7 +1603,7 @@ class CardGame {
const entry = document.createElement('div');
entry.className = extraClass ? `log-entry ${extraClass}` : 'log-entry';
entry.textContent = message;
entry.innerHTML = message;
// Добавляем в начало (новые сверху)
this.gameLog.insertBefore(entry, this.gameLog.firstChild);
@@ -1334,9 +1693,15 @@ class CardGame {
</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}
`;
@@ -1366,6 +1731,12 @@ class CardGame {
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);
@@ -1399,6 +1770,155 @@ class CardGame {
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);
}
}
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';
@@ -1652,6 +2172,19 @@ class CardGame {
}, cardCount * 80);
}
// Анимация тряски карты сброса
animateShakeDiscard() {
if (!this.discardPile) return;
// Добавляем класс для анимации тряски
this.discardPile.classList.add('shaking');
// Убираем класс после завершения анимации
setTimeout(() => {
this.discardPile.classList.remove('shaking');
}, 500);
}
getPlayerColorIndex(playerId) {
// Создаем простой хеш из ID игрока для определения цвета
// Возвращаем число от 1 до 3 для разных цветов
+113 -19
View File
@@ -22,14 +22,21 @@
<!-- iOS Icons -->
<link rel="apple-touch-icon" href="/icon-192.png">
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/style.css?v=3">
<link rel="stylesheet" href="/night-mode.css?v=1">
</head>
<body>
<div id="app">
<!-- Экран лобби -->
<div id="lobby-screen" class="screen active">
<div class="container">
<h1>🎴 Чешский</h1>
<div class="lobby-header">
<h1>🎴 Чешский</h1>
<button id="night-mode-toggle-btn" class="night-mode-btn" title="Переключить ночной режим">
<span class="sun-icon" style="display: none;">☀️</span>
<span class="moon-icon">🌙</span>
</button>
</div>
<div id="create-room-section">
<h2>Создать комнату</h2>
@@ -63,16 +70,25 @@
<div class="container">
<div class="room-header">
<h2>Комната</h2>
<!-- Таймер в заголовке -->
<div id="countdown-display" style="display: none;">
<span class="countdown-timer-small" id="countdown-number">6</span>
</div>
<button id="leave-room-btn" class="btn btn-secondary">Покинуть</button>
</div>
<div id="players-waiting">
<h3>Игроки в комнате:</h3>
<p class="hint">Игра начнётся когда все игроки будут готовы (минимум 2 игрока)</p>
<div id="players-list"></div>
<!-- Кнопка готов после списка игроков -->
<button id="ready-toggle-btn" class="btn btn-primary">Готов</button>
<!-- Настройки комнаты (только для создателя) -->
<div id="room-settings" style="display: none;">
<h3>Настройки игры</h3>
<div class="deck-size-toggle">
<label>
<span class="deck-label">36 карт</span>
@@ -83,15 +99,34 @@
<p class="hint-small">В режиме 36 карт восьмёрка не действует</p>
</div>
</div>
<div id="countdown-display" style="display: none;">
<div class="countdown-timer">
<span id="countdown-number">6</span>
<!-- Переключатель приватной/публичной комнаты -->
<div class="room-settings" id="private-room-settings">
<!-- Переключатель приватной комнаты -->
<div class="deck-size-toggle">
<label>
<span class="deck-label">Публичная</span>
<input type="checkbox" id="private-room-toggle">
<span class="toggle-slider"></span>
<span class="deck-label">Приватная</span>
</label>
</div>
<!-- Блок с ссылкой для приглашения (скрыт по умолчанию) -->
<div id="invite-link-block" style="display: none;">
<p class="invite-hint">Поделитесь ссылкой с друзьями:</p>
<div class="invite-link-container">
<code id="invite-link" class="invite-link"></code>
<button id="copy-link-btn" class="btn btn-copy" title="Копировать ссылку"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 1.25H10.9436C9.10583 1.24998 7.65019 1.24997 6.51098 1.40314C5.33856 1.56076 4.38961 1.89288 3.64124 2.64124C2.89288 3.38961 2.56076 4.33856 2.40314 5.51098C2.24997 6.65019 2.24998 8.10582 2.25 9.94357V16C2.25 17.8722 3.62205 19.424 5.41551 19.7047C5.55348 20.4687 5.81753 21.1208 6.34835 21.6517C6.95027 22.2536 7.70814 22.5125 8.60825 22.6335C9.47522 22.75 10.5775 22.75 11.9451 22.75H15.0549C16.4225 22.75 17.5248 22.75 18.3918 22.6335C19.2919 22.5125 20.0497 22.2536 20.6517 21.6517C21.2536 21.0497 21.5125 20.2919 21.6335 19.3918C21.75 18.5248 21.75 17.4225 21.75 16.0549V10.9451C21.75 9.57754 21.75 8.47522 21.6335 7.60825C21.5125 6.70814 21.2536 5.95027 20.6517 5.34835C20.1208 4.81753 19.4687 4.55348 18.7047 4.41551C18.424 2.62205 16.8722 1.25 15 1.25ZM17.1293 4.27117C16.8265 3.38623 15.9876 2.75 15 2.75H11C9.09318 2.75 7.73851 2.75159 6.71085 2.88976C5.70476 3.02502 5.12511 3.27869 4.7019 3.7019C4.27869 4.12511 4.02502 4.70476 3.88976 5.71085C3.75159 6.73851 3.75 8.09318 3.75 10V16C3.75 16.9876 4.38624 17.8265 5.27117 18.1293C5.24998 17.5194 5.24999 16.8297 5.25 16.0549V10.9451C5.24998 9.57754 5.24996 8.47522 5.36652 7.60825C5.48754 6.70814 5.74643 5.95027 6.34835 5.34835C6.95027 4.74643 7.70814 4.48754 8.60825 4.36652C9.47522 4.24996 10.5775 4.24998 11.9451 4.25H15.0549C15.8297 4.24999 16.5194 4.24998 17.1293 4.27117ZM7.40901 6.40901C7.68577 6.13225 8.07435 5.9518 8.80812 5.85315C9.56347 5.75159 10.5646 5.75 12 5.75H15C16.4354 5.75 17.4365 5.75159 18.1919 5.85315C18.9257 5.9518 19.3142 6.13225 19.591 6.40901C19.8678 6.68577 20.0482 7.07435 20.1469 7.80812C20.2484 8.56347 20.25 9.56458 20.25 11V16C20.25 17.4354 20.2484 18.4365 20.1469 19.1919C20.0482 19.9257 19.8678 20.3142 19.591 20.591C19.3142 20.8678 18.9257 21.0482 18.1919 21.1469C17.4365 21.2484 16.4354 21.25 15 21.25H12C10.5646 21.25 9.56347 21.2484 8.80812 21.1469C8.07435 21.0482 7.68577 20.8678 7.40901 20.591C7.13225 20.3142 6.9518 19.9257 6.85315 19.1919C6.75159 18.4365 6.75 17.4354 6.75 16V11C6.75 9.56458 6.75159 8.56347 6.85315 7.80812C6.9518 7.07435 7.13225 6.68577 7.40901 6.40901Z" fill="#FFFFFF"/>
</svg></button>
<button id="share-link-btn" class="btn btn-share" title="Поделиться" style="display: none;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 1.25H11.9426C9.63423 1.24999 7.82519 1.24998 6.41371 1.43975C4.96897 1.63399 3.82895 2.03933 2.93414 2.93414C2.03933 3.82895 1.63399 4.96897 1.43975 6.41371C1.24998 7.82519 1.24999 9.63423 1.25 11.9426V12.0574C1.24999 14.3658 1.24998 16.1748 1.43975 17.5863C1.63399 19.031 2.03933 20.1711 2.93414 21.0659C3.82895 21.9607 4.96897 22.366 6.41371 22.5603C7.82519 22.75 9.63423 22.75 11.9426 22.75H12.0574C14.3658 22.75 16.1748 22.75 17.5863 22.5603C19.031 22.366 20.1711 21.9607 21.0659 21.0659C21.9607 20.1711 22.366 19.031 22.5603 17.5863C22.75 16.1748 22.75 14.3658 22.75 12.0574V12C22.75 11.5858 22.4142 11.25 22 11.25C21.5858 11.25 21.25 11.5858 21.25 12C21.25 14.3782 21.2484 16.0864 21.0736 17.3864C20.9018 18.6648 20.5749 19.4355 20.0052 20.0052C19.4355 20.5749 18.6648 20.9018 17.3864 21.0736C16.0864 21.2484 14.3782 21.25 12 21.25C9.62178 21.25 7.91356 21.2484 6.61358 21.0736C5.33517 20.9018 4.56445 20.5749 3.9948 20.0052C3.42514 19.4355 3.09825 18.6648 2.92637 17.3864C2.75159 16.0864 2.75 14.3782 2.75 12C2.75 9.62178 2.75159 7.91356 2.92637 6.61358C3.09825 5.33517 3.42514 4.56445 3.9948 3.9948C4.56445 3.42514 5.33517 3.09825 6.61358 2.92637C7.91356 2.75159 9.62178 2.75 12 2.75C12.4142 2.75 12.75 2.41421 12.75 2C12.75 1.58579 12.4142 1.25 12 1.25Z" fill="#FFFFFF"/>
<path d="M12.4697 10.4697C12.1768 10.7626 12.1768 11.2374 12.4697 11.5303C12.7626 11.8232 13.2374 11.8232 13.5303 11.5303L21.25 3.81066V7.34375C21.25 7.75796 21.5858 8.09375 22 8.09375C22.4142 8.09375 22.75 7.75796 22.75 7.34375V2C22.75 1.58579 22.4142 1.25 22 1.25H16.6562C16.242 1.25 15.9062 1.58579 15.9062 2C15.9062 2.41421 16.242 2.75 16.6562 2.75H20.1893L12.4697 10.4697Z" fill="#FFFFFF"/>
</svg></button>
</div>
</div>
<p class="countdown-text">Игра начинается...</p>
</div>
<button id="ready-toggle-btn" class="btn btn-primary">Готов</button>
<p class="hint">Игра начнётся когда все игроки будут готовы (минимум 2 игрока)</p>
</div>
</div>
</div>
@@ -210,15 +245,48 @@
</div>
</div>
<!-- Модальное окно быстрых реакций -->
<div id="reaction-picker" class="reaction-picker">
<div class="reaction-picker-scroll">
<button class="reaction-btn" data-reaction="😡">😡</button>
<button class="reaction-btn" data-reaction="😄">😄</button>
<button class="reaction-btn" data-reaction="😎">😎</button>
<button class="reaction-btn" data-reaction="🙃">🙃</button>
<button class="reaction-btn" data-reaction="🙁">🙁</button>
<button class="reaction-btn" data-reaction="🤔">🤔</button>
<button class="reaction-btn" data-reaction="😐">😐</button>
<button class="reaction-btn" data-reaction="👍">👍</button>
<button class="reaction-btn" data-reaction="👎">👎</button>
<button class="reaction-btn" data-reaction="🫰">🫰</button>
<button class="reaction-btn" data-reaction="🤯">🤯</button>
<button class="reaction-btn" data-reaction="🤨">🤨</button>
<button class="reaction-btn" data-reaction="😑">😑</button>
<button class="reaction-btn" data-reaction="😌">😌</button>
<button class="reaction-btn" data-reaction="😴">😴</button>
<button class="reaction-btn" data-reaction="🌚">🌚</button>
<button class="reaction-btn" data-reaction="🐱">🐱</button>
<button class="reaction-btn" data-reaction="🐸">🐸</button>
<button class="reaction-btn" data-reaction="🌹">🌹</button>
<button class="reaction-btn" data-reaction="🔪">🔪</button>
<button class="reaction-btn" data-reaction="⚔️">⚔️</button>
<button class="reaction-btn" data-reaction="🎲">🎲</button>
<button class="reaction-btn" data-reaction="🎯">🎯</button>
<button class="reaction-btn" data-reaction="♥️">♥️</button>
<button class="reaction-btn" data-reaction="♦️">♦️</button>
<button class="reaction-btn" data-reaction="♣️">♣️</button>
<button class="reaction-btn" data-reaction="♠️">♠️</button>
</div>
</div>
<!-- Модальное окно выбора масти -->
<div id="suit-modal" class="modal">
<div class="modal-content">
<h3>Выберите масть</h3>
<div class="suit-selector">
<button class="suit-btn" data-suit="hearts">♥️ Черви</button>
<button class="suit-btn" data-suit="diamonds">♦️ Бубны</button>
<button class="suit-btn" data-suit="clubs">♣️ Трефы</button>
<button class="suit-btn" data-suit="spades">♠️ Пики</button>
<button class="suit-btn" data-suit="hearts"><span class="suit-emoji">♥️</span> Черви</button>
<button class="suit-btn" data-suit="diamonds"><span class="suit-emoji">♦️</span> Бубны</button>
<button class="suit-btn" data-suit="clubs"><span class="suit-emoji">♣️</span> Трефы</button>
<button class="suit-btn" data-suit="spades"><span class="suit-emoji">♠️</span> Пики</button>
</div>
<div class="modal-buttons" style="margin-top: 15px;">
<button id="cancel-suit-btn" class="btn btn-secondary">Отмена</button>
@@ -287,7 +355,7 @@
<div class="settings-content">
<!-- Полноэкранный режим -->
<div class="setting-item">
<span class="setting-label">Полноэкранный режим</span>
<span class="setting-label">Полноэкранный режим</span>
<div class="deck-size-toggle">
<label>
<span class="deck-label">Выкл</span>
@@ -300,7 +368,7 @@
<!-- Звук -->
<div class="setting-item">
<span class="setting-label">Звук</span>
<span class="setting-label">🔊 Звук</span>
<div class="deck-size-toggle">
<label>
<span class="deck-label">Выкл</span>
@@ -313,7 +381,7 @@
<!-- Анимации -->
<div class="setting-item">
<span class="setting-label">Анимации карт</span>
<span class="setting-label">💫 Анимации карт</span>
<div class="deck-size-toggle">
<label>
<span class="deck-label">Выкл</span>
@@ -324,9 +392,35 @@
</div>
</div>
<!-- Режим Про -->
<div class="setting-item">
<span class="setting-label">🎓 Режим Про</span>
<div class="deck-size-toggle">
<label>
<span class="deck-label">Выкл</span>
<input type="checkbox" id="pro-mode-toggle">
<span class="toggle-slider"></span>
<span class="deck-label">Вкл</span>
</label>
</div>
</div>
<!-- Ночной режим -->
<div class="setting-item">
<span class="setting-label">🌙 Ночной режим</span>
<div class="deck-size-toggle">
<label>
<span class="deck-label">Выкл</span>
<input type="checkbox" id="night-mode-toggle">
<span class="toggle-slider"></span>
<span class="deck-label">Вкл</span>
</label>
</div>
</div>
<!-- Лог (только на мобильных) -->
<div class="setting-item mobile-only">
<span class="setting-label">Показывать лог</span>
<span class="setting-label">📃 Показывать лог</span>
<div class="deck-size-toggle">
<label>
<span class="deck-label">Выкл</span>
@@ -341,8 +435,8 @@
</div>
</div>
<script src="/marked.min.js"></script>
<script src="/game.js"></script>
<script src="/marked.min.js?v=3"></script>
<script src="/game.js?v=3"></script>
<!-- Service Worker Registration -->
<script>
+868
View File
@@ -0,0 +1,868 @@
/* ===== ПОЛНЫЙ НОЧНОЙ РЕЖИМ ===== */
/* Плавные переходы для всех элементов кроме летящих карт и тряски */
*:not(.flying-card):not(.flying-card *):not(.shaking):not(.shaking *) {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease, filter 0.3s ease, transform 0.3s ease, opacity 0.3s ease, width 0.3s ease, height 0.3s ease, margin 0.3s ease, padding 0.3s ease;
}
/* Базовые стили */
body.night-mode {
background: #1a2332;
color: #ffffff;
}
/* Night mode toggle button */
body.night-mode .night-mode-btn {
background: #2a3d52;
border-color: #3b5998;
}
body.night-mode .night-mode-btn:hover {
background: #3b5998;
box-shadow: 0 4px 12px rgba(94, 204, 123, 0.3);
}
/* Контейнеры */
body.night-mode .container {
background: #243447;
color: #ffffff;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
/* Заголовки */
body.night-mode h1 {
color: #ffffff!important;
}
body.night-mode h2 {
color: #e0e0e0!important;
}
body.night-mode h3 {
color: #d0d0d0!important;
}
body.night-mode h4,
body.night-mode h5,
body.night-mode h6 {
color: #ffffff!important;
}
/* Инпуты */
body.night-mode input[type="text"],
body.night-mode input[type="number"],
body.night-mode textarea {
background: #2a3d52;
color: #ffffff;
border-color: #3b5998;
}
body.night-mode input[type="text"]:focus,
body.night-mode input[type="number"]:focus,
body.night-mode textarea:focus {
border-color: #5ecc7b;
}
body.night-mode input[type="text"]::placeholder,
body.night-mode input[type="number"]::placeholder,
body.night-mode textarea::placeholder {
color: #9ca3af;
}
/* Кнопки */
body.night-mode .btn {
color: #ffffff!important;
}
body.night-mode .btn-primary {
background: #3b5998;
color: #ffffff!important;
}
body.night-mode .btn-primary:hover {
background: #4c6baf;
box-shadow: 0 5px 15px rgba(59, 89, 152, 0.4);
}
body.night-mode .btn-secondary {
background: #2a3d52;
color: #ffffff;
}
body.night-mode .btn-secondary:hover {
background: #3b5998;
}
body.night-mode .btn:disabled {
opacity: 0.5;
}
body.night-mode .btn-primary:disabled:hover {
background: #3b5998 !important;
}
body.night-mode .btn-secondary:disabled:hover {
background: #2a3d52 !important;
}
/* Invite Link Block */
body.night-mode #invite-link-block {
background: rgba(59, 89, 152, 0.2);
border-color: #3b5998;
}
body.night-mode .invite-hint {
color: #9ca3af;
}
body.night-mode .invite-link {
background: #2a3d52;
color: #5ecc7b;
border-color: #3b5998;
}
body.night-mode .btn-copy,
body.night-mode .btn-share {
background: #3b5998;
color: #ffffff;
}
body.night-mode .btn-copy:hover,
body.night-mode .btn-share:hover {
background: #4c6baf;
}
/* Bot Game Section */
body.night-mode #bot-game-section {
border-top-color: rgba(255, 255, 255, 0.2);
}
body.night-mode #bot-game-section h3 {
color: #ffffff;
}
body.night-mode .btn-bot {
background: linear-gradient(135deg, #3b5998 0%, #2a3d52 100%);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
}
body.night-mode .btn-bot:hover {
box-shadow: 0 6px 20px rgba(59, 89, 152, 0.5);
}
/* Rooms List */
body.night-mode .room-card {
background: #2a3d52;
border-color: #3b5998;
color: #ffffff;
}
body.night-mode .room-card:hover {
border-color: #5ecc7b;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}
body.night-mode .room-card h4 {
color: #5ecc7b;
}
body.night-mode .room-card p {
color: #d0d0d0;
}
body.night-mode .room-card.room-full {
background: #1e2d3f;
border-color: #374151;
opacity: 0.6;
}
body.night-mode .room-card.room-full h4 {
color: #9ca3af;
}
/* Players List */
body.night-mode #players-list {
color: #ffffff;
}
body.night-mode .player-card {
background: #2a3d52;
border-color: #3b5998;
color: #ffffff;
}
body.night-mode .player-card.ready {
border-color: #2d5a3f;
background: #1e3d2b;
}
body.night-mode .player-card.current {
border-color: #5ecc7b;
background: #1e3d2b;
box-shadow: 0 0 20px rgba(94, 204, 123, 0.3);
}
body.night-mode .player-card h4 {
color: #ffffff;
}
body.night-mode .player-card .status {
color: #9ca3af;
}
body.night-mode .player-card.ready .status {
color: #5ecc7b;
}
body.night-mode .player-card .score {
color: #ffffff;
}
/* Hints */
body.night-mode .hint {
color: #9ca3af;
}
body.night-mode .hint-small {
color: #6b7280;
}
/* Room Settings */
body.night-mode #room-settings,
body.night-mode .room-settings {
background: rgba(59, 89, 152, 0.2);
color: #ffffff;
}
body.night-mode #room-settings h3 {
color: #ffffff;
}
body.night-mode .deck-label {
color: #9ca3af;
}
body.night-mode .toggle-slider {
background-color: #3b5998;
}
body.night-mode input:checked + .toggle-slider {
background-color: #2d5a3f;
}
body.night-mode .deck-size-toggle input[type="checkbox"]:checked ~ .deck-label:last-child {
color: #5ecc7b;
}
body.night-mode .deck-size-toggle input[type="checkbox"]:not(:checked) ~ .deck-label:first-child {
color: #5ecc7b;
}
/* Countdown Timer */
body.night-mode .countdown-timer {
background: linear-gradient(135deg, #2d5a3f, #1e3d2b);
border-color: #5ecc7b;
}
body.night-mode #countdown-number {
color: #ffffff;
}
body.night-mode #countdown-text {
color: #5ecc7b;
}
/* Модальные окна */
body.night-mode .modal {
background-color: rgba(26, 35, 50, 0.95);
}
body.night-mode .modal-content,
body.night-mode .settings-modal-content,
body.night-mode .alert-modal .modal-content,
body.night-mode .rules-modal .modal-content {
background: #243447;
color: #ffffff;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8);
}
body.night-mode #settings-modal .modal-content {
background: #243447;
}
body.night-mode .close-modal-btn {
background: #3b5998;
color: #ffffff;
}
body.night-mode .close-modal-btn:hover {
background: #4c6baf;
}
/* Settings */
body.night-mode .settings-content {
color: #ffffff;
}
body.night-mode .setting-item {
color: #ffffff;
}
body.night-mode .setting-label {
color: #ffffff;
}
/* Правила */
body.night-mode .rules-content {
background: #243447;
color: #ffffff;
}
body.night-mode .rules-content strong {
color: #ababab;
font-weight: 600;
}
body.night-mode .rules-content th {
color: #b9b9b9;
}
body.night-mode #rules-section {
border-top: 2px solid rgb(187 187 187 / 20%);
}
body.night-mode .rules-content h2 {
border-bottom: 2px solid rgb(169 169 169 / 36%);
}
body.night-mode .rules-content hr {
border-bottom: 2px solid rgb(169 169 169 / 36%);
}
body.night-mode .rules-content h3 {
color: #5ecc7b;
}
body.night-mode .rules-content p,
body.night-mode .rules-content li {
color: #ffffff;
}
body.night-mode .rules-content table {
background: rgb(255 255 255 / 27%);
}
body.night-mode .rules-content th {
background: rgb(56 56 56 / 74%);
}
body.night-mode .rules-content td {
background: rgb(255 255 255 / 27%);
}
/* Алерты */
body.night-mode .alert-text {
background: rgb(255 255 255 / 27%);
}
/* Алерты */
body.night-mode .alert-text {
color: #ffffff;
}
/* Игровой стол */
body.night-mode .game-table {
background: linear-gradient(155deg, #2a3d52 0%, #1a2332 100%);
}
body.night-mode .game-circle {
background: radial-gradient(circle, #1e2d3f 0%, #141d2b 100%);
border-color: #3b5998;
}
/* Карты */
body.night-mode .card {
background: #9ca3af;
color: white;
border-color: #374151;
filter: brightness(0.9);
transition: all 0.3s ease;
}
body.night-mode .card.hearts,
body.night-mode .card.diamonds {
color: #ffffff;
}
body.night-mode .card.hearts .card-rank,
body.night-mode .card.diamonds .card-rank {
color: #ffffff;
filter: grayscale(1) contrast(0) brightness(3);
}
body.night-mode .card.hearts .card-suit,
body.night-mode .card.diamonds .card-suit {
color: #ffffff;
filter: grayscale(1) contrast(0) brightness(3) drop-shadow(0px 0px 0.8px #ffffffd0);
}
body.night-mode .card.spades,
body.night-mode .card.clubs {
color: #1a1a1a;
}
body.night-mode .card.spades .card-rank,
body.night-mode .card.clubs .card-rank {
color: #1a1a1a;
filter: contrast(4) saturate(0.5);
}
body.night-mode .card.spades .card-suit,
body.night-mode .card.clubs .card-suit {
color: #1a1a1a;
filter: contrast(4) saturate(0.5) drop-shadow(0px 0px 0.8px #333333c9);
}
body.night-mode .card:hover:not(.disabled) {
border-color: #374151;
box-shadow: 0 0 20px #060606, 0 0 10px #adadad;
}
body.night-mode .card.disabled {
opacity: 0.56;
background: #767676a6;
}
/* Рубашка карт с сетчатым градиентом */
body.night-mode .opponent-card {
background:
repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0, 0, 0, 0.15) 6px, rgba(0, 0, 0, 0.2) 7px),
repeating-linear-gradient(-45deg, transparent, transparent 6px, rgba(0, 0, 0, 0.15) 6px, rgba(0, 0, 0, 0.2) 7px),
linear-gradient(314deg, #1e3d2b, #2d5a3f);
border-color: #1a3025;
color: #ffffff;
}
/* Колода - такая же рубашка как у противников */
body.night-mode .deck {
background:
repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0, 0, 0, 0.15) 6px, rgba(0, 0, 0, 0.2) 7px),
repeating-linear-gradient(-45deg, transparent, transparent 6px, rgba(0, 0, 0, 0.15) 6px, rgba(0, 0, 0, 0.2) 7px),
linear-gradient(314deg, #1e3d2b, #2d5a3f) !important;
border-color: #1a3025 !important;
color: #ffffff;
}
/* Рубашка карты (card-back) - колода */
body.night-mode .card-back {
background:
repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0, 0, 0, 0.15) 6px, rgba(0, 0, 0, 0.2) 7px),
repeating-linear-gradient(-45deg, transparent, transparent 6px, rgba(0, 0, 0, 0.15) 6px, rgba(0, 0, 0, 0.2) 7px),
linear-gradient(314deg, #1e3d2b, #2d5a3f) !important;
border-color: #1a3025 !important;
color: #ffffff;
}
/* Deck count */
body.night-mode .deck-count {
background: #1e3d2b;
color: #5ecc7b;
border-color: #2d5a3f;
}
body.night-mode .deck-count-badge {
background: #2d5a3f;
color: #ffffff;
border-color: #2d5a3f;
}
/* Discard pile */
body.night-mode .discard-pile {
border-color: #3b5998;
}
/* Chosen suit */
body.night-mode .chosen-suit {
background: #2a3d52;
color: #ffffff;
border-color: #3b5998;
}
body.night-mode .chosen-suit-label {
color: #9ca3af;
}
/* Индикатор выбранной масти */
body.night-mode #chosen-suit-indicator {
background: rgb(140 147 157);
border-color: #5ecc7b;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
}
/* Красные масти в индикаторе делаем белыми */
body.night-mode .suit-emoji-indicator.hearts,
body.night-mode .suit-emoji-indicator.diamonds {
filter: grayscale(1) contrast(0) brightness(3) drop-shadow(0px 0px 0.8px #ffffffd0);
}
/* Чёрные масти в индикаторе */
body.night-mode .suit-emoji-indicator.clubs,
body.night-mode .suit-emoji-indicator.spades {
filter: contrast(4) saturate(0.5) drop-shadow(0px 0px 1px #333333c9);
}
/* Player info - табло игрока */
body.night-mode .player-info {
background: rgba(42, 61, 82, 0.95);
color: #ffffff;
border-color: #3b5998;
}
body.night-mode .player-info h3 {
color: #ffffff;
}
body.night-mode .player-info p {
color: #ffffff;
}
body.night-mode .player-name {
color: #ffffff;
}
body.night-mode .player-score {
color: #ffffff;
}
body.night-mode .player-info.current-turn {
background: linear-gradient(135deg, #3b5998 0%, #4c6baf 100%);
border-color: #5ecc7b !important;
}
body.night-mode .player-info.current-turn .player-name,
body.night-mode .player-info.current-turn .player-score {
color: #ffffff;
}
/* Player hand */
body.night-mode .player-hand {
background: rgba(42, 61, 82, 0.95);
}
body.night-mode .player-hand.current-turn {
box-shadow: 0 0 20px #5ecc7b, 0 0 40px #5ecc7b;
}
/* Opponent area - табло противников */
body.night-mode .opponent-area {
color: #ffffff;
}
body.night-mode .opponent-info {
background: rgba(42, 61, 82, 0.95);
color: #ffffff;
}
body.night-mode .opponent-name {
color: #ffffff;
}
body.night-mode .opponent-score {
color: #ffffff;
}
body.night-mode .opponent-area.current-turn .opponent-info {
background: linear-gradient(135deg, #3b5998 0%, #4c6baf 100%);
border-color: #5ecc7b;
box-shadow: 0 4px 15px rgba(94, 204, 123, 0.6),
0 0 20px rgba(94, 204, 123, 0.4);
}
body.night-mode .opponent-area.current-turn .opponent-info .opponent-name,
body.night-mode .opponent-area.current-turn .opponent-info .opponent-score {
color: #ffffff;
}
body.night-mode .opponent-area.current-turn .opponent-cards {
filter: drop-shadow(0 0 15px rgba(94, 204, 123, 0.6))
drop-shadow(0 0 30px rgba(94, 204, 123, 0.4));
}
/* Action buttons */
body.night-mode #draw-card-btn,
body.night-mode #skip-turn-btn {
background: #3b5998;
color: #ffffff;
}
body.night-mode #draw-card-btn:hover,
body.night-mode #skip-turn-btn:hover {
background: #4c6baf;
}
body.night-mode #ready-toggle-btn {
background: #3b5998;
color: #ffffff;
}
body.night-mode #ready-toggle-btn:hover {
background: #4c6baf;
}
body.night-mode #ready-toggle-btn.ready {
background: #2d5a3f;
}
body.night-mode #ready-toggle-btn.ready:hover {
background: #3d6a4f;
}
/* Leave game button */
body.night-mode #leave-game-btn {
background: #3b5998;
color: #ffffff;
}
body.night-mode #leave-game-btn:hover {
background: #4c6baf;
}
/* Settings button */
body.night-mode #settings-btn {
background: #2a3d52;
color: #ffffff;
}
body.night-mode #settings-btn:hover {
background: #3b5998;
}
/* Suit selection modal */
body.night-mode #suit-selection-modal .modal-content {
background: #243447;
}
body.night-mode .suit-selection h3 {
color: #ffffff;
}
body.night-mode .suit-btn {
background: #373f4f;
color: white;
border-color: #3b5998;
}
body.night-mode .suit-btn:hover {
background: #3b5998;
border-color: #5ecc7b;
}
body.night-mode .suit-btn {
color: #ffffff;
}
/* Красные масти в suit-selector делаем белыми */
body.night-mode .suit-btn[data-suit="hearts"] .suit-emoji,
body.night-mode .suit-btn[data-suit="diamonds"] .suit-emoji {
filter: grayscale(1) contrast(0) brightness(3) drop-shadow(0px 0px 0.8px #ffffffd0);
}
/* Черные масти в suit-selector */
body.night-mode .suit-btn[data-suit="spades"] .suit-emoji,
body.night-mode .suit-btn[data-suit="clubs"] .suit-emoji {
filter: contrast(4) saturate(0.5) drop-shadow(0px 0px 0.8px #333333c9);
}
/* Suit symbols (анимация выбора масти) */
body.night-mode .suit-symbol {
background: #2a3d52;
border-color: #3b5998;
}
body.night-mode .suit-symbol.hearts,
body.night-mode .suit-symbol.diamonds {
filter: grayscale(1) contrast(0) brightness(3) drop-shadow(0px 0px 0.8px #ffffffd0);
}
body.night-mode .suit-symbol.clubs,
body.night-mode .suit-symbol.spades {
color: #ffffff;
filter: contrast(4) saturate(0.5) drop-shadow(0px 0px 0.8px #333333c9);
}
body.night-mode .suit-arrow {
color: #5ecc7b;
}
/* Results modal */
body.night-mode #results-modal .modal-content {
background: #243447;
}
body.night-mode #results-content h2 {
color: #ffffff;
}
body.night-mode .result-item {
background: #2a3d52;
color: #ffffff;
border-color: #3b5998;
}
body.night-mode .result-item.winner {
background: #1e3d2b;
border-color: #2d5a3f;
}
body.night-mode .result-item h4 {
color: #ffffff;
}
body.night-mode .result-item p {
color: #ffffff;
}
body.night-mode .result-card {
background: #9ca3af;
color: white;
border-color: #374151;
filter: brightness(0.8);
}
body.night-mode .result-card.hearts,
body.night-mode .result-card.diamonds {
color: #ffffff;
}
body.night-mode .result-card.hearts .card-rank,
body.night-mode .result-card.diamonds .card-rank {
color: #ffffff;
filter: grayscale(1) contrast(0) brightness(3);
}
body.night-mode .result-card.hearts .card-suit,
body.night-mode .result-card.diamonds .card-suit {
color: #ffffff;
filter: grayscale(1) contrast(0) brightness(3) drop-shadow(0px 0px 0.8px #ffffffd0);
}
body.night-mode .result-card.spades,
body.night-mode .result-card.clubs {
color: #1a1a1a;
}
body.night-mode .result-card.spades .card-rank,
body.night-mode .result-card.clubs .card-rank {
color: #1a1a1a;
filter: contrast(4) saturate(0.5);
}
body.night-mode .result-card.spades .card-suit,
body.night-mode .result-card.clubs .card-suit {
color: #1a1a1a;
filter: contrast(4) saturate(0.5) drop-shadow(0px 0px 0.8px #333333c9);
}
/* Game log */
body.night-mode #game-log {
background: rgba(26, 35, 50, 0.9);
color: #ffffff;
border-color: #3b5998;
}
body.night-mode .log-entry {
color: #ffffff;
}
body.night-mode .log-entry.highlight {
color: #5ecc7b;
}
/* Красные масти в чате (логе) делаем белыми */
body.night-mode .suit-emoji-log.hearts,
body.night-mode .suit-emoji-log.diamonds {
filter: grayscale(1) contrast(1) brightness(3) drop-shadow(rgba(189, 189, 189, 0.79) 0px 0px 1px);
}
/* Чёрные масти в чате (логе) */
body.night-mode .suit-emoji-log.clubs,
body.night-mode .suit-emoji-log.spades {
filter: contrast(4) saturate(0.5) drop-shadow(0px 0px 0.8px #c7c7c742);
}
/* Error screen */
body.night-mode #error-screen {
background: #1a2332;
}
body.night-mode #error-screen .container {
background: #243447;
}
body.night-mode #error-message {
color: #ff6b6b;
}
/* Lobby screen */
body.night-mode #lobby-screen {
background: #1a2332;
}
/* Room screen */
body.night-mode #room-screen {
background: #1a2332;
}
/* Все параграфы и тексты */
body.night-mode p,
body.night-mode span,
body.night-mode label,
body.night-mode div {
color: inherit;
}
/* Ссылки */
body.night-mode a {
color: #5ecc7b;
}
body.night-mode a:hover {
color: #4caf50;
}
/* Скроллбары */
body.night-mode ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
body.night-mode ::-webkit-scrollbar-track {
background: #1a2332;
}
body.night-mode ::-webkit-scrollbar-thumb {
background: #3b5998;
border-radius: 6px;
}
body.night-mode ::-webkit-scrollbar-thumb:hover {
background: #4c6baf;
}
/* Mobile only elements */
body.night-mode .mobile-only {
color: #ffffff;
}
/* Flying card animation */
body.night-mode .flying-card {
background:
repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0, 0, 0, 0.15) 6px, rgba(0, 0, 0, 0.2) 7px),
repeating-linear-gradient(-45deg, transparent, transparent 6px, rgba(0, 0, 0, 0.15) 6px, rgba(0, 0, 0, 0.2) 7px),
linear-gradient(314deg, #1e3d2b, #2d5a3f);
}
/* Deck shuffling */
body.night-mode .deck-shuffling {
filter: brightness(1.2);
}
Binary file not shown.
+323 -14
View File
@@ -47,13 +47,52 @@ body {
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
/* Lobby header */
.lobby-header {
display: flex;
justify-content: center;
align-items: center;
position: relative;
margin-bottom: 40px;
}
h1 {
text-align: center;
color: #4c4c4c;
margin-bottom: 40px;
margin-bottom: 0;
font-size: 3em;
}
/* Night mode toggle button */
.night-mode-btn {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
background: #f0f0f0;
border: 2px solid #ddd;
border-radius: 50%;
width: 50px;
height: 50px;
font-size: 24px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.night-mode-btn:hover {
background: #e0e0e0;
transform: translateY(-50%) scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.night-mode-btn:active {
transform: translateY(-50%) scale(0.95);
}
h2 {
color: #707070;
margin-bottom: 20px;
@@ -133,6 +172,62 @@ input[type="text"]:focus {
margin-bottom: 40px;
}
/* Invite Link Block */
#invite-link-block {
margin: 15px 0;
padding: 15px;
background: rgba(50, 160, 130, 0.1);
border: 1px solid rgba(50, 160, 130, 0.3);
border-radius: 8px;
}
.invite-hint {
margin: 0 0 10px 0;
font-size: 14px;
color: #6f6f6f;
text-align: center;
}
.invite-link-container {
display: flex;
gap: 8px;
align-items: center;
}
.invite-link {
flex: 1;
padding: 10px;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px;
color: #32a082;
word-break: break-all;
user-select: all;
}
.btn-copy, .btn-share {
min-width: 44px;
height: 44px;
padding: 8px;
font-size: 20px;
background: #32a082;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
}
.btn-copy:hover, .btn-share:hover {
background: #28866d;
transform: scale(1.05);
}
.btn-copy:active, .btn-share:active {
transform: scale(0.95);
}
/* Bot Game Section */
#bot-game-section {
margin-top: 25px;
@@ -232,6 +327,18 @@ input[type="text"]:focus {
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
gap: 15px;
}
.room-header h2 {
flex: 0 0 auto;
}
.room-header #countdown-display {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
#players-list {
@@ -276,7 +383,8 @@ input[type="text"]:focus {
.hint {
color: #666;
font-size: 14px;
margin-top: 10px;
margin-top: -10px;
padding-bottom: 10px;
}
.hint-small {
@@ -294,6 +402,14 @@ input[type="text"]:focus {
margin: 20px 0;
}
/* Room Settings */
.room-settings {
background: rgba(49, 145, 91, 0.1);
border-radius: 8px;
padding: 15px;
margin: 20px 0;
}
#room-settings h3 {
margin-top: 0;
margin-bottom: 15px;
@@ -368,7 +484,6 @@ input[type="text"]:focus {
/* Countdown Timer */
#countdown-display {
margin: 30px 0;
text-align: center;
}
@@ -385,8 +500,25 @@ input[type="text"]:focus {
animation: pulse 1s ease-in-out infinite;
}
/* Маленький таймер в заголовке */
.countdown-timer-small {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #32a082 0%, #64859a 100%);
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(50, 160, 130, 0.4);
animation: pulse 1s ease-in-out infinite;
font-size: 18px;
font-weight: bold;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
#countdown-number {
font-size: 72px;
font-size: 22px;
font-weight: bold;
color: white;
text-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
@@ -912,7 +1044,7 @@ input[type="text"]:focus {
}
.opponent-card.single-card .card-back-icon {
font-size: 40px;
font-size: 18px;
}
.opponent-card.single-card .card-count {
@@ -1056,7 +1188,7 @@ border: 2px solid #ffd700;
font-size: 24px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
transition: all 0.3s ease;
position: relative;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
@@ -1067,7 +1199,7 @@ border: 2px solid #ffd700;
repeating-linear-gradient(-45deg, transparent, transparent 6px, rgba(0, 0, 0, 0.028) 6px, rgba(0, 0, 0, 0.037) 7px),
linear-gradient(314deg, #646464, #9c9c9c);
color: white;
font-size: 48px;
font-size: 40px;
cursor: default;
}
@@ -1126,6 +1258,13 @@ border: 2px solid #ffd700;
border-bottom: none;
margin-left: 0px;
align-self: flex-start;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
}
.player-info:hover {
background: rgba(255, 255, 255, 0.9);
}
.player-hand {
@@ -1158,7 +1297,6 @@ border: 2px solid #ffd700;
background: linear-gradient(135deg, #f5d742 0%, #e8c84d 100%);
border: 0px;
border-bottom: none;
box-shadow: 0 -2px 12px rgba(245, 215, 66, 0.4);
}
.player-info.current-turn .player-name {
@@ -1412,11 +1550,6 @@ border: 2px solid #ffd700;
margin: 5px 0;
}
.rules-content strong {
color: #1b6d3e;
font-weight: 600;
}
.rules-content code {
background: rgba(56, 56, 56, 0.1);
padding: 2px 6px;
@@ -1432,7 +1565,12 @@ border: 2px solid #ffd700;
}
.suit-btn {
padding: 20px;
display: flex;
padding: 23px;
flex-direction: row;
align-content: center;
align-items: center;
justify-content: space-evenly;
font-size: 18px;
border: 2px solid #ddd;
border-radius: 10px;
@@ -1441,6 +1579,11 @@ border: 2px solid #ffd700;
transition: all 0.3s;
}
.suit-emoji {
font-size: 32px;
filter: drop-shadow(0px 0px 10px #727272c9);
}
.suit-btn:hover {
border-color: #767676;
background: #f0f0f0;
@@ -1585,6 +1728,21 @@ border: 2px solid #ffd700;
font-size: 2em;
}
h2 {
font-size: 1.5em;
}
.room-header {
gap: 8px;
margin-bottom: 20px;
}
.countdown-timer-small {
width: 42px;
height: 42px;
font-size: 17px;
}
.game-table {
padding: 4px;
/* Учитываем safe area на iOS */
@@ -1675,6 +1833,10 @@ border: 2px solid #ffd700;
background: rgba(0, 0, 0, 0.1);
border-radius: 0px 2px 0px 0px;
}
.card-back {
font-size: 18px!important;
}
.player-hand::-webkit-scrollbar-thumb {
background: rgba(49, 145, 91, 0.5);
@@ -2081,3 +2243,150 @@ border: 2px solid #ffd700;
transform: scale(1.1) rotate(-3deg);
}
}
/* Discard pile shake animation */
.shaking {
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% {
transform: translateX(0) rotate(0deg);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-10px) rotate(-3deg);
}
20%, 40%, 60%, 80% {
transform: translateX(10px) rotate(3deg);
}
}
/* Reaction picker */
.reaction-picker {
display: none;
position: fixed;
background: rgba(30, 30, 30, 0.95);
border-radius: 12px;
padding: 1px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
animation: popIn 0.2s ease-out;
max-width: min(500px, 90vw);
}
.reaction-picker.active {
display: block;
}
.reaction-picker-scroll {
display: flex;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
padding: 12px;
cursor: grab;
user-select: none;
}
.reaction-picker-scroll:active {
cursor: grabbing;
}
.reaction-picker-scroll::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.reaction-btn {
width: 50px;
height: 50px;
min-width: 50px;
min-height: 50px;
border: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
font-size: 28px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.reaction-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.15);
}
.reaction-btn:active {
transform: scale(0.95);
}
@keyframes popIn {
from {
opacity: 0;
transform: scale(0.8) translateX(-50%);
}
to {
opacity: 1;
transform: scale(1) translateX(-50%);
}
}
/* Reaction bubble */
.reaction-bubble {
position: absolute;
background: rgba(30, 30, 30, 0.95);
color: white;
padding: 12px 16px;
border-radius: 20px;
font-size: 32px;
z-index: 9999;
pointer-events: none;
animation: reactionFloat 2s ease-out forwards;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
}
/* Стрелочка пузырька */
.reaction-bubble::after {
content: '';
position: absolute;
width: 0;
height: 0;
border-style: solid;
left: 50%;
transform: translateX(-50%);
}
/* Стрелочка вниз (от меня - пузырёк над моими картами) */
.reaction-bubble.from-me::after {
bottom: -12px;
border-width: 12px 12px 0 12px;
border-color: rgba(30, 30, 30, 0.95) transparent transparent transparent;
}
/* Стрелочка вверх (от противника - пузырёк под его картами) */
.reaction-bubble.from-opponent::after {
top: -12px;
border-width: 0 12px 12px 12px;
border-color: transparent transparent rgba(30, 30, 30, 0.95) transparent;
}
@keyframes reactionFloat {
0% {
opacity: 0;
transform: translateX(-50%) translateY(0) scale(0.5);
}
20% {
opacity: 1;
transform: translateX(-50%) translateY(-10px) scale(1.1);
}
40% {
transform: translateX(-50%) translateY(-5px) scale(1);
}
100% {
opacity: 0;
transform: translateX(-50%) translateY(-30px) scale(0.8);
}
}
+138 -49
View File
@@ -1,26 +1,84 @@
const CACHE_NAME = 'czech-fool-v1';
const CACHE_TIMESTAMP_KEY = 'cache-timestamps';
const CACHE_MAX_AGE = 60 * 60 * 1000; // 1 час в миллисекундах
const urlsToCache = [
'/',
'/index.html',
'/style.css',
'/game.js',
'/sounds/playcard.ogg',
'/sounds/drawcard.ogg',
'/sounds/eight.ogg',
'/sounds/change.ogg',
'/sounds/skip.ogg',
'/sounds/alert.ogg',
'/sounds/chat.ogg',
'/sounds/win.ogg',
'/sounds/lose.ogg',
'/sounds/two.ogg',
'/sounds/six.ogg',
'/sounds/seven.ogg',
'/sounds/shuffle.ogg',
'/sounds/ace.ogg',
'/sounds/eightplace.ogg'
'/sounds/playcard.aac',
'/sounds/drawcard.aac',
'/sounds/eight.aac',
'/sounds/change.aac',
'/sounds/skip.aac',
'/sounds/alert.aac',
'/sounds/chat.aac',
'/sounds/win.aac',
'/sounds/lose.aac',
'/sounds/two.aac',
'/sounds/six.aac',
'/sounds/seven.aac',
'/sounds/shuffle.aac',
'/sounds/ace.aac',
'/sounds/eightplace.aac'
];
// Функция для получения временных меток из IndexedDB
async function getTimestamps() {
try {
const cache = await caches.open(CACHE_TIMESTAMP_KEY);
const response = await cache.match('timestamps');
if (response) {
return await response.json();
}
} catch (e) {
console.log('Error reading timestamps:', e);
}
return {};
}
// Функция для сохранения временных меток
async function saveTimestamps(timestamps) {
try {
const cache = await caches.open(CACHE_TIMESTAMP_KEY);
const response = new Response(JSON.stringify(timestamps));
await cache.put('timestamps', response);
} catch (e) {
console.log('Error saving timestamps:', e);
}
}
// Функция для очистки старых записей кеша
async function cleanOldCache() {
try {
const timestamps = await getTimestamps();
const now = Date.now();
const cache = await caches.open(CACHE_NAME);
const requests = await cache.keys();
let cleaned = 0;
for (const request of requests) {
const url = request.url;
const timestamp = timestamps[url];
// Если запись старше часа - удаляем
if (timestamp && (now - timestamp) > CACHE_MAX_AGE) {
await cache.delete(request);
delete timestamps[url];
cleaned++;
}
}
if (cleaned > 0) {
console.log(`Cleaned ${cleaned} old cache entries`);
await saveTimestamps(timestamps);
}
} catch (e) {
console.log('Error cleaning cache:', e);
}
}
// Установка service worker
self.addEventListener('install', event => {
event.waitUntil(
@@ -35,16 +93,21 @@ self.addEventListener('install', event => {
// Активация service worker
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
Promise.all([
// Удаляем старые версии кеша
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME && cacheName !== CACHE_TIMESTAMP_KEY) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}),
// Очищаем записи старше часа
cleanOldCache()
])
);
});
@@ -56,33 +119,59 @@ self.addEventListener('fetch', event => {
}
event.respondWith(
caches.match(event.request)
.then(response => {
// Возвращаем из кеша если есть
if (response) {
(async () => {
// Проверяем кеш
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
// Проверяем возраст записи
const timestamps = await getTimestamps();
const url = event.request.url;
const timestamp = timestamps[url];
const now = Date.now();
// Если запись свежая (младше часа) - возвращаем из кеша
if (timestamp && (now - timestamp) < CACHE_MAX_AGE) {
return cachedResponse;
}
// Если запись старая - удаляем и загружаем заново
console.log('Cache expired for:', url);
const cache = await caches.open(CACHE_NAME);
await cache.delete(event.request);
delete timestamps[url];
await saveTimestamps(timestamps);
}
// Загружаем с сервера
try {
const fetchRequest = event.request.clone();
const response = await fetch(fetchRequest);
// Проверяем валидность ответа
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Клонируем запрос
const fetchRequest = event.request.clone();
// Кешируем новый ресурс с временной меткой
const responseToCache = response.clone();
const cache = await caches.open(CACHE_NAME);
await cache.put(event.request, responseToCache);
return fetch(fetchRequest).then(response => {
// Проверяем валидность ответа
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Клонируем ответ
const responseToCache = response.clone();
// Кешируем новый ресурс
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});
return response;
});
})
// Сохраняем временную метку
const timestamps = await getTimestamps();
timestamps[event.request.url] = Date.now();
await saveTimestamps(timestamps);
return response;
} catch (error) {
console.log('Fetch failed:', error);
// Если есть старый кеш - возвращаем его как fallback
if (cachedResponse) {
return cachedResponse;
}
throw error;
}
})()
);
});
+1811
View File
File diff suppressed because it is too large Load Diff
+190 -22
View File
@@ -64,6 +64,7 @@ class Room:
last_loser_id: Optional[str] = None # ID проигравшего в прошлой игре (станет дилером)
deck_size: int = 52 # Размер колоды: 52 или 36 карт
creator_id: Optional[str] = None # ID создателя комнаты
is_private: bool = False # Приватная комната (не отображается в списке)
def to_dict(self):
return {
@@ -72,7 +73,8 @@ class Room:
'player_count': len(self.players),
'game_started': self.game_started,
'deck_size': self.deck_size,
'creator_id': self.creator_id
'creator_id': self.creator_id,
'is_private': self.is_private
}
class GameServer:
@@ -80,6 +82,7 @@ class GameServer:
self.rooms: Dict[str, Room] = {}
self.clients: Dict[WebSocketServerProtocol, str] = {} # ws -> player_id
self.player_rooms: Dict[str, str] = {} # player_id -> room_id
self.shake_cooldowns: Dict[str, float] = {} # player_id -> timestamp последней тряски
self.db = GameDatabase(
host="localhost",
port=5432,
@@ -168,7 +171,8 @@ class GameServer:
waiting_for_eight=room_data['waiting_for_eight'],
card_drawn_this_turn=room_data['card_drawn_this_turn'],
deck_size=room_data.get('deck_size', 52),
creator_id=room_data.get('creator_id')
creator_id=room_data.get('creator_id'),
is_private=room_data.get('is_private', False)
)
# Инициализируем дополнительные атрибуты
@@ -203,7 +207,8 @@ class GameServer:
'eight_draw_used': room.eight_draw_used,
'card_drawn_this_turn': room.card_drawn_this_turn,
'deck_size': room.deck_size,
'creator_id': room.creator_id
'creator_id': room.creator_id,
'is_private': room.is_private
})
# Сохраняем игроков
@@ -309,6 +314,8 @@ class GameServer:
async def handle_create_room(self, ws: WebSocketServerProtocol, data: dict):
nickname = data.get('nickname')
is_private = data.get('is_private', False)
if not nickname:
await ws.send(json.dumps({'type': 'error', 'message': 'Nickname required'}))
return
@@ -331,7 +338,8 @@ class GameServer:
current_player_index=0,
dealer_index=0,
game_started=False,
creator_id=player_id
creator_id=player_id,
is_private=is_private
)
self.rooms[room_id] = room
@@ -449,11 +457,19 @@ class GameServer:
room = self.rooms.get(room_id)
if not room:
await ws.send(json.dumps({'type': 'error', 'message': 'Room not found'}))
await ws.send(json.dumps({
'type': 'error',
'message': 'Комната не найдена или больше не существует',
'error_code': 'room_not_found'
}))
return
if room.game_started:
await ws.send(json.dumps({'type': 'error', 'message': 'Game already started'}))
await ws.send(json.dumps({
'type': 'error',
'message': 'Игра уже началась. Присоединиться невозможно.',
'error_code': 'game_started'
}))
return
# Проверяем максимальное количество игроков
@@ -521,6 +537,9 @@ class GameServer:
ready_count = sum(1 for p in room.players.values() if p.ready)
total_count = len(room.players)
# Проверяем это первая игра (у всех 0 очков) или между раундами
is_first_game = all(p.score == 0 for p in room.players.values())
# Если все готовы - начинаем сразу
if total_count >= 2 and ready_count == total_count:
# Отменяем таймер если был
@@ -529,8 +548,8 @@ class GameServer:
room.countdown_task = None
room.countdown_active = False
await self.start_game(room_id)
# Если >= 2 готовы но не все - запускаем таймер
elif ready_count >= 2 and not room.countdown_active:
# Если >= 2 готовы но не все - запускаем таймер ТОЛЬКО при первой игре
elif ready_count >= 2 and not room.countdown_active and is_first_game:
room.countdown_active = True
room.countdown_task = asyncio.create_task(self.start_countdown(room_id))
# Если готовых стало меньше 2 - отменяем таймер
@@ -607,12 +626,18 @@ class GameServer:
# Находим подходящие карты
playable_cards = []
for card in bot.hand:
# Для восьмёрки: можно играть двойку из руки ИЛИ любую карту из eight_drawn_cards
# Для восьмёрки: можно играть двойку из руки ИЛИ подходящую карту из eight_drawn_cards
if room.waiting_for_eight:
if card.rank == '2':
playable_cards.append(card)
elif hasattr(room, 'eight_drawn_cards') and card.id in room.eight_drawn_cards:
playable_cards.append(card)
# Проверяем что карта действительно подходит:
# двойка, дама, восьмёрка или та же масть что и восьмёрка на столе
if (card.rank == '2' or
card.rank == 'Q' or
card.rank == '8' or
card.suit == top_card.suit):
playable_cards.append(card)
elif self.can_play_card(card, top_card, room.chosen_suit, room.waiting_for_eight):
playable_cards.append(card)
@@ -1230,14 +1255,21 @@ class GameServer:
# Обрабатываем специальные случаи с очками
players_to_kick = [] # Игроки с >101 очком
players_reset = [] # Игроки с ровно 101 (обнуляются)
for player_id, player in room.players.items():
if player.score == 101:
# Ровно 101 - обнуляем очки
players_reset.append(player_id)
player.score = 0
elif player.score > 101:
# Больше 101 - игрок вылетает
players_to_kick.append(player_id)
# Добавляем флаг reset_to_zero в результаты для обнулившихся игроков
for result in results:
if result['player_id'] in players_reset:
result['reset_to_zero'] = True
# Сохраняем ID проигравшего в раздаче для следующей игры (если он не вылетает)
if round_loser_id and round_loser_id not in players_to_kick:
room.last_loser_id = round_loser_id
@@ -1423,7 +1455,11 @@ class GameServer:
room = self.rooms.get(room_id)
if not room:
# print(f"Room {room_id} not found in DB")
await ws.send(json.dumps({'type': 'error', 'message': 'Room not found'}))
await ws.send(json.dumps({
'type': 'error',
'message': 'Комната не найдена или больше не существует',
'error_code': 'room_not_found'
}))
return
# print(f"Room {room_id} loaded, players: {list(room.players.keys())}")
@@ -1431,7 +1467,11 @@ class GameServer:
# Проверяем существование игрока
if player_id not in room.players:
# print(f"Player {player_id} not found in room {room_id}")
await ws.send(json.dumps({'type': 'error', 'message': 'Player not found in room'}))
await ws.send(json.dumps({
'type': 'error',
'message': 'Игрок не найден в этой комнате',
'error_code': 'player_not_found'
}))
return
# Переподключаем игрока
@@ -1497,7 +1537,8 @@ class GameServer:
if not room.game_started and all(p.score == 0 for p in room.players.values()):
# Проверяем что есть хотя бы один живой игрок (не бот)
has_human = any(not p.is_bot for p in room.players.values())
if has_human:
# Не показываем приватные комнаты в общем списке
if has_human and not room.is_private:
available_rooms.append(room.to_dict())
message = json.dumps({
@@ -1604,6 +1645,106 @@ class GameServer:
'deck_size': deck_size
})
async def handle_toggle_private(self, ws: WebSocketServerProtocol, data: dict):
"""Переключение приватности комнаты (только создатель, до начала игры)"""
player_id = self.clients.get(ws)
if not player_id:
return
room_id = self.player_rooms.get(player_id)
if not room_id:
return
room = self.rooms.get(room_id)
if not room:
return
# Только создатель может менять приватность
if room.creator_id != player_id:
await ws.send(json.dumps({'type': 'error', 'message': 'Только создатель может изменить приватность комнаты'}))
return
# Только до начала игры
if room.game_started or any(p.score > 0 for p in room.players.values()):
await ws.send(json.dumps({'type': 'error', 'message': 'Нельзя изменить приватность после начала игры'}))
return
is_private = data.get('is_private', False)
room.is_private = is_private
# Сохраняем в БД
await self.save_room_to_db(room_id)
# Уведомляем всех в комнате
await self.broadcast_to_room(room_id, {
'type': 'room_privacy_changed',
'is_private': is_private
})
# Обновляем список комнат (приватные не показываются)
await self.broadcast_rooms()
async def handle_shake_discard(self, ws: WebSocketServerProtocol, data: dict):
"""Обработка тряски карты сброса - рассылаем всем игрокам в комнате"""
player_id = self.clients.get(ws)
if not player_id:
return
# Проверяем кулдаун (5 секунд)
import time
now = time.time()
cooldown = 5.0 # 5 секунд
last_shake = self.shake_cooldowns.get(player_id, 0)
if now - last_shake < cooldown:
# Просто игнорируем запрос если кулдаун активен
return
# Обновляем время последней тряски
self.shake_cooldowns[player_id] = now
# Находим комнату игрока
room_id = None
for rid, room in self.rooms.items():
if player_id in room.players:
room_id = rid
break
if not room_id:
return
# Отправляем событие тряски всем игрокам в комнате
await self.broadcast_to_room(room_id, {
'type': 'shake_discard'
})
async def handle_reaction(self, ws: WebSocketServerProtocol, data: dict):
"""Обработка быстрой реакции - рассылаем всем игрокам в комнате"""
player_id = self.clients.get(ws)
if not player_id:
return
emoji = data.get('emoji')
if not emoji:
return
# Находим комнату игрока
room_id = None
for rid, room in self.rooms.items():
if player_id in room.players:
room_id = rid
break
if not room_id:
return
# Отправляем реакцию всем игрокам в комнате
await self.broadcast_to_room(room_id, {
'type': 'reaction',
'player_id': player_id,
'emoji': emoji
})
async def handle_message(self, ws: WebSocketServerProtocol, message: str):
try:
data = json.loads(message)
@@ -1631,6 +1772,12 @@ class GameServer:
await self.handle_chat_message(ws, data)
elif msg_type == 'change_deck_size':
await self.handle_change_deck_size(ws, data)
elif msg_type == 'toggle_private':
await self.handle_toggle_private(ws, data)
elif msg_type == 'shake_discard':
await self.handle_shake_discard(ws, data)
elif msg_type == 'reaction':
await self.handle_reaction(ws, data)
except json.JSONDecodeError:
await ws.send(json.dumps({'type': 'error', 'message': 'Invalid JSON'}))
@@ -1686,17 +1833,38 @@ class GameServer:
# Игра не началась и ни у кого нет очков - удаляем игрока полностью
del room.players[player_id]
# Проверяем остались ли живые игроки (не боты)
human_players = [p for p in room.players.values() if not p.is_bot]
if len(human_players) == 0:
# Если остались только боты или никого - удаляем комнату
del self.rooms[room_id]
else:
# Если это создатель приватной комнаты - удаляем комнату полностью
if room.is_private and room.creator_id == player_id:
# Уведомляем всех игроков что комната закрывается
await self.broadcast_to_room(room_id, {
'type': 'player_left',
'player_id': player_id
'type': 'room_closed',
'message': 'Создатель комнаты покинул игру. Комната закрыта.'
})
# Удаляем всех игроков из player_rooms
for pid in list(room.players.keys()):
if pid in self.player_rooms:
del self.player_rooms[pid]
# Удаляем комнату из памяти
del self.rooms[room_id]
# Удаляем комнату из БД
await self.db.delete_room(room_id)
else:
# Проверяем остались ли живые игроки (не боты)
human_players = [p for p in room.players.values() if not p.is_bot]
if len(human_players) == 0:
# Если остались только боты или никого - удаляем комнату
del self.rooms[room_id]
# Удаляем из БД
await self.db.delete_room(room_id)
else:
await self.broadcast_to_room(room_id, {
'type': 'player_left',
'player_id': player_id
})
await self.broadcast_rooms()