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

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

29
client/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# Сборка приложения
FROM node:20-alpine AS builder
WORKDIR /app
# Копируем package.json
COPY package*.json ./
# Устанавливаем зависимости
RUN npm install
# Копируем исходный код
COPY . .
# Собираем production build
RUN npm run build
# Production образ с nginx
FROM nginx:alpine
# Копируем собранное приложение
COPY --from=builder /app/dist /usr/share/nginx/html
# Копируем конфигурацию nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Своя игра</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

56
client/nginx.conf Normal file
View File

@@ -0,0 +1,56 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Проксирование Socket.io к серверу
location /socket.io/ {
proxy_pass http://svoya-igra-server:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Проксирование API
location /api/ {
proxy_pass http://svoya-igra-server:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Проксирование медиа (изображения, аудио, видео)
location /images/ {
proxy_pass http://svoya-igra-server:3001;
}
location /audio/ {
proxy_pass http://svoya-igra-server:3001;
}
location /video/ {
proxy_pass http://svoya-igra-server:3001;
}
# SPA
location / {
try_files $uri $uri/ /index.html;
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

3257
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
client/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "svoya-igra-client",
"version": "1.0.0",
"description": "Frontend for Svoya Igra",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.1",
"socket.io-client": "^4.7.2"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"vite": "^5.0.8"
}
}

6
client/postcss.config.js Normal file
View File

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

22
client/src/App.jsx Normal file
View File

@@ -0,0 +1,22 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import CreateGame from './pages/CreateGame'
import JoinGame from './pages/JoinGame'
import Game from './pages/Game'
function App() {
return (
<Router>
<div className="min-h-screen bg-game-blue">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/create" element={<CreateGame />} />
<Route path="/join" element={<JoinGame />} />
<Route path="/game/:gameCode" element={<Game />} />
</Routes>
</div>
</Router>
)
}
export default App

View File

@@ -0,0 +1,119 @@
function GameBoard({ questions, usedQuestions, onSelectQuestion, currentRound, isHost }) {
const isQuestionUsed = (category, points, questionIndex) => {
// Ищем этот конкретный вопрос в использованных
// Сначала проверяем по questionIndex (новый метод)
const foundByIndex = usedQuestions.find(
q => q.category === category && q.points === points && q.questionIndex === questionIndex
);
if (foundByIndex) {
console.log(`✓ Question used (by index): cat="${category}", pts=${points}, idx=${questionIndex}`);
return true;
}
// Для обратной совместимости: если в usedQuestions нет questionIndex,
// проверяем, сколько вопросов с такими баллами уже использовано
const usedCount = usedQuestions.filter(
q => q.category === category && q.points === points
).length;
if (usedCount === 0) return false;
// Получаем категорию и находим индекс вопроса среди вопросов с такими же баллами
const categoryData = questions.find(cat => cat.name === category);
const questionsWithSamePoints = categoryData?.questions
.map((q, idx) => ({ ...q, originalIndex: idx }))
.filter(q => q.points === points) || [];
const positionAmongSamePoints = questionsWithSamePoints.findIndex(q => q.originalIndex === questionIndex);
// Если позиция вопроса меньше количества использованных, значит он уже использован
return positionAmongSamePoints >= 0 && positionAmongSamePoints < usedCount;
}
const getPointsForRound = (basePoints, round) => {
return round === 2 ? basePoints * 2 : basePoints
}
// Проверить сколько вопросов осталось в категории
const hasAvailableQuestions = (category) => {
return category.questions.some((q, idx) => !isQuestionUsed(category.name, q.points, idx))
}
// Фильтруем категории - показываем только те, где есть доступные вопросы
const availableCategories = questions.filter(hasAvailableQuestions)
// Лог для отладки
console.log('📋 GameBoard render:', {
totalCategories: questions.length,
availableCategories: availableCategories.length,
usedQuestions: usedQuestions
});
return (
<div className="bg-slate-800 p-3 md:p-6 rounded-xl shadow-xl border border-slate-700">
{!isHost && (
<div className="mb-3 md:mb-4 bg-blue-900 p-3 md:p-4 rounded-lg text-center">
<p className="text-white text-sm md:text-lg font-medium">
Ведущий выбирает вопросы. Будьте готовы отвечать!
</p>
</div>
)}
{isHost && (
<div className="mb-3 md:mb-4 bg-blue-900 p-3 md:p-4 rounded-lg text-center">
<p className="text-white text-sm md:text-lg font-bold">
Выберите вопрос
</p>
</div>
)}
{availableCategories.length > 0 ? (
<div className="space-y-2">
{availableCategories.map((category, catIdx) => (
<div key={catIdx} className="flex flex-col md:flex-row gap-2">
{/* Название категории */}
<div className="bg-blue-900 p-2 md:p-4 rounded-lg flex items-center justify-center border border-blue-700 md:min-w-[200px]">
<h3 className="text-white font-bold text-sm md:text-lg text-center">
{category.name}
</h3>
</div>
{/* Вопросы по номиналам */}
<div className="grid grid-cols-5 md:flex gap-1 md:gap-2 flex-1">
{category.questions.map((question, questionIndex) => {
const displayPoints = getPointsForRound(question.points, currentRound)
const isUsed = isQuestionUsed(category.name, question.points, questionIndex)
return (
<button
key={questionIndex}
onClick={() => !isUsed && isHost && onSelectQuestion(category.name, question.points)}
disabled={isUsed || !isHost}
className={`flex-1 p-3 md:p-6 rounded-lg font-bold text-base md:text-2xl transition-all duration-200 ${
isUsed
? 'bg-slate-700 text-gray-500 cursor-not-allowed border border-slate-600'
: isHost
? 'bg-blue-600 text-white hover:bg-blue-700 cursor-pointer shadow-lg border border-blue-500'
: 'bg-blue-600 text-white cursor-default opacity-60 border border-blue-500'
}`}
>
{isUsed ? '—' : displayPoints}
</button>
)
})}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 md:py-12 bg-slate-700 rounded-lg border border-slate-600">
<p className="text-white text-lg md:text-2xl font-bold px-4">
Все вопросы раунда {currentRound} использованы!
</p>
</div>
)}
</div>
)
}
export default GameBoard

View File

@@ -0,0 +1,237 @@
import { useState, useRef, useEffect } from 'react'
function QuestionModal({ question, timer, onSubmitAnswer, answeringTeamName, isHost, onBuzzIn, canBuzzIn, onCloseQuestion, gameCode, socket }) {
const [showAnswer, setShowAnswer] = useState(false)
const [mediaEnded, setMediaEnded] = useState(false)
const audioRef = useRef(null)
const videoRef = useRef(null)
if (!question) return null
// Обработчик окончания проигрывания медиа
const handleMediaEnded = () => {
console.log('🎬 Media playback ended');
setMediaEnded(true);
// Отправляем событие на сервер, чтобы начался таймер
if (socket && gameCode) {
socket.emit('media-ended', { gameCode });
}
}
// Сбрасываем флаг при смене вопроса
useEffect(() => {
setMediaEnded(false);
}, [question])
const getMediaUrl = (path) => {
// Если определена переменная окружения, используем её
if (import.meta.env.VITE_SERVER_URL) {
return `${import.meta.env.VITE_SERVER_URL}${path}`
}
// В production используем текущий хост (Caddy проксирует на сервер)
if (import.meta.env.PROD) {
return `${window.location.protocol}//${window.location.host}${path}`
}
// В development используем localhost
return `http://localhost:3001${path}`
}
const renderQuestionContent = () => {
switch (question.type) {
case 'image':
return (
<div className="bg-blue-900 p-4 md:p-8 rounded-lg mb-4 md:mb-6">
<img
src={getMediaUrl(question.question)}
alt="Question"
className="max-w-full max-h-64 md:max-h-96 mx-auto rounded-lg mb-4"
/>
{question.questionText && (
<p className="text-white text-base md:text-2xl text-center mt-4">
{question.questionText}
</p>
)}
</div>
)
case 'audio':
return (
<div className="bg-blue-900 p-4 md:p-8 rounded-lg mb-4 md:mb-6">
<audio
ref={audioRef}
controls
autoPlay
src={getMediaUrl(question.question)}
className="w-full mb-4"
onEnded={handleMediaEnded}
>
Ваш браузер не поддерживает аудио элемент.
</audio>
{question.questionText && (
<p className="text-white text-base md:text-2xl text-center mt-4">
{question.questionText}
</p>
)}
{!mediaEnded && (
<p className="text-yellow-400 text-sm md:text-base text-center mt-2">
Таймер начнется после прослушивания
</p>
)}
</div>
)
case 'video':
return (
<div className="bg-blue-900 p-4 md:p-8 rounded-lg mb-4 md:mb-6">
<video
ref={videoRef}
controls
autoPlay
src={getMediaUrl(question.question)}
className="max-w-full max-h-64 md:max-h-96 mx-auto rounded-lg mb-4"
onEnded={handleMediaEnded}
>
Ваш браузер не поддерживает видео элемент.
</video>
{question.questionText && (
<p className="text-white text-base md:text-2xl text-center mt-4">
{question.questionText}
</p>
)}
{!mediaEnded && (
<p className="text-yellow-400 text-sm md:text-base text-center mt-2">
Таймер начнется после просмотра
</p>
)}
</div>
)
case 'text':
default:
return (
<div className="bg-blue-900 p-4 md:p-8 rounded-lg mb-4 md:mb-6">
<p className="text-white text-base md:text-2xl text-center leading-relaxed">
{question.question}
</p>
</div>
)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 p-4 md:p-8 lg:p-12 rounded-lg max-w-4xl w-full max-h-screen overflow-y-auto">
<div className="text-center mb-4 md:mb-6">
<p className="text-game-gold text-base md:text-xl mb-2">{question.category}</p>
<p className="text-game-gold text-xl md:text-3xl font-bold mb-3 md:mb-4">{question.points} баллов</p>
{timer !== null && (
<p className="text-3xl md:text-5xl font-bold text-white mb-4 md:mb-6">{timer}с</p>
)}
</div>
{renderQuestionContent()}
{/* Кнопка "Показать ответ" для ведущего */}
{isHost && (
<div className="mb-4 md:mb-6">
{!showAnswer ? (
<button
onClick={() => setShowAnswer(true)}
className="w-full bg-yellow-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-lg text-base md:text-xl hover:bg-yellow-700 transition"
>
Показать ответ
</button>
) : (
<div className="bg-green-900 p-4 md:p-6 rounded-lg">
<p className="text-white text-lg md:text-2xl font-bold text-center">
Правильный ответ:
</p>
<p className="text-game-gold text-xl md:text-3xl text-center mt-2 md:mt-3">
{question.answer}
</p>
</div>
)}
</div>
)}
{/* Кнопка "Ответить" для команд (до того как кто-то нажал buzz-in) */}
{canBuzzIn && (
<div className="mb-4 md:mb-6">
<button
onClick={onBuzzIn}
className="w-full bg-red-600 text-white font-bold py-4 md:py-6 px-8 md:px-12 rounded-lg text-lg md:text-2xl hover:bg-red-700 transition animate-pulse"
>
ОТВЕТИТЬ
</button>
{timer && (
<p className="text-2xl md:text-3xl text-game-gold mt-3 md:mt-4 font-bold text-center">
{timer}с
</p>
)}
</div>
)}
{/* Для ведущего: показать кто отвечает + кнопки управления */}
{isHost && answeringTeamName && (
<div className="mb-4 md:mb-6">
<div className="bg-blue-900 p-4 md:p-6 rounded-lg mb-3 md:mb-4">
<p className="text-white text-lg md:text-2xl font-bold text-center">
Отвечает команда:
</p>
<p className="text-game-gold text-xl md:text-3xl text-center mt-2">
{answeringTeamName}
</p>
</div>
<div className="flex gap-2 md:gap-4 justify-center">
<button
onClick={() => onSubmitAnswer(true)}
className="flex-1 md:flex-none bg-green-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-lg text-base md:text-xl hover:bg-green-700 transition"
>
Верно
</button>
<button
onClick={() => onSubmitAnswer(false)}
className="flex-1 md:flex-none bg-red-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-lg text-base md:text-xl hover:bg-red-700 transition"
>
Неверно
</button>
</div>
</div>
)}
{/* Для ведущего: кнопка завершить досрочно (если никто не отвечает) */}
{isHost && !answeringTeamName && (
<div className="mb-4 md:mb-6">
<button
onClick={onCloseQuestion}
className="w-full bg-orange-600 text-white font-bold py-2 md:py-3 px-4 md:px-6 rounded-lg text-sm md:text-lg hover:bg-orange-700 transition"
>
Завершить вопрос досрочно
</button>
</div>
)}
{/* Сообщение для команд */}
{!isHost && answeringTeamName && (
<div className="mb-4 md:mb-6">
<p className="text-center text-white text-base md:text-xl">
Отвечает команда: <span className="text-game-gold font-bold">{answeringTeamName}</span>
</p>
</div>
)}
{/* Сообщение ожидания для команд */}
{!isHost && !canBuzzIn && !answeringTeamName && (
<p className="text-center text-gray-400 text-sm md:text-lg">
Ожидание...
</p>
)}
</div>
</div>
)
}
export default QuestionModal

View File

@@ -0,0 +1,21 @@
function TeamScores({ teams, myTeamId }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4 mb-6 md:mb-8">
{teams.map((team) => (
<div
key={team.id}
className={`p-4 md:p-6 rounded-xl border shadow-lg transition-all duration-200 ${
team.id === myTeamId
? 'bg-blue-900 border-blue-500'
: 'bg-slate-800 border-slate-700'
}`}
>
<h3 className="text-lg md:text-2xl font-bold text-white mb-1 md:mb-2">{team.name}</h3>
<p className="text-2xl md:text-4xl font-bold text-blue-400">{team.score} баллов</p>
</div>
))}
</div>
)
}
export default TeamScores

19
client/src/index.css Normal file
View File

@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #0A1E3D;
color: white;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

10
client/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import socket from '../socket'
function CreateGame() {
const [hostName, setHostName] = useState('')
const [gameCode, setGameCode] = useState(null)
const navigate = useNavigate()
useEffect(() => {
// Слушаем событие создания игры
socket.on('game-created', ({ gameCode }) => {
setGameCode(gameCode)
})
return () => {
socket.off('game-created')
}
}, [])
const handleCreate = (e) => {
e.preventDefault()
socket.emit('create-game', { hostName })
}
const copyGameCode = () => {
navigator.clipboard.writeText(gameCode)
alert('Код игры скопирован!')
}
const startGame = () => {
// Сохраняем в localStorage для восстановления после перезагрузки
localStorage.setItem('gameSession', JSON.stringify({
gameCode,
isHost: true,
hostName
}))
navigate(`/game/${gameCode}`, { state: { isHost: true, hostName } })
}
if (gameCode) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 px-4">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-blue-400 mb-6 md:mb-8">
Игра создана!
</h1>
<div className="bg-slate-800 p-6 md:p-8 rounded-xl w-full max-w-md text-center border border-slate-700 shadow-xl">
<p className="text-gray-300 text-base md:text-lg mb-4">
Код для команд:
</p>
<div className="bg-slate-900 p-4 md:p-6 rounded-lg mb-6 border border-blue-500">
<p className="text-blue-400 text-3xl sm:text-4xl md:text-5xl font-bold tracking-wider">
{gameCode}
</p>
</div>
<button
onClick={copyGameCode}
className="w-full bg-slate-700 hover:bg-slate-600 text-white font-bold py-3 rounded-lg transition-all duration-200 mb-3 shadow-lg"
>
📋 Скопировать код
</button>
<button
onClick={startGame}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-all duration-200 shadow-lg"
>
Перейти к игре
</button>
<p className="text-gray-400 text-xs md:text-sm mt-4">
Отправьте этот код командам для присоединения
</p>
</div>
</div>
)
}
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 px-4">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-blue-400 mb-6 md:mb-8">
Создать новую игру
</h1>
<form onSubmit={handleCreate} className="bg-slate-800 p-6 md:p-8 rounded-xl w-full max-w-md border border-slate-700 shadow-xl">
<div className="mb-6">
<label className="block text-gray-300 mb-2 font-medium text-sm md:text-base">Имя ведущего</label>
<input
type="text"
value={hostName}
onChange={(e) => setHostName(e.target.value)}
className="w-full p-3 rounded-lg bg-slate-900 text-white border border-slate-600 focus:outline-none focus:border-blue-500 transition-colors"
placeholder="Введите ваше имя"
required
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl"
>
Создать
</button>
<button
type="button"
onClick={() => navigate('/')}
className="w-full mt-3 bg-slate-700 hover:bg-slate-600 text-white font-bold py-3 rounded-lg transition-all duration-200 shadow-lg"
>
Назад
</button>
</form>
</div>
)
}
export default CreateGame

437
client/src/pages/Game.jsx Normal file
View File

@@ -0,0 +1,437 @@
import { useState, useEffect } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router-dom'
import socket from '../socket'
import GameBoard from '../components/GameBoard'
import QuestionModal from '../components/QuestionModal'
import TeamScores from '../components/TeamScores'
function Game() {
const { gameCode } = useParams()
const location = useLocation()
const navigate = useNavigate()
// Попытка восстановить сессию из localStorage при перезагрузке
const getSessionData = () => {
if (location.state) {
return location.state
}
// Если state пустой, попробуем восстановить из localStorage
const savedSession = localStorage.getItem('gameSession')
if (savedSession) {
const session = JSON.parse(savedSession)
// Проверяем что это та же игра
if (session.gameCode === gameCode) {
return session
}
}
return {}
}
const { teamName, isHost, hostName } = getSessionData()
const [game, setGame] = useState(null)
const [questions, setQuestions] = useState([])
const [currentQuestion, setCurrentQuestion] = useState(null)
const [timer, setTimer] = useState(null)
const [myTeam, setMyTeam] = useState(null)
const [answerMedia, setAnswerMedia] = useState(null)
const [shouldFinishAfterMedia, setShouldFinishAfterMedia] = useState(false)
// Если нет teamName и не хост, редиректим на главную
useEffect(() => {
if (!teamName && !isHost) {
console.warn('No team name or host status, redirecting to home')
navigate('/')
}
}, [teamName, isHost, navigate])
// Подключаемся к игре (хост или команда)
useEffect(() => {
if (isHost) {
socket.emit('host-join-game', { gameCode })
} else if (teamName) {
// Команда переподключается к игре
socket.emit('join-game', { gameCode, teamName })
}
}, [isHost, teamName, gameCode])
useEffect(() => {
// Socket обработчики
socket.on('game-updated', (updatedGame) => {
setGame(updatedGame)
if (!isHost && teamName) {
const team = updatedGame.teams.find(t => t.name === teamName)
setMyTeam(team)
}
// Обновляем selectedCategories когда игра обновляется
if (updatedGame.selectedCategories) {
setQuestions(updatedGame.selectedCategories)
}
})
socket.on('game-started', (game) => {
setGame(game)
if (!isHost && teamName) {
const team = game.teams.find(t => t.name === teamName)
setMyTeam(team)
}
if (game.selectedCategories) {
setQuestions(game.selectedCategories)
}
})
socket.on('host-game-loaded', (loadedGame) => {
setGame(loadedGame)
if (loadedGame.selectedCategories) {
setQuestions(loadedGame.selectedCategories)
}
})
socket.on('question-selected', ({ category, points, questionIndex, timer }) => {
// Ищем вопрос в selectedCategories игры по индексу
const categoryData = game?.selectedCategories?.find(cat => cat.name === category)
// Если есть questionIndex, используем его, иначе ищем по баллам (для обратной совместимости)
const questionData = questionIndex !== undefined
? categoryData?.questions[questionIndex]
: categoryData?.questions.find(q => q.points === points)
console.log(`📥 Question selected: category="${category}", points=${points}, questionIndex=${questionIndex}`, questionData);
setCurrentQuestion({ ...questionData, category, points })
setTimer(timer)
})
socket.on('timer-update', ({ timer }) => {
setTimer(timer)
})
socket.on('time-up', ({ answerMedia, shouldFinish }) => {
if (answerMedia) {
setAnswerMedia(answerMedia)
if (shouldFinish) {
setShouldFinishAfterMedia(true)
}
}
setCurrentQuestion(null)
setTimer(null)
})
socket.on('team-buzzing', ({ teamId, teamName }) => {
console.log(`${teamName} buzzing!`)
})
socket.on('answer-result', ({ teamId, isCorrect, questionClosed, answerMedia, shouldFinish }) => {
console.log('Answer result:', { teamId, isCorrect, questionClosed, answerMedia, shouldFinish })
if (questionClosed) {
// Правильный ответ - закрываем вопрос
if (answerMedia) {
setAnswerMedia(answerMedia)
if (shouldFinish) {
setShouldFinishAfterMedia(true)
}
}
setTimeout(() => {
setCurrentQuestion(null)
setTimer(null)
}, 2000)
} else {
// Неправильный ответ - продолжаем игру
console.log('Incorrect answer, waiting for other teams')
}
})
socket.on('round-changed', ({ round }) => {
console.log(`Round ${round} started`)
// Принудительно обновляем состояние игры после смены раунда
// Сервер отправит game-updated сразу после, но мы сбрасываем локальное состояние
setCurrentQuestion(null)
setTimer(null)
setAnswerMedia(null)
})
socket.on('game-finished', (finishedGame) => {
setGame(finishedGame)
})
return () => {
socket.off('game-updated')
socket.off('game-started')
socket.off('host-game-loaded')
socket.off('question-selected')
socket.off('timer-update')
socket.off('time-up')
socket.off('team-buzzing')
socket.off('answer-result')
socket.off('round-changed')
socket.off('game-finished')
}
}, [teamName, questions, isHost])
const handleStartGame = () => {
socket.emit('start-game', { gameCode })
}
const handleSelectQuestion = (category, points) => {
// Только хост может выбирать вопросы
if (isHost) {
socket.emit('select-question', { gameCode, category, points })
}
}
const handleBuzzIn = () => {
if (myTeam) {
socket.emit('buzz-in', { gameCode, teamId: myTeam.id })
}
}
const handleSubmitAnswer = (isCorrect) => {
// Теперь только ведущий отправляет ответ
console.log('handleSubmitAnswer called', { isHost, answeringTeam: game.answeringTeam, isCorrect })
if (isHost && game.answeringTeam) {
console.log('Sending submit-answer', { gameCode, teamId: game.answeringTeam, isCorrect })
socket.emit('submit-answer', { gameCode, teamId: game.answeringTeam, isCorrect })
} else {
console.log('Not sending: isHost=', isHost, 'answeringTeam=', game.answeringTeam)
}
}
const handleCloseQuestion = () => {
// Ведущий может досрочно закрыть вопрос
if (isHost) {
socket.emit('close-question', { gameCode })
}
}
const handleFinishGame = () => {
// Ведущий завершает игру
if (isHost) {
socket.emit('finish-game', { gameCode })
}
}
const handleCloseAnswerMedia = () => {
setAnswerMedia(null)
if (shouldFinishAfterMedia && isHost) {
setShouldFinishAfterMedia(false)
socket.emit('finish-game', { gameCode })
}
}
if (!game) {
return (
<div className="flex items-center justify-center min-h-screen px-4">
<div className="text-center">
<h2 className="text-2xl md:text-3xl font-bold text-game-gold mb-4">Загрузка...</h2>
</div>
</div>
)
}
// Экран результатов
if (game.status === 'finished') {
const sortedTeams = [...game.teams].sort((a, b) => b.score - a.score)
const winner = sortedTeams[0]
return (
<div className="min-h-screen p-4 md:p-8">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-8 md:mb-12">
<h1 className="text-3xl md:text-6xl font-bold text-game-gold mb-4">ИГРА ЗАВЕРШЕНА!</h1>
<div className="bg-gradient-to-r from-yellow-400 to-yellow-600 p-6 md:p-8 rounded-lg mb-6 md:mb-8">
<p className="text-2xl md:text-4xl font-bold text-gray-900 mb-2">🏆 ПОБЕДИТЕЛЬ 🏆</p>
<p className="text-3xl md:text-5xl font-bold text-gray-900">{winner.name}</p>
<p className="text-xl md:text-3xl font-bold text-gray-900 mt-2">{winner.score} баллов</p>
</div>
</div>
<div className="bg-gray-800 p-4 md:p-8 rounded-lg mb-6 md:mb-8">
<h2 className="text-2xl md:text-3xl font-bold text-white mb-4 md:mb-6 text-center">Итоговая таблица</h2>
<div className="space-y-3 md:space-y-4">
{sortedTeams.map((team, index) => (
<div
key={team.id}
className={`flex justify-between items-center p-4 md:p-6 rounded-lg ${
index === 0
? 'bg-gradient-to-r from-yellow-600 to-yellow-700'
: index === 1
? 'bg-gradient-to-r from-gray-400 to-gray-500'
: index === 2
? 'bg-gradient-to-r from-orange-600 to-orange-700'
: 'bg-gray-700'
}`}
>
<div className="flex items-center gap-2 md:gap-4">
<span className="text-2xl md:text-4xl font-bold text-white">#{index + 1}</span>
<span className="text-base md:text-2xl font-bold text-white">{team.name}</span>
</div>
<span className="text-xl md:text-3xl font-bold text-white">{team.score}</span>
</div>
))}
</div>
</div>
<div className="text-center">
<button
onClick={() => navigate('/')}
className="bg-game-gold text-game-blue font-bold py-3 md:py-4 px-8 md:px-12 rounded-lg text-base md:text-xl hover:bg-yellow-500 transition"
>
Вернуться на главную
</button>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen p-4 md:p-8 bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900">
<div className="max-w-7xl mx-auto">
{/* Заголовок */}
<div className="text-center mb-6 md:mb-8">
<h1 className="text-3xl md:text-5xl font-bold text-blue-400 mb-3 md:mb-4">
СВОЯ ИГРА
</h1>
<div className="bg-slate-800 rounded-xl p-3 md:p-4 inline-block border border-slate-700">
<p className="text-sm md:text-xl text-gray-300">
Код игры: <span className="text-blue-400 font-bold tracking-wider">{gameCode}</span>
</p>
{isHost && (
<p className="text-xs md:text-md text-gray-400 mt-2">
Ведущий: {hostName}
</p>
)}
<div className="mt-2 md:mt-3 inline-block bg-blue-600 text-white px-4 md:px-6 py-1 md:py-2 rounded-lg font-bold shadow-lg text-sm md:text-base">
Раунд {game.currentRound}
</div>
</div>
</div>
{/* Счет команд */}
<TeamScores teams={game.teams} myTeamId={myTeam?.id} />
{/* Статус игры */}
{game.status === 'waiting' && isHost && (
<div className="text-center my-6 md:my-8">
<p className="text-white text-base md:text-lg mb-4">
Ожидание команд... ({game.teams.length} команд(ы) присоединилось)
</p>
{game.teams.length > 0 && (
<button
onClick={handleStartGame}
className="bg-green-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-lg text-lg md:text-xl hover:bg-green-700 transition"
>
Начать игру
</button>
)}
{game.teams.length === 0 && (
<p className="text-gray-400 text-sm md:text-base">
Отправьте код игры командам для присоединения
</p>
)}
</div>
)}
{game.status === 'waiting' && !isHost && (
<div className="text-center my-6 md:my-8">
<p className="text-white text-base md:text-lg">
Ожидание начала игры ведущим...
</p>
</div>
)}
{/* Игровое поле */}
{game.status === 'playing' && (
<>
<GameBoard
questions={questions}
usedQuestions={game.usedQuestions}
onSelectQuestion={handleSelectQuestion}
currentRound={game.currentRound}
isHost={isHost}
/>
</>
)}
{/* Модальное окно с вопросом */}
{currentQuestion && (
<QuestionModal
question={currentQuestion}
timer={timer}
onSubmitAnswer={handleSubmitAnswer}
answeringTeamName={game.teams.find(t => t.id === game.answeringTeam)?.name}
isHost={isHost}
onBuzzIn={handleBuzzIn}
canBuzzIn={!isHost && !game.answeringTeam && myTeam && !(game.answeredTeams || []).includes(myTeam.id)}
onCloseQuestion={handleCloseQuestion}
gameCode={gameCode}
socket={socket}
/>
)}
{/* Модальное окно с ответом (песня) - показываем всем */}
{answerMedia && (
<div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 p-4">
<div className="bg-gradient-to-br from-blue-900 to-slate-900 p-6 md:p-12 rounded-2xl max-w-4xl w-full border-4 border-blue-400 shadow-2xl">
<h2 className="text-2xl md:text-4xl font-bold text-blue-400 mb-6 md:mb-8 text-center">
Правильный ответ
</h2>
<div className="bg-slate-800 p-6 md:p-8 rounded-lg mb-6 md:mb-8">
<audio
controls
autoPlay
src={`${window.location.protocol}//${window.location.host}${answerMedia}`}
className="w-full"
>
Ваш браузер не поддерживает аудио элемент.
</audio>
</div>
<div className="text-center">
<button
onClick={handleCloseAnswerMedia}
className="bg-blue-600 text-white font-bold py-3 md:py-4 px-8 md:px-12 rounded-lg text-lg md:text-xl hover:bg-blue-700 transition shadow-lg"
>
Закрыть
</button>
</div>
</div>
</div>
)}
{/* Кнопка следующего раунда */}
{isHost && game.usedQuestions.length >= 25 && game.status === 'playing' && (
<div className="text-center mt-6 md:mt-8">
<button
onClick={() => socket.emit('next-round', { gameCode })}
className="bg-blue-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-lg text-lg md:text-xl hover:bg-blue-700 transition"
>
Следующий раунд
</button>
</div>
)}
{/* Кнопка завершить игру для ведущего */}
{isHost && game.status === 'playing' && (
<div className="text-center mt-6 md:mt-8">
<button
onClick={handleFinishGame}
className="bg-red-600 text-white font-bold py-2 md:py-3 px-6 md:px-8 rounded-lg text-base md:text-lg hover:bg-red-700 transition"
>
Завершить игру
</button>
</div>
)}
</div>
</div>
)
}
export default Game

36
client/src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,36 @@
import { useNavigate } from 'react-router-dom'
function Home() {
const navigate = useNavigate()
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 px-4">
<div className="text-center w-full max-w-md">
<div className="mb-8 md:mb-16">
<h1 className="text-4xl sm:text-5xl md:text-7xl font-bold text-blue-400 mb-2 md:mb-4">
СВОЯ ИГРА
</h1>
<p className="text-base md:text-lg text-gray-400">Интеллектуальная викторина</p>
</div>
<div className="flex flex-col gap-3 md:gap-4 w-full">
<button
onClick={() => navigate('/create')}
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-xl text-lg md:text-xl transition-all duration-200 shadow-lg hover:shadow-xl"
>
Создать игру
</button>
<button
onClick={() => navigate('/join')}
className="bg-slate-700 hover:bg-slate-600 text-white font-bold py-3 md:py-4 px-6 md:px-8 rounded-xl text-lg md:text-xl transition-all duration-200 shadow-lg hover:shadow-xl"
>
Присоединиться
</button>
</div>
</div>
</div>
)
}
export default Home

View File

@@ -0,0 +1,99 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import socket from '../socket'
function JoinGame() {
const [gameCode, setGameCode] = useState('')
const [teamName, setTeamName] = useState('')
const [error, setError] = useState('')
const navigate = useNavigate()
useEffect(() => {
// Слушаем события
const handleGameUpdated = () => {
// Сохраняем в localStorage перед навигацией
localStorage.setItem('gameSession', JSON.stringify({
gameCode: gameCode.toUpperCase(),
teamName,
isHost: false
}))
navigate(`/game/${gameCode.toUpperCase()}`, { state: { teamName } })
}
const handleError = ({ message }) => {
setError(message)
}
socket.on('game-updated', handleGameUpdated)
socket.on('error', handleError)
return () => {
socket.off('game-updated', handleGameUpdated)
socket.off('error', handleError)
}
}, [gameCode, teamName, navigate])
const handleJoin = (e) => {
e.preventDefault()
setError('')
socket.emit('join-game', { gameCode: gameCode.toUpperCase(), teamName })
}
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 px-4">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-blue-400 mb-6 md:mb-8">
Присоединиться к игре
</h1>
<form onSubmit={handleJoin} className="bg-slate-800 p-6 md:p-8 rounded-xl w-full max-w-md border border-slate-700 shadow-xl">
<div className="mb-4">
<label className="block text-gray-300 mb-2 font-medium text-sm md:text-base">Код игры</label>
<input
type="text"
value={gameCode}
onChange={(e) => setGameCode(e.target.value.toUpperCase())}
className="w-full p-3 rounded-lg bg-slate-900 text-white border border-slate-600 focus:outline-none focus:border-blue-500 uppercase text-center text-xl md:text-2xl tracking-wider transition-colors"
placeholder="ABC123"
maxLength={6}
required
/>
</div>
<div className="mb-6">
<label className="block text-gray-300 mb-2 font-medium text-sm md:text-base">Название команды</label>
<input
type="text"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
className="w-full p-3 rounded-lg bg-slate-900 text-white border border-slate-600 focus:outline-none focus:border-blue-500 transition-colors"
required
/>
</div>
{error && (
<div className="mb-4 p-3 bg-red-600/90 text-white rounded-lg text-sm md:text-base">
{error}
</div>
)}
<button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl"
>
Присоединиться
</button>
<button
type="button"
onClick={() => navigate('/')}
className="w-full mt-3 bg-slate-700 hover:bg-slate-600 text-white font-bold py-3 rounded-lg transition-all duration-200 shadow-lg"
>
Назад
</button>
</form>
</div>
)
}
export default JoinGame

27
client/src/socket.js Normal file
View File

@@ -0,0 +1,27 @@
import { io } from 'socket.io-client'
// Определяем URL сервера в зависимости от окружения
const getServerUrl = () => {
// Если определена переменная окружения, используем её
if (import.meta.env.VITE_SERVER_URL) {
return import.meta.env.VITE_SERVER_URL
}
// В production используем текущий хост (Caddy проксирует на сервер)
if (import.meta.env.PROD) {
return `${window.location.protocol}//${window.location.host}`
}
// В development используем localhost
return 'http://localhost:3001'
}
// Единый socket для всего приложения
const socket = io(getServerUrl(), {
autoConnect: true,
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5
})
export default socket

53
client/tailwind.config.js Normal file
View File

@@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
'game-blue': '#0A1E3D',
'game-gold': '#FFD700',
},
animation: {
'blob': 'blob 7s infinite',
'float': 'float 3s ease-in-out infinite',
'glow': 'glow 2s ease-in-out infinite alternate',
},
keyframes: {
blob: {
'0%': {
transform: 'translate(0px, 0px) scale(1)',
},
'33%': {
transform: 'translate(30px, -50px) scale(1.1)',
},
'66%': {
transform: 'translate(-20px, 20px) scale(0.9)',
},
'100%': {
transform: 'translate(0px, 0px) scale(1)',
},
},
float: {
'0%, 100%': {
transform: 'translateY(0px)',
},
'50%': {
transform: 'translateY(-20px)',
},
},
glow: {
'from': {
'box-shadow': '0 0 20px rgba(255, 215, 0, 0.5)',
},
'to': {
'box-shadow': '0 0 30px rgba(255, 215, 0, 0.8)',
},
},
},
},
},
plugins: [],
}

16
client/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0', // Разрешаем доступ из локальной сети
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
})