Initial commit: Своя Игра - multiplayer quiz game

This commit is contained in:
Cosmo
2026-03-21 05:00:06 +00:00
commit 1d46ad8b06
80 changed files with 9215 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
npm-debug.log
.env
.git
.gitignore
README.md
.DS_Store
dist

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
.DS_Store

302
DEPLOYMENT.md Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

26
client/package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

22
client/src/App.jsx Normal file
View 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

View 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

View 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

View 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
View 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
View 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>,
)

View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

25
server/package.json Normal file
View 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"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Binary file not shown.

View 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": ""
}
]
}
]
}

View 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": "Вынуть батарейки из Дашиных часов в гостиной"
}
]
}
]
}

View File

@@ -0,0 +1,30 @@
Песни наоборот +
Продолжи песню +
Вопросы от Дани +
Сумерки +
Угадай из чьего плейлиста
1 +
2 +
3 +
4 +
5
Поездки +
Факты о нас +
Универ +
Профессии
1
2 +
3 +
4 +
5 +
Наши встречи +
Юля М
- Плейлист
Юля Ш
- Профессии

68
server/src/index.js Normal file
View 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
View 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;

View 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;

View 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;

View 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;

View 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);
});
});
});
};