backend and stuff
This commit is contained in:
parent
876010ef8f
commit
0b64cc5d8b
58 changed files with 14630 additions and 235 deletions
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ls:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npx prisma init)",
|
||||
"Bash(npx prisma generate:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(docker-compose up:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -82,3 +82,14 @@ Thumbs.db
|
|||
* backup.*
|
||||
*.backup
|
||||
|
||||
# Backend specific
|
||||
backend/dist
|
||||
backend/node_modules
|
||||
backend/.env
|
||||
backend/.env.local
|
||||
backend/prisma/migrations
|
||||
backend/coverage
|
||||
|
||||
# Frontend build backup
|
||||
src/App.jsx.backup
|
||||
|
||||
|
|
|
|||
241
README.md
241
README.md
|
|
@ -1,62 +1,231 @@
|
|||
# 100 к 1 - Новогодняя версия
|
||||
# 100 к 1 - Multiplayer Game
|
||||
|
||||
Игра "100 к 1" в новогодней тематике на React. Идеально подходит для семейного застолья!
|
||||
Интерактивная веб-игра "100 к 1" с поддержкой мультиплеера и локальной игры.
|
||||
|
||||
## Установка
|
||||
## 🎮 Возможности
|
||||
|
||||
```bash
|
||||
npm install
|
||||
### 🌐 Мультиплеер (NEW!)
|
||||
- **Игровые комнаты** с уникальными кодами
|
||||
- **QR-коды** для быстрого присоединения
|
||||
- **Real-time синхронизация** через WebSocket
|
||||
- **Роли**: Ведущий, Игрок, Зритель
|
||||
- **Статистика игр** с историей
|
||||
|
||||
### 🏠 Локальная игра
|
||||
- Оригинальная версия для одного устройства
|
||||
- Управление участниками
|
||||
- Редактирование вопросов
|
||||
- Автосохранение прогресса
|
||||
|
||||
## 🛠 Технологический стек
|
||||
|
||||
### Frontend
|
||||
- **React 18.2** + **Vite 5.0**
|
||||
- **React Router v6** - маршрутизация
|
||||
- **Socket.IO Client** - WebSocket
|
||||
- **Axios** - HTTP клиент
|
||||
- **QRCode** - генерация QR-кодов
|
||||
|
||||
### Backend
|
||||
- **NestJS** - TypeScript фреймворк
|
||||
- **PostgreSQL** - база данных
|
||||
- **Prisma ORM** - работа с БД
|
||||
- **Socket.IO** - WebSocket сервер
|
||||
- **JWT** - авторизация
|
||||
- **Docker** - контейнеризация
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
sto_k_odnomu/
|
||||
├── backend/ # NestJS Backend
|
||||
│ ├── src/
|
||||
│ │ ├── auth/ # Авторизация (JWT, анонимные)
|
||||
│ │ ├── rooms/ # Модуль комнат
|
||||
│ │ ├── questions/ # Паки вопросов
|
||||
│ │ ├── game/ # WebSocket игра
|
||||
│ │ ├── stats/ # Статистика
|
||||
│ │ └── prisma/ # Prisma сервис
|
||||
│ ├── prisma/
|
||||
│ │ ├── schema.prisma # Схема БД
|
||||
│ │ └── seed.ts # Seed данные
|
||||
│ └── docker-compose.yml # Docker конфиг
|
||||
│
|
||||
├── src/ # React Frontend
|
||||
│ ├── pages/ # Страницы
|
||||
│ │ ├── Home.jsx # Главная
|
||||
│ │ ├── CreateRoom.jsx # Создание комнаты
|
||||
│ │ ├── JoinRoom.jsx # Присоединение
|
||||
│ │ ├── RoomPage.jsx # Лобби комнаты
|
||||
│ │ └── LocalGame.jsx # Локальная игра
|
||||
│ ├── services/ # API & WebSocket
|
||||
│ ├── context/ # React Context
|
||||
│ ├── hooks/ # Custom hooks
|
||||
│ └── components/ # Компоненты игры
|
||||
│
|
||||
└── PLAN.md # Детальный план разработки
|
||||
```
|
||||
|
||||
## Запуск
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### 1. Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Установить зависимости
|
||||
npm install
|
||||
|
||||
# Настроить .env
|
||||
cp .env.example .env
|
||||
|
||||
# Запустить PostgreSQL (Docker)
|
||||
docker-compose up -d postgres
|
||||
|
||||
# Выполнить миграции
|
||||
npx prisma migrate dev --name init
|
||||
|
||||
# Заполнить демо-данными
|
||||
npm run prisma:seed
|
||||
|
||||
# Запустить backend
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
Backend: http://localhost:3000
|
||||
|
||||
### 2. Frontend
|
||||
|
||||
```bash
|
||||
# В корне проекта
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Правила игры
|
||||
Frontend: http://localhost:5173
|
||||
|
||||
1. На экране появляется вопрос
|
||||
2. Нужно угадать самые популярные ответы
|
||||
3. Кликайте на тайлы с ответами, чтобы открыть их
|
||||
4. Чем популярнее ответ, тем больше очков он приносит (100, 80, 60, 40, 20, 10)
|
||||
5. После открытия всех ответов на вопрос, игра переходит к следующему
|
||||
6. Всего 10 вопросов на новогоднюю тематику
|
||||
7. В конце игры показывается итоговый счёт
|
||||
## 🎯 Как играть
|
||||
|
||||
## Адаптация под ТВ
|
||||
### Мультиплеер
|
||||
|
||||
Игра оптимизирована для отображения на больших экранах (ТВ):
|
||||
- Крупные шрифты для лучшей читаемости
|
||||
- Увеличенные элементы интерфейса
|
||||
- Чёткая видимость с расстояния
|
||||
1. **Главная страница** → Выберите действие
|
||||
2. **Создать комнату**:
|
||||
- Выберите пак вопросов
|
||||
- Настройте параметры
|
||||
- Поделитесь кодом/QR с игроками
|
||||
3. **Присоединиться**:
|
||||
- Введите 6-значный код комнаты
|
||||
- Или отсканируйте QR-код
|
||||
4. **Начать игру** (ведущий)
|
||||
5. Игроки открывают ответы в реальном времени
|
||||
|
||||
## Развёртывание через Coolify
|
||||
### Локальная игра
|
||||
|
||||
Проект настроен для развёртывания через Coolify:
|
||||
1. Главная → **Локальная игра**
|
||||
2. Добавьте участников (👥)
|
||||
3. Играйте на одном устройстве
|
||||
|
||||
1. Подключите репозиторий в Coolify
|
||||
2. Coolify автоматически определит Dockerfile
|
||||
3. Проект будет собран и развёрнут автоматически
|
||||
## 📊 API Endpoints
|
||||
|
||||
### Локальная сборка для проверки
|
||||
### REST API
|
||||
|
||||
Для проверки Docker образа локально:
|
||||
- **Auth**: `/auth/anonymous`, `/auth/register`, `/auth/login`
|
||||
- **Rooms**: `/rooms` (POST, GET), `/rooms/:code`, `/rooms/:id/join`
|
||||
- **Questions**: `/questions/packs` (CRUD)
|
||||
- **Stats**: `/stats/game-history/:userId`, `/stats/user/:userId`
|
||||
|
||||
### WebSocket Events
|
||||
|
||||
**Client → Server:**
|
||||
- `joinRoom`, `startGame`, `revealAnswer`, `updateScore`, `nextQuestion`, `endGame`
|
||||
|
||||
**Server → Client:**
|
||||
- `roomUpdate`, `gameStarted`, `answerRevealed`, `scoreUpdated`, `questionChanged`, `gameEnded`
|
||||
|
||||
## 🗄️ База данных
|
||||
|
||||
### Модели (Prisma)
|
||||
|
||||
- **User** - пользователи (анонимные/зарегистрированные)
|
||||
- **Room** - игровые комнаты
|
||||
- **Participant** - участники (HOST/PLAYER/SPECTATOR)
|
||||
- **QuestionPack** - паки вопросов
|
||||
- **GameHistory** - история игр
|
||||
|
||||
### Seed данные
|
||||
|
||||
- Демо пользователь
|
||||
- 2 пака вопросов (общие, семейные)
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
```bash
|
||||
# Сборка образа
|
||||
docker build -t sto-k-odnomu .
|
||||
# Backend + PostgreSQL
|
||||
cd backend
|
||||
docker-compose up -d
|
||||
|
||||
# Запуск контейнера
|
||||
docker run -p 8080:80 sto-k-odnomu
|
||||
# Только PostgreSQL
|
||||
docker-compose up -d postgres
|
||||
```
|
||||
|
||||
Сайт будет доступен по адресу `http://localhost:8080`
|
||||
## 📝 Разработка
|
||||
|
||||
## Технологии
|
||||
### Backend
|
||||
|
||||
- React 18
|
||||
- Vite
|
||||
- CSS3 с анимациями
|
||||
- Docker + Nginx (для production)
|
||||
```bash
|
||||
cd backend
|
||||
npm run start:dev # Dev режим
|
||||
npm run build # Сборка
|
||||
npm run test # Тесты
|
||||
npx prisma studio # DB GUI
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
npm run dev # Dev сервер
|
||||
npm run build # Сборка
|
||||
npm run preview # Preview build
|
||||
```
|
||||
|
||||
## 🔐 Безопасность
|
||||
|
||||
- JWT токены для авторизации
|
||||
- CORS настройка
|
||||
- Валидация данных (class-validator)
|
||||
- PostgreSQL для надёжного хранения
|
||||
|
||||
## 📄 Документация
|
||||
|
||||
- [Backend README](backend/README.md) - детальная документация backend
|
||||
- [PLAN.md](PLAN.md) - полный план разработки
|
||||
- [API Documentation](backend/README.md#-api-endpoints) - REST и WebSocket API
|
||||
|
||||
## 🎨 Особенности
|
||||
|
||||
- ❄️ Новогодняя анимация снежинок
|
||||
- 🎨 Адаптивный дизайн
|
||||
- 💾 Автосохранение прогресса
|
||||
- 🔄 Real-time синхронизация
|
||||
- 📱 QR-коды для присоединения
|
||||
- 📊 Статистика и история игр
|
||||
|
||||
## 🚧 Roadmap
|
||||
|
||||
- [x] Backend инфраструктура
|
||||
- [x] Frontend интеграция
|
||||
- [x] WebSocket real-time
|
||||
- [x] Игровые комнаты
|
||||
- [x] QR-коды
|
||||
- [ ] Таймер ответов
|
||||
- [ ] Экспорт статистики (PDF)
|
||||
- [ ] Публичные комнаты
|
||||
- [ ] Рейтинг игроков
|
||||
|
||||
## 📜 Лицензия
|
||||
|
||||
Private project
|
||||
|
||||
---
|
||||
|
||||
**Сделано с ❤️ для семейных праздников**
|
||||
|
|
|
|||
4
backend/.env.example
Normal file
4
backend/.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
|
||||
JWT_SECRET="change-me"
|
||||
PORT=3000
|
||||
CORS_ORIGIN="http://localhost:5173"
|
||||
5
backend/.gitignore
vendored
Normal file
5
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
4
backend/.prettierrc
Normal file
4
backend/.prettierrc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
18
backend/Dockerfile
Normal file
18
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
RUN npm ci --production
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
EXPOSE 3000
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]
|
||||
209
backend/README.md
Normal file
209
backend/README.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# Backend - "100 к 1" Multiplayer Game
|
||||
|
||||
NestJS backend для мультиплеерной игры "100 к 1" с WebSocket поддержкой.
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Предварительные требования
|
||||
|
||||
- Node.js 18+
|
||||
- PostgreSQL 15+
|
||||
- Docker (опционально, для запуска PostgreSQL в контейнере)
|
||||
|
||||
### Установка
|
||||
|
||||
```bash
|
||||
# 1. Установить зависимости
|
||||
npm install
|
||||
|
||||
# 2. Настроить переменные окружения
|
||||
cp .env.example .env
|
||||
# Отредактировать .env с вашими настройками
|
||||
|
||||
# 3. Запустить PostgreSQL
|
||||
# Вариант A: Использовать Docker Compose
|
||||
docker-compose up -d postgres
|
||||
|
||||
# Вариант B: Использовать локальный PostgreSQL
|
||||
# Создать базу данных вручную:
|
||||
# createdb sto_k_odnomu
|
||||
|
||||
# 4. Выполнить миграции
|
||||
npx prisma migrate dev --name init
|
||||
|
||||
# 5. Заполнить демо-данными (опционально)
|
||||
npm run prisma:seed
|
||||
|
||||
# 6. Сгенерировать Prisma Client
|
||||
npx prisma generate
|
||||
|
||||
# 7. Запустить backend
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
Backend запустится на http://localhost:3000
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── auth/ # Модуль авторизации (JWT, анонимные пользователи)
|
||||
│ ├── rooms/ # Модуль комнат (создание, присоединение)
|
||||
│ ├── questions/ # Модуль паков вопросов (CRUD)
|
||||
│ ├── game/ # WebSocket модуль (real-time игра)
|
||||
│ ├── stats/ # Модуль статистики (история игр)
|
||||
│ ├── prisma/ # Prisma сервис
|
||||
│ ├── app.module.ts # Главный модуль
|
||||
│ └── main.ts # Точка входа
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # Схема базы данных
|
||||
│ └── seed.ts # Seed скрипт с демо-данными
|
||||
├── Dockerfile # Docker конфигурация
|
||||
├── docker-compose.yml # Docker Compose (PostgreSQL + Backend)
|
||||
└── .env.example # Пример переменных окружения
|
||||
```
|
||||
|
||||
## 🗄️ База данных
|
||||
|
||||
### Модели
|
||||
|
||||
- **User** - пользователи (анонимные или зарегистрированные)
|
||||
- **Room** - игровые комнаты
|
||||
- **Participant** - участники комнат (игроки, ведущие, зрители)
|
||||
- **QuestionPack** - паки вопросов
|
||||
- **GameHistory** - история завершённых игр
|
||||
|
||||
### Миграции
|
||||
|
||||
```bash
|
||||
# Создать новую миграцию
|
||||
npx prisma migrate dev --name migration_name
|
||||
|
||||
# Применить миграции на production
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Сбросить базу данных (ВНИМАНИЕ: удалит все данные!)
|
||||
npx prisma migrate reset
|
||||
```
|
||||
|
||||
### Seed данные
|
||||
|
||||
Seed скрипт создаёт:
|
||||
- Демо пользователя
|
||||
- 2 пака вопросов (общие и семейные)
|
||||
|
||||
```bash
|
||||
npm run prisma:seed
|
||||
```
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### Auth
|
||||
- POST /auth/anonymous - создать анонимного пользователя
|
||||
- POST /auth/register - регистрация
|
||||
- POST /auth/login - вход
|
||||
|
||||
### Rooms
|
||||
- POST /rooms - создать комнату
|
||||
- GET /rooms/:code - получить комнату по коду
|
||||
- POST /rooms/:roomId/join - присоединиться к комнате
|
||||
|
||||
### Questions
|
||||
- POST /questions/packs - создать пак вопросов
|
||||
- GET /questions/packs - получить все паки
|
||||
- GET /questions/packs/:id - получить пак по ID
|
||||
- PUT /questions/packs/:id - обновить пак
|
||||
- DELETE /questions/packs/:id - удалить пак
|
||||
|
||||
### Stats
|
||||
- POST /stats/game-history - сохранить историю игры
|
||||
- GET /stats/game-history/:userId - получить историю пользователя
|
||||
- GET /stats/user/:userId - получить статистику пользователя
|
||||
|
||||
## 🌐 WebSocket Events
|
||||
|
||||
### Client → Server
|
||||
- joinRoom - присоединиться к комнате
|
||||
- startGame - начать игру
|
||||
- revealAnswer - открыть ответ
|
||||
- updateScore - обновить счёт
|
||||
- nextQuestion - следующий вопрос
|
||||
- endGame - завершить игру
|
||||
|
||||
### Server → Client
|
||||
- roomUpdate - обновление комнаты
|
||||
- gameStarted - игра началась
|
||||
- answerRevealed - ответ открыт
|
||||
- scoreUpdated - счёт обновлён
|
||||
- questionChanged - вопрос изменён
|
||||
- gameEnded - игра завершена
|
||||
|
||||
## 🔧 NPM Scripts
|
||||
|
||||
```bash
|
||||
npm run start # Запуск в production режиме
|
||||
npm run start:dev # Запуск в dev режиме с hot reload
|
||||
npm run start:debug # Запуск в debug режиме
|
||||
npm run build # Сборка для production
|
||||
|
||||
npm run lint # Проверка ESLint
|
||||
npm run format # Форматирование кода (Prettier)
|
||||
npm run test # Запуск тестов
|
||||
npm run test:watch # Запуск тестов в watch режиме
|
||||
npm run test:cov # Запуск тестов с coverage
|
||||
|
||||
npm run prisma:generate # Генерация Prisma Client
|
||||
npm run prisma:migrate # Создание и применение миграции
|
||||
npm run prisma:seed # Заполнение БД демо-данными
|
||||
```
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
### Запуск с Docker Compose
|
||||
|
||||
```bash
|
||||
# Запустить всё (PostgreSQL + Backend)
|
||||
docker-compose up -d
|
||||
|
||||
# Остановить
|
||||
docker-compose down
|
||||
|
||||
# Просмотр логов
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Пересобрать и запустить
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### Запуск только PostgreSQL
|
||||
|
||||
```bash
|
||||
docker-compose up -d postgres
|
||||
```
|
||||
|
||||
## 🔐 Переменные окружения
|
||||
|
||||
Создайте файл .env на основе .env.example
|
||||
|
||||
## 📝 Prisma Studio
|
||||
|
||||
```bash
|
||||
npx prisma studio
|
||||
```
|
||||
|
||||
Откроется на http://localhost:5555
|
||||
|
||||
## 🚀 Production Deployment
|
||||
|
||||
```bash
|
||||
# 1. Собрать проект
|
||||
npm run build
|
||||
|
||||
# 2. Применить миграции
|
||||
npx prisma migrate deploy
|
||||
|
||||
# 3. Запустить
|
||||
npm run start:prod
|
||||
```
|
||||
|
||||
30
backend/docker-compose.yml
Normal file
30
backend/docker-compose.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: sto_k_odnomu
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
backend:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/sto_k_odnomu
|
||||
JWT_SECRET: your-secret-key-change-in-production
|
||||
PORT: 3000
|
||||
depends_on:
|
||||
- postgres
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
command: npm run start:dev
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
35
backend/eslint.config.mjs
Normal file
35
backend/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
8
backend/nest-cli.json
Normal file
8
backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
11033
backend/package-lock.json
generated
Normal file
11033
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
93
backend/package.json
Normal file
93
backend/package.json
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.11",
|
||||
"@nestjs/websockets": "^11.1.11",
|
||||
"@prisma/client": "^6.19.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.19.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
16
backend/prisma.config.ts
Normal file
16
backend/prisma.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
|
||||
// This file was generated by Prisma and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
engine: "classic",
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
125
backend/prisma/schema.prisma
Normal file
125
backend/prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String? @unique
|
||||
name String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Связи
|
||||
hostedRooms Room[] @relation("HostedRooms")
|
||||
participants Participant[]
|
||||
questionPacks QuestionPack[]
|
||||
|
||||
// Статистика
|
||||
gamesPlayed Int @default(0)
|
||||
gamesWon Int @default(0)
|
||||
totalPoints Int @default(0)
|
||||
}
|
||||
|
||||
model Room {
|
||||
id String @id @default(uuid())
|
||||
code String @unique // 6-символьный код
|
||||
status RoomStatus @default(WAITING)
|
||||
hostId String
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
// Настройки
|
||||
maxPlayers Int @default(10)
|
||||
allowSpectators Boolean @default(true)
|
||||
timerEnabled Boolean @default(false)
|
||||
timerDuration Int @default(30)
|
||||
questionPackId String
|
||||
autoAdvance Boolean @default(false)
|
||||
|
||||
// Состояние игры
|
||||
currentQuestionIndex Int @default(0)
|
||||
revealedAnswers Json @default("{}")
|
||||
currentPlayerId String?
|
||||
isGameOver Boolean @default(false)
|
||||
|
||||
// Метрики
|
||||
totalQuestions Int @default(0)
|
||||
answeredQuestions Int @default(0)
|
||||
startedAt DateTime?
|
||||
finishedAt DateTime?
|
||||
|
||||
// Связи
|
||||
host User @relation("HostedRooms", fields: [hostId], references: [id])
|
||||
participants Participant[]
|
||||
questionPack QuestionPack @relation(fields: [questionPackId], references: [id])
|
||||
gameHistory GameHistory?
|
||||
}
|
||||
|
||||
enum RoomStatus {
|
||||
WAITING
|
||||
PLAYING
|
||||
FINISHED
|
||||
}
|
||||
|
||||
model Participant {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
roomId String
|
||||
name String
|
||||
role ParticipantRole
|
||||
score Int @default(0)
|
||||
joinedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, roomId])
|
||||
}
|
||||
|
||||
enum ParticipantRole {
|
||||
HOST
|
||||
PLAYER
|
||||
SPECTATOR
|
||||
}
|
||||
|
||||
model QuestionPack {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
description String
|
||||
category String
|
||||
isPublic Boolean @default(false)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
questions Json // Массив вопросов с ответами
|
||||
questionCount Int @default(0)
|
||||
timesUsed Int @default(0)
|
||||
rating Float @default(0)
|
||||
|
||||
creator User @relation(fields: [createdBy], references: [id])
|
||||
rooms Room[]
|
||||
}
|
||||
|
||||
model GameHistory {
|
||||
id String @id @default(uuid())
|
||||
roomId String @unique
|
||||
roomCode String
|
||||
questionPackId String
|
||||
startedAt DateTime
|
||||
finishedAt DateTime
|
||||
|
||||
players Json // { userId: { name, score, rank } }
|
||||
statistics Json // Детальная статистика игры
|
||||
timeline Json // История событий игры
|
||||
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
191
backend/prisma/seed.ts
Normal file
191
backend/prisma/seed.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Starting seed...');
|
||||
|
||||
// Create demo user
|
||||
const demoUser = await prisma.user.upsert({
|
||||
where: { email: 'demo@100k1.ru' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'demo@100k1.ru',
|
||||
name: 'Демо пользователь',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Demo user created:', demoUser);
|
||||
|
||||
// Demo questions data
|
||||
const demoQuestions = [
|
||||
{
|
||||
text: 'Что дед мороз делает летом?',
|
||||
answers: [
|
||||
{ text: 'Отдыхает', points: 100 },
|
||||
{ text: 'Готовит подарки', points: 80 },
|
||||
{ text: 'Спит', points: 60 },
|
||||
{ text: 'Путешествует', points: 40 },
|
||||
{ text: 'Загорает', points: 20 },
|
||||
{ text: 'Работает', points: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Что намазывают на хлеб?',
|
||||
answers: [
|
||||
{ text: 'Масло', points: 100 },
|
||||
{ text: 'Икру', points: 80 },
|
||||
{ text: 'Варенье', points: 60 },
|
||||
{ text: 'Паштет', points: 40 },
|
||||
{ text: 'Майонез', points: 20 },
|
||||
{ text: 'Горчицу', points: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Кто работает в новый год?',
|
||||
answers: [
|
||||
{ text: 'Дед Мороз', points: 100 },
|
||||
{ text: 'Снегурочка', points: 80 },
|
||||
{ text: 'Врач', points: 60 },
|
||||
{ text: 'Полицейский', points: 40 },
|
||||
{ text: 'Таксист', points: 20 },
|
||||
{ text: 'Продавец', points: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Почему лошадь не курит?',
|
||||
answers: [
|
||||
{ text: 'Боится умереть', points: 100 },
|
||||
{ text: 'Неудобно (копыта мешают)', points: 80 },
|
||||
{ text: 'Не хочет', points: 60 },
|
||||
{ text: 'Не продают', points: 40 },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Какая самая "лошадиная" фамилия?',
|
||||
answers: [
|
||||
{ text: 'Конев', points: 100 },
|
||||
{ text: 'Жеребцов', points: 80 },
|
||||
{ text: 'Кобылин', points: 60 },
|
||||
{ text: 'Табунов', points: 40 },
|
||||
{ text: 'Лошадкин', points: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Что носят на голове?',
|
||||
answers: [
|
||||
{ text: 'Шапка', points: 100 },
|
||||
{ text: 'Шляпа', points: 80 },
|
||||
{ text: 'Кепка', points: 60 },
|
||||
{ text: 'Корона', points: 40 },
|
||||
{ text: 'Платок', points: 20 },
|
||||
{ text: 'Панама', points: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Что можно найти в холодильнике?',
|
||||
answers: [
|
||||
{ text: 'Еда', points: 100 },
|
||||
{ text: 'Молоко', points: 80 },
|
||||
{ text: 'Колбаса', points: 60 },
|
||||
{ text: 'Масло', points: 40 },
|
||||
{ text: 'Лёд', points: 20 },
|
||||
{ text: 'Свет', points: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Где можно встретить новый год?',
|
||||
answers: [
|
||||
{ text: 'Дома', points: 100 },
|
||||
{ text: 'На улице', points: 80 },
|
||||
{ text: 'В кафе', points: 60 },
|
||||
{ text: 'У друзей', points: 40 },
|
||||
{ text: 'На работе', points: 20 },
|
||||
{ text: 'В самолёте', points: 10 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Create question pack
|
||||
const questionPack = await prisma.questionPack.upsert({
|
||||
where: { id: 'demo-pack-1' },
|
||||
update: {},
|
||||
create: {
|
||||
id: 'demo-pack-1',
|
||||
name: 'Демо пак вопросов',
|
||||
description: 'Базовый набор вопросов для игры "100 к 1"',
|
||||
category: 'Общие',
|
||||
isPublic: true,
|
||||
createdBy: demoUser.id,
|
||||
questions: demoQuestions,
|
||||
questionCount: demoQuestions.length,
|
||||
rating: 5.0,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Question pack created:', questionPack);
|
||||
|
||||
// Create family questions pack
|
||||
const familyQuestions = [
|
||||
{
|
||||
text: 'Что мама говорит чаще всего?',
|
||||
answers: [
|
||||
{ text: 'Убери', points: 100 },
|
||||
{ text: 'Поешь', points: 80 },
|
||||
{ text: 'Спать', points: 60 },
|
||||
{ text: 'Я люблю тебя', points: 40 },
|
||||
{ text: 'Делай уроки', points: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Что папа делает на выходных?',
|
||||
answers: [
|
||||
{ text: 'Отдыхает', points: 100 },
|
||||
{ text: 'Чинит что-то', points: 80 },
|
||||
{ text: 'Смотрит телевизор', points: 60 },
|
||||
{ text: 'Спит', points: 40 },
|
||||
{ text: 'Работает', points: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Что бабушка любит дарить внукам?',
|
||||
answers: [
|
||||
{ text: 'Деньги', points: 100 },
|
||||
{ text: 'Еду', points: 80 },
|
||||
{ text: 'Одежду', points: 60 },
|
||||
{ text: 'Игрушки', points: 40 },
|
||||
{ text: 'Конфеты', points: 20 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const familyPack = await prisma.questionPack.upsert({
|
||||
where: { id: 'family-pack-1' },
|
||||
update: {},
|
||||
create: {
|
||||
id: 'family-pack-1',
|
||||
name: 'Семейные вопросы',
|
||||
description: 'Вопросы для семейной игры',
|
||||
category: 'Семья',
|
||||
isPublic: true,
|
||||
createdBy: demoUser.id,
|
||||
questions: familyQuestions,
|
||||
questionCount: familyQuestions.length,
|
||||
rating: 4.8,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Family pack created:', familyPack);
|
||||
|
||||
console.log('Seed completed successfully!');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error('Seed error:', e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
22
backend/src/app.controller.spec.ts
Normal file
22
backend/src/app.controller.spec.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
backend/src/app.controller.ts
Normal file
12
backend/src/app.controller.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
25
backend/src/app.module.ts
Normal file
25
backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { RoomsModule } from './rooms/rooms.module';
|
||||
import { QuestionsModule } from './questions/questions.module';
|
||||
import { GameModule } from './game/game.module';
|
||||
import { StatsModule } from './stats/stats.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
RoomsModule,
|
||||
QuestionsModule,
|
||||
GameModule,
|
||||
StatsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
backend/src/app.service.ts
Normal file
8
backend/src/app.service.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
22
backend/src/auth/auth.controller.ts
Normal file
22
backend/src/auth/auth.controller.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('anonymous')
|
||||
async createAnonymous(@Body('name') name?: string) {
|
||||
return this.authService.createAnonymousUser(name);
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() dto: { email: string; password: string; name: string }) {
|
||||
return this.authService.register(dto.email, dto.password, dto.name);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() dto: { email: string; password: string }) {
|
||||
return this.authService.login(dto.email, dto.password);
|
||||
}
|
||||
}
|
||||
19
backend/src/auth/auth.module.ts
Normal file
19
backend/src/auth/auth.module.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
49
backend/src/auth/auth.service.ts
Normal file
49
backend/src/auth/auth.service.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async createAnonymousUser(name?: string) {
|
||||
const user = await this.prisma.user.create({
|
||||
data: { name: name || 'Гость' },
|
||||
});
|
||||
|
||||
const token = this.jwtService.sign({ sub: user.id, type: 'anonymous' });
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async register(email: string, password: string, name: string) {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
const token = this.jwtService.sign({ sub: user.id, type: 'registered' });
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const token = this.jwtService.sign({ sub: user.id, type: 'registered' });
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async validateUser(userId: string) {
|
||||
return this.prisma.user.findUnique({ where: { id: userId } });
|
||||
}
|
||||
}
|
||||
68
backend/src/game/game.gateway.ts
Normal file
68
backend/src/game/game.gateway.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { RoomsService } from '../rooms/rooms.service';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
constructor(private roomsService: RoomsService) {}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
console.log(`Client connected: ${client.id}`);
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
console.log(`Client disconnected: ${client.id}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinRoom')
|
||||
async handleJoinRoom(client: Socket, payload: { roomCode: string; userId: string }) {
|
||||
client.join(payload.roomCode);
|
||||
const room = await this.roomsService.getRoomByCode(payload.roomCode);
|
||||
this.server.to(payload.roomCode).emit('roomUpdate', room);
|
||||
}
|
||||
|
||||
@SubscribeMessage('startGame')
|
||||
async handleStartGame(client: Socket, payload: { roomId: string; roomCode: string }) {
|
||||
await this.roomsService.updateRoomStatus(payload.roomId, 'PLAYING');
|
||||
const room = await this.roomsService.getRoomByCode(payload.roomCode);
|
||||
if (room) {
|
||||
this.server.to(room.code).emit('gameStarted', room);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('revealAnswer')
|
||||
handleRevealAnswer(client: Socket, payload: { roomCode: string; answerIndex: number }) {
|
||||
this.server.to(payload.roomCode).emit('answerRevealed', payload);
|
||||
}
|
||||
|
||||
@SubscribeMessage('updateScore')
|
||||
async handleUpdateScore(client: Socket, payload: { participantId: string; score: number; roomCode: string }) {
|
||||
await this.roomsService.updateParticipantScore(payload.participantId, payload.score);
|
||||
this.server.to(payload.roomCode).emit('scoreUpdated', payload);
|
||||
}
|
||||
|
||||
@SubscribeMessage('nextQuestion')
|
||||
handleNextQuestion(client: Socket, payload: { roomCode: string }) {
|
||||
this.server.to(payload.roomCode).emit('questionChanged', payload);
|
||||
}
|
||||
|
||||
@SubscribeMessage('endGame')
|
||||
async handleEndGame(client: Socket, payload: { roomId: string; roomCode: string }) {
|
||||
await this.roomsService.updateRoomStatus(payload.roomId, 'FINISHED');
|
||||
this.server.to(payload.roomCode).emit('gameEnded', payload);
|
||||
}
|
||||
}
|
||||
9
backend/src/game/game.module.ts
Normal file
9
backend/src/game/game.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GameGateway } from './game.gateway';
|
||||
import { RoomsModule } from '../rooms/rooms.module';
|
||||
|
||||
@Module({
|
||||
imports: [RoomsModule],
|
||||
providers: [GameGateway],
|
||||
})
|
||||
export class GameModule {}
|
||||
21
backend/src/main.ts
Normal file
21
backend/src/main.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
}));
|
||||
|
||||
await app.listen(process.env.PORT || 3000);
|
||||
console.log(`Backend running on http://localhost:${process.env.PORT || 3000}`);
|
||||
}
|
||||
bootstrap();
|
||||
9
backend/src/prisma/prisma.module.ts
Normal file
9
backend/src/prisma/prisma.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
13
backend/src/prisma/prisma.service.ts
Normal file
13
backend/src/prisma/prisma.service.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
32
backend/src/questions/questions.controller.ts
Normal file
32
backend/src/questions/questions.controller.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||
import { QuestionsService } from './questions.service';
|
||||
|
||||
@Controller('questions')
|
||||
export class QuestionsController {
|
||||
constructor(private questionsService: QuestionsService) {}
|
||||
|
||||
@Post('packs')
|
||||
async createPack(@Body() dto: { createdBy: string; name: string; description: string; category: string; questions: any; isPublic?: boolean }) {
|
||||
return this.questionsService.createQuestionPack(dto.createdBy, dto);
|
||||
}
|
||||
|
||||
@Get('packs')
|
||||
async getPacks(@Query('userId') userId?: string) {
|
||||
return this.questionsService.getQuestionPacks(userId);
|
||||
}
|
||||
|
||||
@Get('packs/:id')
|
||||
async getPack(@Param('id') id: string) {
|
||||
return this.questionsService.getQuestionPackById(id);
|
||||
}
|
||||
|
||||
@Put('packs/:id')
|
||||
async updatePack(@Param('id') id: string, @Body() data: any) {
|
||||
return this.questionsService.updateQuestionPack(id, data);
|
||||
}
|
||||
|
||||
@Delete('packs/:id')
|
||||
async deletePack(@Param('id') id: string) {
|
||||
return this.questionsService.deleteQuestionPack(id);
|
||||
}
|
||||
}
|
||||
10
backend/src/questions/questions.module.ts
Normal file
10
backend/src/questions/questions.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { QuestionsService } from './questions.service';
|
||||
import { QuestionsController } from './questions.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [QuestionsController],
|
||||
providers: [QuestionsService],
|
||||
exports: [QuestionsService],
|
||||
})
|
||||
export class QuestionsModule {}
|
||||
53
backend/src/questions/questions.service.ts
Normal file
53
backend/src/questions/questions.service.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class QuestionsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async createQuestionPack(createdBy: string, data: { name: string; description: string; category: string; questions: any; isPublic?: boolean }) {
|
||||
return this.prisma.questionPack.create({
|
||||
data: {
|
||||
...data,
|
||||
createdBy,
|
||||
questionCount: Array.isArray(data.questions) ? data.questions.length : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getQuestionPacks(userId?: string) {
|
||||
return this.prisma.questionPack.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ isPublic: true },
|
||||
{ createdBy: userId },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
creator: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getQuestionPackById(id: string) {
|
||||
return this.prisma.questionPack.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
creator: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateQuestionPack(id: string, data: any) {
|
||||
return this.prisma.questionPack.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteQuestionPack(id: string) {
|
||||
return this.prisma.questionPack.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
25
backend/src/rooms/rooms.controller.ts
Normal file
25
backend/src/rooms/rooms.controller.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Controller, Post, Get, Body, Param } from '@nestjs/common';
|
||||
import { RoomsService } from './rooms.service';
|
||||
|
||||
@Controller('rooms')
|
||||
export class RoomsController {
|
||||
constructor(private roomsService: RoomsService) {}
|
||||
|
||||
@Post()
|
||||
async createRoom(@Body() dto: { hostId: string; questionPackId: string; settings?: any }) {
|
||||
return this.roomsService.createRoom(dto.hostId, dto.questionPackId, dto.settings);
|
||||
}
|
||||
|
||||
@Get(':code')
|
||||
async getRoom(@Param('code') code: string) {
|
||||
return this.roomsService.getRoomByCode(code);
|
||||
}
|
||||
|
||||
@Post(':roomId/join')
|
||||
async joinRoom(
|
||||
@Param('roomId') roomId: string,
|
||||
@Body() dto: { userId: string; name: string; role: 'PLAYER' | 'SPECTATOR' }
|
||||
) {
|
||||
return this.roomsService.joinRoom(roomId, dto.userId, dto.name, dto.role);
|
||||
}
|
||||
}
|
||||
10
backend/src/rooms/rooms.module.ts
Normal file
10
backend/src/rooms/rooms.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { RoomsService } from './rooms.service';
|
||||
import { RoomsController } from './rooms.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [RoomsController],
|
||||
providers: [RoomsService],
|
||||
exports: [RoomsService],
|
||||
})
|
||||
export class RoomsModule {}
|
||||
78
backend/src/rooms/rooms.service.ts
Normal file
78
backend/src/rooms/rooms.service.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
|
||||
|
||||
@Injectable()
|
||||
export class RoomsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async createRoom(hostId: string, questionPackId: string, settings?: any) {
|
||||
const code = nanoid();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
const room = await this.prisma.room.create({
|
||||
data: {
|
||||
code,
|
||||
hostId,
|
||||
questionPackId,
|
||||
expiresAt,
|
||||
...settings,
|
||||
},
|
||||
include: {
|
||||
host: true,
|
||||
questionPack: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.participant.create({
|
||||
data: {
|
||||
userId: hostId,
|
||||
roomId: room.id,
|
||||
name: room.host.name || 'Host',
|
||||
role: 'HOST',
|
||||
},
|
||||
});
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
async getRoomByCode(code: string) {
|
||||
return this.prisma.room.findUnique({
|
||||
where: { code },
|
||||
include: {
|
||||
host: true,
|
||||
participants: {
|
||||
include: { user: true },
|
||||
},
|
||||
questionPack: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async joinRoom(roomId: string, userId: string, name: string, role: 'PLAYER' | 'SPECTATOR') {
|
||||
return this.prisma.participant.create({
|
||||
data: {
|
||||
userId,
|
||||
roomId,
|
||||
name,
|
||||
role,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateRoomStatus(roomId: string, status: 'WAITING' | 'PLAYING' | 'FINISHED') {
|
||||
return this.prisma.room.update({
|
||||
where: { id: roomId },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
|
||||
async updateParticipantScore(participantId: string, score: number) {
|
||||
return this.prisma.participant.update({
|
||||
where: { id: participantId },
|
||||
data: { score },
|
||||
});
|
||||
}
|
||||
}
|
||||
22
backend/src/stats/stats.controller.ts
Normal file
22
backend/src/stats/stats.controller.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
|
||||
import { StatsService } from './stats.service';
|
||||
|
||||
@Controller('stats')
|
||||
export class StatsController {
|
||||
constructor(private statsService: StatsService) {}
|
||||
|
||||
@Post('game-history')
|
||||
async createHistory(@Body() data: any) {
|
||||
return this.statsService.createGameHistory(data);
|
||||
}
|
||||
|
||||
@Get('game-history/:userId')
|
||||
async getHistory(@Param('userId') userId: string) {
|
||||
return this.statsService.getGameHistory(userId);
|
||||
}
|
||||
|
||||
@Get('user/:userId')
|
||||
async getUserStats(@Param('userId') userId: string) {
|
||||
return this.statsService.getUserStats(userId);
|
||||
}
|
||||
}
|
||||
10
backend/src/stats/stats.module.ts
Normal file
10
backend/src/stats/stats.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { StatsService } from './stats.service';
|
||||
import { StatsController } from './stats.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [StatsController],
|
||||
providers: [StatsService],
|
||||
exports: [StatsService],
|
||||
})
|
||||
export class StatsModule {}
|
||||
46
backend/src/stats/stats.service.ts
Normal file
46
backend/src/stats/stats.service.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class StatsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async createGameHistory(data: {
|
||||
roomId: string;
|
||||
roomCode: string;
|
||||
questionPackId: string;
|
||||
startedAt: Date;
|
||||
finishedAt: Date;
|
||||
players: any;
|
||||
statistics: any;
|
||||
timeline: any;
|
||||
}) {
|
||||
return this.prisma.gameHistory.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async getGameHistory(userId: string) {
|
||||
const allHistory = await this.prisma.gameHistory.findMany({
|
||||
orderBy: {
|
||||
finishedAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return allHistory.filter((history) => {
|
||||
const players = history.players as any;
|
||||
return players && players[userId];
|
||||
});
|
||||
}
|
||||
|
||||
async getUserStats(userId: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
gamesPlayed: true,
|
||||
gamesWon: true,
|
||||
totalPoints: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
25
backend/test/app.e2e-spec.ts
Normal file
25
backend/test/app.e2e-spec.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
||||
9
backend/test/jest-e2e.json
Normal file
9
backend/test/jest-e2e.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
25
backend/tsconfig.json
Normal file
25
backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
20
family.md
20
family.md
|
|
@ -1,5 +1,23 @@
|
|||
Кто дольше всех собирается за стол?
|
||||
Катя, Надя, Надя, Вика, Миша, Лера, Бабуля, Андрей, Надя, Надя
|
||||
Кто больше всех ест на Новый год?
|
||||
Что важнее всего в новогоднюю ночь?
|
||||
Егор, Егор, Миша, Миша, Миша, Лера, Бабуля, Вика, Миша, Миша
|
||||
Кто лучше всех говорит тосты?
|
||||
Миша, Бабуля, Егор, Миша, Андрей, ИИ, Надя, Миша, Андрей, Миша
|
||||
Что важнее всего в новогоднюю ночь?
|
||||
Весело встретить вместе, ценить моменты проведённые с близкими, понимать, что ты не одинок, Миша, Доесть еду, Семья, Близкие, компания, компания, Оливье
|
||||
Где мы встретим следующий новый год?
|
||||
тут, тут, незнаю, в рф, тут, В собственном жк, тут, тут, в ресторане, тут
|
||||
|
||||
|
||||
|
||||
мама
|
||||
егор
|
||||
яна
|
||||
катя
|
||||
миша
|
||||
лера
|
||||
бабуля
|
||||
вика
|
||||
дима
|
||||
андрей
|
||||
956
package-lock.json
generated
956
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,8 +8,14 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
|
|
@ -18,4 +24,3 @@
|
|||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
185
src/App.jsx
185
src/App.jsx
|
|
@ -1,168 +1,27 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import Game from './components/Game'
|
||||
import Snowflakes from './components/Snowflakes'
|
||||
import QuestionsModal from './components/QuestionsModal'
|
||||
import { questions as initialQuestions } from './data/questions'
|
||||
import { getCookie, setCookie, deleteCookie } from './utils/cookies'
|
||||
import './App.css'
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import Home from './pages/Home';
|
||||
import CreateRoom from './pages/CreateRoom';
|
||||
import JoinRoom from './pages/JoinRoom';
|
||||
import RoomPage from './pages/RoomPage';
|
||||
import LocalGame from './pages/LocalGame';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false)
|
||||
const [questions, setQuestions] = useState(() => {
|
||||
const savedQuestions = getCookie('gameQuestions')
|
||||
return savedQuestions || initialQuestions
|
||||
})
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(() => {
|
||||
const savedIndex = getCookie('gameQuestionIndex')
|
||||
return savedIndex !== null ? savedIndex : 0
|
||||
})
|
||||
const [areAllRevealed, setAreAllRevealed] = useState(false)
|
||||
const gameRef = useRef(null)
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex]
|
||||
|
||||
// Сохраняем вопросы в cookies при изменении
|
||||
useEffect(() => {
|
||||
if (questions.length > 0) {
|
||||
setCookie('gameQuestions', questions)
|
||||
}
|
||||
}, [questions])
|
||||
|
||||
// Сохраняем индекс вопроса в cookies при изменении
|
||||
useEffect(() => {
|
||||
setCookie('gameQuestionIndex', currentQuestionIndex)
|
||||
}, [currentQuestionIndex])
|
||||
|
||||
// Обновляем состояние открытых ответов при смене вопроса или изменении состояния
|
||||
useEffect(() => {
|
||||
const checkRevealedState = () => {
|
||||
if (gameRef.current && gameRef.current.areAllAnswersRevealed) {
|
||||
setAreAllRevealed(gameRef.current.areAllAnswersRevealed())
|
||||
} else {
|
||||
setAreAllRevealed(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkRevealedState()
|
||||
// Проверяем состояние периодически для обновления кнопки
|
||||
const interval = setInterval(checkRevealedState, 200)
|
||||
return () => clearInterval(interval)
|
||||
}, [currentQuestionIndex, questions])
|
||||
|
||||
const handleUpdateQuestions = (updatedQuestions) => {
|
||||
setQuestions(updatedQuestions)
|
||||
// Если текущий вопрос был удален, сбрасываем индекс
|
||||
if (currentQuestionIndex >= updatedQuestions.length) {
|
||||
setCurrentQuestionIndex(0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenPlayersModal = () => {
|
||||
if (gameRef.current) {
|
||||
gameRef.current.openPlayersModal()
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewGame = () => {
|
||||
if (window.confirm('Начать новую игру? Текущий прогресс будет потерян.')) {
|
||||
deleteCookie('gameQuestions')
|
||||
deleteCookie('gameQuestionIndex')
|
||||
deleteCookie('gamePlayers')
|
||||
deleteCookie('gamePlayerScores')
|
||||
deleteCookie('gameCurrentPlayerId')
|
||||
deleteCookie('gameRevealedAnswers')
|
||||
deleteCookie('gameOver')
|
||||
|
||||
setQuestions(initialQuestions)
|
||||
setCurrentQuestionIndex(0)
|
||||
|
||||
if (gameRef.current) {
|
||||
gameRef.current.newGame()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowAll = () => {
|
||||
if (gameRef.current && gameRef.current.showAllAnswers) {
|
||||
gameRef.current.showAllAnswers()
|
||||
// Обновляем состояние после изменения
|
||||
setTimeout(() => {
|
||||
if (gameRef.current && gameRef.current.areAllAnswersRevealed) {
|
||||
setAreAllRevealed(gameRef.current.areAllAnswersRevealed())
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Snowflakes />
|
||||
<div className="app-content">
|
||||
<div className="app-title-bar">
|
||||
<div className="app-control-buttons">
|
||||
<button
|
||||
className="control-button control-button-players"
|
||||
onClick={handleOpenPlayersModal}
|
||||
title="Управление участниками"
|
||||
>
|
||||
👥
|
||||
</button>
|
||||
<button
|
||||
className="control-button control-button-questions"
|
||||
onClick={() => setIsQuestionsModalOpen(true)}
|
||||
title="Управление вопросами"
|
||||
>
|
||||
❓
|
||||
</button>
|
||||
<button
|
||||
className="control-button control-button-new-game"
|
||||
onClick={handleNewGame}
|
||||
title="Новая игра"
|
||||
>
|
||||
🎮
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 className="app-title">
|
||||
<span className="title-number">100</span>
|
||||
<span className="title-to">к</span>
|
||||
<span className="title-number">1</span>
|
||||
</h1>
|
||||
|
||||
{questions.length > 0 && currentQuestion && (
|
||||
<div className="question-counter-wrapper">
|
||||
<div className="question-counter">
|
||||
{currentQuestionIndex + 1}/{questions.length}
|
||||
</div>
|
||||
<button
|
||||
className="show-all-button-top"
|
||||
onClick={handleShowAll}
|
||||
title={areAllRevealed ? "Скрыть все ответы" : "Показать все ответы"}
|
||||
>
|
||||
{areAllRevealed ? "Скрыть все" : "Показать все"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<QuestionsModal
|
||||
isOpen={isQuestionsModalOpen}
|
||||
onClose={() => setIsQuestionsModalOpen(false)}
|
||||
questions={questions}
|
||||
onUpdateQuestions={handleUpdateQuestions}
|
||||
/>
|
||||
|
||||
<Game
|
||||
ref={gameRef}
|
||||
questions={questions}
|
||||
currentQuestionIndex={currentQuestionIndex}
|
||||
onQuestionIndexChange={setCurrentQuestionIndex}
|
||||
onQuestionsChange={setQuestions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/create-room" element={<CreateRoom />} />
|
||||
<Route path="/join-room" element={<JoinRoom />} />
|
||||
<Route path="/room/:roomCode" element={<RoomPage />} />
|
||||
<Route path="/local-game" element={<LocalGame />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
export default App;
|
||||
|
|
|
|||
161
src/components/LocalGameApp.jsx
Normal file
161
src/components/LocalGameApp.jsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import Game from './Game'
|
||||
import Snowflakes from './Snowflakes'
|
||||
import QuestionsModal from './QuestionsModal'
|
||||
import { questions as initialQuestions } from '../data/questions'
|
||||
import { getCookie, setCookie, deleteCookie } from '../utils/cookies'
|
||||
import '../App.css'
|
||||
|
||||
function LocalGameApp() {
|
||||
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false)
|
||||
const [questions, setQuestions] = useState(() => {
|
||||
const savedQuestions = getCookie('gameQuestions')
|
||||
return savedQuestions || initialQuestions
|
||||
})
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(() => {
|
||||
const savedIndex = getCookie('gameQuestionIndex')
|
||||
return savedIndex !== null ? savedIndex : 0
|
||||
})
|
||||
const [areAllRevealed, setAreAllRevealed] = useState(false)
|
||||
const gameRef = useRef(null)
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex]
|
||||
|
||||
useEffect(() => {
|
||||
if (questions.length > 0) {
|
||||
setCookie('gameQuestions', questions)
|
||||
}
|
||||
}, [questions])
|
||||
|
||||
useEffect(() => {
|
||||
setCookie('gameQuestionIndex', currentQuestionIndex)
|
||||
}, [currentQuestionIndex])
|
||||
|
||||
useEffect(() => {
|
||||
const checkRevealedState = () => {
|
||||
if (gameRef.current && gameRef.current.areAllAnswersRevealed) {
|
||||
setAreAllRevealed(gameRef.current.areAllAnswersRevealed())
|
||||
} else {
|
||||
setAreAllRevealed(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkRevealedState()
|
||||
const interval = setInterval(checkRevealedState, 200)
|
||||
return () => clearInterval(interval)
|
||||
}, [currentQuestionIndex, questions])
|
||||
|
||||
const handleUpdateQuestions = (updatedQuestions) => {
|
||||
setQuestions(updatedQuestions)
|
||||
if (currentQuestionIndex >= updatedQuestions.length) {
|
||||
setCurrentQuestionIndex(0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenPlayersModal = () => {
|
||||
if (gameRef.current) {
|
||||
gameRef.current.openPlayersModal()
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewGame = () => {
|
||||
if (window.confirm('Начать новую игру? Текущий прогресс будет потерян.')) {
|
||||
deleteCookie('gameQuestions')
|
||||
deleteCookie('gameQuestionIndex')
|
||||
deleteCookie('gamePlayers')
|
||||
deleteCookie('gamePlayerScores')
|
||||
deleteCookie('gameCurrentPlayerId')
|
||||
deleteCookie('gameRevealedAnswers')
|
||||
deleteCookie('gameOver')
|
||||
|
||||
setQuestions(initialQuestions)
|
||||
setCurrentQuestionIndex(0)
|
||||
|
||||
if (gameRef.current) {
|
||||
gameRef.current.newGame()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowAll = () => {
|
||||
if (gameRef.current && gameRef.current.showAllAnswers) {
|
||||
gameRef.current.showAllAnswers()
|
||||
setTimeout(() => {
|
||||
if (gameRef.current && gameRef.current.areAllAnswersRevealed) {
|
||||
setAreAllRevealed(gameRef.current.areAllAnswersRevealed())
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Snowflakes />
|
||||
<div className="app-content">
|
||||
<div className="app-title-bar">
|
||||
<div className="app-control-buttons">
|
||||
<button
|
||||
className="control-button control-button-players"
|
||||
onClick={handleOpenPlayersModal}
|
||||
title="Управление участниками"
|
||||
>
|
||||
👥
|
||||
</button>
|
||||
<button
|
||||
className="control-button control-button-questions"
|
||||
onClick={() => setIsQuestionsModalOpen(true)}
|
||||
title="Управление вопросами"
|
||||
>
|
||||
❓
|
||||
</button>
|
||||
<button
|
||||
className="control-button control-button-new-game"
|
||||
onClick={handleNewGame}
|
||||
title="Новая игра"
|
||||
>
|
||||
🎮
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 className="app-title">
|
||||
<span className="title-number">100</span>
|
||||
<span className="title-to">к</span>
|
||||
<span className="title-number">1</span>
|
||||
</h1>
|
||||
|
||||
{questions.length > 0 && currentQuestion && (
|
||||
<div className="question-counter-wrapper">
|
||||
<div className="question-counter">
|
||||
{currentQuestionIndex + 1}/{questions.length}
|
||||
</div>
|
||||
<button
|
||||
className="show-all-button-top"
|
||||
onClick={handleShowAll}
|
||||
title={areAllRevealed ? "Скрыть все ответы" : "Показать все ответы"}
|
||||
>
|
||||
{areAllRevealed ? "Скрыть все" : "Показать все"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<QuestionsModal
|
||||
isOpen={isQuestionsModalOpen}
|
||||
onClose={() => setIsQuestionsModalOpen(false)}
|
||||
questions={questions}
|
||||
onUpdateQuestions={handleUpdateQuestions}
|
||||
/>
|
||||
|
||||
<Game
|
||||
ref={gameRef}
|
||||
questions={questions}
|
||||
currentQuestionIndex={currentQuestionIndex}
|
||||
onQuestionIndexChange={setCurrentQuestionIndex}
|
||||
onQuestionsChange={setQuestions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LocalGameApp
|
||||
|
|
@ -72,3 +72,4 @@ const PlayersModal = ({ isOpen, onClose, players, onAddPlayer, onRemovePlayer })
|
|||
|
||||
export default PlayersModal
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -330,3 +330,4 @@ const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
|
|||
|
||||
export default QuestionsModal
|
||||
|
||||
|
||||
|
|
|
|||
104
src/context/AuthContext.jsx
Normal file
104
src/context/AuthContext.jsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { authApi } from '../services/api';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setToken] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
const storedToken = localStorage.getItem('token');
|
||||
|
||||
if (storedUser && storedToken) {
|
||||
setUser(JSON.parse(storedUser));
|
||||
setToken(storedToken);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const loginAnonymous = async (name) => {
|
||||
try {
|
||||
const response = await authApi.createAnonymous(name);
|
||||
const { user: newUser, token: newToken } = response.data;
|
||||
|
||||
setUser(newUser);
|
||||
setToken(newToken);
|
||||
|
||||
localStorage.setItem('user', JSON.stringify(newUser));
|
||||
localStorage.setItem('token', newToken);
|
||||
|
||||
return { user: newUser, token: newToken };
|
||||
} catch (error) {
|
||||
console.error('Anonymous login error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (email, password, name) => {
|
||||
try {
|
||||
const response = await authApi.register(email, password, name);
|
||||
const { user: newUser, token: newToken } = response.data;
|
||||
|
||||
setUser(newUser);
|
||||
setToken(newToken);
|
||||
|
||||
localStorage.setItem('user', JSON.stringify(newUser));
|
||||
localStorage.setItem('token', newToken);
|
||||
|
||||
return { user: newUser, token: newToken };
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
const response = await authApi.login(email, password);
|
||||
const { user: newUser, token: newToken } = response.data;
|
||||
|
||||
setUser(newUser);
|
||||
setToken(newToken);
|
||||
|
||||
localStorage.setItem('user', JSON.stringify(newUser));
|
||||
localStorage.setItem('token', newToken);
|
||||
|
||||
return { user: newUser, token: newToken };
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
loginAnonymous,
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated: !!user,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
|
@ -19,7 +19,7 @@ export const questions = [
|
|||
{ text: 'Икру', points: 80 },
|
||||
{ text: 'Варенье', points: 60 },
|
||||
{ text: 'Паштет', points: 40 },
|
||||
{ text: 'Плавленный сыр', points: 20 },
|
||||
{ text: 'Майонез', points: 20 },
|
||||
{ text: 'Горчицу', points: 10 },
|
||||
],
|
||||
},
|
||||
|
|
@ -41,10 +41,8 @@ export const questions = [
|
|||
answers: [
|
||||
{ text: 'Боится умереть', points: 100 },
|
||||
{ text: 'Неудобно (копыта мешают)', points: 80 },
|
||||
{ text: 'Не хочет, бросила', points: 60 },
|
||||
{ text: 'Ведёт здоровый образ жизни (вредно)', points: 40 },
|
||||
{ text: 'Болеет', points: 20 },
|
||||
{ text: 'Не предлагают', points: 10 },
|
||||
{ text: 'Не хочет', points: 60 },
|
||||
{ text: 'Не продают', points: 40 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -63,24 +61,23 @@ export const questions = [
|
|||
id: 10,
|
||||
text: 'Кто больше всех ест на Новый год?',
|
||||
answers: [
|
||||
{ text: 'Дети', points: 100 },
|
||||
{ text: 'Мужчины', points: 80 },
|
||||
{ text: 'Все', points: 60 },
|
||||
{ text: 'Женщины', points: 40 },
|
||||
{ text: 'Подростки', points: 20 },
|
||||
{ text: 'Бабушки', points: 10 },
|
||||
{ text: 'Миша', points: 100 },
|
||||
{ text: 'Егор', points: 40 },
|
||||
{ text: 'Лера', points: 40 },
|
||||
{ text: 'Бабуля', points: 40 },
|
||||
{ text: 'Вика', points: 40 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
text: 'Кто лучше всех говорит тосты?',
|
||||
answers: [
|
||||
{ text: 'Дедушка', points: 100 },
|
||||
{ text: 'Папа', points: 80 },
|
||||
{ text: 'Дядя', points: 60 },
|
||||
{ text: 'Муж', points: 40 },
|
||||
{ text: 'Друзья', points: 20 },
|
||||
{ text: 'Все', points: 10 },
|
||||
{ text: 'Миша', points: 100 },
|
||||
{ text: 'Андрей', points: 80 },
|
||||
{ text: 'Егор', points: 60 },
|
||||
{ text: 'Бабуля', points: 40 },
|
||||
{ text: 'Надя', points: 20 },
|
||||
{ text: 'ИИ', points: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -123,24 +120,21 @@ export const questions = [
|
|||
id: 8,
|
||||
text: 'Кто дольше всех собирается за стол?',
|
||||
answers: [
|
||||
{ text: 'Женщины', points: 100 },
|
||||
{ text: 'Дети', points: 80 },
|
||||
{ text: 'Мужчины', points: 60 },
|
||||
{ text: 'Бабушки', points: 40 },
|
||||
{ text: 'Подростки', points: 20 },
|
||||
{ text: 'Дедушки', points: 10 },
|
||||
{ text: 'Надя', points: 100 },
|
||||
{ text: 'Вика', points: 40 },
|
||||
{ text: 'Лера', points: 40 },
|
||||
{ text: 'Бабуля', points: 40 },
|
||||
{ text: 'Катя', points: 40 },
|
||||
{ text: 'Миша', points: 40 },
|
||||
{ text: 'Андрей', points: 40 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 24,
|
||||
text: 'Где мы встретим следующий новый год?',
|
||||
answers: [
|
||||
{ text: 'Дома', points: 100 },
|
||||
{ text: 'В лесу', points: 80 },
|
||||
{ text: 'С друзьями', points: 60 },
|
||||
{ text: 'На улице', points: 40 },
|
||||
{ text: 'На природе', points: 20 },
|
||||
{ text: 'В бане', points: 10 },
|
||||
{ text: 'Тут', points: 50 },
|
||||
{ text: 'Не тут', points: 50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -203,6 +197,17 @@ export const questions = [
|
|||
{ text: 'Свечи', points: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 333,
|
||||
text: 'Что важнее всего в новогоднюю ночь?',
|
||||
answers: [
|
||||
{ text: 'Семя', points: 100 },
|
||||
{ text: 'Компания', points: 80 },
|
||||
{ text: 'Оливье', points: 60 },
|
||||
{ text: 'Доесть еду', points: 40 },
|
||||
{ text: 'Миша', points: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
text: 'Чем обычно заканчивается новогодняя ночь?',
|
||||
|
|
|
|||
152
src/hooks/useRoom.js
Normal file
152
src/hooks/useRoom.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { roomsApi } from '../services/api';
|
||||
import socketService from '../services/socket';
|
||||
|
||||
export const useRoom = (roomCode) => {
|
||||
const [room, setRoom] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [participants, setParticipants] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomCode) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchRoom = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await roomsApi.getByCode(roomCode);
|
||||
setRoom(response.data);
|
||||
setParticipants(response.data.participants || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error('Error fetching room:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRoom();
|
||||
|
||||
// Connect to WebSocket
|
||||
socketService.connect();
|
||||
|
||||
// Listen for room updates
|
||||
const handleRoomUpdate = (updatedRoom) => {
|
||||
setRoom(updatedRoom);
|
||||
setParticipants(updatedRoom.participants || []);
|
||||
};
|
||||
|
||||
const handleGameStarted = (updatedRoom) => {
|
||||
setRoom(updatedRoom);
|
||||
};
|
||||
|
||||
const handleAnswerRevealed = (data) => {
|
||||
console.log('Answer revealed:', data);
|
||||
};
|
||||
|
||||
const handleScoreUpdated = (data) => {
|
||||
console.log('Score updated:', data);
|
||||
setParticipants((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === data.participantId ? { ...p, score: data.score } : p
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleQuestionChanged = (data) => {
|
||||
console.log('Question changed:', data);
|
||||
};
|
||||
|
||||
const handleGameEnded = (data) => {
|
||||
console.log('Game ended:', data);
|
||||
if (room) {
|
||||
setRoom({ ...room, status: 'FINISHED' });
|
||||
}
|
||||
};
|
||||
|
||||
socketService.on('roomUpdate', handleRoomUpdate);
|
||||
socketService.on('gameStarted', handleGameStarted);
|
||||
socketService.on('answerRevealed', handleAnswerRevealed);
|
||||
socketService.on('scoreUpdated', handleScoreUpdated);
|
||||
socketService.on('questionChanged', handleQuestionChanged);
|
||||
socketService.on('gameEnded', handleGameEnded);
|
||||
|
||||
return () => {
|
||||
socketService.off('roomUpdate', handleRoomUpdate);
|
||||
socketService.off('gameStarted', handleGameStarted);
|
||||
socketService.off('answerRevealed', handleAnswerRevealed);
|
||||
socketService.off('scoreUpdated', handleScoreUpdated);
|
||||
socketService.off('questionChanged', handleQuestionChanged);
|
||||
socketService.off('gameEnded', handleGameEnded);
|
||||
};
|
||||
}, [roomCode]);
|
||||
|
||||
const createRoom = useCallback(async (hostId, questionPackId, settings = {}) => {
|
||||
try {
|
||||
const response = await roomsApi.create(hostId, questionPackId, settings);
|
||||
setRoom(response.data);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const joinRoom = useCallback(async (roomId, userId, name, role = 'PLAYER') => {
|
||||
try {
|
||||
const response = await roomsApi.join(roomId, userId, name, role);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
if (room) {
|
||||
socketService.startGame(room.id, room.code);
|
||||
}
|
||||
}, [room]);
|
||||
|
||||
const revealAnswer = useCallback((answerIndex) => {
|
||||
if (room) {
|
||||
socketService.revealAnswer(room.code, answerIndex);
|
||||
}
|
||||
}, [room]);
|
||||
|
||||
const updateScore = useCallback((participantId, score) => {
|
||||
if (room) {
|
||||
socketService.updateScore(participantId, score, room.code);
|
||||
}
|
||||
}, [room]);
|
||||
|
||||
const nextQuestion = useCallback(() => {
|
||||
if (room) {
|
||||
socketService.nextQuestion(room.code);
|
||||
}
|
||||
}, [room]);
|
||||
|
||||
const endGame = useCallback(() => {
|
||||
if (room) {
|
||||
socketService.endGame(room.id, room.code);
|
||||
}
|
||||
}, [room]);
|
||||
|
||||
return {
|
||||
room,
|
||||
participants,
|
||||
loading,
|
||||
error,
|
||||
createRoom,
|
||||
joinRoom,
|
||||
startGame,
|
||||
revealAnswer,
|
||||
updateScore,
|
||||
nextQuestion,
|
||||
endGame,
|
||||
};
|
||||
};
|
||||
147
src/pages/CreateRoom.jsx
Normal file
147
src/pages/CreateRoom.jsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useRoom } from '../hooks/useRoom';
|
||||
import { questionsApi } from '../services/api';
|
||||
|
||||
const CreateRoom = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { createRoom, loading: roomLoading } = useRoom();
|
||||
|
||||
const [questionPacks, setQuestionPacks] = useState([]);
|
||||
const [selectedPackId, setSelectedPackId] = useState('');
|
||||
const [settings, setSettings] = useState({
|
||||
maxPlayers: 10,
|
||||
allowSpectators: true,
|
||||
timerEnabled: false,
|
||||
timerDuration: 30,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPacks = async () => {
|
||||
try {
|
||||
const response = await questionsApi.getPacks(user?.id);
|
||||
setQuestionPacks(response.data);
|
||||
if (response.data.length > 0) {
|
||||
setSelectedPackId(response.data[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching question packs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPacks();
|
||||
}, [user]);
|
||||
|
||||
const handleCreateRoom = async () => {
|
||||
if (!user || !selectedPackId) {
|
||||
alert('Выберите пак вопросов');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const room = await createRoom(user.id, selectedPackId, settings);
|
||||
navigate(`/room/${room.code}`);
|
||||
} catch (error) {
|
||||
console.error('Error creating room:', error);
|
||||
alert('Ошибка создания комнаты');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="create-room-page">
|
||||
<div className="create-room-container">
|
||||
<h1>Создать комнату</h1>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Выберите пак вопросов:</label>
|
||||
<select
|
||||
value={selectedPackId}
|
||||
onChange={(e) => setSelectedPackId(e.target.value)}
|
||||
>
|
||||
{questionPacks.map((pack) => (
|
||||
<option key={pack.id} value={pack.id}>
|
||||
{pack.name} ({pack.questionCount} вопросов)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Максимум игроков:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="2"
|
||||
max="20"
|
||||
value={settings.maxPlayers}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, maxPlayers: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.allowSpectators}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, allowSpectators: e.target.checked })
|
||||
}
|
||||
/>
|
||||
Разрешить зрителей
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.timerEnabled}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, timerEnabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
Включить таймер
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.timerEnabled && (
|
||||
<div className="form-group">
|
||||
<label>Время на ответ (сек):</label>
|
||||
<input
|
||||
type="number"
|
||||
min="10"
|
||||
max="120"
|
||||
value={settings.timerDuration}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, timerDuration: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="button-group">
|
||||
<button
|
||||
onClick={handleCreateRoom}
|
||||
disabled={roomLoading || !selectedPackId}
|
||||
className="primary"
|
||||
>
|
||||
{roomLoading ? 'Создание...' : 'Создать комнату'}
|
||||
</button>
|
||||
<button onClick={() => navigate('/')}>Назад</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateRoom;
|
||||
69
src/pages/Home.jsx
Normal file
69
src/pages/Home.jsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const Home = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user, loginAnonymous, isAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
if (!isAuthenticated) {
|
||||
try {
|
||||
await loginAnonymous('Гость');
|
||||
} catch (error) {
|
||||
console.error('Auto login failed:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, [isAuthenticated, loginAnonymous]);
|
||||
|
||||
const handleCreateRoom = () => {
|
||||
navigate('/create-room');
|
||||
};
|
||||
|
||||
const handleJoinRoom = () => {
|
||||
navigate('/join-room');
|
||||
};
|
||||
|
||||
const handleLocalGame = () => {
|
||||
navigate('/local-game');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home-page">
|
||||
<div className="home-container">
|
||||
<h1>100 к 1</h1>
|
||||
<p className="welcome-text">
|
||||
{user ? `Привет, ${user.name}!` : 'Добро пожаловать!'}
|
||||
</p>
|
||||
|
||||
<div className="menu-buttons">
|
||||
<button onClick={handleCreateRoom} className="menu-button primary">
|
||||
Создать комнату
|
||||
</button>
|
||||
|
||||
<button onClick={handleJoinRoom} className="menu-button">
|
||||
Присоединиться к комнате
|
||||
</button>
|
||||
|
||||
<button onClick={handleLocalGame} className="menu-button">
|
||||
Локальная игра
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="user-stats">
|
||||
<p>Игр сыграно: {user.gamesPlayed || 0}</p>
|
||||
<p>Побед: {user.gamesWon || 0}</p>
|
||||
<p>Очков: {user.totalPoints || 0}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
55
src/pages/JoinRoom.jsx
Normal file
55
src/pages/JoinRoom.jsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const JoinRoom = () => {
|
||||
const navigate = useNavigate();
|
||||
const [roomCode, setRoomCode] = useState('');
|
||||
|
||||
const handleJoin = () => {
|
||||
if (roomCode.trim().length === 6) {
|
||||
navigate(`/room/${roomCode.toUpperCase()}`);
|
||||
} else {
|
||||
alert('Введите 6-символьный код комнаты');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleJoin();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="join-room-page">
|
||||
<div className="join-room-container">
|
||||
<h1>Присоединиться к комнате</h1>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Код комнаты:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={roomCode}
|
||||
onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Введите код"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="button-group">
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
disabled={roomCode.trim().length !== 6}
|
||||
className="primary"
|
||||
>
|
||||
Присоединиться
|
||||
</button>
|
||||
<button onClick={() => navigate('/')}>Назад</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JoinRoom;
|
||||
8
src/pages/LocalGame.jsx
Normal file
8
src/pages/LocalGame.jsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
import LocalGameApp from '../components/LocalGameApp';
|
||||
|
||||
const LocalGame = () => {
|
||||
return <LocalGameApp />;
|
||||
};
|
||||
|
||||
export default LocalGame;
|
||||
127
src/pages/RoomPage.jsx
Normal file
127
src/pages/RoomPage.jsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useRoom } from '../hooks/useRoom';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
const RoomPage = () => {
|
||||
const { roomCode } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { room, participants, loading, error, joinRoom, startGame } = useRoom(roomCode);
|
||||
const [qrCode, setQrCode] = useState('');
|
||||
const [joined, setJoined] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const generateQR = async () => {
|
||||
try {
|
||||
const url = `${window.location.origin}/join-room?code=${roomCode}`;
|
||||
const qr = await QRCode.toDataURL(url);
|
||||
setQrCode(qr);
|
||||
} catch (err) {
|
||||
console.error('QR generation error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (roomCode) {
|
||||
generateQR();
|
||||
}
|
||||
}, [roomCode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleJoin = async () => {
|
||||
if (room && user && !joined) {
|
||||
const isParticipant = participants.some((p) => p.userId === user.id);
|
||||
if (!isParticipant) {
|
||||
try {
|
||||
await joinRoom(room.id, user.id, user.name || 'Гость', 'PLAYER');
|
||||
setJoined(true);
|
||||
} catch (error) {
|
||||
console.error('Join error:', error);
|
||||
}
|
||||
} else {
|
||||
setJoined(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleJoin();
|
||||
}, [room, user, participants, joined, joinRoom]);
|
||||
|
||||
const handleStartGame = () => {
|
||||
startGame();
|
||||
navigate(`/game/${roomCode}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Загрузка комнаты...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-page">
|
||||
<h1>Ошибка</h1>
|
||||
<p>{error}</p>
|
||||
<button onClick={() => navigate('/')}>На главную</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<div className="error-page">
|
||||
<h1>Комната не найдена</h1>
|
||||
<button onClick={() => navigate('/')}>На главную</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isHost = user && room.hostId === user.id;
|
||||
|
||||
return (
|
||||
<div className="room-page">
|
||||
<div className="room-container">
|
||||
<h1>Комната: {room.code}</h1>
|
||||
|
||||
<div className="room-info">
|
||||
<p>Статус: {room.status === 'WAITING' ? 'Ожидание игроков' : room.status}</p>
|
||||
<p>Игроков: {participants.length}/{room.maxPlayers}</p>
|
||||
</div>
|
||||
|
||||
{qrCode && (
|
||||
<div className="qr-code">
|
||||
<h3>QR-код для присоединения:</h3>
|
||||
<img src={qrCode} alt="QR Code" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="participants-list">
|
||||
<h3>Участники:</h3>
|
||||
<ul>
|
||||
{participants.map((participant) => (
|
||||
<li key={participant.id}>
|
||||
{participant.name} {participant.role === 'HOST' && '(Ведущий)'}
|
||||
{participant.role === 'SPECTATOR' && '(Зритель)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="button-group">
|
||||
{isHost && room.status === 'WAITING' && (
|
||||
<button
|
||||
onClick={handleStartGame}
|
||||
disabled={participants.length < 2}
|
||||
className="primary"
|
||||
>
|
||||
Начать игру
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => navigate('/')}>Покинуть комнату</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomPage;
|
||||
42
src/services/api.js
Normal file
42
src/services/api.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Auth endpoints
|
||||
export const authApi = {
|
||||
createAnonymous: (name) => api.post('/auth/anonymous', { name }),
|
||||
register: (email, password, name) => api.post('/auth/register', { email, password, name }),
|
||||
login: (email, password) => api.post('/auth/login', { email, password }),
|
||||
};
|
||||
|
||||
// Rooms endpoints
|
||||
export const roomsApi = {
|
||||
create: (hostId, questionPackId, settings) =>
|
||||
api.post('/rooms', { hostId, questionPackId, settings }),
|
||||
getByCode: (code) => api.get(`/rooms/${code}`),
|
||||
join: (roomId, userId, name, role) =>
|
||||
api.post(`/rooms/${roomId}/join`, { userId, name, role }),
|
||||
};
|
||||
|
||||
// Questions endpoints
|
||||
export const questionsApi = {
|
||||
createPack: (data) => api.post('/questions/packs', data),
|
||||
getPacks: (userId) => api.get('/questions/packs', { params: { userId } }),
|
||||
getPack: (id) => api.get(`/questions/packs/${id}`),
|
||||
updatePack: (id, data) => api.put(`/questions/packs/${id}`, data),
|
||||
deletePack: (id) => api.delete(`/questions/packs/${id}`),
|
||||
};
|
||||
|
||||
// Stats endpoints
|
||||
export const statsApi = {
|
||||
createHistory: (data) => api.post('/stats/game-history', data),
|
||||
getHistory: (userId) => api.get(`/stats/game-history/${userId}`),
|
||||
getUserStats: (userId) => api.get(`/stats/user/${userId}`),
|
||||
};
|
||||
|
||||
export default api;
|
||||
102
src/services/socket.js
Normal file
102
src/services/socket.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { io } from 'socket.io-client';
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:3000';
|
||||
|
||||
class SocketService {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.socket?.connected) {
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
this.socket = io(WS_URL, {
|
||||
withCredentials: true,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('WebSocket connected:', this.socket.id);
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('WebSocket disconnected');
|
||||
});
|
||||
|
||||
this.socket.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
});
|
||||
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.socket) {
|
||||
this.connect();
|
||||
}
|
||||
this.socket.on(event, callback);
|
||||
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event).push(callback);
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (this.socket) {
|
||||
this.socket.off(event, callback);
|
||||
}
|
||||
|
||||
if (this.listeners.has(event)) {
|
||||
const callbacks = this.listeners.get(event);
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
if (!this.socket) {
|
||||
this.connect();
|
||||
}
|
||||
this.socket.emit(event, data);
|
||||
}
|
||||
|
||||
// Game-specific methods
|
||||
joinRoom(roomCode, userId) {
|
||||
this.emit('joinRoom', { roomCode, userId });
|
||||
}
|
||||
|
||||
startGame(roomId, roomCode) {
|
||||
this.emit('startGame', { roomId, roomCode });
|
||||
}
|
||||
|
||||
revealAnswer(roomCode, answerIndex) {
|
||||
this.emit('revealAnswer', { roomCode, answerIndex });
|
||||
}
|
||||
|
||||
updateScore(participantId, score, roomCode) {
|
||||
this.emit('updateScore', { participantId, score, roomCode });
|
||||
}
|
||||
|
||||
nextQuestion(roomCode) {
|
||||
this.emit('nextQuestion', { roomCode });
|
||||
}
|
||||
|
||||
endGame(roomId, roomCode) {
|
||||
this.emit('endGame', { roomId, roomCode });
|
||||
}
|
||||
}
|
||||
|
||||
export default new SocketService();
|
||||
Loading…
Reference in a new issue