diff --git a/POSTGRES_SETUP.md b/POSTGRES_SETUP.md new file mode 100644 index 0000000..8127064 --- /dev/null +++ b/POSTGRES_SETUP.md @@ -0,0 +1,107 @@ +# Настройка PostgreSQL для игры + +## 1. Установка PostgreSQL + +```bash +# Ubuntu/Debian +sudo apt update +sudo apt install postgresql postgresql-contrib + +# Проверка статуса +sudo systemctl status postgresql +``` + +## 2. Создание базы данных и пользователя + +```bash +# Войти в PostgreSQL +sudo -u postgres psql + +# Выполнить SQL команды +\i /path/to/setup_postgres.sql + +# Или вручную: +CREATE DATABASE card_game; +CREATE USER card_game_user WITH PASSWORD 'your_secure_password_here'; +GRANT ALL PRIVILEGES ON DATABASE card_game TO card_game_user; +\c card_game +GRANT ALL ON SCHEMA public TO card_game_user; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO card_game_user; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO card_game_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO card_game_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO card_game_user; + +# Выход +\q +``` + +## 3. Настройка подключения + +Отредактируйте `/etc/postgresql/*/main/pg_hba.conf`: + +``` +# Добавьте строку для локального подключения +local card_game card_game_user md5 +host card_game card_game_user 127.0.0.1/32 md5 +``` + +Перезапустите PostgreSQL: +```bash +sudo systemctl restart postgresql +``` + +## 4. Установка Python зависимостей + +```bash +pip install asyncpg +``` + +## 5. Настройка в коде + +В `server.py` измените параметры подключения: + +```python +from database_postgres import GameDatabase + +# В __init__: +self.db = GameDatabase( + host="localhost", + port=5432, + database="card_game", + user="card_game_user", + password="your_secure_password_here" +) + +# В main(): +await game_server.db.connect() +``` + +## 6. Проверка подключения + +```bash +psql -U card_game_user -d card_game -h localhost +# Введите пароль +``` + +## Преимущества PostgreSQL vs SQLite + +- ✅ **Асинхронные операции** - не блокируют игру +- ✅ **Пул соединений** - быстрое переиспользование +- ✅ **Параллельные запросы** - несколько игроков одновременно +- ✅ **Лучшая производительность** - оптимизированные индексы +- ✅ **MVCC** - нет блокировок при чтении +- ✅ **Масштабируемость** - готово к росту нагрузки + +## Мониторинг + +```sql +-- Активные соединения +SELECT * FROM pg_stat_activity WHERE datname = 'card_game'; + +-- Размер базы +SELECT pg_size_pretty(pg_database_size('card_game')); + +-- Статистика таблиц +SELECT schemaname, tablename, n_tup_ins, n_tup_upd, n_tup_del +FROM pg_stat_user_tables; +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bdc8b0 --- /dev/null +++ b/README.md @@ -0,0 +1,248 @@ +# 🎴 Чешский дурак + +Многопользовательская карточная игра на WebSocket с уникальными правилами и специальными картами. + +## Правила игры + +### Колода +- Стандартная колода из 52 карт +- 4 масти: ♥️ Черви, ♦️ Бубны, ♣️ Трефы, ♠️ Пики +- 13 рангов: 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A +- Когда колода заканчивается, все карты из сброса (кроме верхней) перемешиваются и становятся новой колодой + +### Начало игры +- Каждому игроку раздаётся по 5 карт +- Одна карта кладётся рубашкой вниз на поле (первый ход дилера) +- Колода кладётся рядом +- Ход делает игрок после дилера +- **Первая карта имеет эффект:** + - Если **6** или **7**: Следующий игрок берёт карты и пропускает ход + - Если **8**: Следующий игрок должен брать карты или положить двойку + - Если **A**: Следующий игрок пропускает ход + - Если **Q**: Дилер автоматически выбирает масть (случайную) + +### Ходы +- Карты кладутся по принципу UNO: масть на масть или номинал на номинал +- **Взятие карт:** + - Можно взять карту из колоды даже если есть чем ходить (только 1 раз за ход) + - После взятия карты появляется кнопка "Пропустить ход" + - Вы можете: + - Сыграть любую подходящую карту из руки (включая только что взятую) + - Или нажать "Пропустить ход" чтобы передать ход следующему игроку +- Верхняя карта на поле остаётся лежать пока её не покроют следующей картой + +### Специальные карты + +**Шестёрка (6)**: Следующий игрок берёт 1 карту и пропускает ход + +**Семёрка (7)**: Следующий игрок берёт 2 карты и пропускает ход + +**Восьмёрка (8)**: Следующий игрок должен: +- **Из руки**: Может положить только любую двойку (2) любой масти +- **Из колоды**: Берёт карты пока не попадётся: + - Любая двойка (2) + - Любая дама (Q) + - Карта той же масти что и восьмёрка + +**Туз (A)**: Следующий игрок пропускает ход + +**Дама (Q)**: +- Можно положить на любую карту +- Игрок выбирает следующую масть +- Выбранная масть отображается над картой на поле в золотом бейдже +- Индикатор масти остаётся пока её не покроют другой картой +- Следующий игрок должен положить карту выбранной масти (или даму) +- **ВАЖНО**: Если дама - последняя карта, вычитается 20 очков (пиковая дама - 40 очков) + +### Победа и подсчёт очков + +Побеждает тот, кто первым сбросил все карты. + +Остальным начисляются очки за оставшиеся карты: +- Цифры (2-10) = номинал карты +- Валет (J) = 2 очка +- Дама (Q) = 20 очков (пиковая дама = 40 очков) +- Король (K) = 4 очка +- Туз (A) = 11 очков + +**Специальные правила:** +- **Победа дамой**: Вычитается 20 очков (пиковая дама - 40) +- **Ровно 101 очко**: Счёт обнуляется до 0 +- **Больше 101 очка**: Игрок выбывает из игры +- **Дилер следующего раунда**: Проигравший предыдущего раунда + +## Установка и запуск + +### Требования +- Python 3.8+ + +### Установка зависимостей + +```bash +pip install -r requirements.txt +``` + +### Запуск сервера + +```bash +python server.py +``` + +Сервер запустится на: +- WebSocket: `ws://localhost:8765` +- HTTP: `http://localhost:8080` + +### Игра + +#### Локально (на одном компьютере): +1. Откройте браузер и перейдите на `http://localhost:8080` +2. Введите никнейм и создайте комнату или присоединитесь к существующей +3. Дождитесь минимум 2 игроков +4. Все игроки нажимают "Готов" +5. Игра начинается автоматически! + +#### По сети (с друзьями): +1. **Хост (тот кто запустил сервер):** + - Узнайте свой IP адрес: + - Linux/Mac: `ip addr` или `ifconfig` + - Windows: `ipconfig` + - Откройте `http://localhost:8080` или `http://ВАШ_IP:8080` + - Создайте комнату + +2. **Другие игроки:** + - Откройте `http://IP_ХОСТА:8080` в браузере + - Присоединитесь к комнате + +3. **Важно:** Убедитесь что порты 8080 и 8765 открыты в файрволе + +**Примечание:** Каждый игрок видит только свои карты! Карты других игроков скрыты. + +#### С reverse proxy (HTTPS/WSS): +Если вы используете reverse proxy (nginx, caddy и т.д.) с доменами: + +1. **Настройте reverse proxy:** + - HTTP/HTTPS домен (например `domain.com`) → `localhost:8080` + - WebSocket домен (например `ws.domain.com`) → `localhost:8765` + +2. **Раскомментируйте в `client/index.html`:** + ```html + + ``` + Укажите ваш WebSocket домен без протокола (wss:// добавится автоматически) + +3. **Пример конфигурации nginx:** + ```nginx + # HTTP сервер + server { + listen 443 ssl; + server_name domain.com; + + location / { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + } + } + + # WebSocket сервер + server { + listen 443 ssl; + server_name ws.domain.com; + + location / { + proxy_pass http://localhost:8765; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + } + ``` + +## Структура проекта + +``` +chekh/ +├── server.py # Серверная часть на Python +├── requirements.txt # Зависимости Python +├── game_state.db # SQLite база данных (создаётся автоматически) +├── client/ +│ ├── index.html # HTML интерфейс +│ ├── style.css # Стили (разработка) +│ ├── style.min.css # Минифицированные стили (продакшн) +│ ├── game.js # Клиентская логика +│ ├── sw.js # Service Worker для PWA +│ ├── manifest.json # PWA манифест +│ ├── rules.md # Правила игры (Markdown) +│ ├── sounds/ # Звуковые эффекты (.aac) +│ └── icons/ # Иконки для PWA +├── rules.md # Правила игры +└── README.md # Документация +``` + +## Технологии + +- **Backend**: Python, websockets, aiohttp, SQLite +- **Frontend**: Vanilla JavaScript, HTML5, CSS3 +- **Протокол**: WebSocket для real-time коммуникации +- **База данных**: SQLite для сохранения состояния игры +- **Хранилище**: localStorage для идентификации игроков + +## Особенности + +### 🎮 Игровой процесс +- ✅ Real-time многопользовательская игра (2-4 игрока) +- ✅ **Круговой игровой стол** с визуальным отображением +- ✅ **Карты противников отображаются рубашками** по количеству +- ✅ Полная реализация всех правил и специальных карт +- ✅ Система подсчёта очков с особыми правилами (101 очко) +- ✅ **Игра с ботами** (можно играть с 1, 2 или 3 ботами) +- ✅ **Умные боты** с приоритетом специальных карт и оценкой очков + +### 💾 Сохранение и переподключение +- ✅ **Сохранение состояния игры в SQLite** +- ✅ **URL для комнат**: `/room/{room_id}` +- ✅ **Продолжение игры после обновления страницы** +- ✅ **localStorage для идентификации игроков** +- ✅ Автоматическое переподключение при разрыве связи +- ✅ **Page Visibility API** - игра останавливается в фоне + +### 🎨 Интерфейс +- ✅ Современный и красивый UI с градиентами +- ✅ Адаптивный дизайн (десктоп + мобильные) +- ✅ **Анимации** карт и действий +- ✅ **Звуковые эффекты** для всех игровых событий (.aac) +- ✅ **Лог игры** (последние 10 событий) +- ✅ **Чат** для общения между игроками +- ✅ **Правила игры** встроены в интерфейс (Markdown) + +### ⌨️ Управление +- ✅ **Горячие клавиши**: + - `T` / `Е` - открыть чат + - `Enter` - отправить сообщение + - `Пробел` - взять карту / пропустить ход +- ✅ Кнопки управления звуком, полноэкранным режимом + +### 📱 PWA (Progressive Web App) +- ✅ **Установка как приложение** на телефон/десктоп +- ✅ **Service Worker** для офлайн-кэширования +- ✅ **Манифест** с иконками и настройками +- ✅ Работает без интернета (после первой загрузки) + +### Игровой интерфейс: + +**Круговое расположение игроков:** +- **Центр**: Текущая карта на поле + колода справа +- **Внизу**: Ваши карты (повёрнуты к вам) +- **Сверху**: Противник (2 игрока) - карты рубашками +- **Слева**: Противник (3+ игрока) - карты повёрнуты на 90° +- **Справа**: Противник (4 игрока) - карты повёрнуты на 90° +- **Подсветка**: Текущий игрок подсвечивается синим с анимацией + +### Как работает сохранение: + +1. При создании/присоединении к комнате URL меняется на `/room/{room_id}` +2. Ваш `player_id` сохраняется в localStorage +3. При обновлении страницы игра автоматически восстанавливается +4. При выходе из комнаты localStorage очищается +5. Все состояние игры хранится в SQLite базе данных + +Приятной игры! 🎮 diff --git a/START_POSTGRES.md b/START_POSTGRES.md new file mode 100644 index 0000000..fd71954 --- /dev/null +++ b/START_POSTGRES.md @@ -0,0 +1,113 @@ +# Запуск с PostgreSQL + +## 1. Установите asyncpg + +```bash +pip install asyncpg +``` + +## 2. Укажите пароль в server.py + +Откройте `/opt/czech-fool/server.py` и найдите строку 80: + +```python +password="password" # Замените на ваш пароль +``` + +Замените `password` на пароль пользователя `czech` в PostgreSQL. + +## 3. Проверьте подключение + +```bash +psql -U czech -d czechdb -h localhost +# Введите пароль +``` + +Если подключение успешно, выйдите: `\q` + +## 4. Запустите сервер + +```bash +cd /opt/czech-fool +python3 server.py +``` + +Вы должны увидеть: +``` +Connecting to PostgreSQL... +PostgreSQL connected successfully! +Room stats: 0 total, 0 active games, 0 inactive >1h, 0 inactive >24h +WebSocket server started on ws://0.0.0.0:8765 +HTTP server started on http://0.0.0.0:8080 +Open https://game.ruwk.ru in your browser +Automatic cleanup task started (runs every 6 hours) +``` + +## Что изменилось + +### Производительность +- ✅ **Асинхронные операции** - игра не зависает при сохранении +- ✅ **Пул соединений** - 2-10 одновременных подключений +- ✅ **Батчинг** - массовая вставка карт +- ✅ **Нет блокировок** - MVCC в PostgreSQL + +### База данных +- База: `czechdb` +- Пользователь: `czech` +- Порт: `5432` +- Хост: `localhost` + +### Сохранение +Теперь сохранение происходит: +- ✅ При создании комнаты +- ✅ При присоединении игрока +- ✅ При старте игры +- ✅ При каждом ходе (асинхронно!) +- ✅ При взятии карты +- ✅ При окончании игры +- ❌ НЕ блокирует игру! + +## Мониторинг + +### Проверка активных соединений +```sql +SELECT * FROM pg_stat_activity WHERE datname = 'czechdb'; +``` + +### Статистика таблиц +```sql +SELECT tablename, n_tup_ins, n_tup_upd, n_tup_del +FROM pg_stat_user_tables; +``` + +### Размер базы +```sql +SELECT pg_size_pretty(pg_database_size('czechdb')); +``` + +## Troubleshooting + +### Ошибка: "password authentication failed" +Проверьте пароль в `server.py` строка 80. + +### Ошибка: "database does not exist" +Создайте базу: +```bash +sudo -u postgres psql +CREATE DATABASE czechdb; +GRANT ALL PRIVILEGES ON DATABASE czechdb TO czech; +``` + +### Ошибка: "role does not exist" +Создайте пользователя: +```bash +sudo -u postgres psql +CREATE USER czech WITH PASSWORD 'password'; +``` + +### Ошибка: "could not connect to server" +Запустите PostgreSQL: +```bash +sudo systemctl start postgresql +sudo systemctl enable postgresql +``` diff --git a/SYSTEMD_SETUP.md b/SYSTEMD_SETUP.md new file mode 100644 index 0000000..a6a73ea --- /dev/null +++ b/SYSTEMD_SETUP.md @@ -0,0 +1,156 @@ +# Настройка systemd service для игры + +## Установка service + +### 1. Скопируйте файл service + +```bash +sudo cp /opt/czech-fool/czech-fool.service /etc/systemd/system/ +``` + +### 2. Перезагрузите systemd + +```bash +sudo systemctl daemon-reload +``` + +### 3. Включите автозапуск + +```bash +sudo systemctl enable czech-fool +``` + +### 4. Запустите сервис + +```bash +sudo systemctl start czech-fool +``` + +## Управление сервисом + +### Проверка статуса +```bash +sudo systemctl status czech-fool +``` + +### Просмотр логов +```bash +sudo journalctl -u czech-fool -f +``` + +### Просмотр последних 100 строк логов +```bash +sudo journalctl -u czech-fool -n 100 +``` + +### Перезапуск (с очисткой комнат) +```bash +sudo systemctl restart czech-fool +``` + +### Остановка +```bash +sudo systemctl stop czech-fool +``` + +### Отключение автозапуска +```bash +sudo systemctl disable czech-fool +``` + +## Флаг --clearrooms + +Флаг `--clearrooms` в файле service: +``` +ExecStart=/usr/bin/python3 /opt/czech-fool/server.py --clearrooms +``` + +### Что делает: +- ✅ Удаляет **ВСЕ** комнаты из базы данных при запуске +- ✅ Очищает зависшие комнаты с отключенными игроками +- ✅ Начинает с чистого листа + +### Когда использовать: +- При рестарте сервера +- После обновления кода +- Для очистки зависших комнат + +### Без флага: +Если убрать `--clearrooms` из ExecStart: +``` +ExecStart=/usr/bin/python3 /opt/czech-fool/server.py +``` +- Загружает существующие комнаты из БД +- Удаляет только старые (>24 часов) +- Игроки могут продолжить игру после рестарта + +## Редактирование service + +```bash +sudo systemctl edit --full czech-fool +``` + +После изменений: +```bash +sudo systemctl daemon-reload +sudo systemctl restart czech-fool +``` + +## Проверка работы + +### 1. Проверьте что сервис запущен +```bash +sudo systemctl is-active czech-fool +# Должно вывести: active +``` + +### 2. Проверьте логи запуска +```bash +sudo journalctl -u czech-fool -n 50 +``` + +Должны увидеть: +``` +Starting with --clearrooms flag: all rooms will be deleted +Connecting to PostgreSQL... +PostgreSQL connected successfully! +Clearing all rooms from database... +Deleted X rooms from database +WebSocket server started on ws://0.0.0.0:8765 +HTTP server started on http://0.0.0.0:8080 +``` + +### 3. Проверьте порты +```bash +sudo netstat -tulpn | grep python3 +``` + +Должны быть открыты: +- `0.0.0.0:8765` (WebSocket) +- `0.0.0.0:8080` (HTTP) + +## Troubleshooting + +### Сервис не запускается +```bash +sudo journalctl -u czech-fool -n 100 +``` + +### Ошибка подключения к PostgreSQL +Проверьте что PostgreSQL запущен: +```bash +sudo systemctl status postgresql +``` + +### Ошибка прав доступа +Убедитесь что ваш пользователь может читать файлы: +```bash +ls -la /opt/czech-fool/server.py +``` + +### Порты заняты +Проверьте что порты 8765 и 8080 свободны: +```bash +sudo lsof -i :8765 +sudo lsof -i :8080 +``` diff --git a/client/game.js b/client/game.js new file mode 100644 index 0000000..1c28f2e --- /dev/null +++ b/client/game.js @@ -0,0 +1,1375 @@ +class CardGame { + constructor() { + this.ws = null; + this.playerId = null; + this.roomId = null; + this.currentRoom = null; + this.hand = []; + this.pendingCardToPlay = null; + this.eightDrawnCards = []; // ID карт взятых из колоды на восьмёрку + + // Загружаем состояние звука из localStorage + this.soundEnabled = localStorage.getItem('soundEnabled') !== 'false'; + + // Инициализация звуков + 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'), + win: new Audio('/sounds/win.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') + }; + + 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] && this.pageVisible) { + // Используем существующий объект вместо клонирования + const sound = this.sounds[soundName]; + sound.currentTime = 0; // Сбрасываем на начало для повторного воспроизведения + sound.volume = 0.5; + sound.play().catch(err => {}); // Убираем console.log для производительности + } + } + + toggleSound() { + this.soundEnabled = !this.soundEnabled; + localStorage.setItem('soundEnabled', this.soundEnabled); + this.updateSoundButton(); + } + + updateSoundButton() { + const btn = document.getElementById('toggle-sound-btn'); + const icon = btn.querySelector('.sound-icon'); + if (this.soundEnabled) { + btn.classList.remove('muted'); + icon.textContent = '🔊'; + btn.title = 'Выключить звук'; + } else { + btn.classList.add('muted'); + icon.textContent = '🔇'; + btn.title = 'Включить звук'; + } + } + + 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; + const 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'); + + // Room elements + this.playersList = document.getElementById('players-list'); + this.readyToggleBtn = document.getElementById('ready-toggle-btn'); + this.leaveRoomBtn = document.getElementById('leave-room-btn'); + + // 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'); + + // 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'); + + // Sound button + this.toggleSoundBtn = document.getElementById('toggle-sound-btn'); + + // 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'); + + // Инициализируем состояние кнопки звука + this.updateSoundButton(); + } + + initEventListeners() { + this.createRoomBtn.addEventListener('click', () => this.createRoom()); + this.nicknameInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') this.createRoom(); + }); + + this.readyToggleBtn.addEventListener('click', () => this.toggleReady()); + this.leaveRoomBtn.addEventListener('click', () => this.leaveRoom()); + + // 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()); + + // Sound toggle + this.toggleSoundBtn.addEventListener('click', () => this.toggleSound()); + + // Leave game button + this.leaveGameBtn.addEventListener('click', () => this.showLeaveConfirm()); + this.leaveConfirmBtn.addEventListener('click', () => this.confirmLeaveGame()); + this.leaveCancelBtn.addEventListener('click', () => this.cancelLeaveGame()); + + // Fullscreen button + const fullscreenBtn = document.getElementById('fullscreen-btn'); + if (fullscreenBtn) { + fullscreenBtn.addEventListener('click', () => this.toggleFullscreen()); + } + + // Toggle log button + const toggleLogBtn = document.getElementById('toggle-log-btn'); + if (toggleLogBtn) { + toggleLogBtn.addEventListener('click', () => this.toggleLog()); + } + + // Suit selection + document.querySelectorAll('.suit-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const suit = e.target.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.resultsModal.classList.remove('active'); + // Переходим в комнату для новой игры + this.showScreen('room'); + }); + + // 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(); + } + }); + + // 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; + }); + } + } + + 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 { + // Обычное подключение + 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); + 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); + 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': + 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.clearLog(); + this.addLogEntry('Игра началась!'); + this.hideCountdown(); + this.showScreen('game'); + + // Воспроизводим звук если игра началась со специальной карты + if (data.top_card) { + const rank = data.top_card.rank; + if (rank === 'A') { + this.playSound('ace'); + } else if (rank === '8') { + 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) { + const cardName = `${data.top_card.rank}${this.getSuitSymbol(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}`); + } + + this.updateGameState(data); + break; + case 'card_played': + // Сохраняем старое значение waiting_for_eight перед обновлением + const wasWaitingForEight = this.waitingForEight; + + 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.card.rank === 'A') { + this.playSound('playcard'); + setTimeout(() => this.playSound('ace'), 60); + } else if (data.card.rank === '8') { + 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.getSuitSymbol(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) { + 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': + 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.getSuitSymbol(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.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 'error': + // Если ошибка связана с комнатой/игроком - показываем экран ошибки + 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 + }); + } + + 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 = '
Нет доступных комнат
'; + return; + } + + rooms.forEach(room => { + const roomCard = document.createElement('div'); + const isFull = room.player_count >= 4; + + roomCard.className = 'room-card' + (isFull ? ' room-full' : ''); + roomCard.innerHTML = ` +Игроков: ${room.player_count}/4
+Статус: ${isFull ? 'Полная' : 'Ожидание'}
+ `; + + 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; + } + + this.send({ + type: 'join_room', + room_id: this.pendingRoomId, + nickname: nickname + }); + + this.joinModal.classList.remove('active'); + } + + 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 = ` +Очки: ${player.score || 0}
+${player.ready ? '✓ Готов' : 'Не готов'}
+ `; + + this.playersList.appendChild(playerCard); + }); + } + + toggleReady() { + this.send({ type: 'toggle_ready' }); + } + + leaveRoom() { + this.goToLobby(); + } + + 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); + this.discardPile.appendChild(cardElement); + } + + // Update chosen suit indicator + // Показываем индикатор всегда когда есть выбранная масть (после дамы) + if (data.chosen_suit) { + this.chosenSuitIndicator.style.display = 'block'; + this.chosenSuitIndicator.textContent = this.getSuitSymbol(data.chosen_suit); + } else { + this.chosenSuitIndicator.style.display = 'none'; + } + + // 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'; + + 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 = ` + +Бонус за даму: ${result.queen_bonus} 👑
` + : ''; + + resultItem.innerHTML = ` +Очки за раунд: ${result.points}
+ ${queenBonusText} +Всего очков: ${result.total_score}
+ ${winningCardHtml} + `; + } else { + const cardsHtml = result.hand && result.hand.length > 0 + ? `Очки за раунд: +${result.points}
+Всего очков: ${result.total_score}
+ ${cardsHtml} + `; + } + + resultsContent.appendChild(resultItem); + }); + + this.resultsModal.classList.add('active'); + } + + 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(); + } + + 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 = `${markdown}`;
+ }
+
+ // Загружаем в оба контейнера
+ 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 = `Не удалось загрузить правила игры.
Ошибка: ${error.message}
`; + 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'); + }); + } + + 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(); +}); diff --git a/client/icon-192.png b/client/icon-192.png new file mode 100644 index 0000000..a5dd5de Binary files /dev/null and b/client/icon-192.png differ diff --git a/client/icon-512.png b/client/icon-512.png new file mode 100644 index 0000000..46b0f00 Binary files /dev/null and b/client/icon-512.png differ diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..52078f0 --- /dev/null +++ b/client/index.html @@ -0,0 +1,386 @@ + + + + + + + + + +Игра начнётся когда все игроки будут готовы (минимум 2 игрока)
+Эта комната не существует или была удалена
+ +'+(n?i:R(i,!0))+`
+`:""+(n?i:R(i,!0))+`
+`}blockquote({tokens:e}){return`+${this.parser.parse(e)}+`}html({text:e}){return e}heading({tokens:e,depth:t}){return`
${this.parser.parseInline(e)}
+`}table(e){let t="",n="";for(let i=0;i${R(e,!0)}`}br(e){return"An error occurred:
"+R(n.message+"",!0)+"";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex; + +if(__exports != exports)module.exports = exports;return module.exports})); diff --git a/client/rules.md b/client/rules.md new file mode 100644 index 0000000..93570d9 --- /dev/null +++ b/client/rules.md @@ -0,0 +1,165 @@ +# "Чешский дурак" + +## 🎯 Цель игры + +Избавиться от всех карт в руке первым. Игрок, который первым выложит все свои карты, побеждает в раунде. Остальные игроки получают штрафные очки за оставшиеся карты. + +## 👥 Количество игроков + +От 2 до 4 игроков. + +## 🃏 Колода + +Стандартная колода из 52 карт (без джокеров). + +## 🎲 Начало игры + +1. Каждому игроку раздаётся по **5 карт** +2. Одна карта кладётся в центр стола (открытая карта) +3. Остальные карты образуют колоду для взятия +4. **Дилер** определяется случайно в первой игре +5. Ход начинается с игрока **слева от дилера** (по часовой стрелке) + +## 🎮 Как ходить + +На свой ход игрок должен положить карту, которая подходит к верхней карте на столе: +- **По масти** (♥️ на ♥️, ♠️ на ♠️, и т.д.) +- **По рангу** (7 на 7, К на К, и т.д.) + +### Если нет подходящей карты: +1. Нажмите кнопку **"Взять"** - возьмите одну карту из колоды +2. Если взятая карта подходит - можете её сыграть +3. Если не подходит - нажмите **"Нету"** (пропуск хода) + +## 🎴 Специальные карты + +### 🔴 Шестёрка (6) +- Следующий игрок **берёт 1 карту** из колоды +- Его ход **пропускается** + +### 🔴 Семёрка (7) +- Следующий игрок **берёт 2 карты** из колоды +- Его ход **пропускается** + +### 🔴 Восьмёрка (8) +- Следующий игрок **тянет карты из колоды**, пока не попадётся: + - **Двойка (2)** - любой масти + - **Дама (Q)** - любой масти + - **Восьмёрка (8)** - любой масти + - **Карта той же масти**, что и восьмёрка на столе +- Из руки на восьмёрку можно положить **только двойку** +- Карты, взятые из колоды, можно положить если они подходят +- ⚠️ **Если игрок заканчивает на восьмёрку** - игра сразу завершается, никто не берёт карты + +### 🔴 Туз (A) +- Следующий игрок **пропускает ход** +- Карты не берёт + +### 👑 Дама (Q) +- **Универсальная карта** - можно положить на любую карту +- После игры дамы нужно **выбрать масть** для следующего хода +- Следующий игрок должен положить карту выбранной масти или того же ранга (другую **Даму (Q)** ) + +## 📊 Подсчёт очков + +После каждого раунда проигравшие получают очки за карты, оставшиеся в руке: + +| Карта | Очки | +|:---:|:---:| +| A | 11 | +| K | 4 | +| Q | ♥️♣️♦️ 20 ♠️ 40 | +| J | 2 | +| 10 | 10 | +| 9 | 9 | +| 8 | 8 | +| 7 | 7 | +| 6 | 6 | +| 5 | 5 | +| 4 | 4 | +| 3 | 3 | +| 2 | 2 | + +**Победитель раунда** получает **0 очков**. + +## 🏆 Победа и поражение + +### Условия продолжения игры: +- Игра продолжается раунд за раундом +- Очки **накапливаются** после каждого раунда +- В новом раунде **дилером становится проигравший** предыдущего раунда (кто набрал больше всех очков в раунде) + +### Специальные правила по очкам: + +#### 👑 Победа дамой +Если игрок **побеждает, положив даму** как последнюю карту: +- У него **вычитается 20 очков** от общего счёта (для любой дамы) +- Если это **пиковая дама (Q♠️)** - вычитается **40 очков** +- Счёт может стать отрицательным + +#### ⚡ Ровно 101 очко +Если игрок набирает **ровно 101 очко**, его счёт **обнуляется до 0**. + +#### ❌ Больше 101 очка +Если игрок набирает **больше 101 очка**, он **выбывает из игры**: +- При 2 игроках: второй игрок объявляется **победителем**, комната закрывается +- При 3-4 игроках: выбывший покидает комнату, остальные продолжают играть + +### 🎊 Финальная победа +Игрок, который остался последним (все остальные набрали >101), становится **абсолютным победителем**. + +## 🎯 Стратегия + +### Советы для игры: +1. **Избавляйтесь от старших карт** (тузы, короли) - они дают больше очков +2. **Держите дам** для критических моментов - они универсальны +3. **Используйте специальные карты** (6, 7, 8, A) стратегически +4. **Следите за счётом** - приближение к 101 очку опасно +5. **Помните**: на 101 очко счёт обнуляется, но 102+ = вылет! + +## 🔄 Порядок хода + +1. Ходы идут **по часовой стрелке** +2. Текущий игрок **подсвечивается** на экране +3. В свой ход можно: + - Сыграть подходящую карту + - Взять карту из колоды + - Пропустить ход (если уже взяли карту) + +## 📱 Интерфейс + +- **Ваши карты** - внизу экрана +- **Противники** - вверху и по бокам (на десктопе) или в ряд сверху (на мобильном) +- **Центр** - текущая карта на столе и колода +- **Счёт** - отображается у каждого игрока +- **Лог** - история действий в левом углу (последние 10 событий) + +## ⌨️ Горячие клавиши + +Для удобства игры доступны следующие горячие клавиши: + +### 💬 Чат +- **T** (или **Е** на русской раскладке) - открыть чат +- **Enter** - отправить сообщение и закрыть чат + +### 🎮 Игровые действия +- **Пробел (Space)** - взять карту из колоды / пропустить ход + - Автоматически нажимает нужную кнопку в зависимости от ситуации + - Если карта не взята → нажимает **"Взять"** + - Если карта уже взята → нажимает **"Нету"** (пропуск хода) + - ⚠️ **Не работает когда чат открыт** - пробел вводится в текст + +### 📋 Правила использования +- Горячие клавиши работают только в свой ход +- Не срабатывают в полях ввода (кроме специальных случаев) +- Чат можно открыть в любой момент игры + +## ⚙️ Дополнительные правила + +- Если **колода закончилась**, сброс (кроме верхней карты) перемешивается и становится новой колодой +- Нельзя присоединиться к комнате, где у игроков уже есть очки (игра началась) +- При выходе игрока из комнаты во время игры, игра продолжается с оставшимися игроками + +--- + +**Приятной игры! 🎴** diff --git a/client/sounds/ace.aac b/client/sounds/ace.aac new file mode 100644 index 0000000..57e7aae Binary files /dev/null and b/client/sounds/ace.aac differ diff --git a/client/sounds/ace.ogg b/client/sounds/ace.ogg new file mode 100644 index 0000000..c44d7d8 Binary files /dev/null and b/client/sounds/ace.ogg differ diff --git a/client/sounds/alert.aac b/client/sounds/alert.aac new file mode 100644 index 0000000..dc75bd2 Binary files /dev/null and b/client/sounds/alert.aac differ diff --git a/client/sounds/alert.ogg b/client/sounds/alert.ogg new file mode 100644 index 0000000..fdf91a7 Binary files /dev/null and b/client/sounds/alert.ogg differ diff --git a/client/sounds/change.aac b/client/sounds/change.aac new file mode 100644 index 0000000..2fa8037 Binary files /dev/null and b/client/sounds/change.aac differ diff --git a/client/sounds/change.ogg b/client/sounds/change.ogg new file mode 100644 index 0000000..6d659e7 Binary files /dev/null and b/client/sounds/change.ogg differ diff --git a/client/sounds/chat.aac b/client/sounds/chat.aac new file mode 100644 index 0000000..8ff5ee4 Binary files /dev/null and b/client/sounds/chat.aac differ diff --git a/client/sounds/chat.ogg b/client/sounds/chat.ogg new file mode 100644 index 0000000..c95473d Binary files /dev/null and b/client/sounds/chat.ogg differ diff --git a/client/sounds/drawcard.aac b/client/sounds/drawcard.aac new file mode 100644 index 0000000..972911e Binary files /dev/null and b/client/sounds/drawcard.aac differ diff --git a/client/sounds/drawcard.ogg b/client/sounds/drawcard.ogg new file mode 100644 index 0000000..575e752 Binary files /dev/null and b/client/sounds/drawcard.ogg differ diff --git a/client/sounds/eight.aac b/client/sounds/eight.aac new file mode 100644 index 0000000..41850f0 Binary files /dev/null and b/client/sounds/eight.aac differ diff --git a/client/sounds/eight.ogg b/client/sounds/eight.ogg new file mode 100644 index 0000000..d72d35c Binary files /dev/null and b/client/sounds/eight.ogg differ diff --git a/client/sounds/eightplace.aac b/client/sounds/eightplace.aac new file mode 100644 index 0000000..5f36bfb Binary files /dev/null and b/client/sounds/eightplace.aac differ diff --git a/client/sounds/eightplace.ogg b/client/sounds/eightplace.ogg new file mode 100644 index 0000000..09edd38 Binary files /dev/null and b/client/sounds/eightplace.ogg differ diff --git a/client/sounds/lose.aac b/client/sounds/lose.aac new file mode 100644 index 0000000..53ffb0b Binary files /dev/null and b/client/sounds/lose.aac differ diff --git a/client/sounds/lose.ogg b/client/sounds/lose.ogg new file mode 100644 index 0000000..521ebe2 Binary files /dev/null and b/client/sounds/lose.ogg differ diff --git a/client/sounds/playcard.aac b/client/sounds/playcard.aac new file mode 100644 index 0000000..64ea757 Binary files /dev/null and b/client/sounds/playcard.aac differ diff --git a/client/sounds/playcard.ogg b/client/sounds/playcard.ogg new file mode 100644 index 0000000..a00b823 Binary files /dev/null and b/client/sounds/playcard.ogg differ diff --git a/client/sounds/seven.aac b/client/sounds/seven.aac new file mode 100644 index 0000000..6790c2f Binary files /dev/null and b/client/sounds/seven.aac differ diff --git a/client/sounds/seven.ogg b/client/sounds/seven.ogg new file mode 100644 index 0000000..9bcca8f Binary files /dev/null and b/client/sounds/seven.ogg differ diff --git a/client/sounds/shuffle.aac b/client/sounds/shuffle.aac new file mode 100644 index 0000000..3033ceb Binary files /dev/null and b/client/sounds/shuffle.aac differ diff --git a/client/sounds/shuffle.ogg b/client/sounds/shuffle.ogg new file mode 100644 index 0000000..34ce45e Binary files /dev/null and b/client/sounds/shuffle.ogg differ diff --git a/client/sounds/six.aac b/client/sounds/six.aac new file mode 100644 index 0000000..fc67190 Binary files /dev/null and b/client/sounds/six.aac differ diff --git a/client/sounds/six.ogg b/client/sounds/six.ogg new file mode 100644 index 0000000..c31f74c Binary files /dev/null and b/client/sounds/six.ogg differ diff --git a/client/sounds/skip.aac b/client/sounds/skip.aac new file mode 100644 index 0000000..f790b78 Binary files /dev/null and b/client/sounds/skip.aac differ diff --git a/client/sounds/skip.ogg b/client/sounds/skip.ogg new file mode 100644 index 0000000..2971f79 Binary files /dev/null and b/client/sounds/skip.ogg differ diff --git a/client/sounds/two.aac b/client/sounds/two.aac new file mode 100644 index 0000000..0ad9ae9 Binary files /dev/null and b/client/sounds/two.aac differ diff --git a/client/sounds/two.ogg b/client/sounds/two.ogg new file mode 100644 index 0000000..9c1a731 Binary files /dev/null and b/client/sounds/two.ogg differ diff --git a/client/sounds/win.aac b/client/sounds/win.aac new file mode 100644 index 0000000..770fce9 Binary files /dev/null and b/client/sounds/win.aac differ diff --git a/client/sounds/win.ogg b/client/sounds/win.ogg new file mode 100644 index 0000000..2e63838 Binary files /dev/null and b/client/sounds/win.ogg differ diff --git a/client/style.css b/client/style.css new file mode 100644 index 0000000..d1266ba --- /dev/null +++ b/client/style.css @@ -0,0 +1,1906 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + /* Поддержка safe area для iOS */ + padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + color: #333; + -webkit-overflow-scrolling: touch; +} + +/* Блокируем жесты только НЕ на главной странице */ +#room-screen.active ~ body, +#game-screen.active ~ body, +#error-screen.active ~ body { + overscroll-behavior: none; + overscroll-behavior-y: contain; + touch-action: pan-x pan-y; + overflow: hidden; + height: 100%; +} + +.screen { + display: none; + min-height: 100vh; + padding: 0px; +} + +.screen.active { + display: block; +} + +.container { + max-width: 1200px; + margin: 0 auto; + background: white; + border-radius: 20px; + padding: 40px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +h1 { + text-align: center; + color: #667eea; + margin-bottom: 40px; + font-size: 3em; +} + +h2 { + color: #667eea; + margin-bottom: 20px; +} + +h3 { + color: #555; + margin-bottom: 15px; +} + +/* Inputs and Buttons */ +input[type="text"] { + width: 100%; + padding: 15px; + font-size: 16px; + border: 2px solid #ddd; + border-radius: 10px; + margin-bottom: 15px; + transition: border-color 0.3s; +} + +input[type="text"]:focus { + outline: none; + border-color: #667eea; +} + +.btn { + padding: 12px 30px; + font-size: 16px; + border: none; + border-radius: 10px; + cursor: pointer; + transition: all 0.3s; + font-weight: 600; +} + +.btn-primary { + background: #667eea; + color: white; +} + +.btn-primary:hover { + background: #5568d3; + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); +} + +.btn-secondary { + background: #e0e0e0; + color: #333; +} + +.btn-secondary:hover { + background: #d0d0d0; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Create Room Section */ +#create-room-section { + margin-bottom: 40px; +} + +/* Bot Game Section */ +#bot-game-section { + margin-top: 25px; + padding-top: 20px; + border-top: 1px solid rgba(102, 126, 234, 0.2); +} + +#bot-game-section h3 { + margin-top: 0; + margin-bottom: 15px; + text-align: center; + color: #667eea; + font-size: 1.2em; +} + +.bot-buttons { + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: center; +} + +.btn-bot { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 12px 24px; + border-radius: 10px; + font-size: 16px; + cursor: pointer; + transition: all 0.3s; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); +} + +.btn-bot:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); +} + +.btn-bot:active { + transform: translateY(0); +} + +/* Rooms List */ +#rooms-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.room-card { + background: #f8f9fa; + padding: 20px; + border-radius: 15px; + border: 2px solid #e0e0e0; + cursor: pointer; + transition: all 0.3s; +} + +.room-card:hover { + border-color: #667eea; + transform: translateY(-5px); + box-shadow: 0 10px 25px rgba(102, 126, 234, 0.2); +} + +.room-card h4 { + color: #667eea; + margin-bottom: 10px; +} + +.room-card p { + color: #666; + margin: 5px 0; +} + +.room-card.room-full { + background: #f0f0f0; + border-color: #ccc; + opacity: 0.6; + cursor: not-allowed; +} + +.room-card.room-full:hover { + transform: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-color: #ccc; +} + +.room-card.room-full h4 { + color: #999; +} + +/* Room Screen */ +.room-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; +} + +#players-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.player-card { + background: #f8f9fa; + padding: 15px; + border-radius: 10px; + border: 2px solid #e0e0e0; +} + +.player-card.ready { + border-color: #4caf50; + background: #e8f5e9; +} + +.player-card.current { + border-color: #667eea; + background: #e8eaf6; + box-shadow: 0 0 20px rgba(102, 126, 234, 0.3); +} + +.player-card h4 { + margin-bottom: 5px; +} + +.player-card .status { + color: #666; + font-size: 14px; +} + +.player-card.ready .status { + color: #4caf50; + font-weight: 600; +} + +.hint { + color: #666; + font-size: 14px; + margin-top: 10px; +} + +/* Countdown Timer */ +#countdown-display { + margin: 30px 0; + text-align: center; +} + +.countdown-timer { + width: 150px; + height: 150px; + margin: 0 auto; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4); + animation: pulse 1s ease-in-out infinite; +} + +#countdown-number { + font-size: 72px; + font-weight: bold; + color: white; + text-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.countdown-text { + font-size: 20px; + font-weight: bold; + color: #667eea; + margin-top: 20px; + animation: fadeInOut 1s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +@keyframes fadeInOut { + 0%, 100% { + opacity: 0.6; + } + 50% { + opacity: 1; + } +} + +/* Error Screen */ +.error-container { + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.error-content { + background: white; + padding: 60px; + border-radius: 20px; + text-align: center; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-width: 500px; +} + +.error-content h1 { + font-size: 80px; + margin-bottom: 20px; +} + +.error-content h2 { + color: #667eea; + margin-bottom: 15px; + font-size: 28px; +} + +.error-content p { + color: #666; + margin-bottom: 30px; + font-size: 16px; +} + +.error-content .btn { + padding: 15px 40px; + font-size: 18px; +} + +/* Game Screen */ +.game-container { + width: 100vw; + height: 100vh; + height: 100dvh; /* Dynamic viewport height для мобильных */ + margin: 0; + padding: 0; + overflow: hidden; + position: fixed; + top: 0; + left: 0; +} + +#game-screen { + overflow: hidden; + position: fixed; + width: 100%; + height: 100%; +} + +/* Game Log */ +.game-log { + position: fixed; + top: 20px; + left: 20px; + min-width: 20vw; + max-width: 50vw; + max-height: 40vh; + background: rgba(0, 0, 0, 0.7); + border-radius: 10px; + padding: 15px; + z-index: 1000; + overflow: hidden; + display: flex; + flex-direction: column-reverse; + gap: 5px; + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.log-entry { + color: white; + font-size: 13px; + line-height: 1.4; + padding: 5px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + transition: opacity 0.3s; +} + +.log-entry:nth-child(1) { + opacity: 1; +} + +.log-entry:nth-child(2) { + opacity: 0.95; +} + +.log-entry:nth-child(3) { + opacity: 0.9; +} + +.log-entry:nth-child(4) { + opacity: 0.85; +} + +.log-entry:nth-child(5) { + opacity: 0.8; +} + +.log-entry:nth-child(6) { + opacity: 0.75; +} + +.log-entry:nth-child(7) { + opacity: 0.7; +} + +.log-entry:nth-child(8) { + opacity: 0.65; +} + +.log-entry:nth-child(9) { + opacity: 0.6; +} + +.log-entry:nth-child(10) { + opacity: 0.55; +} + +.log-entry:nth-child(n+11) { + display: none; +} + +.log-entry:last-child { + border-bottom: none; +} + +/* Уменьшение высоты game-log на маленьких экранах */ + @media (max-height: 780px) { + .game-log { + max-height: calc(35vh - 14px) !important; + } +} + +/* Сообщения чата - свои сообщения */ +.log-entry.chat-player { + background: rgba(102, 126, 234, 0.3); + padding: 5px 8px; + border-radius: 4px; + border-left: 3px solid #667eea; + font-weight: 600; +} + +/* Сообщения чата - чужие сообщения */ +.log-entry.chat-other { + background: rgba(255, 255, 255, 0.1); + padding: 5px 8px; + border-radius: 4px; + border-left: 3px solid rgba(255, 255, 255, 0.3); +} + +/* Цвета для разных игроков */ +.log-entry.chat-color-1 { + background: rgba(76, 175, 80, 0.2); + border-left-color: #4caf50; +} + +.log-entry.chat-color-2 { + background: rgba(255, 152, 0, 0.2); + border-left-color: #ff9800; +} + +.log-entry.chat-color-3 { + background: rgba(156, 39, 176, 0.2); + border-left-color: #9c27b0; +} + +.game-log.hidden { + opacity: 0; + pointer-events: none; + transform: translateY(100%); +} + +/* Toggle Log Button (скрыта на десктопе) */ +.toggle-log-btn { + position: fixed; + top: 20px; + left: 20px; + width: 50px; + height: 50px; + background: rgba(255, 255, 255, 0.9); + border: 2px solid #667eea; + border-radius: 10px; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + font-size: 24px; + color: #667eea; + transition: all 0.3s; + z-index: 1001; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.toggle-log-btn:hover { + background: #667eea; + color: white; + transform: scale(1.1); +} + +.toggle-log-btn:active { + transform: scale(0.95); +} + +/* Toggle Sound Button */ +.toggle-sound-btn { + position: fixed; + top: 260px; + right: 20px; + width: 50px; + height: 50px; + background: rgba(255, 255, 255, 0.9); + border: 2px solid #667eea; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: #667eea; + transition: all 0.3s; + z-index: 1001; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.toggle-sound-btn:hover { + background: #667eea; + color: white; + transform: scale(1.1); +} + +.toggle-sound-btn:active { + transform: scale(0.95); +} + +.toggle-sound-btn.muted { + background: rgba(255, 100, 100, 0.9); + border-color: #e53935; + color: #e53935; +} + +.toggle-sound-btn.muted:hover { + background: #e53935; + color: white; +} + +/* Leave Game Button */ +.leave-game-btn { + position: fixed; + top: 20px; + right: 20px; + width: 50px; + height: 50px; + background: rgba(255, 255, 255, 0.9); + border: 2px solid #e74c3c; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: #e74c3c; + transition: all 0.3s; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.leave-game-btn:hover { + background: #e74c3c; + color: white; + transform: scale(1.1); +} + +.leave-game-btn:active { + transform: scale(0.95); +} + +/* Fullscreen Button */ +.fullscreen-btn { + position: fixed; + top: 80px; + right: 20px; + width: 50px; + height: 50px; + background: rgba(255, 255, 255, 0.9); + border: 2px solid #667eea; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: #667eea; + transition: all 0.3s; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.fullscreen-btn:hover { + background: #667eea; + color: white; + transform: scale(1.1); +} + +.fullscreen-btn:active { + transform: scale(0.95); +} + +/* Rules Button */ +.rules-btn { + position: fixed; + top: 140px; + right: 20px; + width: 50px; + height: 50px; + background: rgba(255, 255, 255, 0.9); + border: 2px solid #667eea; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: #667eea; + transition: all 0.3s; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.rules-btn:hover { + background: #667eea; + color: white; + transform: scale(1.1); +} + +.rules-btn:active { + transform: scale(0.95); +} + +/* Chat Button */ +.chat-btn { + position: fixed; + top: 200px; + right: 20px; + width: 50px; + height: 50px; + background: rgba(255, 255, 255, 0.9); + border: 2px solid #667eea; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: #667eea; + transition: all 0.3s; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.chat-btn:hover { + background: #667eea; + color: white; + transform: scale(1.1); +} + +.chat-btn:active { + transform: scale(0.95); +} + +/* Game Table - Круговой стол */ +.game-table { + width: 100vw; + height: 100vh; + height: 100dvh; /* Dynamic viewport height для мобильных */ + position: relative; + display: flex; + justify-content: center; + align-items: center; + padding: 10px; + background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); + overflow: hidden; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + touch-action: none; /* Отключаем жесты браузера */ +} + +/* Центральный круг */ +.game-circle { + position: absolute; + width: 400px; + height: 400px; + background: radial-gradient(circle, #2d5016 0%, #1a3d0f 100%); + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5), inset 0 0 50px rgba(0, 0, 0, 0.3); + border: 8px solid #8b6914; +} + +.center-area { + display: flex; + gap: 40px; + align-items: center; +} + +.center-card-wrapper { + position: relative; + display: flex; + flex-direction: column; + align-items: center; +} + +.center-card { + display: flex; + align-items: center; + gap: 15px; +} + +.deck-area { + position: relative; + text-align: center; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; +} + +.card-pile { + margin-bottom: 5px; +} + +.deck-buttons { + display: flex; + gap: 5px; +} + +.btn-sm { + min-width: 70px; + max-width: 120px; + padding: 28px 32px; + font-size: 14px; +} + +/* Контейнер для всех противников */ +#opponents-container { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + display: grid; + gap: 20px; + z-index: 10; +} + +/* Один противник - 1 колонка */ +#opponents-container.opponents-1 { + grid-template-columns: 1fr; +} + +/* Два противника - 2 колонки */ +#opponents-container.opponents-2 { + grid-template-columns: repeat(2, 1fr); +} + +/* Три противника - 3 колонки */ +#opponents-container.opponents-3 { + grid-template-columns: repeat(3, 1fr); +} + +/* Позиции противников */ +.opponent-area { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; +} + +.opponent-info { + background: rgba(255, 255, 255, 0.9); + padding: 8px 16px; + border-radius: 10px; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + margin-bottom: 10px; + position: relative; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.opponent-info.current-turn { + background: rgba(102, 126, 234, 0.9); + color: white; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { box-shadow: 0 4px 12px rgba(102, 126, 234, 0.5); } + 50% { box-shadow: 0 4px 20px rgba(102, 126, 234, 0.8); } +} + +.opponent-name { + font-weight: 600; + font-size: 14px; +} + +.opponent-score { + font-size: 12px; + color: #666; +} + +.opponent-info.current-turn .opponent-score { + color: rgba(255, 255, 255, 0.9); +} + +/* Карты противников - все горизонтально */ +.opponent-cards { + display: flex; + flex-direction: row; + gap: -30px; +} + +.opponent-card { + width: 60px; + height: 85px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: 2px solid #333; + border-radius: 6px; + display: flex; + justify-content: center; + align-items: center; + font-size: 24px; + color: white; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3); + transition: transform 0.2s; +} + +/* Одиночная карта с счётчиком (для 3+ игроков) */ +.opponent-card.single-card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 60px; + height: 90px; + font-size: 32px; +} + +.opponent-card.single-card .card-back-icon { + font-size: 40px; +} + +.opponent-card.single-card .card-count { + position: absolute; + bottom: 5px; + right: 5px; + background: rgba(255, 215, 0, 0.95); + color: #000; + font-size: 14px; + font-weight: bold; + padding: 2px 6px; + border-radius: 50%; + min-width: 20px; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +/* Подсветка текущего игрока */ +.opponent-area.current-turn .opponent-cards { + filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.3)) + drop-shadow(0 0 15px rgba(255, 215, 0, 0.2)); +} + +.opponent-area.current-turn .opponent-card { + animation: card-glow-pulse 1.5s ease-in-out infinite; +} + +/* Свечение блока руки игрока */ +.player-hand.current-turn { + box-shadow: 0 -5px 20px rgba(0, 0, 0, 0.3), + 0 0 15px rgba(255, 215, 0, 0.3), + 0 0 25px rgba(255, 215, 0, 0.2), + 0 0 35px rgba(255, 215, 0, 0.1); + animation: hand-glow-pulse 1.5s ease-in-out infinite; +} + +@keyframes hand-glow-pulse { + 0%, 100% { + box-shadow: 0 -5px 20px rgba(0, 0, 0, 0.3), + 0 0 15px rgba(255, 215, 0, 0.3), + 0 0 25px rgba(255, 215, 0, 0.2), + 0 0 35px rgba(255, 215, 0, 0.1); + } + 50% { + box-shadow: 0 -5px 20px rgba(0, 0, 0, 0.3), + 0 0 20px rgba(255, 215, 0, 0.4), + 0 0 30px rgba(255, 215, 0, 0.3), + 0 0 40px rgba(255, 215, 0, 0.15); + } +} + +@keyframes card-glow-pulse { + 0%, 100% { + box-shadow: 0 0 10px rgba(255, 215, 0, 0.3), 0 0 20px rgba(255, 215, 0, 0.2); + } + 50% { + box-shadow: 0 0 15px rgba(255, 215, 0, 0.4), 0 0 25px rgba(255, 215, 0, 0.3); + } +} + +/* Счётчик карт в колоде */ +.deck-count-badge { +position: absolute; +top: -30px; +left: 50%; +transform: translateX(-50%); +background: rgba(255, 215, 0, 0.95); +color: #333; +padding: 5px 15px; +border-radius: 20px; +font-weight: bold; +font-size: 16px; +box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +border: 2px solid #ffd700; + top: -30px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 215, 0, 0.95); + color: #333; + padding: 5px 15px; + border-radius: 20px; + font-weight: bold; + font-size: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border: 2px solid #ffd700; +} + +/* Индикатор выбранной масти */ +#chosen-suit-indicator { + font-size: 48px; + background: rgba(255, 255, 255, 0.95); + padding: 10px 20px; + border-radius: 15px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border: 3px solid #ffd700; + margin-bottom: 15px; + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Suit Indicator */ +.suit-indicator { + display: flex; + align-items: center; + gap: 10px; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.suit-arrow { + font-size: 32px; + color: #667eea; + font-weight: bold; +} + +.suit-symbol { + font-size: 48px; + padding: 15px; + background: white; + border: 3px solid #333; + border-radius: 10px; + min-width: 80px; + text-align: center; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); +} + +.suit-symbol.hearts, +.suit-symbol.diamonds { + color: #e53935; +} + +.suit-symbol.clubs, +.suit-symbol.spades { + color: #000; +} + +/* Cards */ +.card { + width: 100px; + height: 120px; + background: white; + border: 3px solid #333; + border-radius: 10px; + display: inline-flex; + flex-direction: column; + justify-content: center; + align-items: center; + font-size: 24px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s; + position: relative; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); +} + +.card-back { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + font-size: 48px; + cursor: default; +} + +.card.hearts, .card.diamonds { + color: #e53935; +} + +.card.clubs, .card.spades { + color: #000; +} + +.card:hover:not(.card-back) { + transform: translateY(-10px); + box-shadow: 0 15px 30px rgba(0, 0, 0, 0.3); +} + +.card.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.card.disabled:hover { + transform: none; +} + +.card-rank { + font-size: 32px; + margin-bottom: 5px; +} + +.card-suit { + font-size: 40px; +} + +.player-hand { + position: absolute; + bottom: 0; + background: rgba(255, 255, 255, 0.95); + border-radius: 11px 11px 0 0; + box-shadow: 0 -5px 20px rgba(0, 0, 0, 0.3); + z-index: 1100; + max-height: 30vh; + padding: 20px 10px 10px 10px; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; + overscroll-behavior: contain; +} + +#hand-cards { + display: flex; + gap: 4px; + justify-content: center; + flex-wrap: wrap; + flex-direction: row; + position: relative; +} + +/* Стили скроллбара для руки игрока на десктопе */ +.player-hand::-webkit-scrollbar { + width: 8px; +} + +.player-hand::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.1); + border-radius: 4px; +} + +.player-hand::-webkit-scrollbar-thumb { + background: rgba(102, 126, 234, 0.5); + border-radius: 4px; +} + +.player-hand::-webkit-scrollbar-thumb:hover { + background: rgba(102, 126, 234, 0.8); +} + + +/* Modal */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal.active { + display: flex; +} + +.modal-content { + background: white; + padding: 40px; + border-radius: 20px; + max-width: 500px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.modal-content h2, .modal-content h3 { + margin-bottom: 20px; + text-align: center; +} + +/* Модалка чата */ +.chat-modal-content { + max-width: 400px; +} + +.chat-modal-content textarea { + width: 100%; + min-height: 100px; + padding: 12px; + border: 2px solid #e0e0e0; + border-radius: 10px; + font-size: 16px; + font-family: inherit; + resize: vertical; + margin-bottom: 15px; + transition: border-color 0.3s ease; +} + +.chat-modal-content textarea:focus { + outline: none; + border-color: #667eea; +} + +.chat-modal-buttons { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.chat-modal-buttons .btn { + flex: 1; + padding: 12px; + font-size: 16px; +} + +/* Модалка правил */ +.rules-modal-content { + max-width: 700px; + max-height: 80vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + touch-action: pan-y; + position: relative; +} + +.close-modal-btn { + position: sticky; + top: 10px; + float: right; + margin: 10px 10px 0 0; + width: 30px; + height: 30px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.95); + border: 2px solid #667eea; + color: #667eea; + font-size: 20px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; + z-index: 10; + padding: 0; + line-height: 1; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.close-modal-btn:hover { + background: #667eea; + color: white; + transform: scale(1.1); +} + +.close-modal-btn:active { + transform: scale(0.95); +} + +.rules-content { + text-align: left; + line-height: 1.6; + margin: 20px 0; +} + +.rules-content h1, +.rules-content h2, +.rules-content h3 { + color: #667eea; + margin-top: 20px; + margin-bottom: 10px; +} + +.rules-content h1 { + font-size: 24px; +} + +.rules-content h2 { + font-size: 20px; +} + +.rules-content h3 { + font-size: 18px; +} + +.rules-content p { + margin: 10px 0; +} + +.rules-content ul, +.rules-content ol { + margin: 10px 0; + padding-left: 25px; +} + +.rules-content li { + margin: 5px 0; +} + +.rules-content strong { + color: #667eea; + font-weight: 600; +} + +.rules-content code { + background: rgba(102, 126, 234, 0.1); + padding: 2px 6px; + border-radius: 3px; + font-family: monospace; +} + +.suit-selector { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; + margin-top: 20px; +} + +.suit-btn { + padding: 20px; + font-size: 18px; + border: 2px solid #ddd; + border-radius: 10px; + background: white; + cursor: pointer; + transition: all 0.3s; +} + +.suit-btn:hover { + border-color: #667eea; + background: #f0f0f0; + transform: scale(1.05); +} + +.modal-buttons { + display: flex; + gap: 15px; + margin-top: 20px; +} + +.modal-buttons .btn { + flex: 1; +} + +#results-content { + margin: 20px 0; +} + +.result-item { + padding: 15px; + margin: 10px 0; + background: #f8f9fa; + border-radius: 10px; + border-left: 4px solid #667eea; +} + +.result-item.winner { + background: #e8f5e9; + border-left-color: #4caf50; +} + +.result-item h4 { + margin-bottom: 5px; + color: #333; +} + +.result-item p { + color: #666; + margin: 3px 0; +} + +.result-cards { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} + +.result-card { + width: 50px; + height: 70px; + background: white; + border: 2px solid #333; + border-radius: 6px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + font-size: 12px; + font-weight: bold; +} + +.result-card .card-rank { + font-size: 14px; +} + +.result-card .card-suit { + font-size: 18px; +} + +.result-card.hearts, +.result-card.diamonds { + color: #e53935; +} + +.result-card.clubs, +.result-card.spades { + color: #000; +} + +/* Responsive */ +@media (max-width: 1280px) { + /* Квадратные маленькие карты для средних экранов */ + .card { + width: 70px; + height: 70px; + font-size: 14px; + border-width: 2px; + border-radius: 8px; + } + + .card-rank { + font-size: 18px; + } + + .card-suit { + font-size: 24px; + } + + /* Центральные карты на поле */ + .center-card .card { + width: 85px; + height: 85px; + font-size: 16px; + } + + .center-card .card-rank { + font-size: 22px; + } + + .center-card .card-suit { + font-size: 30px; + } + + /* Рука игрока для средних экранов */ + .player-hand { + max-height: calc((70px + 4px) * 4 + 30px); + padding: 20px 10px 10px 10px; + touch-action: pan-y; + overscroll-behavior: contain; + } + + #hand-cards { + gap: 4px; + } +} + +@media (max-width: 768px) { + html { + /* Убираем padding на мобильных для полного экрана */ + padding: 0; + } + + .container { + padding: 14px; + } + + h1 { + font-size: 2em; + } + + .game-table { + padding: 4px; + /* Учитываем safe area на iOS */ + padding-bottom: max(4px, env(safe-area-inset-bottom)); + } + + .game-circle { + width: 260px; + height: 260px; + } + + .center-area { + gap: 12px; + flex-direction: row; + } + + .card { + width: 64px; + height: 92px; + font-size: 15px; + } + + .card-rank { + font-size: 20px; + } + + .card-suit { + font-size: 24px; + } + + .opponent-card { + width: 45px; + height: 65px; + font-size: 18px; + } + + .opponent-info { + padding: 6px 12px; + font-size: 12px; + } + + .opponent-name { + font-size: 12px; + } + + .opponent-score { + font-size: 10px; + } + + .player-hand { + padding: 15px 5px 5px 5px; + max-width: 100%; + border-radius: 4px 4px 0px 0px; + max-height: calc((62px + 2px) * 4 + 25px); + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; + overscroll-behavior: contain; + } + + #hand-cards { + gap: 2px; + max-width: 100%; + } + + /* Стили скроллбара для руки игрока */ + .player-hand::-webkit-scrollbar { + width: 4px; + } + + .player-hand::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.1); + border-radius: 2px; + } + + .player-hand::-webkit-scrollbar-thumb { + background: rgba(102, 126, 234, 0.5); + border-radius: 2px; + } + + .player-hand::-webkit-scrollbar-thumb:hover { + background: rgba(102, 126, 234, 0.8); + } + + /* Квадратные карты на мобильных */ + .card { + width: 62px; + height: 62px; + font-size: 13px; + border-width: 2px; + border-radius: 8px; + flex-shrink: 0; + } + + .card-rank { + font-size: 16px; + } + + .card-suit { + font-size: 20px; + } + + /* Центральные карты на поле */ + .center-card .card { + width: 78px; + height: 78px; + font-size: 15px; + } + + .center-card .card-rank { + font-size: 20px; + } + + .center-card .card-suit { + font-size: 28px; + } + + .suit-arrow { + font-size: 20px; + } + + .suit-symbol { + font-size: 32px; + padding: 10px; + min-width: 50px; + } + + /* Уменьшаем круг игрового поля */ + .game-circle { + width: 360px; + height: 360px; + } + + .center-area { + gap: 20px; + } + + .deck-count-badge { + font-size: 12px; + padding: 3px 10px; + } + + #chosen-suit-indicator { + font-size: 32px; + padding: 6px 12px; + margin-bottom: 8px; + } + + .btn-sm { + min-width: 70px; + max-width: 70px; + padding: 28px 18px; + font-size: 12px; + } + + /* Toggle log button на мобильных */ + .toggle-log-btn { + display: flex; + width: 40px; + height: 40px; + font-size: 20px; + top: 10px; + left: 10px; + } + + /* Leave game button на мобильных */ + .leave-game-btn { + width: 40px; + height: 40px; + font-size: 20px; + top: 10px; + right: 10px; + } + + /* Fullscreen button на мобильных */ + .fullscreen-btn { + width: 40px; + height: 40px; + font-size: 20px; + top: 60px; + right: 10px; + } + + /* Rules button на мобильных */ + .rules-btn { + width: 40px; + height: 40px; + font-size: 20px; + top: 110px; + right: 10px; + } + + /* Chat button на мобильных */ + .chat-btn { + width: 40px; + height: 40px; + font-size: 20px; + top: 160px; + right: 10px; + } + + /* Toggle sound button на мобильных */ + .toggle-sound-btn { + width: 40px; + height: 40px; + font-size: 20px; + top: 60px; + left: 10px; + } + + /* Game log на мобильных */ + .game-log { + position: fixed; + top: auto; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 50vw; + height: calc(37vh - 14px); + padding: 8px; + background: rgba(0, 0, 0, 0.42); + flex-direction: column; + z-index: 900; + border-radius: 8px 8px 0 0; + transition: opacity 0.4s ease, transform 0.4s ease; + } + + .game-log.hidden { + opacity: 0; + pointer-events: none; + transform: translate(-50%, 100%); + } + + .log-entry { + font-size: 11px; + padding: 3px 10px; + text-align: center; + } + + .log-entry:nth-child(1) { + opacity: 1; + } + + .log-entry:nth-child(2) { + opacity: 0.95; + } + + .log-entry:nth-child(3) { + opacity: 0.9; + } + + .log-entry:nth-child(4) { + opacity: 0.85; + } + + .log-entry:nth-child(5) { + opacity: 0.8; + } + + .log-entry:nth-child(6) { + opacity: 0.75; + } + + .log-entry:nth-child(7) { + opacity: 0.7; + } + + .log-entry:nth-child(8) { + opacity: 0.65; + } + + .log-entry:nth-child(9) { + opacity: 0.6; + } + + .log-entry:nth-child(10) { + opacity: 0.55; + } + + .log-entry:nth-child(n+11) { + display: none; + } + + .deck-count-badge { + font-size: 14px; + padding: 4px 12px; + } + + #chosen-suit-indicator { + font-size: 30px; + padding: 6px 12px; + top: -48px; + } + + .error-content { + padding: 40px 30px; + margin: 20px; + } + + .error-content h1 { + font-size: 60px; + } + + .error-content h2 { + font-size: 24px; + } + + .error-content p { + font-size: 14px; + } + + .error-content .btn { + padding: 12px 30px; + font-size: 16px; + } + + /* Противники на мобильных */ + #opponents-container { + top: 4px; + gap: 10px; + } + + .opponent-area { + gap: 3px; + } +} + +/* Rules Section */ +#rules-section { + margin-top: 40px; + padding-top: 30px; + border-top: 2px solid rgba(102, 126, 234, 0.2); +} + +#rules-section h2 { + text-align: center; + color: #667eea; + margin-bottom: 20px; +} + +.rules-content { + max-width: 900px; + margin: 0 auto; + padding: 20px; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + line-height: 1.6; +} + +.rules-content h1 { + color: #667eea; + font-size: 2em; + margin-top: 0; + margin-bottom: 20px; + text-align: center; +} + +.rules-content h2 { + color: #764ba2; + font-size: 1.5em; + margin-top: 30px; + margin-bottom: 15px; + border-bottom: 2px solid rgba(102, 126, 234, 0.3); + padding-bottom: 8px; +} + +.rules-content h3 { + color: #667eea; + font-size: 1.2em; + margin-top: 20px; + margin-bottom: 10px; +} + +.rules-content h4 { + color: #764ba2; + font-size: 1.1em; + margin-top: 15px; + margin-bottom: 8px; +} + +.rules-content p { + margin: 10px 0; +} + +.rules-content ul, .rules-content ol { + margin: 10px 0; + padding-left: 25px; +} + +.rules-content li { + margin: 8px 0; +} + +.rules-content table { + width: 100%; + border-collapse: collapse; + margin: 20px 0; + background: rgba(255, 255, 255, 0.05); +} + +.rules-content th, .rules-content td { + border: 1px solid rgba(102, 126, 234, 0.3); + padding: 12px; + text-align: left; +} + +.rules-content th { + background: rgba(102, 126, 234, 0.2); + color: #667eea; + font-weight: bold; +} + +.rules-content code { + background: rgba(102, 126, 234, 0.1); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Courier New', monospace; +} + +.rules-content hr { + border: none; + border-top: 2px solid rgba(102, 126, 234, 0.2); + margin: 30px 0; +} + +.rules-content strong { + color: #667eea; + font-weight: 600; +} + +.rules-content blockquote { + border-left: 4px solid #667eea; + padding-left: 15px; + margin: 15px 0; + color: rgba(255, 255, 255, 0.8); + font-style: italic; +} diff --git a/client/sw.js b/client/sw.js new file mode 100644 index 0000000..d6c30b9 --- /dev/null +++ b/client/sw.js @@ -0,0 +1,88 @@ +const CACHE_NAME = 'czech-fool-v1'; +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' +]; + +// Установка service worker +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => { + console.log('Opened cache'); + return cache.addAll(urlsToCache); + }) + ); +}); + +// Активация 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); + } + }) + ); + }) + ); +}); + +// Обработка запросов +self.addEventListener('fetch', event => { + // Игнорируем запросы не http/https (chrome-extension, data, blob и т.д.) + if (!event.request.url.startsWith('http')) { + return; + } + + event.respondWith( + caches.match(event.request) + .then(response => { + // Возвращаем из кеша если есть + if (response) { + return response; + } + + // Клонируем запрос + const fetchRequest = event.request.clone(); + + 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; + }); + }) + ); +}); diff --git a/czech-fool.service b/czech-fool.service new file mode 100644 index 0000000..87252cc --- /dev/null +++ b/czech-fool.service @@ -0,0 +1,23 @@ +[Unit] +Description=Card Game Server +After=network.target postgresql.service +Wants=postgresql.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/czech-fool +ExecStart=/usr/bin/python3 /opt/czech-fool/server.py --clearrooms +Restart=always +RestartSec=10 + +# Переменные окружения (если нужны) +Environment="PYTHONUNBUFFERED=1" + +# Логирование +StandardOutput=journal +StandardError=journal +SyslogIdentifier=card-game + +[Install] +WantedBy=multi-user.target diff --git a/database_postgres.py b/database_postgres.py new file mode 100644 index 0000000..e70b27e --- /dev/null +++ b/database_postgres.py @@ -0,0 +1,338 @@ +import asyncpg +import json +from typing import Optional, Dict, List +from datetime import datetime + +class GameDatabase: + def __init__(self, host: str = "localhost", port: int = 5432, + database: str = "czechdb", user: str = "czech", + password: str = "password"): + self.host = host + self.port = port + self.database = database + self.user = user + self.password = password + self.pool = None + + async def connect(self): + """Создание пула соединений""" + self.pool = await asyncpg.create_pool( + host=self.host, + port=self.port, + database=self.database, + user=self.user, + password=self.password, + min_size=2, + max_size=10 + ) + await self.init_db() + + async def close(self): + """Закрытие пула соединений""" + if self.pool: + await self.pool.close() + + async def init_db(self): + """Инициализация базы данных""" + async with self.pool.acquire() as conn: + # Таблица комнат + await conn.execute(''' + CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY, + game_started BOOLEAN DEFAULT FALSE, + current_player_index INTEGER DEFAULT 0, + dealer_index INTEGER DEFAULT 0, + chosen_suit TEXT, + waiting_for_eight BOOLEAN DEFAULT FALSE, + card_drawn_this_turn BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Таблица игроков + await conn.execute(''' + CREATE TABLE IF NOT EXISTS players ( + id TEXT PRIMARY KEY, + room_id TEXT, + nickname TEXT NOT NULL, + ready BOOLEAN DEFAULT FALSE, + score INTEGER DEFAULT 0, + player_order INTEGER DEFAULT 0, + FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE + ) + ''') + + # Таблица карт в руке + await conn.execute(''' + CREATE TABLE IF NOT EXISTS player_cards ( + id SERIAL PRIMARY KEY, + player_id TEXT, + card_suit TEXT NOT NULL, + card_rank TEXT NOT NULL, + card_id TEXT NOT NULL, + FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE + ) + ''') + + # Таблица колоды + await conn.execute(''' + CREATE TABLE IF NOT EXISTS deck_cards ( + id SERIAL PRIMARY KEY, + room_id TEXT, + card_suit TEXT NOT NULL, + card_rank TEXT NOT NULL, + card_id TEXT NOT NULL, + card_order INTEGER, + FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE + ) + ''') + + # Таблица сброса + await conn.execute(''' + CREATE TABLE IF NOT EXISTS discard_pile ( + id SERIAL PRIMARY KEY, + room_id TEXT, + card_suit TEXT NOT NULL, + card_rank TEXT NOT NULL, + card_id TEXT NOT NULL, + card_order INTEGER, + FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE + ) + ''') + + async def save_room(self, room_data: Dict): + """Сохранение состояния комнаты""" + async with self.pool.acquire() as conn: + await conn.execute(''' + INSERT INTO rooms + (id, game_started, current_player_index, dealer_index, chosen_suit, + waiting_for_eight, card_drawn_this_turn, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP) + ON CONFLICT (id) DO UPDATE SET + game_started = $2, + current_player_index = $3, + dealer_index = $4, + chosen_suit = $5, + waiting_for_eight = $6, + card_drawn_this_turn = $7, + updated_at = CURRENT_TIMESTAMP + ''', + room_data['id'], + room_data['game_started'], + room_data['current_player_index'], + room_data['dealer_index'], + room_data.get('chosen_suit'), + room_data.get('waiting_for_eight', False), + room_data.get('card_drawn_this_turn', False) + ) + + async def save_player(self, player_data: Dict, room_id: str, order: int): + """Сохранение игрока""" + async with self.pool.acquire() as conn: + await conn.execute(''' + INSERT INTO players + (id, room_id, nickname, ready, score, player_order) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) DO UPDATE SET + room_id = $2, + nickname = $3, + ready = $4, + score = $5, + player_order = $6 + ''', + player_data['id'], + room_id, + player_data['nickname'], + player_data['ready'], + player_data['score'], + order + ) + + async def save_player_cards(self, player_id: str, cards: List[Dict]): + """Сохранение карт игрока""" + async with self.pool.acquire() as conn: + # Удаляем старые карты + await conn.execute('DELETE FROM player_cards WHERE player_id = $1', player_id) + + # Добавляем новые + if cards: + await conn.executemany(''' + INSERT INTO player_cards (player_id, card_suit, card_rank, card_id) + VALUES ($1, $2, $3, $4) + ''', [(player_id, card['suit'], card['rank'], card['id']) for card in cards]) + + async def save_deck(self, room_id: str, cards: List[Dict]): + """Сохранение колоды""" + async with self.pool.acquire() as conn: + await conn.execute('DELETE FROM deck_cards WHERE room_id = $1', room_id) + + if cards: + await conn.executemany(''' + INSERT INTO deck_cards (room_id, card_suit, card_rank, card_id, card_order) + VALUES ($1, $2, $3, $4, $5) + ''', [(room_id, card['suit'], card['rank'], card['id'], idx) for idx, card in enumerate(cards)]) + + async def save_discard_pile(self, room_id: str, cards: List[Dict]): + """Сохранение сброса""" + async with self.pool.acquire() as conn: + await conn.execute('DELETE FROM discard_pile WHERE room_id = $1', room_id) + + if cards: + await conn.executemany(''' + INSERT INTO discard_pile (room_id, card_suit, card_rank, card_id, card_order) + VALUES ($1, $2, $3, $4, $5) + ''', [(room_id, card['suit'], card['rank'], card['id'], idx) for idx, card in enumerate(cards)]) + + async def load_room(self, room_id: str) -> Optional[Dict]: + """Загрузка комнаты""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow('SELECT * FROM rooms WHERE id = $1', room_id) + + if not row: + return None + + return { + 'id': row['id'], + 'game_started': row['game_started'], + 'current_player_index': row['current_player_index'], + 'dealer_index': row['dealer_index'], + 'chosen_suit': row['chosen_suit'], + 'waiting_for_eight': row['waiting_for_eight'], + 'card_drawn_this_turn': row['card_drawn_this_turn'] + } + + async def load_players(self, room_id: str) -> List[Dict]: + """Загрузка игроков комнаты""" + async with self.pool.acquire() as conn: + rows = await conn.fetch(''' + SELECT id, nickname, ready, score, player_order + FROM players + WHERE room_id = $1 + ORDER BY player_order + ''', room_id) + + return [{ + 'id': row['id'], + 'nickname': row['nickname'], + 'ready': row['ready'], + 'score': row['score'], + 'order': row['player_order'] + } for row in rows] + + async def load_player_cards(self, player_id: str) -> List[Dict]: + """Загрузка карт игрока""" + async with self.pool.acquire() as conn: + rows = await conn.fetch(''' + SELECT card_suit, card_rank, card_id + FROM player_cards + WHERE player_id = $1 + ''', player_id) + + return [{ + 'suit': row['card_suit'], + 'rank': row['card_rank'], + 'id': row['card_id'] + } for row in rows] + + async def load_deck(self, room_id: str) -> List[Dict]: + """Загрузка колоды""" + async with self.pool.acquire() as conn: + rows = await conn.fetch(''' + SELECT card_suit, card_rank, card_id + FROM deck_cards + WHERE room_id = $1 + ORDER BY card_order + ''', room_id) + + return [{ + 'suit': row['card_suit'], + 'rank': row['card_rank'], + 'id': row['card_id'] + } for row in rows] + + async def load_discard_pile(self, room_id: str) -> List[Dict]: + """Загрузка сброса""" + async with self.pool.acquire() as conn: + rows = await conn.fetch(''' + SELECT card_suit, card_rank, card_id + FROM discard_pile + WHERE room_id = $1 + ORDER BY card_order + ''', room_id) + + return [{ + 'suit': row['card_suit'], + 'rank': row['card_rank'], + 'id': row['card_id'] + } for row in rows] + + async def delete_room(self, room_id: str): + """Удаление комнаты и всех связанных данных""" + async with self.pool.acquire() as conn: + # Получаем всех игроков комнаты + player_ids = await conn.fetch('SELECT id FROM players WHERE room_id = $1', room_id) + + # Удаляем карты игроков + for row in player_ids: + await conn.execute('DELETE FROM player_cards WHERE player_id = $1', row['id']) + + # Удаляем игроков + await conn.execute('DELETE FROM players WHERE room_id = $1', room_id) + + # Удаляем колоду и сброс + await conn.execute('DELETE FROM deck_cards WHERE room_id = $1', room_id) + await conn.execute('DELETE FROM discard_pile WHERE room_id = $1', room_id) + + # Удаляем комнату + await conn.execute('DELETE FROM rooms WHERE id = $1', room_id) + + async def room_exists(self, room_id: str) -> bool: + """Проверка существования комнаты""" + async with self.pool.acquire() as conn: + result = await conn.fetchval('SELECT 1 FROM rooms WHERE id = $1 LIMIT 1', room_id) + return result is not None + + async def get_all_rooms(self) -> List[str]: + """Получение всех комнат""" + async with self.pool.acquire() as conn: + rows = await conn.fetch('SELECT id FROM rooms') + return [row['id'] for row in rows] + + async def cleanup_old_rooms(self, hours: int = 24) -> int: + """Удаление комнат, неактивных более N часов""" + async with self.pool.acquire() as conn: + # Находим старые комнаты + rows = await conn.fetch(''' + SELECT id FROM rooms + WHERE updated_at < NOW() - INTERVAL '%s hours' + ''' % hours) + + old_rooms = [row['id'] for row in rows] + + # Удаляем каждую старую комнату + for room_id in old_rooms: + await self.delete_room(room_id) + + return len(old_rooms) + + async def get_room_stats(self) -> Dict: + """Получение статистики по комнатам""" + async with self.pool.acquire() as conn: + total_rooms = await conn.fetchval('SELECT COUNT(*) FROM rooms') + active_games = await conn.fetchval('SELECT COUNT(*) FROM rooms WHERE game_started = TRUE') + inactive_1h = await conn.fetchval(''' + SELECT COUNT(*) FROM rooms + WHERE updated_at < NOW() - INTERVAL '1 hour' + ''') + inactive_24h = await conn.fetchval(''' + SELECT COUNT(*) FROM rooms + WHERE updated_at < NOW() - INTERVAL '24 hours' + ''') + + return { + 'total_rooms': total_rooms, + 'active_games': active_games, + 'inactive_1h': inactive_1h, + 'inactive_24h': inactive_24h + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dcc9d5e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +websockets==12.0 +aiohttp==3.9.1 diff --git a/server.py b/server.py new file mode 100644 index 0000000..85b7328 --- /dev/null +++ b/server.py @@ -0,0 +1,1667 @@ +import asyncio +import json +import random +import uuid +from typing import Dict, List, Set, Optional +from dataclasses import dataclass, asdict +from websockets.server import serve, WebSocketServerProtocol +from aiohttp import web +import os +from database_postgres import GameDatabase + +@dataclass +class Card: + suit: str # hearts, diamonds, clubs, spades + rank: str # 2-10, J, Q, K, A + id: str + + def to_dict(self): + return asdict(self) + +@dataclass +class Player: + id: str + nickname: str + hand: List[Card] + ready: bool + score: int = 0 + is_bot: bool = False + + def to_dict(self): + return { + 'id': self.id, + 'nickname': self.nickname, + 'hand_count': len(self.hand), + 'ready': self.ready, + 'score': self.score, + 'is_bot': self.is_bot + } + + def to_dict_with_hand(self): + return { + 'id': self.id, + 'nickname': self.nickname, + 'hand': [card.to_dict() for card in self.hand], + 'ready': self.ready, + 'score': self.score + } + +@dataclass +class Room: + id: str + players: Dict[str, Player] + deck: List[Card] + discard_pile: List[Card] + current_player_index: int + dealer_index: int + game_started: bool + chosen_suit: Optional[str] = None + waiting_for_eight: bool = False + card_drawn_this_turn: bool = False # Флаг что карта уже взята в этом ходу + eight_draw_used: bool = False # Флаг что взятие на восьмёрку уже использовано + countdown_active: bool = False # Активен ли обратный отсчёт + countdown_task: Optional[object] = None # Задача таймера + last_loser_id: Optional[str] = None # ID проигравшего в прошлой игре (станет дилером) + + def to_dict(self): + return { + 'id': self.id, + 'players': [p.to_dict() for p in self.players.values()], + 'player_count': len(self.players), + 'game_started': self.game_started + } + +class GameServer: + def __init__(self): + self.rooms: Dict[str, Room] = {} + self.clients: Dict[WebSocketServerProtocol, str] = {} # ws -> player_id + self.player_rooms: Dict[str, str] = {} # player_id -> room_id + self.db = GameDatabase( + host="localhost", + port=5432, + database="czechdb", + user="czech", + password="password" # Замените на ваш пароль + ) + self.cleanup_task = None + + async def cleanup_old_rooms(self): + """Очистка комнат старше 24 часов""" + deleted = await self.db.cleanup_old_rooms(hours=24) + if deleted > 0: + print(f"Cleaned up {deleted} old rooms from database") + + # Получаем статистику + stats = await self.db.get_room_stats() + print(f"Room stats: {stats['total_rooms']} total, {stats['active_games']} active games, " + f"{stats['inactive_1h']} inactive >1h, {stats['inactive_24h']} inactive >24h") + + def cleanup_bot_only_rooms(self): + """Удаляет комнаты где остались только боты""" + rooms_to_delete = [] + for room_id, room in self.rooms.items(): + # Проверяем есть ли живые игроки + human_players = [p for p in room.players.values() if not p.is_bot] + if len(human_players) == 0 and len(room.players) > 0: + rooms_to_delete.append(room_id) + + for room_id in rooms_to_delete: + del self.rooms[room_id] + print(f"Deleted bot-only room: {room_id}") + + return len(rooms_to_delete) + + async def start_cleanup_task(self): + """Запуск периодической очистки каждые 6 часов""" + while True: + await asyncio.sleep(6 * 60 * 60) # 6 часов + await self.cleanup_old_rooms() + + async def load_rooms_from_db(self): + """Загрузка всех комнат из базы данных""" + room_ids = await self.db.get_all_rooms() + for room_id in room_ids: + await self.load_room(room_id) + + async def load_room(self, room_id: str) -> Optional[Room]: + """Загрузка комнаты из БД""" + # print(f"Loading room {room_id} from DB...") + room_data = await self.db.load_room(room_id) + if not room_data: + # print(f"Room {room_id} not found in DB") + return None + + # print(f"Room {room_id} data loaded: game_started={room_data.get('game_started')}") + + # Загружаем игроков + players_data = await self.db.load_players(room_id) + players = {} + + for player_data in players_data: + cards = await self.db.load_player_cards(player_data['id']) + player = Player( + id=player_data['id'], + nickname=player_data['nickname'], + hand=[Card(**card) for card in cards], + ready=player_data['ready'], + score=player_data['score'] + ) + players[player_data['id']] = player + + # Загружаем колоду и сброс + deck_data = await self.db.load_deck(room_id) + discard_data = await self.db.load_discard_pile(room_id) + + room = Room( + id=room_data['id'], + players=players, + deck=[Card(**card) for card in deck_data], + discard_pile=[Card(**card) for card in discard_data], + current_player_index=room_data['current_player_index'], + dealer_index=room_data['dealer_index'], + game_started=room_data['game_started'], + chosen_suit=room_data['chosen_suit'], + waiting_for_eight=room_data['waiting_for_eight'], + card_drawn_this_turn=room_data['card_drawn_this_turn'] + ) + + # Инициализируем дополнительные атрибуты + room.countdown_active = False + room.countdown_task = None + room.eight_drawn_cards = set() + + self.rooms[room_id] = room + # print(f"Room {room_id} loaded successfully with {len(players)} players") + + # Восстанавливаем player_rooms + for player_id in players.keys(): + self.player_rooms[player_id] = room_id + + return room + + async def save_room_to_db(self, room_id: str): + """Сохранение комнаты в БД""" + room = self.rooms.get(room_id) + if not room: + return + + try: + # Сохраняем комнату + await self.db.save_room({ + 'id': room.id, + 'game_started': room.game_started, + 'current_player_index': room.current_player_index, + 'dealer_index': room.dealer_index, + 'chosen_suit': room.chosen_suit, + 'waiting_for_eight': room.waiting_for_eight, + 'eight_draw_used': room.eight_draw_used, + 'card_drawn_this_turn': room.card_drawn_this_turn + }) + + # Сохраняем игроков + player_ids = list(room.players.keys()) + for idx, (player_id, player) in enumerate(room.players.items()): + await self.db.save_player({ + 'id': player.id, + 'nickname': player.nickname, + 'ready': player.ready, + 'score': player.score + }, room_id, idx) + + # Сохраняем карты игрока + await self.db.save_player_cards(player_id, [card.to_dict() for card in player.hand]) + + # Сохраняем колоду и сброс + await self.db.save_deck(room_id, [card.to_dict() for card in room.deck]) + await self.db.save_discard_pile(room_id, [card.to_dict() for card in room.discard_pile]) + except Exception as e: + pass # print(f"Error saving room to DB: {e}") + + def create_deck(self) -> List[Card]: + suits = ['hearts', 'diamonds', 'clubs', 'spades'] + ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'] + deck = [] + + for suit in suits: + for rank in ranks: + deck.append(Card(suit=suit, rank=rank, id=str(uuid.uuid4()))) + + random.shuffle(deck) + return deck + + def can_play_card(self, card: Card, top_card: Card, chosen_suit: Optional[str] = None) -> bool: + # Дама может быть положена на любую карту + if card.rank == 'Q': + return True + + # Если была выбрана масть дамой, проверяем её + if chosen_suit: + return card.suit == chosen_suit or card.rank == top_card.rank + + # Обычная проверка: масть или номинал + return card.suit == top_card.suit or card.rank == top_card.rank + + def calculate_card_points(self, card: Card) -> int: + if card.rank in ['2', '3', '4', '5', '6', '7', '8', '9', '10']: + return int(card.rank) + elif card.rank == 'J': + return 2 + elif card.rank == 'Q': + return 40 if card.suit == 'spades' else 20 + elif card.rank == 'K': + return 4 + elif card.rank == 'A': + return 11 + return 0 + + def calculate_hand_points(self, hand: List[Card]) -> int: + return sum(self.calculate_card_points(card) for card in hand) + + def replenish_deck(self, room: Room) -> bool: + """Пополняет колоду из сброса если карты закончились. Возвращает True если перемешивание произошло""" + if len(room.deck) == 0 and len(room.discard_pile) > 1: + # Оставляем верхнюю карту на поле + top_card = room.discard_pile[-1] + # Берём все остальные карты из сброса + cards_to_shuffle = room.discard_pile[:-1] + # Перемешиваем + random.shuffle(cards_to_shuffle) + # Делаем новой колодой + room.deck = cards_to_shuffle + # Оставляем только верхнюю карту в сбросе + room.discard_pile = [top_card] + return True + return False + + async def broadcast_to_room(self, room_id: str, message: dict, exclude_player: Optional[str] = None): + room = self.rooms.get(room_id) + if not room: + return + + for ws, player_id in self.clients.items(): + if player_id in room.players and player_id != exclude_player: + try: + await ws.send(json.dumps(message)) + except: + pass + + async def send_to_player(self, player_id: str, message: dict): + for ws, pid in self.clients.items(): + if pid == player_id: + try: + await ws.send(json.dumps(message)) + except: + pass + break + + async def handle_create_room(self, ws: WebSocketServerProtocol, data: dict): + nickname = data.get('nickname') + if not nickname: + await ws.send(json.dumps({'type': 'error', 'message': 'Nickname required'})) + return + + room_id = str(uuid.uuid4()) + player_id = str(uuid.uuid4()) + + player = Player( + id=player_id, + nickname=nickname, + hand=[], + ready=False + ) + + room = Room( + id=room_id, + players={player_id: player}, + deck=[], + discard_pile=[], + current_player_index=0, + dealer_index=0, + game_started=False + ) + + self.rooms[room_id] = room + self.clients[ws] = player_id + self.player_rooms[player_id] = room_id + + # Сохраняем в БД + await self.save_room_to_db(room_id) + + await ws.send(json.dumps({ + 'type': 'room_created', + 'room': room.to_dict(), + 'player_id': player_id, + 'room_id': room_id # Отправляем room_id для URL + })) + + await self.broadcast_rooms() + + async def handle_create_bot_game(self, ws: WebSocketServerProtocol, data: dict): + nickname = data.get('nickname') + bot_count = data.get('bot_count', 1) + + if not nickname: + await ws.send(json.dumps({'type': 'error', 'message': 'Nickname required'})) + return + + if bot_count < 1 or bot_count > 3: + await ws.send(json.dumps({'type': 'error', 'message': 'Bot count must be 1-3'})) + return + + room_id = str(uuid.uuid4()) + player_id = str(uuid.uuid4()) + + # Создаём игрока + player = Player( + id=player_id, + nickname=nickname, + hand=[], + ready=False, + is_bot=False + ) + + players = {player_id: player} + + # Создаём ботов + available_bot_names = ['Челик', 'Здравый', 'Бывалый', 'Арсений', 'Кент', 'Братан', 'Мужик', 'Напарник', 'Чувакбрат'] + selected_bot_names = random.sample(available_bot_names, min(bot_count, len(available_bot_names))) + + for i in range(bot_count): + bot_id = f'bot_{uuid.uuid4()}' + bot = Player( + id=bot_id, + nickname=selected_bot_names[i], + hand=[], + ready=True, # Боты всегда готовы + is_bot=True + ) + players[bot_id] = bot + + room = Room( + id=room_id, + players=players, + deck=[], + discard_pile=[], + current_player_index=0, + dealer_index=0, + game_started=False + ) + + self.rooms[room_id] = room + self.clients[ws] = player_id + self.player_rooms[player_id] = room_id + + # Сохраняем в БД + await self.save_room_to_db(room_id) + + await ws.send(json.dumps({ + 'type': 'room_created', + 'room': room.to_dict(), + 'player_id': player_id, + 'room_id': room_id + })) + + await self.broadcast_rooms() + + async def handle_join_room(self, ws: WebSocketServerProtocol, data: dict): + room_id = data.get('room_id') + nickname = data.get('nickname') + + if not room_id or not nickname: + await ws.send(json.dumps({'type': 'error', 'message': 'Room ID and nickname required'})) + return + + room = self.rooms.get(room_id) + if not room: + await ws.send(json.dumps({'type': 'error', 'message': 'Room not found'})) + return + + if room.game_started: + await ws.send(json.dumps({'type': 'error', 'message': 'Game already started'})) + return + + # Проверяем максимальное количество игроков + if len(room.players) >= 4: + await ws.send(json.dumps({'type': 'error', 'message': 'Room is full (max 4 players)'})) + return + + # Нельзя присоединяться к комнатам, где у кого-либо уже есть очки + if any(p.score > 0 for p in room.players.values()): + await ws.send(json.dumps({'type': 'error', 'message': 'В эту комнату нельзя войти: у игроков уже есть очки'})) + return + + player_id = str(uuid.uuid4()) + player = Player( + id=player_id, + nickname=nickname, + hand=[], + ready=False + ) + + room.players[player_id] = player + self.clients[ws] = player_id + self.player_rooms[player_id] = room_id + + # Сохраняем в БД + await self.save_room_to_db(room_id) + + await ws.send(json.dumps({ + 'type': 'room_joined', + 'room': room.to_dict(), + 'player_id': player_id, + 'room_id': room_id # Отправляем room_id для URL + })) + + await self.broadcast_to_room(room_id, { + 'type': 'player_joined', + 'player': player.to_dict() + }, exclude_player=player_id) + + await self.broadcast_rooms() + + async def handle_toggle_ready(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 or player_id not in room.players: + return + + player = room.players[player_id] + player.ready = not player.ready + + await self.broadcast_to_room(room_id, { + 'type': 'player_ready_changed', + 'player_id': player_id, + 'ready': player.ready + }) + + # Подсчитываем готовых игроков + ready_count = sum(1 for p in room.players.values() if p.ready) + total_count = len(room.players) + + # Если все готовы - начинаем сразу + if total_count >= 2 and ready_count == total_count: + # Отменяем таймер если был + if room.countdown_task: + room.countdown_task.cancel() + room.countdown_task = None + room.countdown_active = False + await self.start_game(room_id) + # Если >= 2 готовы но не все - запускаем таймер + elif ready_count >= 2 and not room.countdown_active: + room.countdown_active = True + room.countdown_task = asyncio.create_task(self.start_countdown(room_id)) + # Если готовых стало меньше 2 - отменяем таймер + elif ready_count < 2 and room.countdown_active: + if room.countdown_task: + room.countdown_task.cancel() + room.countdown_task = None + room.countdown_active = False + await self.broadcast_to_room(room_id, { + 'type': 'countdown_cancelled' + }) + + async def start_countdown(self, room_id: str): + """Обратный отсчёт 24 секунды перед началом игры""" + room = self.rooms.get(room_id) + if not room: + return + + try: + for seconds in range(24, 0, -1): + await self.broadcast_to_room(room_id, { + 'type': 'countdown_tick', + 'seconds': seconds + }) + await asyncio.sleep(1) + + # Таймер закончился - начинаем игру только с готовыми игроками + room.countdown_active = False + room.countdown_task = None + + # Удаляем неготовых игроков + ready_players = {pid: p for pid, p in room.players.items() if p.ready} + if len(ready_players) >= 2: + room.players = ready_players + await self.start_game(room_id) + else: + # Недостаточно готовых игроков + await self.broadcast_to_room(room_id, { + 'type': 'countdown_cancelled' + }) + except asyncio.CancelledError: + # Таймер был отменён + pass + + async def make_bot_move(self, room_id: str, bot_id: str): + """Бот делает ход""" + await asyncio.sleep(1.5) # Задержка для реалистичности + + room = self.rooms.get(room_id) + if not room or not room.game_started: + return + + # Проверяем что бот еще в комнате + if bot_id not in room.players: + return + + bot = room.players[bot_id] + if not bot.is_bot: + return # Это не бот + + player_ids = list(room.players.keys()) + + # Проверяем что индекс в пределах массива + if room.current_player_index >= len(player_ids): + return + + if player_ids[room.current_player_index] != bot_id: + return # Не ход бота + top_card = room.discard_pile[-1] if room.discard_pile else None + + if not top_card: + return + + # Находим подходящие карты + playable_cards = [] + for card in bot.hand: + # Для восьмёрки: можно играть двойку из руки ИЛИ любую карту из 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) + elif self.can_play_card(card, top_card, room.chosen_suit, room.waiting_for_eight): + playable_cards.append(card) + + # Если есть подходящая карта - играем с приоритетом + if playable_cards: + # Приоритет: 7, 6, 8, A (специальные карты) + priority_ranks = ['7', '6', '8', 'A'] + priority_cards = [c for c in playable_cards if c.rank in priority_ranks] + + if priority_cards: + # Есть специальные карты - выбираем случайную из них + card_to_play = random.choice(priority_cards) + elif not room.waiting_for_eight: + # Нет специальных карт и не восьмёрка - выбираем карту с максимальными очками + # Сортируем по очкам (от больших к меньшим) + playable_cards.sort(key=lambda c: self.calculate_card_points(c), reverse=True) + # Берём карты с максимальными очками (может быть несколько одинаковых) + max_points = self.calculate_card_points(playable_cards[0]) + max_point_cards = [c for c in playable_cards if self.calculate_card_points(c) == max_points] + card_to_play = random.choice(max_point_cards) + else: + # Восьмёрка - выбираем случайную + card_to_play = random.choice(playable_cards) + + # Если дама - выбираем масть умно + chosen_suit = None + if card_to_play.rank == 'Q': + # Приоритет: масти с сильными картами (7, 6, 8, A) + priority_ranks = ['7', '6', '8', 'A'] + priority_cards = [c for c in bot.hand if c.id != card_to_play.id and c.rank in priority_ranks] + + if priority_cards: + # Есть сильные карты - выбираем случайную масть из них + suits_with_priority = list(set(c.suit for c in priority_cards)) + chosen_suit = random.choice(suits_with_priority) + else: + # Нет сильных карт - выбираем любую масть из руки + suits_in_hand = list(set(c.suit for c in bot.hand if c.id != card_to_play.id)) + chosen_suit = random.choice(suits_in_hand) if suits_in_hand else random.choice(['hearts', 'diamonds', 'clubs', 'spades']) + + # Играем карту (используем существующую логику) + await self.handle_play_card(None, { + 'card_id': card_to_play.id, + 'chosen_suit': chosen_suit + }, bot_id=bot_id, room_id=room_id) + else: + # Нет подходящих карт - берём карту из колоды + if room.card_drawn_this_turn: + # Карта уже взята ранее - просто пропускаем ход + await self.handle_skip_turn(None, {}, bot_id=bot_id, room_id=room_id) + return + + # Берём карту + await self.handle_draw_card(None, {}, bot_id=bot_id, room_id=room_id) + + # Обновляем данные комнаты после draw + room = self.rooms.get(room_id) + if not room: + return + + # Для обычного draw вызываем бота снова + # Для восьмёрки вызов происходит в handle_draw_card + if not room.waiting_for_eight: + await asyncio.sleep(1) + await self.make_bot_move(room_id, bot_id) + + def can_play_card(self, card: Card, top_card: Card, chosen_suit: str, waiting_for_eight: bool) -> bool: + """Проверяет можно ли сыграть карту""" + if waiting_for_eight: + return card.rank == '2' + + if card.rank == 'Q': + return True + + if chosen_suit: + return card.suit == chosen_suit or card.rank == top_card.rank + + return card.suit == top_card.suit or card.rank == top_card.rank + + async def start_game(self, room_id: str): + room = self.rooms.get(room_id) + if not room: + return + + room.game_started = True + room.deck = self.create_deck() + + # Раздаём по 5 карт каждому игроку + for player in room.players.values(): + player.hand = [room.deck.pop() for _ in range(5)] + + # Снимаем одну карту на поле + first_card = room.deck.pop() + room.discard_pile = [first_card] + + # Выбираем дилера: проигравший в прошлой игре или случайно + player_ids = list(room.players.keys()) + if room.last_loser_id and room.last_loser_id in room.players: + # Дилер - проигравший в прошлой игре + room.dealer_index = player_ids.index(room.last_loser_id) + else: + # Первая игра или проигравший вышел - выбираем случайно + room.dealer_index = random.randint(0, len(player_ids) - 1) + room.current_player_index = (room.dealer_index + 1) % len(player_ids) + + # Обрабатываем специальные карты при старте + next_player = room.players[player_ids[room.current_player_index]] + forced_draw_player_id = None + forced_draw_count = 0 + + if first_card.rank == '6': + # Следующий игрок берёт 1 карту + forced_draw_player_id = player_ids[room.current_player_index] + forced_draw_count = 1 + self.replenish_deck(room) + if room.deck: + next_player.hand.append(room.deck.pop()) + # Пропускаем его ход + room.current_player_index = (room.current_player_index + 1) % len(player_ids) + + elif first_card.rank == '7': + # Следующий игрок берёт 2 карты + forced_draw_player_id = player_ids[room.current_player_index] + forced_draw_count = 2 + for _ in range(2): + self.replenish_deck(room) + if room.deck: + next_player.hand.append(room.deck.pop()) + # Пропускаем его ход + room.current_player_index = (room.current_player_index + 1) % len(player_ids) + + elif first_card.rank == '8': + # Следующий игрок должен будет брать карты + room.waiting_for_eight = True + room.eight_draw_used = False + + elif first_card.rank == 'A': + # Следующий игрок пропускает ход + room.current_player_index = (room.current_player_index + 1) % len(player_ids) + + elif first_card.rank == 'Q': + # Дилер выбирает масть (автоматически выбираем случайную) + suits = ['hearts', 'diamonds', 'clubs', 'spades'] + room.chosen_suit = random.choice(suits) + + # Сохраняем в БД асинхронно + asyncio.create_task(self.save_room_to_db(room_id)) + + # Отправляем каждому игроку его карты + for player_id, player in room.players.items(): + # Отправляем ID карт взятых из колоды на восьмёрку (только текущему игроку) + eight_drawn_card_ids = [] + if player_id == player_ids[room.current_player_index] and hasattr(room, 'eight_drawn_cards'): + eight_drawn_card_ids = list(room.eight_drawn_cards) + + message = { + 'type': 'game_started', + 'hand': [card.to_dict() for card in player.hand], + 'top_card': first_card.to_dict(), + 'current_player': player_ids[room.current_player_index], + 'dealer': player_ids[room.dealer_index], + 'players': [p.to_dict() for p in room.players.values()], + 'deck_count': len(room.deck), + 'chosen_suit': room.chosen_suit, + 'waiting_for_eight': room.waiting_for_eight, + 'eight_draw_used': room.eight_draw_used, + 'eight_drawn_cards': eight_drawn_card_ids + } + + # Добавляем информацию о принудительном взятии карт (6 или 7) + if forced_draw_player_id and forced_draw_count > 0: + message['forced_draw_player_id'] = forced_draw_player_id + message['forced_draw_player_nickname'] = room.players[forced_draw_player_id].nickname + message['forced_draw_count'] = forced_draw_count + + await self.send_to_player(player_id, message) + + await self.broadcast_rooms() + + # Если первый игрок - бот, запускаем его ход + first_player_id = player_ids[room.current_player_index] + if room.players[first_player_id].is_bot: + asyncio.create_task(self.make_bot_move(room_id, first_player_id)) + + async def handle_play_card(self, ws: WebSocketServerProtocol, data: dict, bot_id: str = None, room_id: str = None): + # Для ботов используем переданные параметры + if bot_id and room_id: + player_id = bot_id + else: + 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 or not room.game_started: + return + + player_ids = list(room.players.keys()) + if player_ids[room.current_player_index] != player_id: + if ws: # Только для реальных игроков + await ws.send(json.dumps({'type': 'error', 'message': 'Not your turn'})) + return + + card_id = data.get('card_id') + chosen_suit = data.get('chosen_suit') # Для дамы + + player = room.players[player_id] + card = next((c for c in player.hand if c.id == card_id), None) + + if not card: + if ws: # Только для реальных игроков + await ws.send(json.dumps({'type': 'error', 'message': 'Card not found'})) + return + + if not room.discard_pile: + if ws: + await ws.send(json.dumps({'type': 'error', 'message': 'No cards on table'})) + return + + top_card = room.discard_pile[-1] + + # Проверка на восьмёрку + if room.waiting_for_eight: + # Можно положить: + # 1. Любую двойку из руки + # 2. Карты взятые из колоды (двойка, дама или та же масть) + is_drawn_card = hasattr(room, 'eight_drawn_cards') and card.id in room.eight_drawn_cards + + if card.rank != '2' and not is_drawn_card: + await ws.send(json.dumps({'type': 'error', 'message': 'Must play a 2 or draw cards'})) + return + + room.waiting_for_eight = False + room.eight_draw_used = False + if hasattr(room, 'eight_drawn_cards'): + room.eight_drawn_cards.clear() + else: + # Обычная проверка + if not self.can_play_card(card, top_card, room.chosen_suit, room.waiting_for_eight): + if ws: # Только для реальных игроков + await ws.send(json.dumps({'type': 'error', 'message': 'Cannot play this card'})) + return + + # Убираем карту из руки и кладём на стол + player.hand.remove(card) + room.discard_pile.append(card) + + # Сбрасываем chosen_suit только если кладём НЕ даму + # Для дамы масть будет установлена ниже + if card.rank != 'Q': + room.chosen_suit = None + + # Обработка специальных карт ПЕРЕД проверкой победы + next_player_index = (room.current_player_index + 1) % len(player_ids) + forced_draw_player_id = None + forced_draw_count = 0 + + if card.rank == '6': + # Следующий игрок берёт 1 карту и пропускает ход + next_player = room.players[player_ids[next_player_index]] + forced_draw_player_id = player_ids[next_player_index] + self.replenish_deck(room) + if room.deck: + next_player.hand.append(room.deck.pop()) + forced_draw_count = 1 + next_player_index = (next_player_index + 1) % len(player_ids) + + elif card.rank == '7': + # Следующий игрок берёт 2 карты и пропускает ход + next_player = room.players[player_ids[next_player_index]] + forced_draw_player_id = player_ids[next_player_index] + cards_drawn = 0 + for _ in range(2): + self.replenish_deck(room) + if room.deck: + next_player.hand.append(room.deck.pop()) + cards_drawn += 1 + forced_draw_count = cards_drawn + next_player_index = (next_player_index + 1) % len(player_ids) + + # Проверяем победу ПЕРЕД обработкой остальных специальных карт + # Если игрок выиграл на 8, A или Q - игра заканчивается без дополнительных эффектов + if len(player.hand) == 0: + await self.handle_game_end(room_id, player_id) + return + + elif card.rank == 'A': + # Следующий игрок пропускает ход + next_player_index = (next_player_index + 1) % len(player_ids) + + elif card.rank == '8': + # Следующий игрок должен брать карты или положить двойку + room.waiting_for_eight = True + room.eight_draw_used = False + + elif card.rank == 'Q': + # Игрок выбирает масть + if chosen_suit: + room.chosen_suit = chosen_suit + + room.current_player_index = next_player_index + room.card_drawn_this_turn = False # Сбрасываем флаг для следующего игрока + + # Сохраняем в БД асинхронно (не ждём) + asyncio.create_task(self.save_room_to_db(room_id)) + + # Отправляем обновление всем игрокам параллельно + tasks = [] + for pid, p in room.players.items(): + # Отправляем ID карт взятых из колоды на восьмёрку (только текущему игроку) + eight_drawn_card_ids = [] + if pid == player_ids[room.current_player_index] and hasattr(room, 'eight_drawn_cards'): + eight_drawn_card_ids = list(room.eight_drawn_cards) + + message = { + 'type': 'card_played', + 'player_id': player_id, + 'player_nickname': room.players[player_id].nickname, + 'card': card.to_dict(), + 'top_card': room.discard_pile[-1].to_dict(), + 'current_player': player_ids[room.current_player_index], + 'hand': [c.to_dict() for c in p.hand], + 'players': [pl.to_dict() for pl in room.players.values()], + 'deck_count': len(room.deck), + 'chosen_suit': room.chosen_suit, + 'waiting_for_eight': room.waiting_for_eight, + 'eight_draw_used': room.eight_draw_used, + 'card_drawn_this_turn': False, + 'eight_drawn_cards': eight_drawn_card_ids + } + + # Добавляем информацию о принудительном взятии карт (6 или 7) + if forced_draw_player_id and forced_draw_count > 0: + message['forced_draw_player_id'] = forced_draw_player_id + message['forced_draw_player_nickname'] = room.players[forced_draw_player_id].nickname + message['forced_draw_count'] = forced_draw_count + + tasks.append(self.send_to_player(pid, message)) + + # Отправляем все сообщения одновременно + await asyncio.gather(*tasks) + + # Если следующий игрок - бот, делаем его ход + next_player_id = player_ids[room.current_player_index] + if room.players[next_player_id].is_bot: + asyncio.create_task(self.make_bot_move(room_id, next_player_id)) + + async def handle_draw_card(self, ws: WebSocketServerProtocol, data: dict, bot_id: str = None, room_id: str = None): + # Для ботов используем переданные параметры + if bot_id and room_id: + player_id = bot_id + else: + 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 or not room.game_started: + return + + player_ids = list(room.players.keys()) + if player_ids[room.current_player_index] != player_id: + if ws: # Только для реальных игроков + await ws.send(json.dumps({'type': 'error', 'message': 'Not your turn'})) + return + + player = room.players[player_id] + + # Обработка восьмёрки + if room.waiting_for_eight: + # Проверяем использовал ли игрок уже взятие на восьмёрку + if room.eight_draw_used: + if ws: + await ws.send(json.dumps({ + 'type': 'error', + 'message': 'Вы уже использовали взятие карт на восьмёрку' + })) + return + + # Помечаем что взятие использовано + room.eight_draw_used = True + + # Игрок тянет карты из колоды пока не попадётся подходящая + drawn_cards = [] + drawn_card_ids = [] # ID карт взятых из колоды + deck_was_shuffled = False + max_iterations = 1000 # Защита от бесконечного цикла + iterations = 0 + + while iterations < max_iterations: + iterations += 1 + + # Пытаемся пополнить колоду из сброса если она пустая + shuffled = self.replenish_deck(room) + if shuffled: + deck_was_shuffled = True + + # Если колода пустая и перемешивание не помогло + # (в игре осталась только 1 карта на столе и больше нет карт нигде) + if not room.deck: + # Это критическая ситуация - карты закончились совсем + # Игрок не может взять карту, пропускаем ход + break + + # Проверяем что есть верхняя карта на столе + if not room.discard_pile: + break + + card = room.deck.pop() + player.hand.append(card) + drawn_cards.append(card) + drawn_card_ids.append(card.id) + + top_card = room.discard_pile[-1] + # Из колоды подходят: + # 1. Любая двойка (2) + # 2. Любая дама (Q) + # 3. Любая восьмёрка (8) + # 4. Карта той же масти что и восьмёрка + if (card.rank == '2' or + card.rank == 'Q' or + card.rank == '8' or + card.suit == top_card.suit): + # Нашли подходящую карту - выходим + break + + # Если не подошла - продолжаем цикл + # При необходимости колода перемешается на следующей итерации + + # НЕ сбрасываем waiting_for_eight - он сбросится когда игрок сыграет карту + # НЕ устанавливаем card_drawn_this_turn - это автоматическое взятие + + # Сохраняем ID карт взятых из колоды + if not hasattr(room, 'eight_drawn_cards'): + room.eight_drawn_cards = set() + room.eight_drawn_cards.update(drawn_card_ids) + + # Если не нашли подходящую карту, пропускаем ход + if not drawn_cards: + room.waiting_for_eight = False + room.eight_draw_used = False + room.eight_drawn_cards.clear() + room.current_player_index = (room.current_player_index + 1) % len(player_ids) + else: + last_card = drawn_cards[-1] + # Проверяем последнюю взятую карту + if room.discard_pile: + top_card = room.discard_pile[-1] + card_matches = (last_card.rank == '2' or + last_card.rank == 'Q' or + last_card.rank == '8' or + last_card.suit == top_card.suit) + else: + card_matches = False + + if not card_matches: + room.waiting_for_eight = False + room.eight_draw_used = False + room.eight_drawn_cards.clear() + room.current_player_index = (room.current_player_index + 1) % len(player_ids) + + # Если колода была перемешана во время взятия карт на восьмёрку + if deck_was_shuffled: + for pid in room.players.keys(): + await self.send_to_player(pid, { + 'type': 'deck_shuffled', + 'log_message': 'Колода перемешана' + }) + else: + # Обычное взятие карты (можно взять даже если есть чем ходить) + if room.card_drawn_this_turn: + await ws.send(json.dumps({'type': 'error', 'message': 'Вы уже взяли карту в этом ходу'})) + return + + deck_shuffled = self.replenish_deck(room) + + if not room.deck: + await ws.send(json.dumps({'type': 'error', 'message': 'Колода пуста'})) + return + + card = room.deck.pop() + player.hand.append(card) + drawn_cards = [card] # Инициализируем для использования в логировании + room.card_drawn_this_turn = True + + # Если колода была перемешана, отправляем лог всем + if deck_shuffled: + for pid in room.players.keys(): + await self.send_to_player(pid, { + 'type': 'deck_shuffled', + 'log_message': 'Колода перемешана' + }) + + # Сохраняем в БД асинхронно (не ждём) + asyncio.create_task(self.save_room_to_db(room_id)) + + # Отправляем обновление (ход НЕ переходит автоматически) + for pid, p in room.players.items(): + # Отправляем ID карт взятых из колоды на восьмёрку (только текущему игроку) + eight_drawn_card_ids = [] + if pid == player_id and hasattr(room, 'eight_drawn_cards'): + eight_drawn_card_ids = list(room.eight_drawn_cards) + + await self.send_to_player(pid, { + 'type': 'card_drawn', + 'player_id': player_id, + 'player_nickname': room.players[player_id].nickname, + 'cards_count': len(drawn_cards), + 'current_player': player_ids[room.current_player_index], + 'hand': [c.to_dict() for c in p.hand], + 'players': [pl.to_dict() for pl in room.players.values()], + 'deck_count': len(room.deck), + 'waiting_for_eight': room.waiting_for_eight, + 'eight_draw_used': room.eight_draw_used, + 'card_drawn_this_turn': room.card_drawn_this_turn, + 'chosen_suit': room.chosen_suit, + 'top_card': room.discard_pile[-1].to_dict() if room.discard_pile else None, + 'eight_drawn_cards': eight_drawn_card_ids + }) + + # Если это был бот и он взял карты на восьмёрку, вызываем его ход снова + # Для обычного draw вызов происходит в make_bot_move + if bot_id and room.waiting_for_eight: + # Бот взял карты на восьмёрку и должен сыграть одну из них + async def delayed_bot_move(): + await asyncio.sleep(1) + await self.make_bot_move(room_id, bot_id) + asyncio.create_task(delayed_bot_move()) + + async def handle_game_end(self, room_id: str, winner_id: str): + room = self.rooms.get(room_id) + if not room: + return + + # Проверяем последнюю карту победителя (дама даёт бонус) + winning_card = room.discard_pile[-1] if room.discard_pile else None + queen_bonus = 0 + if winning_card and winning_card.rank == 'Q': + if winning_card.suit == 'spades': + queen_bonus = -40 # Пиковая дама + else: + queen_bonus = -20 # Любая другая дама + + # Подсчитываем очки для всех игроков кроме победителя + results = [] + for player_id, player in room.players.items(): + if player_id != winner_id: + points = self.calculate_hand_points(player.hand) + player.score += points + results.append({ + 'player_id': player_id, + 'nickname': player.nickname, + 'points': points, + 'total_score': player.score, + 'hand': [c.to_dict() for c in player.hand] + }) + else: + # Победитель получает бонус за даму + player.score += queen_bonus + results.append({ + 'player_id': player_id, + 'nickname': player.nickname, + 'points': queen_bonus, + 'total_score': player.score, + 'hand': [], # Победитель без карт + 'queen_bonus': queen_bonus # Для отображения на клиенте + }) + + # Находим проигравшего в этой раздаче (игрок с максимальными очками в раздаче) + # Это тот, кто набрал больше всего очков в этой конкретной игре, не победитель + round_loser_id = None + max_round_points = -1 + for result in results: + if result['player_id'] != winner_id and result['points'] > max_round_points: + max_round_points = result['points'] + round_loser_id = result['player_id'] + + # Обрабатываем специальные случаи с очками + players_to_kick = [] # Игроки с >101 очком + for player_id, player in room.players.items(): + if player.score == 101: + # Ровно 101 - обнуляем очки + player.score = 0 + elif player.score > 101: + # Больше 101 - игрок вылетает + players_to_kick.append(player_id) + + # Сохраняем ID проигравшего в раздаче для следующей игры (если он не вылетает) + if round_loser_id and round_loser_id not in players_to_kick: + room.last_loser_id = round_loser_id + else: + # Если проигравший вылетает, выбираем случайно в следующий раз + room.last_loser_id = None + + # Последняя карта победителя (верхняя в discard_pile) + winning_card = room.discard_pile[-1].to_dict() if room.discard_pile else None + + # Отправляем результаты + for player_id in room.players.keys(): + await self.send_to_player(player_id, { + 'type': 'game_ended', + 'winner_id': winner_id, + 'winning_card': winning_card, + 'results': results + }) + + # Удаляем игроков с >101 очком + if players_to_kick: + # Проверяем: если осталось меньше 2 игроков - закрываем комнату + remaining_players = len(room.players) - len(players_to_kick) + + if remaining_players < 2: + # Закрываем комнату полностью + # Определяем победителя (игрок с минимальными очками среди оставшихся) + final_winner_id = None + min_score = float('inf') + for player_id, player in room.players.items(): + if player_id not in players_to_kick and player.score < min_score: + min_score = player.score + final_winner_id = player_id + + # Отправляем финальное сообщение о победе + for player_id in room.players.keys(): + await self.send_to_player(player_id, { + 'type': 'final_winner', + 'winner_id': final_winner_id, + 'winner_nickname': room.players[final_winner_id].nickname if final_winner_id else '', + 'kicked_players': [room.players[pid].nickname for pid in players_to_kick] + }) + + # Удаляем комнату из памяти и БД + await self.db.delete_room(room_id) + if room_id in self.rooms: + del self.rooms[room_id] + + # Очищаем player_rooms для всех игроков + for player_id in list(room.players.keys()): + if player_id in self.player_rooms: + del self.player_rooms[player_id] + + await self.broadcast_rooms() + return + else: + # Удаляем проигравших игроков из комнаты + for player_id in players_to_kick: + player_nickname = room.players[player_id].nickname + del room.players[player_id] + if player_id in self.player_rooms: + del self.player_rooms[player_id] + + # Уведомляем всех о вылете игрока + await self.broadcast_to_room(room_id, { + 'type': 'player_kicked', + 'player_id': player_id, + 'player_nickname': player_nickname, + 'reason': 'Набрал больше 101 очка' + }) + + # Сбрасываем комнату для новой игры + room.game_started = False + for player in room.players.values(): + player.hand = [] + # Боты всегда готовы, реальные игроки - нет + player.ready = player.is_bot + room.deck = [] + room.discard_pile = [] + room.chosen_suit = None + room.waiting_for_eight = False + room.eight_draw_used = False + + # Сохраняем обновлённые очки в БД асинхронно + asyncio.create_task(self.save_room_to_db(room_id)) + + # Отправляем обновление комнаты всем игрокам + for player_id in room.players.keys(): + await self.send_to_player(player_id, { + 'type': 'room_updated', + 'room': room.to_dict() + }) + + await self.broadcast_rooms() + + async def handle_skip_turn(self, ws: WebSocketServerProtocol, data: dict, bot_id: str = None, room_id: str = None): + # Для ботов используем переданные параметры + if bot_id and room_id: + player_id = bot_id + else: + 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 or not room.game_started: + return + + player_ids = list(room.players.keys()) + if player_ids[room.current_player_index] != player_id: + if ws: # Только для реальных игроков + await ws.send(json.dumps({'type': 'error', 'message': 'Not your turn'})) + return + + # Нельзя пропустить ход при восьмёрке + if room.waiting_for_eight: + if ws: # Только для реальных игроков + await ws.send(json.dumps({'type': 'error', 'message': 'Нельзя пропустить ход на восьмёрке'})) + return + + # Можно пропустить ход только если карта уже была взята + if not room.card_drawn_this_turn: + if ws: # Только для реальных игроков + await ws.send(json.dumps({'type': 'error', 'message': 'Сначала возьмите карту из колоды'})) + return + + # Пропускаем ход + room.current_player_index = (room.current_player_index + 1) % len(player_ids) + room.card_drawn_this_turn = False + + # Сохраняем в БД асинхронно (не ждём) + asyncio.create_task(self.save_room_to_db(room_id)) + + # Отправляем обновление с логом + for pid, p in room.players.items(): + # Отправляем ID карт взятых из колоды на восьмёрку (только текущему игроку) + eight_drawn_card_ids = [] + if pid == player_ids[room.current_player_index] and hasattr(room, 'eight_drawn_cards'): + eight_drawn_card_ids = list(room.eight_drawn_cards) + + await self.send_to_player(pid, { + 'type': 'turn_skipped', + 'player_id': player_id, + 'player_nickname': room.players[player_id].nickname, + 'current_player': player_ids[room.current_player_index], + 'hand': [c.to_dict() for c in p.hand], + 'players': [pl.to_dict() for pl in room.players.values()], + 'card_drawn_this_turn': False, + 'chosen_suit': room.chosen_suit, + 'waiting_for_eight': room.waiting_for_eight, + 'eight_draw_used': room.eight_draw_used, + 'eight_drawn_cards': eight_drawn_card_ids, + 'top_card': room.discard_pile[-1].to_dict() if room.discard_pile else None, + 'log_message': f"{room.players[player_id].nickname} пропустил ход" + }) + + # Если следующий игрок - бот, делаем его ход + next_player_id = player_ids[room.current_player_index] + if room.players[next_player_id].is_bot: + asyncio.create_task(self.make_bot_move(room_id, next_player_id)) + + async def handle_reconnect(self, ws: WebSocketServerProtocol, data: dict): + """Переподключение игрока по player_id""" + player_id = data.get('player_id') + room_id = data.get('room_id') + + # print(f"Reconnect attempt: player_id={player_id}, room_id={room_id}") + + if not player_id or not room_id: + await ws.send(json.dumps({'type': 'error', 'message': 'Player ID and Room ID required'})) + return + + # Проверяем существование комнаты + if room_id not in self.rooms: + # print(f"Room {room_id} not in memory, loading from DB...") + # Пытаемся загрузить из БД + await self.load_room(room_id) + + 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'})) + return + + # print(f"Room {room_id} loaded, players: {list(room.players.keys())}") + + # Проверяем существование игрока + 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'})) + return + + # Переподключаем игрока + self.clients[ws] = player_id + self.player_rooms[player_id] = room_id + + player = room.players[player_id] + + if room.game_started: + # Отправляем состояние игры + player_ids = list(room.players.keys()) + await ws.send(json.dumps({ + 'type': 'game_started', + 'hand': [card.to_dict() for card in player.hand], + 'top_card': room.discard_pile[-1].to_dict() if room.discard_pile else None, + 'current_player': player_ids[room.current_player_index], + 'dealer': player_ids[room.dealer_index], + 'players': [p.to_dict() for p in room.players.values()], + 'deck_count': len(room.deck), + 'chosen_suit': room.chosen_suit, + 'waiting_for_eight': room.waiting_for_eight, + 'eight_draw_used': room.eight_draw_used, + 'card_drawn_this_turn': room.card_drawn_this_turn + })) + else: + # Отправляем состояние комнаты + await ws.send(json.dumps({ + 'type': 'room_joined', + 'room': room.to_dict(), + 'player_id': player_id, + 'room_id': room_id + })) + + async def broadcast_rooms(self): + # Очищаем комнаты где только боты + self.cleanup_bot_only_rooms() + + available_rooms = [] + for room in self.rooms.values(): + # Проверяем что игра не началась и все очки 0 + 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: + available_rooms.append(room.to_dict()) + + message = json.dumps({ + 'type': 'rooms_list', + 'rooms': available_rooms + }) + + for ws in self.clients.keys(): + try: + await ws.send(message) + except: + pass + + async def handle_chat_message(self, ws: WebSocketServerProtocol, data: dict): + """Обработка сообщения чата""" + player_id = self.clients.get(ws) + # print(f"Chat message from player_id: {player_id}") + if not player_id: + # print("No player_id found") + return + + room_id = self.player_rooms.get(player_id) + # print(f"Player in room: {room_id}") + if not room_id: + await ws.send(json.dumps({'type': 'error', 'message': 'You are not in a room'})) + return + + room = self.rooms.get(room_id) + if not room: + # print(f"Room {room_id} not found") + return + + player = room.players.get(player_id) + if not player: + # print(f"Player {player_id} not in room players") + return + + message_text = data.get('message', '').strip() + # print(f"Message text: {message_text}") + if not message_text: + return + + # Ограничиваем длину сообщения + message_text = message_text[:200] + + # Отправляем сообщение всем игрокам в комнате + chat_message = f"{player.nickname}: {message_text}" + # print(f"Sending chat message to {len(room.players)} players: {chat_message}") + + for pid in room.players.keys(): + await self.send_to_player(pid, { + 'type': 'chat_message', + 'message': chat_message, + 'player_id': player_id, + 'player_nickname': player.nickname + }) + + async def handle_message(self, ws: WebSocketServerProtocol, message: str): + try: + data = json.loads(message) + msg_type = data.get('type') + + if msg_type == 'create_room': + await self.handle_create_room(ws, data) + elif msg_type == 'create_bot_game': + await self.handle_create_bot_game(ws, data) + elif msg_type == 'join_room': + await self.handle_join_room(ws, data) + elif msg_type == 'toggle_ready': + await self.handle_toggle_ready(ws, data) + elif msg_type == 'play_card': + await self.handle_play_card(ws, data) + elif msg_type == 'draw_card': + await self.handle_draw_card(ws, data) + elif msg_type == 'skip_turn': + await self.handle_skip_turn(ws, data) + elif msg_type == 'reconnect': + await self.handle_reconnect(ws, data) + elif msg_type == 'get_rooms': + await self.broadcast_rooms() + elif msg_type == 'chat_message': + await self.handle_chat_message(ws, data) + + except json.JSONDecodeError: + await ws.send(json.dumps({'type': 'error', 'message': 'Invalid JSON'})) + except Exception as e: + print(f"Error handling message: {e}") + await ws.send(json.dumps({'type': 'error', 'message': str(e)})) + + async def handle_client(self, ws: WebSocketServerProtocol): + # Добавляем временный ID для нового клиента чтобы он получал обновления + temp_id = str(uuid.uuid4()) + self.clients[ws] = temp_id + + try: + # Отправляем список комнат новому клиенту + available_rooms = [ + room.to_dict() for room in self.rooms.values() + if not room.game_started and all(p.score == 0 for p in room.players.values()) + ] + await ws.send(json.dumps({ + 'type': 'rooms_list', + 'rooms': available_rooms + })) + + async for message in ws: + await self.handle_message(ws, message) + except Exception as e: + print(f"Client error: {e}") + finally: + # Удаляем клиента при отключении + player_id = self.clients.get(ws) + if player_id: + # Если это реальный игрок (не временный ID) + if player_id != temp_id: + room_id = self.player_rooms.get(player_id) + if room_id: + room = self.rooms.get(room_id) + if room and player_id in room.players: + 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: + await self.broadcast_to_room(room_id, { + 'type': 'player_left', + 'player_id': player_id + }) + + await self.broadcast_rooms() + + if player_id in self.player_rooms: + del self.player_rooms[player_id] + + # Всегда удаляем из clients + if ws in self.clients: + del self.clients[ws] + +async def http_handler(request): + """Serve static files with SPA fallback""" + path = request.path + if path == '/': + path = '/index.html' + + file_path = os.path.join(os.path.dirname(__file__), 'client' + path) + + # Пытаемся отдать файл + if os.path.exists(file_path) and os.path.isfile(file_path): + with open(file_path, 'rb') as f: + content = f.read() + + # Определяем MIME тип + content_type = 'text/html' + if path.endswith('.css'): + content_type = 'text/css' + elif path.endswith('.js'): + content_type = 'application/javascript' + elif path.endswith('.png'): + content_type = 'image/png' + elif path.endswith('.jpg') or path.endswith('.jpeg'): + content_type = 'image/jpeg' + elif path.endswith('.svg'): + content_type = 'image/svg+xml' + elif path.endswith('.md'): + content_type = 'text/markdown' + + # Заголовки для отключения кэша (для разработки) + headers = { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + } + + return web.Response(body=content, content_type=content_type, headers=headers, charset='utf-8') + + # SPA fallback - только для HTML маршрутов (не для статических файлов) + # Если путь содержит расширение файла - это статический файл, возвращаем 404 + if '.' in path.split('/')[-1]: + print(f"Static file not found: {path}") + return web.Response(status=404, text='Not Found') + + # Для маршрутов без расширения (например /room/xxx) отдаём index.html + print(f"SPA fallback for: {path}") + index_path = os.path.join(os.path.dirname(__file__), 'client/index.html') + if os.path.exists(index_path): + with open(index_path, 'rb') as f: + content = f.read() + + headers = { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + } + + return web.Response(body=content, content_type='text/html', headers=headers, charset='utf-8') + + return web.Response(status=404, text='Not Found') + +async def main(clear_rooms=False): + game_server = GameServer() + + # Подключаемся к PostgreSQL + print("Connecting to PostgreSQL...") + await game_server.db.connect() + print("PostgreSQL connected successfully!") + + # Если указан флаг --clearrooms, удаляем все комнаты + if clear_rooms: + print("Clearing all rooms from database...") + room_ids = await game_server.db.get_all_rooms() + for room_id in room_ids: + await game_server.db.delete_room(room_id) + print(f"Deleted {len(room_ids)} rooms from database") + else: + # Очищаем старые комнаты + await game_server.cleanup_old_rooms() + + # Загружаем существующие комнаты из БД + await game_server.load_rooms_from_db() + + # Запускаем задачу периодической очистки + cleanup_task = asyncio.create_task(game_server.start_cleanup_task()) + + # WebSocket сервер + async with serve(game_server.handle_client, "0.0.0.0", 8765): + print("WebSocket server started on ws://0.0.0.0:8765") + + # HTTP сервер для статики + app = web.Application() + app.router.add_get('/{path:.*}', http_handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '0.0.0.0', 8080) + await site.start() + + print("HTTP server started on http://0.0.0.0:8080") + print("Open https://game.ruwk.ru in your browser") + print("Automatic cleanup task started (runs every 6 hours)") + + await asyncio.Future() # run forever + +if __name__ == "__main__": + import sys + + # Проверяем аргументы командной строки + clear_rooms = '--clearrooms' in sys.argv + + if clear_rooms: + print("Starting with --clearrooms flag: all rooms will be deleted") + + asyncio.run(main(clear_rooms=clear_rooms)) diff --git a/setup_postgres.sql b/setup_postgres.sql new file mode 100644 index 0000000..f8272af --- /dev/null +++ b/setup_postgres.sql @@ -0,0 +1,23 @@ +-- Создание базы данных и пользователя для игры +-- Выполните эти команды от имени postgres пользователя + +-- Создание базы данных +CREATE DATABASE czechdb; + +-- Создание пользователя +CREATE USER czech WITH PASSWORD 'password'; + +-- Выдача прав +GRANT ALL PRIVILEGES ON DATABASE czechdb TO czech; + +-- Подключитесь к базе card_game +\c czechdb + +-- Выдача прав на схему public +GRANT ALL ON SCHEMA public TO czech; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO czech; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO czech; + +-- Права по умолчанию для будущих таблиц +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO czech; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO czech; diff --git a/ПРАВИЛА_ИГРЫ.md b/ПРАВИЛА_ИГРЫ.md new file mode 100644 index 0000000..953ff80 --- /dev/null +++ b/ПРАВИЛА_ИГРЫ.md @@ -0,0 +1,145 @@ +# Правила игры "Чешский дурак" + +## 🎯 Цель игры + +Избавиться от всех карт в руке первым. Игрок, который первым выложит все свои карты, побеждает в раунде. Остальные игроки получают штрафные очки за оставшиеся карты. + +## 👥 Количество игроков + +От 2 до 4 игроков. + +## 🃏 Колода + +Стандартная колода из 52 карт (без джокеров). + +## 🎲 Начало игры + +1. Каждому игроку раздаётся по **5 карт** +2. Одна карта кладётся в центр стола (открытая карта) +3. Остальные карты образуют колоду для взятия +4. **Дилер** определяется случайно в первой игре +5. Ход начинается с игрока **слева от дилера** (по часовой стрелке) + +## 🎮 Как ходить + +На свой ход игрок должен положить карту, которая подходит к верхней карте на столе: +- **По масти** (♥️ на ♥️, ♠️ на ♠️, и т.д.) +- **По рангу** (7 на 7, К на К, и т.д.) + +### Если нет подходящей карты: +1. Нажмите кнопку **"Взять"** - возьмите одну карту из колоды +2. Если взятая карта подходит - можете её сыграть +3. Если не подходит - нажмите **"Нету"** (пропуск хода) + +## 🎴 Специальные карты + +### 🔴 Шестёрка (6) +- Следующий игрок **берёт 1 карту** из колоды +- Его ход **пропускается** + +### 🔴 Семёрка (7) +- Следующий игрок **берёт 2 карты** из колоды +- Его ход **пропускается** + +### 🔴 Восьмёрка (8) +- Следующий игрок **тянет карты из колоды**, пока не попадётся: + - **Двойка (2)** - любой масти + - **Дама (Q)** - любой масти + - **Восьмёрка (8)** - любой масти + - **Карта той же масти**, что и восьмёрка на столе +- Из руки на восьмёрку можно положить **только двойку** +- Карты, взятые из колоды, можно положить если они подходят +- ⚠️ **Если игрок заканчивает на восьмёрку** - игра сразу завершается, никто не берёт карты + +### 🔴 Туз (A) +- Следующий игрок **пропускает ход** +- Карты не берёт + +### 👑 Дама (Q) +- **Универсальная карта** - можно положить на любую карту +- После игры дамы нужно **выбрать масть** для следующего хода +- Следующий игрок должен положить карту выбранной масти или того же ранга + +## 📊 Подсчёт очков + +После каждого раунда проигравшие получают очки за карты, оставшиеся в руке: + +| Карта | Очки | +|-------|------| +| A | 11 | +| K | 4 | +| Q | 20 | Пиковая 40 +| J | 2 | +| 10 | 10 | +| 9 | 9 | +| 8 | 8 | +| 7 | 7 | +| 6 | 6 | +| 5 | 5 | +| 4 | 4 | +| 3 | 3 | +| 2 | 2 | + +**Победитель раунда** получает **0 очков**. + +## 🏆 Победа и поражение + +### Условия продолжения игры: +- Игра продолжается раунд за раундом +- Очки **накапливаются** после каждого раунда +- В новом раунде **дилером становится проигравший** предыдущего раунда (кто набрал больше всех очков в раунде) + +### Специальные правила по очкам: + +#### 👑 Победа дамой +Если игрок **побеждает, положив даму** как последнюю карту: +- У него **вычитается 20 очков** от общего счёта (для любой дамы) +- Если это **пиковая дама (Q♠️)** - вычитается **40 очков** +- Счёт может стать отрицательным + +#### ⚡ Ровно 101 очко +Если игрок набирает **ровно 101 очко**, его счёт **обнуляется до 0**. + +#### ❌ Больше 101 очка +Если игрок набирает **больше 101 очка**, он **выбывает из игры**: +- При 2 игроках: второй игрок объявляется **победителем**, комната закрывается +- При 3-4 игроках: выбывший покидает комнату, остальные продолжают играть + +### 🎊 Финальная победа +Игрок, который остался последним (все остальные набрали >101), становится **абсолютным победителем**. + +## 🎯 Стратегия + +### Советы для игры: +1. **Избавляйтесь от старших карт** (тузы, короли) - они дают больше очков +2. **Держите дам** для критических моментов - они универсальны +3. **Используйте специальные карты** (6, 7, 8, A) стратегически +4. **Следите за счётом** - приближение к 101 очку опасно +5. **Помните**: на 101 очко счёт обнуляется, но 102+ = вылет! + +## 🔄 Порядок хода + +1. Ходы идут **по часовой стрелке** +2. Текущий игрок **подсвечивается** на экране +3. В свой ход можно: + - Сыграть подходящую карту + - Взять карту из колоды + - Пропустить ход (если уже взяли карту) + +## 📱 Интерфейс + +- **Ваши карты** - внизу экрана +- **Противники** - вверху и по бокам (на десктопе) или в ряд сверху (на мобильном) +- **Центр** - текущая карта на столе и колода +- **Счёт** - отображается у каждого игрока +- **Лог** - история действий в левом углу + +## ⚙️ Дополнительные правила + +- Если **колода закончилась**, сброс (кроме верхней карты) перемешивается и становится новой колодой +- Нельзя присоединиться к комнате, где у игроков уже есть очки (игра началась) +- При выходе игрока из комнаты во время игры, игра продолжается с оставшимися игроками + +--- + +**Приятной игры! 🎴**