Initial commit: Своя Игра - multiplayer quiz game
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
302
DEPLOYMENT.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# Инструкция по развертыванию на удаленном сервере
|
||||||
|
|
||||||
|
## Вариант 1: Один сервер (проще)
|
||||||
|
|
||||||
|
Если у вас один сервер и вы хотите развернуть все на одном домене:
|
||||||
|
|
||||||
|
### Требования:
|
||||||
|
- Ubuntu/Debian сервер
|
||||||
|
- Docker и Docker Compose
|
||||||
|
- Домен (например, `game.example.com`)
|
||||||
|
|
||||||
|
### Шаги:
|
||||||
|
|
||||||
|
1. **Подключитесь к серверу:**
|
||||||
|
```bash
|
||||||
|
ssh user@your-server.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Установите Docker и Docker Compose** (если еще не установлены):
|
||||||
|
```bash
|
||||||
|
# Docker
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sudo sh get-docker.sh
|
||||||
|
|
||||||
|
# Docker Compose
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install docker-compose-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Клонируйте репозиторий:**
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd my-game
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Запустите production версию:**
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Настройте nginx на сервере для проксирования:**
|
||||||
|
|
||||||
|
Создайте `/etc/nginx/sites-available/game`:
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name game.example.com;
|
||||||
|
|
||||||
|
# Клиент (фронтенд)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API и Socket.io
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Статические файлы (изображения, аудио)
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Активируйте конфигурацию:
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/game /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Настройте SSL с Let's Encrypt:**
|
||||||
|
```bash
|
||||||
|
sudo apt-get install certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d game.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Готово! Игра доступна по адресу `https://game.example.com`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Вариант 2: Отдельные порты (быстрее)
|
||||||
|
|
||||||
|
Если не хотите настраивать nginx, можно открыть порты напрямую:
|
||||||
|
|
||||||
|
1. **Запустите production версию:**
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Откройте порты в firewall:**
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 80
|
||||||
|
sudo ufw allow 3001
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Доступ:**
|
||||||
|
- Клиент: `http://your-server.com:80`
|
||||||
|
- Сервер: `http://your-server.com:3001`
|
||||||
|
|
||||||
|
**Важно:** Socket.io и изображения будут работать автоматически, так как клиент использует логику из `socket.js`:
|
||||||
|
```javascript
|
||||||
|
// Автоматически определяет хост
|
||||||
|
const serverUrl = `${window.location.protocol}//${window.location.hostname}:3001`
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Вариант 3: Два отдельных сервера/домена
|
||||||
|
|
||||||
|
Если фронтенд и бэкенд на разных серверах:
|
||||||
|
|
||||||
|
### Сервер 1 (Backend): `api.example.com`
|
||||||
|
|
||||||
|
1. **Запустите только сервер:**
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 3001:3001 \
|
||||||
|
-e MONGODB_URI=mongodb://mongodb:27017/svoya-igra \
|
||||||
|
svoya-igra-server
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Настройте CORS в `server/src/index.js`:**
|
||||||
|
```javascript
|
||||||
|
const cors = require('cors');
|
||||||
|
app.use(cors({
|
||||||
|
origin: 'https://game.example.com', // URL вашего фронтенда
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сервер 2 (Frontend): `game.example.com`
|
||||||
|
|
||||||
|
1. **Создайте `.env.production`:**
|
||||||
|
```bash
|
||||||
|
VITE_SERVER_URL=https://api.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Соберите и запустите:**
|
||||||
|
```bash
|
||||||
|
docker build -f Dockerfile -t svoya-igra-client .
|
||||||
|
docker run -d -p 80:80 svoya-igra-client
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переменные окружения для production
|
||||||
|
|
||||||
|
### Client (.env.production):
|
||||||
|
```bash
|
||||||
|
# Если API на другом домене/сервере
|
||||||
|
VITE_SERVER_URL=https://api.example.com
|
||||||
|
|
||||||
|
# Если на том же сервере, но другой порт
|
||||||
|
VITE_SERVER_URL=https://example.com:3001
|
||||||
|
|
||||||
|
# Если оставить пустым - автоопределение
|
||||||
|
VITE_SERVER_URL=
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server (.env):
|
||||||
|
```bash
|
||||||
|
PORT=3001
|
||||||
|
MONGODB_URI=mongodb://localhost:27017/svoya-igra
|
||||||
|
NODE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проверка работоспособности
|
||||||
|
|
||||||
|
После развертывания проверьте:
|
||||||
|
|
||||||
|
1. **Клиент загружается:**
|
||||||
|
```bash
|
||||||
|
curl https://game.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Сервер отвечает:**
|
||||||
|
```bash
|
||||||
|
curl https://game.example.com:3001
|
||||||
|
# или
|
||||||
|
curl https://api.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Socket.io подключается:**
|
||||||
|
Откройте DevTools → Network → WS → проверьте подключение к `socket.io`
|
||||||
|
|
||||||
|
4. **Изображения загружаются:**
|
||||||
|
Создайте игру с вопросом-картинкой и проверьте загрузку
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Обновление приложения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Остановите контейнеры
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# Обновите код
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# Пересоберите и запустите
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Мониторинг и логи
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Посмотреть логи всех сервисов
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f
|
||||||
|
|
||||||
|
# Логи конкретного сервиса
|
||||||
|
docker logs svoya-igra-client
|
||||||
|
docker logs svoya-igra-server
|
||||||
|
docker logs svoya-igra-mongodb
|
||||||
|
|
||||||
|
# Проверить статус
|
||||||
|
docker-compose -f docker-compose.prod.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Резервное копирование MongoDB
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать бэкап
|
||||||
|
docker exec svoya-igra-mongodb mongodump --out /data/backup
|
||||||
|
|
||||||
|
# Копировать бэкап на хост
|
||||||
|
docker cp svoya-igra-mongodb:/data/backup ./backup
|
||||||
|
|
||||||
|
# Восстановить
|
||||||
|
docker exec svoya-igra-mongodb mongorestore /data/backup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решение проблем
|
||||||
|
|
||||||
|
### Socket.io не подключается
|
||||||
|
|
||||||
|
**Проблема:** В DevTools видно `WebSocket connection failed`
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
1. Проверьте что порт 3001 открыт
|
||||||
|
2. Убедитесь что nginx проксирует `/socket.io/`
|
||||||
|
3. Проверьте CORS настройки на сервере
|
||||||
|
|
||||||
|
### Изображения не загружаются
|
||||||
|
|
||||||
|
**Проблема:** 404 на `/uploads/image.jpg`
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
1. Убедитесь что файлы есть в `server/public/uploads/`
|
||||||
|
2. Проверьте volume в docker-compose: `./server/public:/app/public`
|
||||||
|
3. Проверьте статическую раздачу в `server/src/index.js`
|
||||||
|
|
||||||
|
### Клиент не подключается к серверу
|
||||||
|
|
||||||
|
**Проблема:** Клиент пытается подключиться к localhost вместо домена
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
1. Проверьте `.env.production`
|
||||||
|
2. Пересоберите клиент: `docker-compose -f docker-compose.prod.yml up -d --build client`
|
||||||
|
3. Очистите кеш браузера
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Оптимизация для production
|
||||||
|
|
||||||
|
1. **Включите gzip в nginx** (уже добавлено в nginx.conf)
|
||||||
|
|
||||||
|
2. **Настройте CDN** для статических файлов (опционально)
|
||||||
|
|
||||||
|
3. **Добавьте rate limiting** в nginx для защиты от DDoS:
|
||||||
|
```nginx
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
|
|
||||||
|
location /socket.io/ {
|
||||||
|
limit_req zone=api burst=20;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Настройте PM2** для автоперезагрузки сервера при падении (опционально)
|
||||||
|
|
||||||
|
5. **Мониторинг:** используйте Grafana + Prometheus или простой uptime мониторинг
|
||||||
256
README.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# Своя игра - Multiplayer Quiz Game
|
||||||
|
|
||||||
|
Многопользовательская игра в стиле "Своя игра" с поддержкой команд и режимом реального времени.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- ✅ Создание игровых комнат с уникальными кодами
|
||||||
|
- ✅ Поддержка нескольких команд
|
||||||
|
- ✅ Два раунда с разными стоимостями вопросов (100-500 и 200-1000)
|
||||||
|
- ✅ **Рандомный выбор 5 категорий на каждый раунд без повторений**
|
||||||
|
- ✅ **Поддержка трех типов вопросов: текст, изображения, аудио**
|
||||||
|
- ✅ Таймер на ответ (60 секунд)
|
||||||
|
- ✅ Система очков (правильный ответ = +баллы, неправильный = -баллы)
|
||||||
|
- ✅ WebSocket для синхронизации в реальном времени
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Node.js + Express
|
||||||
|
- Socket.io
|
||||||
|
- JSON-хранилище вопросов
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- React + Vite
|
||||||
|
- TailwindCSS
|
||||||
|
- Socket.io-client
|
||||||
|
- React Router
|
||||||
|
|
||||||
|
## Установка и запуск
|
||||||
|
|
||||||
|
### Вариант 1: Docker (Рекомендуется) 🐳
|
||||||
|
|
||||||
|
Самый простой способ - использовать Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запустить всё одной командой
|
||||||
|
docker-compose up
|
||||||
|
|
||||||
|
# Или в фоновом режиме
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Остановить
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
После запуска:
|
||||||
|
- Frontend: http://localhost:5173
|
||||||
|
- Backend: http://localhost:3001
|
||||||
|
- MongoDB: localhost:27017
|
||||||
|
|
||||||
|
**Все зависимости и MongoDB установятся автоматически!**
|
||||||
|
|
||||||
|
#### Доступ из локальной сети (с телефона/планшета)
|
||||||
|
|
||||||
|
1. **Узнайте IP-адрес вашего компьютера:**
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
```bash
|
||||||
|
ipconfig
|
||||||
|
```
|
||||||
|
Найдите IPv4-адрес (например, `192.168.1.100`)
|
||||||
|
|
||||||
|
**Linux/Mac:**
|
||||||
|
```bash
|
||||||
|
ifconfig
|
||||||
|
# или
|
||||||
|
ip addr show
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Запустите Docker как обычно:**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Откройте на любом устройстве в сети:**
|
||||||
|
```
|
||||||
|
http://192.168.1.100:5173
|
||||||
|
```
|
||||||
|
(замените на ваш IP-адрес)
|
||||||
|
|
||||||
|
4. **Если не работает:**
|
||||||
|
- Проверьте firewall (разрешите порты 5173 и 3001)
|
||||||
|
- Убедитесь, что все устройства в одной Wi-Fi сети
|
||||||
|
- Попробуйте перезапустить Docker: `docker-compose down && docker-compose up -d`
|
||||||
|
|
||||||
|
### Вариант 2: Ручная установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установка всех зависимостей
|
||||||
|
npm run install-all
|
||||||
|
|
||||||
|
# Или вручную
|
||||||
|
npm install
|
||||||
|
cd server && npm install
|
||||||
|
cd ../client && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Настройка MongoDB
|
||||||
|
|
||||||
|
Для сохранения игр установите MongoDB:
|
||||||
|
|
||||||
|
1. Установите MongoDB: https://www.mongodb.com/try/download/community
|
||||||
|
2. Запустите MongoDB сервер
|
||||||
|
3. Создайте файл `server/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3001
|
||||||
|
MONGODB_URI=mongodb://localhost:27017/svoya-igra
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** Без MongoDB игры будут храниться только в памяти сервера и пропадут при перезагрузке страницы или сервера.
|
||||||
|
|
||||||
|
#### Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запустить всё одновременно
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Или по отдельности
|
||||||
|
npm run server # Backend на порту 3001
|
||||||
|
npm run client # Frontend на порту 5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
1. Откройте http://localhost:5173
|
||||||
|
2. Создайте новую игру или присоединитесь к существующей
|
||||||
|
3. Пригласите другие команды используя код игры
|
||||||
|
4. Начните игру - игра автоматически выберет 5 случайных категорий
|
||||||
|
5. Выбирайте вопросы и отвечайте
|
||||||
|
|
||||||
|
## Управление вопросами
|
||||||
|
|
||||||
|
### Структура JSON файла
|
||||||
|
|
||||||
|
Редактируйте `server/src/data/questions.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "unique-id",
|
||||||
|
"name": "Название категории",
|
||||||
|
"round": 1,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Ваш вопрос?",
|
||||||
|
"answer": "Правильный ответ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/photo.jpg",
|
||||||
|
"questionText": "Что изображено на фото?",
|
||||||
|
"answer": "Описание"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/song.mp3",
|
||||||
|
"questionText": "Что это за песня?",
|
||||||
|
"answer": "Название песни"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Типы вопросов
|
||||||
|
|
||||||
|
1. **Текстовые (`type: "text"`)**:
|
||||||
|
- `question` - текст вопроса
|
||||||
|
- `answer` - правильный ответ
|
||||||
|
|
||||||
|
2. **Изображения (`type: "image"`)**:
|
||||||
|
- `question` - путь к файлу (например `/images/photo.jpg`)
|
||||||
|
- `questionText` - дополнительный текст вопроса
|
||||||
|
- `answer` - правильный ответ
|
||||||
|
- Файлы размещать в: `server/public/images/`
|
||||||
|
|
||||||
|
3. **Аудио (`type: "audio"`)**:
|
||||||
|
- `question` - путь к файлу (например `/audio/song.mp3`)
|
||||||
|
- `questionText` - дополнительный текст вопроса
|
||||||
|
- `answer` - правильный ответ
|
||||||
|
- Файлы размещать в: `server/public/audio/`
|
||||||
|
|
||||||
|
### Добавление медиа файлов
|
||||||
|
|
||||||
|
1. Поместите изображения в `server/public/images/`
|
||||||
|
2. Поместите аудио в `server/public/audio/`
|
||||||
|
3. В JSON используйте относительные пути: `/images/filename.jpg` или `/audio/filename.mp3`
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
my-game/
|
||||||
|
├── server/
|
||||||
|
│ ├── public/
|
||||||
|
│ │ ├── images/ # Изображения для вопросов
|
||||||
|
│ │ └── audio/ # Аудио файлы для вопросов
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── data/
|
||||||
|
│ │ │ └── questions.json # Вопросы и категории
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ ├── Game.js # Модель игры
|
||||||
|
│ │ │ └── Question.js # Модель вопроса
|
||||||
|
│ │ ├── routes/
|
||||||
|
│ │ │ ├── games.js # API игр
|
||||||
|
│ │ │ └── questions.js # API вопросов
|
||||||
|
│ │ ├── socket/
|
||||||
|
│ │ │ └── handlers.js # WebSocket + логика выбора категорий
|
||||||
|
│ │ └── index.js # Главный файл сервера
|
||||||
|
│ └── package.json
|
||||||
|
├── client/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── GameBoard.jsx # Игровое поле
|
||||||
|
│ │ │ ├── QuestionModal.jsx # Модальное окно с медиа-поддержкой
|
||||||
|
│ │ │ └── TeamScores.jsx # Счет команд
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── Home.jsx # Главная страница
|
||||||
|
│ │ │ ├── CreateGame.jsx # Создание игры
|
||||||
|
│ │ │ ├── JoinGame.jsx # Присоединение к игре
|
||||||
|
│ │ │ └── Game.jsx # Игровая страница
|
||||||
|
│ │ ├── App.jsx
|
||||||
|
│ │ └── main.jsx
|
||||||
|
│ └── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Игровой процесс
|
||||||
|
|
||||||
|
1. **Создание игры**: Первый игрок создает игру и получает уникальный код. Система автоматически выбирает 5 случайных категорий для раунда 1.
|
||||||
|
2. **Присоединение**: Другие команды присоединяются по коду
|
||||||
|
3. **Выбор вопроса**: Команды по очереди выбирают категорию и стоимость
|
||||||
|
4. **Ответ**: Первая нажавшая кнопку "Ответить" команда отвечает
|
||||||
|
5. **Правильный ответ**: Команда получает баллы, вопрос закрывается
|
||||||
|
6. **Неправильный ответ**: Команда теряет баллы, другие могут попробовать
|
||||||
|
7. **Раунд 2**: После первого раунда выбираются новые 5 категорий для раунда 2, стоимость вопросов удваивается
|
||||||
|
|
||||||
|
## Особенности
|
||||||
|
|
||||||
|
- **Случайный выбор категорий**: В каждой игре будут разные категории
|
||||||
|
- **Без повторений**: Категории не повторяются между раундами
|
||||||
|
- **Мультимедиа**: Поддержка изображений и аудио делает игру разнообразнее
|
||||||
|
- **Простое управление**: Все через JSON файл, не нужна админ-панель
|
||||||
|
- **Персистентность**: Игры сохраняются в MongoDB и доступны после перезагрузки страницы
|
||||||
|
- **Роль ведущего**: Ведущий управляет игрой, выбирает вопросы и контролирует процесс
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
29
client/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Сборка приложения
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем package.json
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Устанавливаем зависимости
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Копируем исходный код
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Собираем production build
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production образ с nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Копируем собранное приложение
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Копируем конфигурацию nginx
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Своя игра</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
56
client/nginx.conf
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||||
|
|
||||||
|
# Проксирование Socket.io к серверу
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass http://svoya-igra-server:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проксирование API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://svoya-igra-server:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проксирование медиа (изображения, аудио, видео)
|
||||||
|
location /images/ {
|
||||||
|
proxy_pass http://svoya-igra-server:3001;
|
||||||
|
}
|
||||||
|
location /audio/ {
|
||||||
|
proxy_pass http://svoya-igra-server:3001;
|
||||||
|
}
|
||||||
|
location /video/ {
|
||||||
|
proxy_pass http://svoya-igra-server:3001;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
3257
client/package-lock.json
generated
Normal file
26
client/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "svoya-igra-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Frontend for Svoya Igra",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.1",
|
||||||
|
"socket.io-client": "^4.7.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
22
client/src/App.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||||
|
import Home from './pages/Home'
|
||||||
|
import CreateGame from './pages/CreateGame'
|
||||||
|
import JoinGame from './pages/JoinGame'
|
||||||
|
import Game from './pages/Game'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<div className="min-h-screen bg-game-blue">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/create" element={<CreateGame />} />
|
||||||
|
<Route path="/join" element={<JoinGame />} />
|
||||||
|
<Route path="/game/:gameCode" element={<Game />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
119
client/src/components/GameBoard.jsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
function GameBoard({ questions, usedQuestions, onSelectQuestion, currentRound, isHost }) {
|
||||||
|
const isQuestionUsed = (category, points, questionIndex) => {
|
||||||
|
// Ищем этот конкретный вопрос в использованных
|
||||||
|
// Сначала проверяем по questionIndex (новый метод)
|
||||||
|
const foundByIndex = usedQuestions.find(
|
||||||
|
q => q.category === category && q.points === points && q.questionIndex === questionIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundByIndex) {
|
||||||
|
console.log(`✓ Question used (by index): cat="${category}", pts=${points}, idx=${questionIndex}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для обратной совместимости: если в usedQuestions нет questionIndex,
|
||||||
|
// проверяем, сколько вопросов с такими баллами уже использовано
|
||||||
|
const usedCount = usedQuestions.filter(
|
||||||
|
q => q.category === category && q.points === points
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (usedCount === 0) return false;
|
||||||
|
|
||||||
|
// Получаем категорию и находим индекс вопроса среди вопросов с такими же баллами
|
||||||
|
const categoryData = questions.find(cat => cat.name === category);
|
||||||
|
const questionsWithSamePoints = categoryData?.questions
|
||||||
|
.map((q, idx) => ({ ...q, originalIndex: idx }))
|
||||||
|
.filter(q => q.points === points) || [];
|
||||||
|
|
||||||
|
const positionAmongSamePoints = questionsWithSamePoints.findIndex(q => q.originalIndex === questionIndex);
|
||||||
|
|
||||||
|
// Если позиция вопроса меньше количества использованных, значит он уже использован
|
||||||
|
return positionAmongSamePoints >= 0 && positionAmongSamePoints < usedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPointsForRound = (basePoints, round) => {
|
||||||
|
return round === 2 ? basePoints * 2 : basePoints
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверить сколько вопросов осталось в категории
|
||||||
|
const hasAvailableQuestions = (category) => {
|
||||||
|
return category.questions.some((q, idx) => !isQuestionUsed(category.name, q.points, idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтруем категории - показываем только те, где есть доступные вопросы
|
||||||
|
const availableCategories = questions.filter(hasAvailableQuestions)
|
||||||
|
|
||||||
|
// Лог для отладки
|
||||||
|
console.log('📋 GameBoard render:', {
|
||||||
|
totalCategories: questions.length,
|
||||||
|
availableCategories: availableCategories.length,
|
||||||
|
usedQuestions: usedQuestions
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800 p-3 md:p-6 rounded-xl shadow-xl border border-slate-700">
|
||||||
|
{!isHost && (
|
||||||
|
<div className="mb-3 md:mb-4 bg-blue-900 p-3 md:p-4 rounded-lg text-center">
|
||||||
|
<p className="text-white text-sm md:text-lg font-medium">
|
||||||
|
Ведущий выбирает вопросы. Будьте готовы отвечать!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isHost && (
|
||||||
|
<div className="mb-3 md:mb-4 bg-blue-900 p-3 md:p-4 rounded-lg text-center">
|
||||||
|
<p className="text-white text-sm md:text-lg font-bold">
|
||||||
|
Выберите вопрос
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{availableCategories.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableCategories.map((category, catIdx) => (
|
||||||
|
<div key={catIdx} className="flex flex-col md:flex-row gap-2">
|
||||||
|
{/* Название категории */}
|
||||||
|
<div className="bg-blue-900 p-2 md:p-4 rounded-lg flex items-center justify-center border border-blue-700 md:min-w-[200px]">
|
||||||
|
<h3 className="text-white font-bold text-sm md:text-lg text-center">
|
||||||
|
{category.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Вопросы по номиналам */}
|
||||||
|
<div className="grid grid-cols-5 md:flex gap-1 md:gap-2 flex-1">
|
||||||
|
{category.questions.map((question, questionIndex) => {
|
||||||
|
const displayPoints = getPointsForRound(question.points, currentRound)
|
||||||
|
const isUsed = isQuestionUsed(category.name, question.points, questionIndex)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={questionIndex}
|
||||||
|
onClick={() => !isUsed && isHost && onSelectQuestion(category.name, question.points)}
|
||||||
|
disabled={isUsed || !isHost}
|
||||||
|
className={`flex-1 p-3 md:p-6 rounded-lg font-bold text-base md:text-2xl transition-all duration-200 ${
|
||||||
|
isUsed
|
||||||
|
? 'bg-slate-700 text-gray-500 cursor-not-allowed border border-slate-600'
|
||||||
|
: isHost
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700 cursor-pointer shadow-lg border border-blue-500'
|
||||||
|
: 'bg-blue-600 text-white cursor-default opacity-60 border border-blue-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isUsed ? '—' : displayPoints}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 md:py-12 bg-slate-700 rounded-lg border border-slate-600">
|
||||||
|
<p className="text-white text-lg md:text-2xl font-bold px-4">
|
||||||
|
Все вопросы раунда {currentRound} использованы!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GameBoard
|
||||||
237
client/src/components/QuestionModal.jsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
|
||||||
|
function QuestionModal({ question, timer, onSubmitAnswer, answeringTeamName, isHost, onBuzzIn, canBuzzIn, onCloseQuestion, gameCode, socket }) {
|
||||||
|
const [showAnswer, setShowAnswer] = useState(false)
|
||||||
|
const [mediaEnded, setMediaEnded] = useState(false)
|
||||||
|
const audioRef = useRef(null)
|
||||||
|
const videoRef = useRef(null)
|
||||||
|
|
||||||
|
if (!question) return null
|
||||||
|
|
||||||
|
// Обработчик окончания проигрывания медиа
|
||||||
|
const handleMediaEnded = () => {
|
||||||
|
console.log('🎬 Media playback ended');
|
||||||
|
setMediaEnded(true);
|
||||||
|
|
||||||
|
// Отправляем событие на сервер, чтобы начался таймер
|
||||||
|
if (socket && gameCode) {
|
||||||
|
socket.emit('media-ended', { gameCode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем флаг при смене вопроса
|
||||||
|
useEffect(() => {
|
||||||
|
setMediaEnded(false);
|
||||||
|
}, [question])
|
||||||
|
|
||||||
|
const getMediaUrl = (path) => {
|
||||||
|
// Если определена переменная окружения, используем её
|
||||||
|
if (import.meta.env.VITE_SERVER_URL) {
|
||||||
|
return `${import.meta.env.VITE_SERVER_URL}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// В production используем текущий хост (Caddy проксирует на сервер)
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
return `${window.location.protocol}//${window.location.host}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// В development используем localhost
|
||||||
|
return `http://localhost:3001${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderQuestionContent = () => {
|
||||||
|
switch (question.type) {
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-900 p-4 md:p-8 rounded-lg mb-4 md:mb-6">
|
||||||
|
<img
|
||||||
|
src={getMediaUrl(question.question)}
|
||||||
|
alt="Question"
|
||||||
|
className="max-w-full max-h-64 md:max-h-96 mx-auto rounded-lg mb-4"
|
||||||
|
/>
|
||||||
|
{question.questionText && (
|
||||||
|
<p className="text-white text-base md:text-2xl text-center mt-4">
|
||||||
|
{question.questionText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'audio':
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-900 p-4 md:p-8 rounded-lg mb-4 md:mb-6">
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
src={getMediaUrl(question.question)}
|
||||||
|
className="w-full mb-4"
|
||||||
|
onEnded={handleMediaEnded}
|
||||||
|
>
|
||||||
|
Ваш браузер не поддерживает аудио элемент.
|
||||||
|
</audio>
|
||||||
|
{question.questionText && (
|
||||||
|
<p className="text-white text-base md:text-2xl text-center mt-4">
|
||||||
|
{question.questionText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!mediaEnded && (
|
||||||
|
<p className="text-yellow-400 text-sm md:text-base text-center mt-2">
|
||||||
|
⏸️ Таймер начнется после прослушивания
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'video':
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-900 p-4 md:p-8 rounded-lg mb-4 md:mb-6">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
src={getMediaUrl(question.question)}
|
||||||
|
className="max-w-full max-h-64 md:max-h-96 mx-auto rounded-lg mb-4"
|
||||||
|
onEnded={handleMediaEnded}
|
||||||
|
>
|
||||||
|
Ваш браузер не поддерживает видео элемент.
|
||||||
|
</video>
|
||||||
|
{question.questionText && (
|
||||||
|
<p className="text-white text-base md:text-2xl text-center mt-4">
|
||||||
|
{question.questionText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!mediaEnded && (
|
||||||
|
<p className="text-yellow-400 text-sm md:text-base text-center mt-2">
|
||||||
|
⏸️ Таймер начнется после просмотра
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-900 p-4 md:p-8 rounded-lg mb-4 md:mb-6">
|
||||||
|
<p className="text-white text-base md:text-2xl text-center leading-relaxed">
|
||||||
|
{question.question}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-gray-800 p-4 md:p-8 lg:p-12 rounded-lg max-w-4xl w-full max-h-screen overflow-y-auto">
|
||||||
|
<div className="text-center mb-4 md:mb-6">
|
||||||
|
<p className="text-game-gold text-base md:text-xl mb-2">{question.category}</p>
|
||||||
|
<p className="text-game-gold text-xl md:text-3xl font-bold mb-3 md:mb-4">{question.points} баллов</p>
|
||||||
|
{timer !== null && (
|
||||||
|
<p className="text-3xl md:text-5xl font-bold text-white mb-4 md:mb-6">{timer}с</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderQuestionContent()}
|
||||||
|
|
||||||
|
{/* Кнопка "Показать ответ" для ведущего */}
|
||||||
|
{isHost && (
|
||||||
|
<div className="mb-4 md:mb-6">
|
||||||
|
{!showAnswer ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAnswer(true)}
|
||||||
|
className="w-full bg-yellow-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-lg text-base md:text-xl hover:bg-yellow-700 transition"
|
||||||
|
>
|
||||||
|
Показать ответ
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="bg-green-900 p-4 md:p-6 rounded-lg">
|
||||||
|
<p className="text-white text-lg md:text-2xl font-bold text-center">
|
||||||
|
Правильный ответ:
|
||||||
|
</p>
|
||||||
|
<p className="text-game-gold text-xl md:text-3xl text-center mt-2 md:mt-3">
|
||||||
|
{question.answer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка "Ответить" для команд (до того как кто-то нажал buzz-in) */}
|
||||||
|
{canBuzzIn && (
|
||||||
|
<div className="mb-4 md:mb-6">
|
||||||
|
<button
|
||||||
|
onClick={onBuzzIn}
|
||||||
|
className="w-full bg-red-600 text-white font-bold py-4 md:py-6 px-8 md:px-12 rounded-lg text-lg md:text-2xl hover:bg-red-700 transition animate-pulse"
|
||||||
|
>
|
||||||
|
ОТВЕТИТЬ
|
||||||
|
</button>
|
||||||
|
{timer && (
|
||||||
|
<p className="text-2xl md:text-3xl text-game-gold mt-3 md:mt-4 font-bold text-center">
|
||||||
|
{timer}с
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Для ведущего: показать кто отвечает + кнопки управления */}
|
||||||
|
{isHost && answeringTeamName && (
|
||||||
|
<div className="mb-4 md:mb-6">
|
||||||
|
<div className="bg-blue-900 p-4 md:p-6 rounded-lg mb-3 md:mb-4">
|
||||||
|
<p className="text-white text-lg md:text-2xl font-bold text-center">
|
||||||
|
Отвечает команда:
|
||||||
|
</p>
|
||||||
|
<p className="text-game-gold text-xl md:text-3xl text-center mt-2">
|
||||||
|
{answeringTeamName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 md:gap-4 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmitAnswer(true)}
|
||||||
|
className="flex-1 md:flex-none bg-green-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-lg text-base md:text-xl hover:bg-green-700 transition"
|
||||||
|
>
|
||||||
|
Верно
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmitAnswer(false)}
|
||||||
|
className="flex-1 md:flex-none bg-red-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-lg text-base md:text-xl hover:bg-red-700 transition"
|
||||||
|
>
|
||||||
|
Неверно
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Для ведущего: кнопка завершить досрочно (если никто не отвечает) */}
|
||||||
|
{isHost && !answeringTeamName && (
|
||||||
|
<div className="mb-4 md:mb-6">
|
||||||
|
<button
|
||||||
|
onClick={onCloseQuestion}
|
||||||
|
className="w-full bg-orange-600 text-white font-bold py-2 md:py-3 px-4 md:px-6 rounded-lg text-sm md:text-lg hover:bg-orange-700 transition"
|
||||||
|
>
|
||||||
|
Завершить вопрос досрочно
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Сообщение для команд */}
|
||||||
|
{!isHost && answeringTeamName && (
|
||||||
|
<div className="mb-4 md:mb-6">
|
||||||
|
<p className="text-center text-white text-base md:text-xl">
|
||||||
|
Отвечает команда: <span className="text-game-gold font-bold">{answeringTeamName}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Сообщение ожидания для команд */}
|
||||||
|
{!isHost && !canBuzzIn && !answeringTeamName && (
|
||||||
|
<p className="text-center text-gray-400 text-sm md:text-lg">
|
||||||
|
Ожидание...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuestionModal
|
||||||
21
client/src/components/TeamScores.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
function TeamScores({ teams, myTeamId }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4 mb-6 md:mb-8">
|
||||||
|
{teams.map((team) => (
|
||||||
|
<div
|
||||||
|
key={team.id}
|
||||||
|
className={`p-4 md:p-6 rounded-xl border shadow-lg transition-all duration-200 ${
|
||||||
|
team.id === myTeamId
|
||||||
|
? 'bg-blue-900 border-blue-500'
|
||||||
|
: 'bg-slate-800 border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg md:text-2xl font-bold text-white mb-1 md:mb-2">{team.name}</h3>
|
||||||
|
<p className="text-2xl md:text-4xl font-bold text-blue-400">{team.score} баллов</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TeamScores
|
||||||
19
client/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #0A1E3D;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
10
client/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
119
client/src/pages/CreateGame.jsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import socket from '../socket'
|
||||||
|
|
||||||
|
function CreateGame() {
|
||||||
|
const [hostName, setHostName] = useState('')
|
||||||
|
const [gameCode, setGameCode] = useState(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Слушаем событие создания игры
|
||||||
|
socket.on('game-created', ({ gameCode }) => {
|
||||||
|
setGameCode(gameCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('game-created')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCreate = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
socket.emit('create-game', { hostName })
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyGameCode = () => {
|
||||||
|
navigator.clipboard.writeText(gameCode)
|
||||||
|
alert('Код игры скопирован!')
|
||||||
|
}
|
||||||
|
|
||||||
|
const startGame = () => {
|
||||||
|
// Сохраняем в localStorage для восстановления после перезагрузки
|
||||||
|
localStorage.setItem('gameSession', JSON.stringify({
|
||||||
|
gameCode,
|
||||||
|
isHost: true,
|
||||||
|
hostName
|
||||||
|
}))
|
||||||
|
|
||||||
|
navigate(`/game/${gameCode}`, { state: { isHost: true, hostName } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameCode) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 px-4">
|
||||||
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-blue-400 mb-6 md:mb-8">
|
||||||
|
Игра создана!
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="bg-slate-800 p-6 md:p-8 rounded-xl w-full max-w-md text-center border border-slate-700 shadow-xl">
|
||||||
|
<p className="text-gray-300 text-base md:text-lg mb-4">
|
||||||
|
Код для команд:
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-900 p-4 md:p-6 rounded-lg mb-6 border border-blue-500">
|
||||||
|
<p className="text-blue-400 text-3xl sm:text-4xl md:text-5xl font-bold tracking-wider">
|
||||||
|
{gameCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={copyGameCode}
|
||||||
|
className="w-full bg-slate-700 hover:bg-slate-600 text-white font-bold py-3 rounded-lg transition-all duration-200 mb-3 shadow-lg"
|
||||||
|
>
|
||||||
|
📋 Скопировать код
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={startGame}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-all duration-200 shadow-lg"
|
||||||
|
>
|
||||||
|
Перейти к игре
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-gray-400 text-xs md:text-sm mt-4">
|
||||||
|
Отправьте этот код командам для присоединения
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 px-4">
|
||||||
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-blue-400 mb-6 md:mb-8">
|
||||||
|
Создать новую игру
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="bg-slate-800 p-6 md:p-8 rounded-xl w-full max-w-md border border-slate-700 shadow-xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-gray-300 mb-2 font-medium text-sm md:text-base">Имя ведущего</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hostName}
|
||||||
|
onChange={(e) => setHostName(e.target.value)}
|
||||||
|
className="w-full p-3 rounded-lg bg-slate-900 text-white border border-slate-600 focus:outline-none focus:border-blue-500 transition-colors"
|
||||||
|
placeholder="Введите ваше имя"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="w-full mt-3 bg-slate-700 hover:bg-slate-600 text-white font-bold py-3 rounded-lg transition-all duration-200 shadow-lg"
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateGame
|
||||||
437
client/src/pages/Game.jsx
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import socket from '../socket'
|
||||||
|
import GameBoard from '../components/GameBoard'
|
||||||
|
import QuestionModal from '../components/QuestionModal'
|
||||||
|
import TeamScores from '../components/TeamScores'
|
||||||
|
|
||||||
|
function Game() {
|
||||||
|
const { gameCode } = useParams()
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// Попытка восстановить сессию из localStorage при перезагрузке
|
||||||
|
const getSessionData = () => {
|
||||||
|
if (location.state) {
|
||||||
|
return location.state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если state пустой, попробуем восстановить из localStorage
|
||||||
|
const savedSession = localStorage.getItem('gameSession')
|
||||||
|
if (savedSession) {
|
||||||
|
const session = JSON.parse(savedSession)
|
||||||
|
// Проверяем что это та же игра
|
||||||
|
if (session.gameCode === gameCode) {
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { teamName, isHost, hostName } = getSessionData()
|
||||||
|
|
||||||
|
const [game, setGame] = useState(null)
|
||||||
|
const [questions, setQuestions] = useState([])
|
||||||
|
const [currentQuestion, setCurrentQuestion] = useState(null)
|
||||||
|
const [timer, setTimer] = useState(null)
|
||||||
|
const [myTeam, setMyTeam] = useState(null)
|
||||||
|
const [answerMedia, setAnswerMedia] = useState(null)
|
||||||
|
const [shouldFinishAfterMedia, setShouldFinishAfterMedia] = useState(false)
|
||||||
|
|
||||||
|
// Если нет teamName и не хост, редиректим на главную
|
||||||
|
useEffect(() => {
|
||||||
|
if (!teamName && !isHost) {
|
||||||
|
console.warn('No team name or host status, redirecting to home')
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
}, [teamName, isHost, navigate])
|
||||||
|
|
||||||
|
// Подключаемся к игре (хост или команда)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHost) {
|
||||||
|
socket.emit('host-join-game', { gameCode })
|
||||||
|
} else if (teamName) {
|
||||||
|
// Команда переподключается к игре
|
||||||
|
socket.emit('join-game', { gameCode, teamName })
|
||||||
|
}
|
||||||
|
}, [isHost, teamName, gameCode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Socket обработчики
|
||||||
|
socket.on('game-updated', (updatedGame) => {
|
||||||
|
setGame(updatedGame)
|
||||||
|
|
||||||
|
if (!isHost && teamName) {
|
||||||
|
const team = updatedGame.teams.find(t => t.name === teamName)
|
||||||
|
setMyTeam(team)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем selectedCategories когда игра обновляется
|
||||||
|
if (updatedGame.selectedCategories) {
|
||||||
|
setQuestions(updatedGame.selectedCategories)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('game-started', (game) => {
|
||||||
|
setGame(game)
|
||||||
|
|
||||||
|
if (!isHost && teamName) {
|
||||||
|
const team = game.teams.find(t => t.name === teamName)
|
||||||
|
setMyTeam(team)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.selectedCategories) {
|
||||||
|
setQuestions(game.selectedCategories)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('host-game-loaded', (loadedGame) => {
|
||||||
|
setGame(loadedGame)
|
||||||
|
if (loadedGame.selectedCategories) {
|
||||||
|
setQuestions(loadedGame.selectedCategories)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('question-selected', ({ category, points, questionIndex, timer }) => {
|
||||||
|
// Ищем вопрос в selectedCategories игры по индексу
|
||||||
|
const categoryData = game?.selectedCategories?.find(cat => cat.name === category)
|
||||||
|
|
||||||
|
// Если есть questionIndex, используем его, иначе ищем по баллам (для обратной совместимости)
|
||||||
|
const questionData = questionIndex !== undefined
|
||||||
|
? categoryData?.questions[questionIndex]
|
||||||
|
: categoryData?.questions.find(q => q.points === points)
|
||||||
|
|
||||||
|
console.log(`📥 Question selected: category="${category}", points=${points}, questionIndex=${questionIndex}`, questionData);
|
||||||
|
|
||||||
|
setCurrentQuestion({ ...questionData, category, points })
|
||||||
|
setTimer(timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('timer-update', ({ timer }) => {
|
||||||
|
setTimer(timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('time-up', ({ answerMedia, shouldFinish }) => {
|
||||||
|
if (answerMedia) {
|
||||||
|
setAnswerMedia(answerMedia)
|
||||||
|
if (shouldFinish) {
|
||||||
|
setShouldFinishAfterMedia(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCurrentQuestion(null)
|
||||||
|
setTimer(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('team-buzzing', ({ teamId, teamName }) => {
|
||||||
|
console.log(`${teamName} buzzing!`)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('answer-result', ({ teamId, isCorrect, questionClosed, answerMedia, shouldFinish }) => {
|
||||||
|
console.log('Answer result:', { teamId, isCorrect, questionClosed, answerMedia, shouldFinish })
|
||||||
|
if (questionClosed) {
|
||||||
|
// Правильный ответ - закрываем вопрос
|
||||||
|
if (answerMedia) {
|
||||||
|
setAnswerMedia(answerMedia)
|
||||||
|
if (shouldFinish) {
|
||||||
|
setShouldFinishAfterMedia(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
setCurrentQuestion(null)
|
||||||
|
setTimer(null)
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
// Неправильный ответ - продолжаем игру
|
||||||
|
console.log('Incorrect answer, waiting for other teams')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('round-changed', ({ round }) => {
|
||||||
|
console.log(`Round ${round} started`)
|
||||||
|
// Принудительно обновляем состояние игры после смены раунда
|
||||||
|
// Сервер отправит game-updated сразу после, но мы сбрасываем локальное состояние
|
||||||
|
setCurrentQuestion(null)
|
||||||
|
setTimer(null)
|
||||||
|
setAnswerMedia(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('game-finished', (finishedGame) => {
|
||||||
|
setGame(finishedGame)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('game-updated')
|
||||||
|
socket.off('game-started')
|
||||||
|
socket.off('host-game-loaded')
|
||||||
|
socket.off('question-selected')
|
||||||
|
socket.off('timer-update')
|
||||||
|
socket.off('time-up')
|
||||||
|
socket.off('team-buzzing')
|
||||||
|
socket.off('answer-result')
|
||||||
|
socket.off('round-changed')
|
||||||
|
socket.off('game-finished')
|
||||||
|
}
|
||||||
|
}, [teamName, questions, isHost])
|
||||||
|
|
||||||
|
const handleStartGame = () => {
|
||||||
|
socket.emit('start-game', { gameCode })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectQuestion = (category, points) => {
|
||||||
|
// Только хост может выбирать вопросы
|
||||||
|
if (isHost) {
|
||||||
|
socket.emit('select-question', { gameCode, category, points })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBuzzIn = () => {
|
||||||
|
if (myTeam) {
|
||||||
|
socket.emit('buzz-in', { gameCode, teamId: myTeam.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitAnswer = (isCorrect) => {
|
||||||
|
// Теперь только ведущий отправляет ответ
|
||||||
|
console.log('handleSubmitAnswer called', { isHost, answeringTeam: game.answeringTeam, isCorrect })
|
||||||
|
if (isHost && game.answeringTeam) {
|
||||||
|
console.log('Sending submit-answer', { gameCode, teamId: game.answeringTeam, isCorrect })
|
||||||
|
socket.emit('submit-answer', { gameCode, teamId: game.answeringTeam, isCorrect })
|
||||||
|
} else {
|
||||||
|
console.log('Not sending: isHost=', isHost, 'answeringTeam=', game.answeringTeam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseQuestion = () => {
|
||||||
|
// Ведущий может досрочно закрыть вопрос
|
||||||
|
if (isHost) {
|
||||||
|
socket.emit('close-question', { gameCode })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFinishGame = () => {
|
||||||
|
// Ведущий завершает игру
|
||||||
|
if (isHost) {
|
||||||
|
socket.emit('finish-game', { gameCode })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseAnswerMedia = () => {
|
||||||
|
setAnswerMedia(null)
|
||||||
|
if (shouldFinishAfterMedia && isHost) {
|
||||||
|
setShouldFinishAfterMedia(false)
|
||||||
|
socket.emit('finish-game', { gameCode })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen px-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-game-gold mb-4">Загрузка...</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экран результатов
|
||||||
|
if (game.status === 'finished') {
|
||||||
|
const sortedTeams = [...game.teams].sort((a, b) => b.score - a.score)
|
||||||
|
const winner = sortedTeams[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-4 md:p-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="text-center mb-8 md:mb-12">
|
||||||
|
<h1 className="text-3xl md:text-6xl font-bold text-game-gold mb-4">ИГРА ЗАВЕРШЕНА!</h1>
|
||||||
|
<div className="bg-gradient-to-r from-yellow-400 to-yellow-600 p-6 md:p-8 rounded-lg mb-6 md:mb-8">
|
||||||
|
<p className="text-2xl md:text-4xl font-bold text-gray-900 mb-2">🏆 ПОБЕДИТЕЛЬ 🏆</p>
|
||||||
|
<p className="text-3xl md:text-5xl font-bold text-gray-900">{winner.name}</p>
|
||||||
|
<p className="text-xl md:text-3xl font-bold text-gray-900 mt-2">{winner.score} баллов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 p-4 md:p-8 rounded-lg mb-6 md:mb-8">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-white mb-4 md:mb-6 text-center">Итоговая таблица</h2>
|
||||||
|
<div className="space-y-3 md:space-y-4">
|
||||||
|
{sortedTeams.map((team, index) => (
|
||||||
|
<div
|
||||||
|
key={team.id}
|
||||||
|
className={`flex justify-between items-center p-4 md:p-6 rounded-lg ${
|
||||||
|
index === 0
|
||||||
|
? 'bg-gradient-to-r from-yellow-600 to-yellow-700'
|
||||||
|
: index === 1
|
||||||
|
? 'bg-gradient-to-r from-gray-400 to-gray-500'
|
||||||
|
: index === 2
|
||||||
|
? 'bg-gradient-to-r from-orange-600 to-orange-700'
|
||||||
|
: 'bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
|
<span className="text-2xl md:text-4xl font-bold text-white">#{index + 1}</span>
|
||||||
|
<span className="text-base md:text-2xl font-bold text-white">{team.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl md:text-3xl font-bold text-white">{team.score}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="bg-game-gold text-game-blue font-bold py-3 md:py-4 px-8 md:px-12 rounded-lg text-base md:text-xl hover:bg-yellow-500 transition"
|
||||||
|
>
|
||||||
|
Вернуться на главную
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen p-4 md:p-8 bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="text-center mb-6 md:mb-8">
|
||||||
|
<h1 className="text-3xl md:text-5xl font-bold text-blue-400 mb-3 md:mb-4">
|
||||||
|
СВОЯ ИГРА
|
||||||
|
</h1>
|
||||||
|
<div className="bg-slate-800 rounded-xl p-3 md:p-4 inline-block border border-slate-700">
|
||||||
|
<p className="text-sm md:text-xl text-gray-300">
|
||||||
|
Код игры: <span className="text-blue-400 font-bold tracking-wider">{gameCode}</span>
|
||||||
|
</p>
|
||||||
|
{isHost && (
|
||||||
|
<p className="text-xs md:text-md text-gray-400 mt-2">
|
||||||
|
Ведущий: {hostName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 md:mt-3 inline-block bg-blue-600 text-white px-4 md:px-6 py-1 md:py-2 rounded-lg font-bold shadow-lg text-sm md:text-base">
|
||||||
|
Раунд {game.currentRound}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Счет команд */}
|
||||||
|
<TeamScores teams={game.teams} myTeamId={myTeam?.id} />
|
||||||
|
|
||||||
|
{/* Статус игры */}
|
||||||
|
{game.status === 'waiting' && isHost && (
|
||||||
|
<div className="text-center my-6 md:my-8">
|
||||||
|
<p className="text-white text-base md:text-lg mb-4">
|
||||||
|
Ожидание команд... ({game.teams.length} команд(ы) присоединилось)
|
||||||
|
</p>
|
||||||
|
{game.teams.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleStartGame}
|
||||||
|
className="bg-green-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-lg text-lg md:text-xl hover:bg-green-700 transition"
|
||||||
|
>
|
||||||
|
Начать игру
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{game.teams.length === 0 && (
|
||||||
|
<p className="text-gray-400 text-sm md:text-base">
|
||||||
|
Отправьте код игры командам для присоединения
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{game.status === 'waiting' && !isHost && (
|
||||||
|
<div className="text-center my-6 md:my-8">
|
||||||
|
<p className="text-white text-base md:text-lg">
|
||||||
|
Ожидание начала игры ведущим...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Игровое поле */}
|
||||||
|
{game.status === 'playing' && (
|
||||||
|
<>
|
||||||
|
<GameBoard
|
||||||
|
questions={questions}
|
||||||
|
usedQuestions={game.usedQuestions}
|
||||||
|
onSelectQuestion={handleSelectQuestion}
|
||||||
|
currentRound={game.currentRound}
|
||||||
|
isHost={isHost}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно с вопросом */}
|
||||||
|
{currentQuestion && (
|
||||||
|
<QuestionModal
|
||||||
|
question={currentQuestion}
|
||||||
|
timer={timer}
|
||||||
|
onSubmitAnswer={handleSubmitAnswer}
|
||||||
|
answeringTeamName={game.teams.find(t => t.id === game.answeringTeam)?.name}
|
||||||
|
isHost={isHost}
|
||||||
|
onBuzzIn={handleBuzzIn}
|
||||||
|
canBuzzIn={!isHost && !game.answeringTeam && myTeam && !(game.answeredTeams || []).includes(myTeam.id)}
|
||||||
|
onCloseQuestion={handleCloseQuestion}
|
||||||
|
gameCode={gameCode}
|
||||||
|
socket={socket}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно с ответом (песня) - показываем всем */}
|
||||||
|
{answerMedia && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-gradient-to-br from-blue-900 to-slate-900 p-6 md:p-12 rounded-2xl max-w-4xl w-full border-4 border-blue-400 shadow-2xl">
|
||||||
|
<h2 className="text-2xl md:text-4xl font-bold text-blue-400 mb-6 md:mb-8 text-center">
|
||||||
|
Правильный ответ
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="bg-slate-800 p-6 md:p-8 rounded-lg mb-6 md:mb-8">
|
||||||
|
<audio
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
src={`${window.location.protocol}//${window.location.host}${answerMedia}`}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Ваш браузер не поддерживает аудио элемент.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={handleCloseAnswerMedia}
|
||||||
|
className="bg-blue-600 text-white font-bold py-3 md:py-4 px-8 md:px-12 rounded-lg text-lg md:text-xl hover:bg-blue-700 transition shadow-lg"
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка следующего раунда */}
|
||||||
|
{isHost && game.usedQuestions.length >= 25 && game.status === 'playing' && (
|
||||||
|
<div className="text-center mt-6 md:mt-8">
|
||||||
|
<button
|
||||||
|
onClick={() => socket.emit('next-round', { gameCode })}
|
||||||
|
className="bg-blue-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-lg text-lg md:text-xl hover:bg-blue-700 transition"
|
||||||
|
>
|
||||||
|
Следующий раунд
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка завершить игру для ведущего */}
|
||||||
|
{isHost && game.status === 'playing' && (
|
||||||
|
<div className="text-center mt-6 md:mt-8">
|
||||||
|
<button
|
||||||
|
onClick={handleFinishGame}
|
||||||
|
className="bg-red-600 text-white font-bold py-2 md:py-3 px-6 md:px-8 rounded-lg text-base md:text-lg hover:bg-red-700 transition"
|
||||||
|
>
|
||||||
|
Завершить игру
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Game
|
||||||
36
client/src/pages/Home.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 px-4">
|
||||||
|
<div className="text-center w-full max-w-md">
|
||||||
|
<div className="mb-8 md:mb-16">
|
||||||
|
<h1 className="text-4xl sm:text-5xl md:text-7xl font-bold text-blue-400 mb-2 md:mb-4">
|
||||||
|
СВОЯ ИГРА
|
||||||
|
</h1>
|
||||||
|
<p className="text-base md:text-lg text-gray-400">Интеллектуальная викторина</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 md:gap-4 w-full">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/create')}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-xl text-lg md:text-xl transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
Создать игру
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/join')}
|
||||||
|
className="bg-slate-700 hover:bg-slate-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-xl text-lg md:text-xl transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
Присоединиться
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home
|
||||||
99
client/src/pages/JoinGame.jsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import socket from '../socket'
|
||||||
|
|
||||||
|
function JoinGame() {
|
||||||
|
const [gameCode, setGameCode] = useState('')
|
||||||
|
const [teamName, setTeamName] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Слушаем события
|
||||||
|
const handleGameUpdated = () => {
|
||||||
|
// Сохраняем в localStorage перед навигацией
|
||||||
|
localStorage.setItem('gameSession', JSON.stringify({
|
||||||
|
gameCode: gameCode.toUpperCase(),
|
||||||
|
teamName,
|
||||||
|
isHost: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
navigate(`/game/${gameCode.toUpperCase()}`, { state: { teamName } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = ({ message }) => {
|
||||||
|
setError(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('game-updated', handleGameUpdated)
|
||||||
|
socket.on('error', handleError)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('game-updated', handleGameUpdated)
|
||||||
|
socket.off('error', handleError)
|
||||||
|
}
|
||||||
|
}, [gameCode, teamName, navigate])
|
||||||
|
|
||||||
|
const handleJoin = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
socket.emit('join-game', { gameCode: gameCode.toUpperCase(), teamName })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 px-4">
|
||||||
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-blue-400 mb-6 md:mb-8">
|
||||||
|
Присоединиться к игре
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleJoin} className="bg-slate-800 p-6 md:p-8 rounded-xl w-full max-w-md border border-slate-700 shadow-xl">
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-300 mb-2 font-medium text-sm md:text-base">Код игры</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={gameCode}
|
||||||
|
onChange={(e) => setGameCode(e.target.value.toUpperCase())}
|
||||||
|
className="w-full p-3 rounded-lg bg-slate-900 text-white border border-slate-600 focus:outline-none focus:border-blue-500 uppercase text-center text-xl md:text-2xl tracking-wider transition-colors"
|
||||||
|
placeholder="ABC123"
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-gray-300 mb-2 font-medium text-sm md:text-base">Название команды</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={teamName}
|
||||||
|
onChange={(e) => setTeamName(e.target.value)}
|
||||||
|
className="w-full p-3 rounded-lg bg-slate-900 text-white border border-slate-600 focus:outline-none focus:border-blue-500 transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-600/90 text-white rounded-lg text-sm md:text-base">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
Присоединиться
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="w-full mt-3 bg-slate-700 hover:bg-slate-600 text-white font-bold py-3 rounded-lg transition-all duration-200 shadow-lg"
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JoinGame
|
||||||
27
client/src/socket.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { io } from 'socket.io-client'
|
||||||
|
|
||||||
|
// Определяем URL сервера в зависимости от окружения
|
||||||
|
const getServerUrl = () => {
|
||||||
|
// Если определена переменная окружения, используем её
|
||||||
|
if (import.meta.env.VITE_SERVER_URL) {
|
||||||
|
return import.meta.env.VITE_SERVER_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// В production используем текущий хост (Caddy проксирует на сервер)
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
return `${window.location.protocol}//${window.location.host}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// В development используем localhost
|
||||||
|
return 'http://localhost:3001'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Единый socket для всего приложения
|
||||||
|
const socket = io(getServerUrl(), {
|
||||||
|
autoConnect: true,
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionAttempts: 5
|
||||||
|
})
|
||||||
|
|
||||||
|
export default socket
|
||||||
53
client/tailwind.config.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'game-blue': '#0A1E3D',
|
||||||
|
'game-gold': '#FFD700',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'blob': 'blob 7s infinite',
|
||||||
|
'float': 'float 3s ease-in-out infinite',
|
||||||
|
'glow': 'glow 2s ease-in-out infinite alternate',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
blob: {
|
||||||
|
'0%': {
|
||||||
|
transform: 'translate(0px, 0px) scale(1)',
|
||||||
|
},
|
||||||
|
'33%': {
|
||||||
|
transform: 'translate(30px, -50px) scale(1.1)',
|
||||||
|
},
|
||||||
|
'66%': {
|
||||||
|
transform: 'translate(-20px, 20px) scale(0.9)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'translate(0px, 0px) scale(1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
float: {
|
||||||
|
'0%, 100%': {
|
||||||
|
transform: 'translateY(0px)',
|
||||||
|
},
|
||||||
|
'50%': {
|
||||||
|
transform: 'translateY(-20px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
glow: {
|
||||||
|
'from': {
|
||||||
|
'box-shadow': '0 0 20px rgba(255, 215, 0, 0.5)',
|
||||||
|
},
|
||||||
|
'to': {
|
||||||
|
'box-shadow': '0 0 30px rgba(255, 215, 0, 0.8)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
16
client/vite.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0', // Разрешаем доступ из локальной сети
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
51
docker-compose.yml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7
|
||||||
|
container_name: svoya-igra-mongodb
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- mongodb_data:/data/db
|
||||||
|
environment:
|
||||||
|
- MONGO_INITDB_DATABASE=svoya-igra
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: ./server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: svoya-igra-server
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- PORT=3001
|
||||||
|
- MONGODB_URI=mongodb://mongodb:27017/svoya-igra
|
||||||
|
- NODE_ENV=production
|
||||||
|
- CLIENT_URL=*
|
||||||
|
depends_on:
|
||||||
|
- mongodb
|
||||||
|
volumes:
|
||||||
|
- ./server/public:/app/public
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- services_proxy
|
||||||
|
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: ./client
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: svoya-igra-client
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- services_proxy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongodb_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
services_proxy:
|
||||||
|
external: true
|
||||||
373
package-lock.json
generated
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
{
|
||||||
|
"name": "svoya-igra",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "svoya-igra",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^8.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.28.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||||
|
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk/node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wrap-ansi": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/concurrently": {
|
||||||
|
"version": "8.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
||||||
|
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"shell-quote": "^1.8.1",
|
||||||
|
"spawn-command": "0.0.2",
|
||||||
|
"supports-color": "^8.1.1",
|
||||||
|
"tree-kill": "^1.2.2",
|
||||||
|
"yargs": "^17.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"conc": "dist/bin/concurrently.js",
|
||||||
|
"concurrently": "dist/bin/concurrently.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.13.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "2.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||||
|
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.21.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.11"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/date-fns"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shell-quote": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/spawn-command": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "8.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||||
|
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tree-kill": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tree-kill": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "17.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^8.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "svoya-igra",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Multiplayer quiz game similar to Jeopardy",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||||
|
"server": "cd server && npm run dev",
|
||||||
|
"client": "cd client && npm run dev",
|
||||||
|
"install-all": "npm install && cd server && npm install && cd ../client && npm install"
|
||||||
|
},
|
||||||
|
"keywords": ["quiz", "game", "multiplayer"],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
server/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
CMD ["npm", "start"]
|
||||||
1731
server/package-lock.json
generated
Normal file
25
server/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "svoya-igra-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend server for Svoya Igra",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon src/index.js",
|
||||||
|
"start": "node src/index.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"socket.io": "^4.7.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"mongoose": "^8.0.3",
|
||||||
|
"nanoid": "^5.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
server/public/audio/aeropotri_cut.mp3
Normal file
BIN
server/public/audio/aeropotri_reverse.mp3
Normal file
BIN
server/public/audio/dasha_playlist.mp3
Normal file
BIN
server/public/audio/derji_cut.mp3
Normal file
BIN
server/public/audio/derji_reverse.mp3
Normal file
BIN
server/public/audio/grim_answer.mp3
Normal file
BIN
server/public/audio/grim_cut.mp3
Normal file
BIN
server/public/audio/kolambia_answer.mp3
Normal file
BIN
server/public/audio/kolambia_cut.mp3
Normal file
BIN
server/public/audio/korni_cut.mp3
Normal file
BIN
server/public/audio/korni_reverse.mp3
Normal file
BIN
server/public/audio/ksusha_playlist.mp3
Normal file
BIN
server/public/audio/kvartali_cut.mp3
Normal file
BIN
server/public/audio/kvartali_reverse.mp3
Normal file
BIN
server/public/audio/narkotik_answer.mp3
Normal file
BIN
server/public/audio/narkotik_cut.mp3
Normal file
BIN
server/public/audio/prichin_answer.mp3
Normal file
BIN
server/public/audio/prichin_cut.mp3
Normal file
BIN
server/public/audio/shubina_playlist.mp3
Normal file
BIN
server/public/audio/sveta_playlist.mp3
Normal file
BIN
server/public/audio/vera_answer.mp3
Normal file
BIN
server/public/audio/vera_cut.mp3
Normal file
BIN
server/public/audio/znaesh_cut.mp3
Normal file
BIN
server/public/audio/znaesh_reverse.mp3
Normal file
BIN
server/public/images/danya_4.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
server/public/images/danya_5.jpg
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
server/public/images/kto_2.jpeg
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
server/public/images/kto_4.jpg
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
server/public/images/kto_5.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
server/public/images/poezdki_5.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
server/public/images/professii_2.jpg
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
server/public/images/professii_3.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
server/public/images/professii_5.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
server/public/images/sumerki_1.jpg
Normal file
|
After Width: | Height: | Size: 347 KiB |
BIN
server/public/images/sumerki_2.jpg
Normal file
|
After Width: | Height: | Size: 631 KiB |
BIN
server/public/images/sumerki_3.jpg
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
server/public/images/sumerki_4.jpg
Normal file
|
After Width: | Height: | Size: 883 KiB |
BIN
server/public/images/sumerki_5.jpg
Normal file
|
After Width: | Height: | Size: 446 KiB |
BIN
server/public/images/univer_5.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
server/public/images/vstrechi_3.jpg
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
server/public/video/univer_4.mp4
Normal file
BIN
server/public/video/vstrechi_4.mp4
Normal file
396
server/src/data/Untitled-1.groovy
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "reverse",
|
||||||
|
"name": "Песни наоборот",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/aeropotri_reverse.mp3",
|
||||||
|
"questionText": "Что это за песня?",
|
||||||
|
"answer": "Агутин - Аэропорты",
|
||||||
|
"answerMedia": "/audio/aeropotri_cut.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/derji_reverse.mp3",
|
||||||
|
"questionText": "Что это за песня?",
|
||||||
|
"answer": "Дима Билан - Держи",
|
||||||
|
"answerMedia": "/audio/derji_cut.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/kvartali_reverse.mp3",
|
||||||
|
"questionText": "Что это за песня?",
|
||||||
|
"answer": "Звери - Районы кварталы",
|
||||||
|
"answerMedia": "/audio/kvartali_cut.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/korni_reverse.mp3",
|
||||||
|
"questionText": "Что это за песня?",
|
||||||
|
"answer": "Корни - Ты узнаешь ее",
|
||||||
|
"answerMedia": "/audio/korni_cut.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/znaesh_reverse.mp3",
|
||||||
|
"questionText": "Что это за песня?",
|
||||||
|
"answer": "Максим - Знаешь ли ты",
|
||||||
|
"answerMedia": "/audio/znaesh_cut.mp3"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prodolji",
|
||||||
|
"name": "Продолжи песню",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/kolambia_cut.mp3",
|
||||||
|
"questionText": "Что поется дальше?",
|
||||||
|
"answer": "Мы увидим сзади",
|
||||||
|
"answerMedia": "/audio/kolambia_answer.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/vera_cut.mp3",
|
||||||
|
"questionText": "Слова из чего? (продолжи)",
|
||||||
|
"answer": "Из ярких лампочек",
|
||||||
|
"answerMedia": "/audio/vera_answer.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/prichin_cut.mp3",
|
||||||
|
"questionText": "Третья причина - это?",
|
||||||
|
"answer": "Все твои слова",
|
||||||
|
"answerMedia": "/audio/prichin_answer.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/grim_cut.mp3",
|
||||||
|
"questionText": "Что дальше?",
|
||||||
|
"answer": "Силы не жалей",
|
||||||
|
"answerMedia": "/audio/grim_answer.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/narkotik_cut.mp3",
|
||||||
|
"questionText": "Что дальше?",
|
||||||
|
"answer": "Что любят гламур",
|
||||||
|
"answerMedia": "/audio/narkotik_answer.mp3"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "danya",
|
||||||
|
"name": "Вопросы от Дани",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Из-за чего Даня обиделся на Юлю Шубину на даче летом?",
|
||||||
|
"answer": "Она забыла его имя"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Баскетбол, танцы, легкая атлетика, настольный теннис. Чем из этого не занимался Даня?",
|
||||||
|
"answer": "Настольный теннис"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "text",
|
||||||
|
"question": "У какого героя мультфильма в голове были опилки?",
|
||||||
|
"answer": "Винни-пух"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/danya_4.jpg",
|
||||||
|
"questionText": "Какой патронус у МакГонагалл?",
|
||||||
|
"answer": "Кошка"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/danya_5.jpg",
|
||||||
|
"questionText": "Сколько это будет в десятичной системе?",
|
||||||
|
"answer": "25"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sumerki",
|
||||||
|
"name": "Сумерки",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/sumerki_1.jpg",
|
||||||
|
"questionText": "Как зовут главную героиню?",
|
||||||
|
"answer": "Белла"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/sumerki_2.jpg",
|
||||||
|
"questionText": "Как зовут вампира, в которого влюбляется главная героиня?",
|
||||||
|
"answer": "Эдвард Каллен"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/sumerki_3.jpg",
|
||||||
|
"questionText": "В какую игру играет семья Калленов во время грозы?",
|
||||||
|
"answer": "Бейсбол"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/sumerki_4.jpg",
|
||||||
|
"questionText": "Куда Джеймс заманивает Беллу для финальной схватки?",
|
||||||
|
"answer": "В балетную студию в Фениксе"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/sumerki_5.jpg",
|
||||||
|
"questionText": "Какую ложь придумывают, чтобы объяснить травмы Беллы после нападения Джеймса?",
|
||||||
|
"answer": "Что она упала с лестницы и вылетела через окно"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "playlist",
|
||||||
|
"name": "Чей плейлист",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/ksusha_playlist.mp3",
|
||||||
|
"questionText": "Кто выбрал эту песню?",
|
||||||
|
"answer": "Ксюша"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/dasha_playlist.mp3",
|
||||||
|
"questionText": "Кто выбрал эту песню?",
|
||||||
|
"answer": "Даша"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/sveta_playlist.mp3",
|
||||||
|
"questionText": "Кто выбрал эту песню?",
|
||||||
|
"answer": "Света"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/shubina_playlist.mp3",
|
||||||
|
"questionText": "Кто выбрал эту песню?",
|
||||||
|
"answer": "Юля Шубина"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/mosina_playlist.mp3",
|
||||||
|
"questionText": "Кто выбрал эту песню?",
|
||||||
|
"answer": "Юля Мосина (Слесарчук)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poezdki",
|
||||||
|
"name": "Наши поездки",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Сколько шагов мы прошли в Пскове? (Диапазон в 2 тысячи шагов)",
|
||||||
|
"answer": "25 тысяч шагов"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "facti",
|
||||||
|
"name": "Факты о нас",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Сколько лет Ксюша носила брекеты? (Диапазон в 2 месяца)",
|
||||||
|
"answer": "1 год и 7 месяцев (19 месяцев)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "univer",
|
||||||
|
"name": "Универ",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "text",
|
||||||
|
"question": "В каком году основан наш любимый универ?",
|
||||||
|
"answer": "1832"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stroyka",
|
||||||
|
"name": "Вопросы о стройке",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Как расшифровывается КГИОП?",
|
||||||
|
"answer": "Комитет по государственному контролю, использованию и охране памятников истории и культуры Санкт-Петербурга"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vstrechi",
|
||||||
|
"name": "Наши встречи",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Когда мы толпой ходили на БухайТанцуй? Месяц и год",
|
||||||
|
"answer": "Август 2023 года"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
407
server/src/data/questions.json
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "reverse",
|
||||||
|
"name": "Песни наоборот",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/aeropotri_reverse.mp3",
|
||||||
|
"questionText": "Что это за песня?",
|
||||||
|
"answer": "Агутин - Аэропорты",
|
||||||
|
"answerMedia": "/audio/aeropotri_cut.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/derji_reverse.mp3",
|
||||||
|
"questionText": "Что это за песня?",
|
||||||
|
"answer": "Дима Билан - Держи",
|
||||||
|
"answerMedia": "/audio/derji_cut.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/kvartali_reverse.mp3",
|
||||||
|
"questionText": "Что это за песня?",
|
||||||
|
"answer": "Звери - Районы кварталы",
|
||||||
|
"answerMedia": "/audio/kvartali_cut.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/korni_reverse.mp3",
|
||||||
|
"questionText": "Что это за песня?",
|
||||||
|
"answer": "Корни - Ты узнаешь ее",
|
||||||
|
"answerMedia": "/audio/korni_cut.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/znaesh_reverse.mp3",
|
||||||
|
"questionText": "Что это за песня?",
|
||||||
|
"answer": "Максим - Знаешь ли ты",
|
||||||
|
"answerMedia": "/audio/znaesh_cut.mp3"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prodolji",
|
||||||
|
"name": "Продолжи песню",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/kolambia_cut.mp3",
|
||||||
|
"questionText": "Что поется дальше?",
|
||||||
|
"answer": "Мы увидим сзади",
|
||||||
|
"answerMedia": "/audio/kolambia_answer.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/vera_cut.mp3",
|
||||||
|
"questionText": "Слова из чего? (продолжи)",
|
||||||
|
"answer": "Из ярких лампочек",
|
||||||
|
"answerMedia": "/audio/vera_answer.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/prichin_cut.mp3",
|
||||||
|
"questionText": "Третья причина - это?",
|
||||||
|
"answer": "Все твои слова",
|
||||||
|
"answerMedia": "/audio/prichin_answer.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/grim_cut.mp3",
|
||||||
|
"questionText": "Что дальше?",
|
||||||
|
"answer": "Силы не жалей",
|
||||||
|
"answerMedia": "/audio/grim_answer.mp3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/narkotik_cut.mp3",
|
||||||
|
"questionText": "Что дальше?",
|
||||||
|
"answer": "Что любят гламур",
|
||||||
|
"answerMedia": "/audio/narkotik_answer.mp3"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "danya",
|
||||||
|
"name": "Вопросы от Дани",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Из-за чего Даня обиделся на Юлю Шубину на даче летом?",
|
||||||
|
"answer": "Она забыла его имя"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Баскетбол, танцы, легкая атлетика, настольный теннис. Чем из этого не занимался Даня?",
|
||||||
|
"answer": "Настольный теннис"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "text",
|
||||||
|
"question": "У какого героя мультфильма в голове были опилки?",
|
||||||
|
"answer": "Винни-пух"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/danya_4.jpg",
|
||||||
|
"questionText": "Какой патронус у МакГонагалл?",
|
||||||
|
"answer": "Кошка"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/danya_5.jpg",
|
||||||
|
"questionText": "Сколько это будет в десятичной системе?",
|
||||||
|
"answer": "25"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sumerki",
|
||||||
|
"name": "Сумерки",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/sumerki_1.jpg",
|
||||||
|
"questionText": "Как зовут главную героиню?",
|
||||||
|
"answer": "Белла"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/sumerki_2.jpg",
|
||||||
|
"questionText": "Как зовут вампира, в которого влюбляется главная героиня?",
|
||||||
|
"answer": "Эдвард Каллен"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/sumerki_3.jpg",
|
||||||
|
"questionText": "В какую игру играет семья Калленов во время грозы?",
|
||||||
|
"answer": "Бейсбол"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/sumerki_4.jpg",
|
||||||
|
"questionText": "Куда Джеймс заманивает Беллу для финальной схватки?",
|
||||||
|
"answer": "В балетную студию в Фениксе"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/sumerki_5.jpg",
|
||||||
|
"questionText": "Какую ложь придумывают, чтобы объяснить травмы Беллы после нападения Джеймса?",
|
||||||
|
"answer": "Что она упала с лестницы и вылетела через окно"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "playlist",
|
||||||
|
"name": "Чей плейлист",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/ksusha_playlist.mp3",
|
||||||
|
"questionText": "Кто выбрал эту песню?",
|
||||||
|
"answer": "Ксюша (Гарик Сукачёв - Ботик)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/dasha_playlist.mp3",
|
||||||
|
"questionText": "Кто выбрал эту песню?",
|
||||||
|
"answer": "Даша (Skinny Kaleo)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/sveta_playlist.mp3",
|
||||||
|
"questionText": "Кто выбрал эту песню?",
|
||||||
|
"answer": "Света (30 Seconds to Mars - The Kill)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/shubina_playlist.mp3",
|
||||||
|
"questionText": "Кто выбрал эту песню?",
|
||||||
|
"answer": "Юля Шубина (Второй Ка - Тени от пальм)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "audio",
|
||||||
|
"question": "/audio/mosina_playlist.mp3",
|
||||||
|
"questionText": "Кто выбрал эту песню?",
|
||||||
|
"answer": "Юля Мосина (Слесарчук)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poezdki",
|
||||||
|
"name": "Наши поездки",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Сколько шагов мы прошли в Пскове? (Диапазон в 2 тысячи шагов)",
|
||||||
|
"answer": "25 тысяч шагов"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Как называются керамические плитки, которые украшают храмы Ярославля?",
|
||||||
|
"answer": "Изразцы"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Как звали проводника в поезде Санкт-Петербург -> Ярославль?",
|
||||||
|
"answer": "Альберт"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Эта знаменитая княгиня, предположительно родом из Пскова, первая на Руси приняла христианство",
|
||||||
|
"answer": "Княгиня Ольга"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/poezdki_5.jpg",
|
||||||
|
"questionText": "Сколько лет исполнилось бы самой первой Церкви Благовещения на Городище в Великом Новгороде?",
|
||||||
|
"answer": "922"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "facti",
|
||||||
|
"name": "Факты о нас",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Сколько времени жила Юля Мосина у Даши Неберовой на карантине?",
|
||||||
|
"answer": "2 месяца"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/kto_2.jpeg",
|
||||||
|
"questionText": "Кто на фото?",
|
||||||
|
"answer": "Даша Неберова"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Сколько лет Ксюша носила брекеты? (Диапазон в 2 месяца)",
|
||||||
|
"answer": "1 год и 7 месяцев (19 месяцев)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/kto_4.jpg",
|
||||||
|
"questionText": "С чем Света больше всего любила играть в детстве?",
|
||||||
|
"answer": "Животные игрушки"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/kto_5.jpg",
|
||||||
|
"questionText": "Что с ней случилось?",
|
||||||
|
"answer": "Аллергия на текилу"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "univer",
|
||||||
|
"name": "Универ",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "В каком году основан наш любимый универ?",
|
||||||
|
"answer": "1832"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "text",
|
||||||
|
"question": "По мнению Барышниковой Тамары Николаевны (препод по экологии), какой самый экологически грязный город России?",
|
||||||
|
"answer": "Норильск"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Назовите полное название предмета, на котором мы изучали AutoCAD",
|
||||||
|
"answer": "Информационные технологии графического проектирования"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/univer_5.jpg",
|
||||||
|
"questionText": "Кто это (какой предмет вел)?",
|
||||||
|
"answer": "Препод по философии (Гурьев Евгений Павлович)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "video",
|
||||||
|
"question": "/video/univer_4.mp4",
|
||||||
|
"questionText": "Чей это голос (аудиозапись)?",
|
||||||
|
"answer": "Разумнова Елена Альбертовна"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stroyka",
|
||||||
|
"name": "Профессия",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "",
|
||||||
|
"answer": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/professii_3.jpg",
|
||||||
|
"questionText": "Какой кондиционер изображен на картинке?",
|
||||||
|
"answer": "Канальный"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/professii_2.jpg",
|
||||||
|
"questionText": "Кто из глав нашей страны по своей основной профессии был строителем?",
|
||||||
|
"answer": "Б.Н. Ельцин"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Как расшифровывается КГИОП?",
|
||||||
|
"answer": "Комитет по государственному контролю, использованию и охране памятников истории и культуры Санкт-Петербурга"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/professii_5.jpg",
|
||||||
|
"questionText": "Выберите верный вариант армирования конструкции",
|
||||||
|
"answer": "4"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vstrechi",
|
||||||
|
"name": "Наши встречи",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"points": 100,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Какой предмет личной гигиены только одна из нас взяла на девичник Светы?",
|
||||||
|
"answer": "Зубная паста"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 200,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Когда мы толпой ходили на Бухай-Танцуй? Месяц и год",
|
||||||
|
"answer": "Август 2023 года"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 300,
|
||||||
|
"type": "image",
|
||||||
|
"question": "/images/vstrechi_3.jpg",
|
||||||
|
"questionText": "Что скрыто за черным квадратом и находится на столе перед Ксюшей?",
|
||||||
|
"answer": "Учебник по металлическим конструкциям"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 400,
|
||||||
|
"type": "video",
|
||||||
|
"question": "/video/vstrechi_4.mp4",
|
||||||
|
"questionText": "Продолжите крылатую фразу, которую сказала в тот вечер Даша. Для чего велась съемка?",
|
||||||
|
"answer": "Для смысла"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"points": 500,
|
||||||
|
"type": "text",
|
||||||
|
"question": "Как остановить время при встрече?",
|
||||||
|
"answer": "Вынуть батарейки из Дашиных часов в гостиной"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
30
server/src/data/Сумерки.txt
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
Песни наоборот +
|
||||||
|
Продолжи песню +
|
||||||
|
Вопросы от Дани +
|
||||||
|
Сумерки +
|
||||||
|
|
||||||
|
Угадай из чьего плейлиста
|
||||||
|
1 +
|
||||||
|
2 +
|
||||||
|
3 +
|
||||||
|
4 +
|
||||||
|
5
|
||||||
|
|
||||||
|
Поездки +
|
||||||
|
Факты о нас +
|
||||||
|
Универ +
|
||||||
|
|
||||||
|
Профессии
|
||||||
|
1
|
||||||
|
2 +
|
||||||
|
3 +
|
||||||
|
4 +
|
||||||
|
5 +
|
||||||
|
|
||||||
|
Наши встречи +
|
||||||
|
|
||||||
|
Юля М
|
||||||
|
- Плейлист
|
||||||
|
|
||||||
|
Юля Ш
|
||||||
|
- Профессии
|
||||||
68
server/src/index.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import cors from 'cors';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: process.env.CLIENT_URL || 'http://localhost:5173',
|
||||||
|
methods: ['GET', 'POST']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Статичные файлы для медиа
|
||||||
|
app.use('/images', express.static(join(__dirname, '../public/images')));
|
||||||
|
app.use('/audio', express.static(join(__dirname, '../public/audio')));
|
||||||
|
app.use('/video', express.static(join(__dirname, '../public/video')));
|
||||||
|
|
||||||
|
// MongoDB connection (optional - можно использовать JSON файлы)
|
||||||
|
const connectDB = async () => {
|
||||||
|
if (process.env.MONGODB_URI) {
|
||||||
|
try {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI);
|
||||||
|
console.log('✅ MongoDB connected');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ MongoDB not connected, using JSON storage');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ No MongoDB URI, using JSON storage');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Хранилище активных игр в памяти
|
||||||
|
const activeGames = new Map();
|
||||||
|
|
||||||
|
// Импорт роутов и сокет обработчиков
|
||||||
|
import questionsRouter from './routes/questions.js';
|
||||||
|
import gamesRouter from './routes/games.js';
|
||||||
|
import { setupSocketHandlers } from './socket/handlers.js';
|
||||||
|
|
||||||
|
app.use('/api/questions', questionsRouter);
|
||||||
|
app.use('/api/games', gamesRouter);
|
||||||
|
|
||||||
|
// WebSocket обработчики
|
||||||
|
setupSocketHandlers(io, activeGames);
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
connectDB().then(() => {
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`🚀 Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export { io, activeGames };
|
||||||
54
server/src/models/Game.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
const gameSchema = new mongoose.Schema({
|
||||||
|
gameCode: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
hostId: String,
|
||||||
|
hostName: String,
|
||||||
|
teams: [{
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
score: { type: Number, default: 0 },
|
||||||
|
players: [{
|
||||||
|
id: String
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
currentRound: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
selectedCategories: {
|
||||||
|
type: mongoose.Schema.Types.Mixed,
|
||||||
|
default: []
|
||||||
|
},
|
||||||
|
usedCategoryIds: {
|
||||||
|
type: [String],
|
||||||
|
default: []
|
||||||
|
},
|
||||||
|
usedQuestions: [{
|
||||||
|
category: String,
|
||||||
|
points: Number
|
||||||
|
}],
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['waiting', 'playing', 'finished'],
|
||||||
|
default: 'waiting'
|
||||||
|
},
|
||||||
|
currentQuestion: {
|
||||||
|
type: mongoose.Schema.Types.Mixed,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
answeringTeam: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const Game = mongoose.model('Game', gameSchema);
|
||||||
|
|
||||||
|
export default Game;
|
||||||
33
server/src/models/Question.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
const questionSchema = new mongoose.Schema({
|
||||||
|
category: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
points: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
enum: [100, 200, 300, 400, 500, 200, 400, 600, 800, 1000]
|
||||||
|
},
|
||||||
|
question: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
answer: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
round: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
enum: [1, 2],
|
||||||
|
default: 1
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const Question = mongoose.model('Question', questionSchema);
|
||||||
|
|
||||||
|
export default Question;
|
||||||
46
server/src/routes/games.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Создать новую игру
|
||||||
|
router.post('/create', (req, res) => {
|
||||||
|
try {
|
||||||
|
const gameCode = nanoid(6).toUpperCase();
|
||||||
|
const { teamName } = req.body;
|
||||||
|
|
||||||
|
const game = {
|
||||||
|
gameCode,
|
||||||
|
teams: [{
|
||||||
|
name: teamName,
|
||||||
|
score: 0,
|
||||||
|
players: []
|
||||||
|
}],
|
||||||
|
currentRound: 1,
|
||||||
|
usedQuestions: [],
|
||||||
|
status: 'waiting',
|
||||||
|
createdAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
gameCode,
|
||||||
|
game
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверить существование игры
|
||||||
|
router.get('/:gameCode', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { gameCode } = req.params;
|
||||||
|
// Проверка будет в Socket.io, т.к. игры хранятся в памяти
|
||||||
|
res.json({ exists: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
31
server/src/routes/questions.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const questionsPath = join(__dirname, '../data/questions.json');
|
||||||
|
|
||||||
|
// Helper функция для чтения вопросов
|
||||||
|
const getQuestions = () => {
|
||||||
|
if (!existsSync(questionsPath)) {
|
||||||
|
return { categories: [] };
|
||||||
|
}
|
||||||
|
const data = readFileSync(questionsPath, 'utf-8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET все категории и вопросы
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const questions = getQuestions();
|
||||||
|
res.json(questions);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
762
server/src/socket/handlers.js
Normal file
@@ -0,0 +1,762 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import Game from '../models/Game.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Функция для выбора случайных 5 категорий (из всех доступных)
|
||||||
|
const selectRandomCategories = (allCategories, usedCategoryIds) => {
|
||||||
|
const availableCategories = allCategories.filter(
|
||||||
|
cat => !usedCategoryIds.includes(cat.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Перемешиваем и берем первые 5
|
||||||
|
const shuffled = availableCategories.sort(() => Math.random() - 0.5);
|
||||||
|
return shuffled.slice(0, 5);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция проверки, закончилась ли игра
|
||||||
|
const checkGameFinished = (game, allCategories) => {
|
||||||
|
// Проверяем, остались ли доступные категории
|
||||||
|
const availableCategories = allCategories.filter(
|
||||||
|
cat => !game.usedCategoryIds.includes(cat.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Проверяем, все ли вопросы текущего раунда использованы
|
||||||
|
const totalQuestionsInRound = game.selectedCategories.reduce(
|
||||||
|
(sum, cat) => sum + cat.questions.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const allQuestionsUsed = game.usedQuestions.length >= totalQuestionsInRound;
|
||||||
|
|
||||||
|
// Игра заканчивается если:
|
||||||
|
// 1. Все вопросы текущего раунда использованы
|
||||||
|
// 2. И нет больше доступных категорий для следующего раунда
|
||||||
|
return allQuestionsUsed && availableCategories.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для отправки обновления игры (без timerInterval)
|
||||||
|
const sendGameUpdate = (io, gameCode, game) => {
|
||||||
|
const { timerInterval, ...gameToSend } = game;
|
||||||
|
io.to(gameCode).emit('game-updated', gameToSend);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupSocketHandlers = (io, activeGames) => {
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
console.log('👤 Client connected:', socket.id);
|
||||||
|
|
||||||
|
// Создание игры
|
||||||
|
socket.on('create-game', async ({ hostName }) => {
|
||||||
|
const gameCode = nanoid(6).toUpperCase();
|
||||||
|
|
||||||
|
// Загружаем все категории
|
||||||
|
const questionsPath = join(__dirname, '../data/questions.json');
|
||||||
|
const questionsData = JSON.parse(readFileSync(questionsPath, 'utf-8'));
|
||||||
|
|
||||||
|
console.log(`📚 Total categories available: ${questionsData.categories.length}`);
|
||||||
|
|
||||||
|
// Выбираем 5 случайных категорий
|
||||||
|
const selectedCategories = selectRandomCategories(questionsData.categories, []);
|
||||||
|
|
||||||
|
console.log(`🎲 Selected ${selectedCategories.length} categories for game ${gameCode}`);
|
||||||
|
|
||||||
|
const gameData = {
|
||||||
|
gameCode,
|
||||||
|
hostId: socket.id,
|
||||||
|
hostName: hostName,
|
||||||
|
teams: [],
|
||||||
|
currentRound: 1,
|
||||||
|
selectedCategories: selectedCategories,
|
||||||
|
usedCategoryIds: selectedCategories.map(cat => cat.id),
|
||||||
|
usedQuestions: [],
|
||||||
|
status: 'waiting',
|
||||||
|
currentQuestion: null,
|
||||||
|
answeringTeam: null,
|
||||||
|
timer: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сохраняем в MongoDB
|
||||||
|
try {
|
||||||
|
await Game.create(gameData);
|
||||||
|
console.log(`💾 Game ${gameCode} saved to DB`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error saving to DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
activeGames.set(gameCode, gameData);
|
||||||
|
socket.join(gameCode);
|
||||||
|
|
||||||
|
socket.emit('game-created', { gameCode, game: gameData });
|
||||||
|
console.log(`🎮 Game created: ${gameCode} by host ${hostName}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ведущий присоединяется к игре
|
||||||
|
socket.on('host-join-game', async ({ gameCode }) => {
|
||||||
|
let game = activeGames.get(gameCode);
|
||||||
|
|
||||||
|
// Если игры нет в памяти, пытаемся загрузить из БД
|
||||||
|
if (!game) {
|
||||||
|
try {
|
||||||
|
const dbGame = await Game.findOne({ gameCode }).lean();
|
||||||
|
if (dbGame) {
|
||||||
|
game = { ...dbGame, timer: null, timerInterval: null };
|
||||||
|
activeGames.set(gameCode, game);
|
||||||
|
console.log(`📥 Game ${gameCode} loaded from DB`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error loading from DB:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
socket.emit('error', { message: 'Игра не найдена' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем hostId на текущий socket
|
||||||
|
game.hostId = socket.id;
|
||||||
|
|
||||||
|
// Обновляем в БД
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate({ gameCode }, { hostId: socket.id });
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error updating DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.join(gameCode);
|
||||||
|
socket.emit('host-game-loaded', game);
|
||||||
|
|
||||||
|
console.log(`🎤 Host joined game ${gameCode}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Присоединение к игре
|
||||||
|
socket.on('join-game', async ({ gameCode, teamName }) => {
|
||||||
|
let game = activeGames.get(gameCode);
|
||||||
|
|
||||||
|
// Если игры нет в памяти, пытаемся загрузить из БД
|
||||||
|
if (!game) {
|
||||||
|
try {
|
||||||
|
const dbGame = await Game.findOne({ gameCode }).lean();
|
||||||
|
if (dbGame) {
|
||||||
|
game = { ...dbGame, timer: null, timerInterval: null };
|
||||||
|
activeGames.set(gameCode, game);
|
||||||
|
console.log(`📥 Game ${gameCode} loaded from DB for join`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error loading from DB:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
socket.emit('error', { message: 'Игра не найдена' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let team = game.teams.find(t => t.name === teamName);
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
// Создаем новую команду
|
||||||
|
team = {
|
||||||
|
id: nanoid(),
|
||||||
|
name: teamName,
|
||||||
|
score: 0,
|
||||||
|
players: []
|
||||||
|
};
|
||||||
|
game.teams.push(team);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем socket id в команду (без имени игрока)
|
||||||
|
team.players.push({ id: socket.id });
|
||||||
|
|
||||||
|
// Обновляем в БД
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate({ gameCode }, { teams: game.teams });
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error updating teams in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.join(gameCode);
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
|
||||||
|
console.log(`👥 Team ${teamName} joined game ${gameCode}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Начать игру
|
||||||
|
socket.on('start-game', async ({ gameCode }) => {
|
||||||
|
const game = activeGames.get(gameCode);
|
||||||
|
if (game) {
|
||||||
|
game.status = 'playing';
|
||||||
|
|
||||||
|
// Обновляем в БД
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate({ gameCode }, { status: 'playing' });
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error updating status in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.to(gameCode).emit('game-started', game);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Выбор вопроса
|
||||||
|
socket.on('select-question', ({ gameCode, category, points }) => {
|
||||||
|
const game = activeGames.get(gameCode);
|
||||||
|
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
// Находим категорию
|
||||||
|
const categoryData = game.selectedCategories.find(cat => cat.name === category);
|
||||||
|
|
||||||
|
// Подсчитываем, сколько вопросов с такими баллами уже использовано в этой категории
|
||||||
|
const usedCount = game.usedQuestions.filter(
|
||||||
|
q => q.category === category && q.points === points
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Находим следующий неиспользованный вопрос с такими баллами (по порядку)
|
||||||
|
const questionsWithPoints = categoryData?.questions
|
||||||
|
.map((q, idx) => ({ ...q, originalIndex: idx }))
|
||||||
|
.filter(q => q.points === points);
|
||||||
|
|
||||||
|
const questionIndex = questionsWithPoints[usedCount]?.originalIndex;
|
||||||
|
|
||||||
|
console.log(`📊 Select question: category="${category}", points=${points}, usedCount=${usedCount}, questionIndex=${questionIndex}`);
|
||||||
|
console.log(`📊 Questions with these points:`, questionsWithPoints.map(q => q.originalIndex));
|
||||||
|
|
||||||
|
if (questionIndex === undefined) {
|
||||||
|
console.log('❌ No available question found');
|
||||||
|
socket.emit('error', { message: 'Этот вопрос уже был использован' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
game.currentQuestion = { category, points, questionIndex };
|
||||||
|
console.log(`✅ Selected question:`, game.currentQuestion);
|
||||||
|
game.answeringTeam = null;
|
||||||
|
game.answeredTeams = []; // Список команд, которые уже отвечали на этот вопрос
|
||||||
|
|
||||||
|
// Получаем тип вопроса
|
||||||
|
const questionData = categoryData?.questions[questionIndex];
|
||||||
|
const questionType = questionData?.type || 'text';
|
||||||
|
|
||||||
|
game.timer = 60; // 60 секунд на ответ
|
||||||
|
game.timerStarted = false; // Флаг, запущен ли таймер
|
||||||
|
|
||||||
|
io.to(gameCode).emit('question-selected', {
|
||||||
|
category,
|
||||||
|
points,
|
||||||
|
questionIndex,
|
||||||
|
questionType,
|
||||||
|
timer: game.timer
|
||||||
|
});
|
||||||
|
|
||||||
|
// Для текстовых и картинок запускаем таймер сразу
|
||||||
|
// Для аудио и видео - ждем события 'media-ended' от клиента
|
||||||
|
if (questionType === 'text' || questionType === 'image') {
|
||||||
|
console.log(`⏱️ Starting timer immediately for ${questionType} question`);
|
||||||
|
game.timerStarted = true;
|
||||||
|
|
||||||
|
const timerInterval = setInterval(() => {
|
||||||
|
game.timer--;
|
||||||
|
io.to(gameCode).emit('timer-update', { timer: game.timer });
|
||||||
|
|
||||||
|
if (game.timer <= 0) {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
io.to(gameCode).emit('time-up');
|
||||||
|
game.currentQuestion = null;
|
||||||
|
game.answeringTeam = null;
|
||||||
|
game.answeredTeams = [];
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
game.timerInterval = timerInterval;
|
||||||
|
} else {
|
||||||
|
console.log(`⏸️ Waiting for media to end before starting timer (${questionType} question)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Событие окончания проигрывания медиа (аудио/видео)
|
||||||
|
socket.on('media-ended', ({ gameCode }) => {
|
||||||
|
const game = activeGames.get(gameCode);
|
||||||
|
|
||||||
|
if (!game || !game.currentQuestion || game.timerStarted) return;
|
||||||
|
|
||||||
|
console.log(`🎬 Media ended, starting timer now`);
|
||||||
|
game.timerStarted = true;
|
||||||
|
|
||||||
|
// Запускаем таймер
|
||||||
|
const timerInterval = setInterval(() => {
|
||||||
|
game.timer--;
|
||||||
|
io.to(gameCode).emit('timer-update', { timer: game.timer });
|
||||||
|
|
||||||
|
if (game.timer <= 0) {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
io.to(gameCode).emit('time-up');
|
||||||
|
game.currentQuestion = null;
|
||||||
|
game.answeringTeam = null;
|
||||||
|
game.answeredTeams = [];
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
game.timerInterval = timerInterval;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Нажатие кнопки "Ответить"
|
||||||
|
socket.on('buzz-in', ({ gameCode, teamId }) => {
|
||||||
|
const game = activeGames.get(gameCode);
|
||||||
|
|
||||||
|
if (!game || !game.currentQuestion || game.answeringTeam) return;
|
||||||
|
|
||||||
|
// Проверяем, не отвечала ли уже эта команда на этот вопрос
|
||||||
|
if (game.answeredTeams && game.answeredTeams.includes(teamId)) {
|
||||||
|
return; // Команда уже отвечала, игнорируем
|
||||||
|
}
|
||||||
|
|
||||||
|
game.answeringTeam = teamId;
|
||||||
|
|
||||||
|
// Останавливаем таймер
|
||||||
|
if (game.timerInterval) {
|
||||||
|
clearInterval(game.timerInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = game.teams.find(t => t.id === teamId);
|
||||||
|
io.to(gameCode).emit('team-buzzing', {
|
||||||
|
teamId,
|
||||||
|
teamName: team.name
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправляем обновление игры чтобы у ведущего появились кнопки
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверка ответа
|
||||||
|
socket.on('submit-answer', async ({ gameCode, teamId, isCorrect }) => {
|
||||||
|
const game = activeGames.get(gameCode);
|
||||||
|
|
||||||
|
console.log(`📝 Submit answer: gameCode=${gameCode}, teamId=${teamId}, isCorrect=${isCorrect}`);
|
||||||
|
|
||||||
|
if (!game || !game.currentQuestion) {
|
||||||
|
console.log('⚠️ No game or no current question');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = game.teams.find(t => t.id === teamId);
|
||||||
|
const basePoints = game.currentQuestion.points;
|
||||||
|
// Удваиваем баллы во втором раунде
|
||||||
|
const points = game.currentRound === 2 ? basePoints * 2 : basePoints;
|
||||||
|
|
||||||
|
console.log(`Team: ${team?.name}, Base Points: ${basePoints}, Actual Points (Round ${game.currentRound}): ${points}`);
|
||||||
|
|
||||||
|
// Найдем answerMedia из вопроса
|
||||||
|
const category = game.selectedCategories.find(cat => cat.name === game.currentQuestion.category);
|
||||||
|
const questionData = category?.questions.find(q => q.points === game.currentQuestion.points);
|
||||||
|
const answerMedia = questionData?.answerMedia;
|
||||||
|
|
||||||
|
if (isCorrect) {
|
||||||
|
console.log('✅ Correct answer!');
|
||||||
|
team.score += points;
|
||||||
|
game.usedQuestions.push(game.currentQuestion);
|
||||||
|
game.currentQuestion = null;
|
||||||
|
game.answeringTeam = null;
|
||||||
|
|
||||||
|
// Проверяем, закончилась ли игра
|
||||||
|
const questionsPath = join(__dirname, '../data/questions.json');
|
||||||
|
const questionsData = JSON.parse(readFileSync(questionsPath, 'utf-8'));
|
||||||
|
const isGameFinished = checkGameFinished(game, questionsData.categories);
|
||||||
|
|
||||||
|
if (isGameFinished) {
|
||||||
|
console.log(`🏁 All questions answered and no more categories.`);
|
||||||
|
|
||||||
|
// Если есть answerMedia, отправляем флаг shouldFinish вместо немедленного завершения
|
||||||
|
if (answerMedia) {
|
||||||
|
console.log('Answer media exists, delaying game finish');
|
||||||
|
// Обновляем в БД (но не меняем статус на finished)
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate(
|
||||||
|
{ gameCode },
|
||||||
|
{
|
||||||
|
teams: game.teams,
|
||||||
|
usedQuestions: game.usedQuestions,
|
||||||
|
currentQuestion: null,
|
||||||
|
answeringTeam: null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error updating in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.to(gameCode).emit('answer-result', {
|
||||||
|
teamId,
|
||||||
|
isCorrect: true,
|
||||||
|
newScore: team.score,
|
||||||
|
questionClosed: true,
|
||||||
|
answerMedia: answerMedia,
|
||||||
|
shouldFinish: true
|
||||||
|
});
|
||||||
|
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если нет answerMedia, завершаем игру сразу
|
||||||
|
game.status = 'finished';
|
||||||
|
|
||||||
|
// Обновляем в БД
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate(
|
||||||
|
{ gameCode },
|
||||||
|
{
|
||||||
|
teams: game.teams,
|
||||||
|
usedQuestions: game.usedQuestions,
|
||||||
|
currentQuestion: null,
|
||||||
|
answeringTeam: null,
|
||||||
|
status: 'finished'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error finishing game in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.to(gameCode).emit('answer-result', {
|
||||||
|
teamId,
|
||||||
|
isCorrect: true,
|
||||||
|
newScore: team.score,
|
||||||
|
questionClosed: true,
|
||||||
|
answerMedia: answerMedia
|
||||||
|
});
|
||||||
|
|
||||||
|
io.to(gameCode).emit('game-finished', game);
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем в БД
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate(
|
||||||
|
{ gameCode },
|
||||||
|
{
|
||||||
|
teams: game.teams,
|
||||||
|
usedQuestions: game.usedQuestions,
|
||||||
|
currentQuestion: null,
|
||||||
|
answeringTeam: null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error updating answer in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Sending answer-result (correct) and game-updated');
|
||||||
|
io.to(gameCode).emit('answer-result', {
|
||||||
|
teamId,
|
||||||
|
isCorrect: true,
|
||||||
|
newScore: team.score,
|
||||||
|
questionClosed: true,
|
||||||
|
answerMedia: answerMedia
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('❌ Incorrect answer!');
|
||||||
|
team.score -= points;
|
||||||
|
|
||||||
|
// Добавляем команду в список ответивших
|
||||||
|
if (!game.answeredTeams) {
|
||||||
|
game.answeredTeams = [];
|
||||||
|
}
|
||||||
|
game.answeredTeams.push(teamId);
|
||||||
|
|
||||||
|
game.answeringTeam = null;
|
||||||
|
|
||||||
|
// Обновляем в БД
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate(
|
||||||
|
{ gameCode },
|
||||||
|
{ teams: game.teams, answeringTeam: null }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error updating answer in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Sending answer-result (incorrect)');
|
||||||
|
io.to(gameCode).emit('answer-result', {
|
||||||
|
teamId,
|
||||||
|
isCorrect: false,
|
||||||
|
newScore: team.score,
|
||||||
|
questionClosed: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяем, остались ли команды которые могут ответить
|
||||||
|
const remainingTeams = game.teams.filter(t => !game.answeredTeams.includes(t.id));
|
||||||
|
console.log(`Remaining teams: ${remainingTeams.length}, Timer: ${game.timer}`);
|
||||||
|
|
||||||
|
if (remainingTeams.length === 0 || game.timer <= 0) {
|
||||||
|
console.log('No more teams or time up - closing question');
|
||||||
|
// Все команды ответили неправильно или время вышло
|
||||||
|
game.usedQuestions.push(game.currentQuestion);
|
||||||
|
game.currentQuestion = null;
|
||||||
|
game.answeredTeams = [];
|
||||||
|
|
||||||
|
// Проверяем, закончилась ли игра
|
||||||
|
const questionsPath = join(__dirname, '../data/questions.json');
|
||||||
|
const questionsData = JSON.parse(readFileSync(questionsPath, 'utf-8'));
|
||||||
|
const isGameFinished = checkGameFinished(game, questionsData.categories);
|
||||||
|
|
||||||
|
if (isGameFinished) {
|
||||||
|
console.log(`🏁 All questions answered and no more categories. Finishing game ${gameCode}`);
|
||||||
|
game.status = 'finished';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate(
|
||||||
|
{ gameCode },
|
||||||
|
{
|
||||||
|
usedQuestions: game.usedQuestions,
|
||||||
|
currentQuestion: null,
|
||||||
|
answeredTeams: [],
|
||||||
|
status: 'finished'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error finishing game in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.to(gameCode).emit('time-up');
|
||||||
|
io.to(gameCode).emit('game-finished', game);
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
io.to(gameCode).emit('time-up');
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
} else {
|
||||||
|
console.log('Resuming timer for other teams');
|
||||||
|
// Возобновляем таймер для других команд
|
||||||
|
const timerInterval = setInterval(() => {
|
||||||
|
game.timer--;
|
||||||
|
io.to(gameCode).emit('timer-update', { timer: game.timer });
|
||||||
|
|
||||||
|
if (game.timer <= 0) {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
io.to(gameCode).emit('time-up');
|
||||||
|
game.currentQuestion = null;
|
||||||
|
game.answeredTeams = [];
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
game.timerInterval = timerInterval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Досрочное закрытие вопроса ведущим
|
||||||
|
socket.on('close-question', async ({ gameCode }) => {
|
||||||
|
const game = activeGames.get(gameCode);
|
||||||
|
|
||||||
|
if (!game || !game.currentQuestion) return;
|
||||||
|
|
||||||
|
// Найдем answerMedia из вопроса перед закрытием
|
||||||
|
const category = game.selectedCategories.find(cat => cat.name === game.currentQuestion.category);
|
||||||
|
const questionData = category?.questions.find(q => q.points === game.currentQuestion.points);
|
||||||
|
const answerMedia = questionData?.answerMedia;
|
||||||
|
|
||||||
|
// Останавливаем таймер
|
||||||
|
if (game.timerInterval) {
|
||||||
|
clearInterval(game.timerInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем вопрос в использованные (чтобы он пропал с доски)
|
||||||
|
game.usedQuestions.push(game.currentQuestion);
|
||||||
|
|
||||||
|
// Закрываем вопрос
|
||||||
|
game.currentQuestion = null;
|
||||||
|
game.answeringTeam = null;
|
||||||
|
game.timer = null;
|
||||||
|
|
||||||
|
// Проверяем, закончилась ли игра
|
||||||
|
const questionsPath = join(__dirname, '../data/questions.json');
|
||||||
|
const questionsData = JSON.parse(readFileSync(questionsPath, 'utf-8'));
|
||||||
|
const isGameFinished = checkGameFinished(game, questionsData.categories);
|
||||||
|
|
||||||
|
if (isGameFinished) {
|
||||||
|
console.log(`🏁 All questions answered and no more categories.`);
|
||||||
|
|
||||||
|
// Если есть answerMedia, отправляем флаг shouldFinish вместо немедленного завершения
|
||||||
|
if (answerMedia) {
|
||||||
|
console.log('Answer media exists, delaying game finish');
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate(
|
||||||
|
{ gameCode },
|
||||||
|
{
|
||||||
|
usedQuestions: game.usedQuestions,
|
||||||
|
currentQuestion: null,
|
||||||
|
answeringTeam: null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error updating in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.to(gameCode).emit('time-up', { answerMedia: answerMedia, shouldFinish: true });
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если нет answerMedia, завершаем игру сразу
|
||||||
|
game.status = 'finished';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate(
|
||||||
|
{ gameCode },
|
||||||
|
{
|
||||||
|
usedQuestions: game.usedQuestions,
|
||||||
|
currentQuestion: null,
|
||||||
|
answeringTeam: null,
|
||||||
|
status: 'finished'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error finishing game in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.to(gameCode).emit('time-up', { answerMedia: answerMedia });
|
||||||
|
io.to(gameCode).emit('game-finished', game);
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем в БД
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate(
|
||||||
|
{ gameCode },
|
||||||
|
{
|
||||||
|
usedQuestions: game.usedQuestions,
|
||||||
|
currentQuestion: null,
|
||||||
|
answeringTeam: null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error updating close-question in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.to(gameCode).emit('time-up', { answerMedia: answerMedia });
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Завершение игры
|
||||||
|
socket.on('finish-game', async ({ gameCode }) => {
|
||||||
|
const game = activeGames.get(gameCode);
|
||||||
|
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
game.status = 'finished';
|
||||||
|
|
||||||
|
// Обновляем в БД
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate({ gameCode }, { status: 'finished' });
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error updating game status in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.to(gameCode).emit('game-finished', game);
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
|
||||||
|
console.log(`🏁 Game ${gameCode} finished`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Переход к следующему раунду
|
||||||
|
socket.on('next-round', async ({ gameCode }) => {
|
||||||
|
const game = activeGames.get(gameCode);
|
||||||
|
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
// Загружаем все категории
|
||||||
|
const questionsPath = join(__dirname, '../data/questions.json');
|
||||||
|
const questionsData = JSON.parse(readFileSync(questionsPath, 'utf-8'));
|
||||||
|
|
||||||
|
// Выбираем 5 новых случайных категорий (исключая использованные)
|
||||||
|
const selectedCategories = selectRandomCategories(
|
||||||
|
questionsData.categories,
|
||||||
|
game.usedCategoryIds
|
||||||
|
);
|
||||||
|
|
||||||
|
// Проверяем, есть ли еще доступные категории
|
||||||
|
if (selectedCategories.length === 0) {
|
||||||
|
// Категории закончились - завершаем игру автоматически
|
||||||
|
console.log(`🏁 No more categories available. Finishing game ${gameCode}`);
|
||||||
|
game.status = 'finished';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate(
|
||||||
|
{ gameCode },
|
||||||
|
{ status: 'finished' }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error finishing game in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.to(gameCode).emit('game-finished', game);
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Увеличиваем номер раунда
|
||||||
|
const nextRound = game.currentRound + 1;
|
||||||
|
|
||||||
|
game.currentRound = nextRound;
|
||||||
|
game.selectedCategories = selectedCategories;
|
||||||
|
game.usedCategoryIds = [...game.usedCategoryIds, ...selectedCategories.map(cat => cat.id)];
|
||||||
|
game.usedQuestions = [];
|
||||||
|
game.currentQuestion = null;
|
||||||
|
|
||||||
|
// Обновляем в БД
|
||||||
|
try {
|
||||||
|
await Game.findOneAndUpdate(
|
||||||
|
{ gameCode },
|
||||||
|
{
|
||||||
|
currentRound: nextRound,
|
||||||
|
selectedCategories: selectedCategories,
|
||||||
|
usedCategoryIds: game.usedCategoryIds,
|
||||||
|
usedQuestions: [],
|
||||||
|
currentQuestion: null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ Error updating round in DB:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🎮 Game ${gameCode}: Starting round ${nextRound} with ${selectedCategories.length} categories`);
|
||||||
|
io.to(gameCode).emit('round-changed', { round: nextRound });
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отключение
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('👤 Client disconnected:', socket.id);
|
||||||
|
|
||||||
|
// Удаляем игрока из всех игр
|
||||||
|
activeGames.forEach((game, gameCode) => {
|
||||||
|
// Проверяем, является ли отключившийся хостом
|
||||||
|
const isHost = game.hostId === socket.id;
|
||||||
|
|
||||||
|
if (isHost) {
|
||||||
|
// Если отключился хост, игра завершается
|
||||||
|
activeGames.delete(gameCode);
|
||||||
|
io.to(gameCode).emit('host-disconnected');
|
||||||
|
console.log(`🗑️ Game ${gameCode} deleted (host disconnected)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем игрока из команд
|
||||||
|
game.teams.forEach(team => {
|
||||||
|
team.players = team.players.filter(p => p.id !== socket.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Удаляем пустые команды
|
||||||
|
game.teams = game.teams.filter(team => team.players.length > 0);
|
||||||
|
|
||||||
|
// Обновляем игру для оставшихся игроков
|
||||||
|
sendGameUpdate(io, gameCode, game);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||