This commit is contained in:
Dmitry 2026-01-09 00:44:38 +03:00
parent 0c86868e0f
commit d6471d60c4
12 changed files with 640 additions and 969 deletions

7
.cursor/rules/rule.mdc Normal file
View file

@ -0,0 +1,7 @@
---
alwaysApply: true
---
App runs on a dedicated server with coolify and docker.
Migrations are run via docker on the server.
Keep code clean, never add todos and stubs outside tests.
Keep the final review short.

View file

@ -48,7 +48,8 @@ model Room {
// Состояние игры
currentQuestionIndex Int @default(0)
revealedAnswers Json @default("{}")
currentQuestionId String? // UUID текущего вопроса
revealedAnswers Json @default("{}") // {"questionUuid": ["answerUuid1", "answerUuid2"]}
currentPlayerId String?
isGameOver Boolean @default(false)

View file

@ -12,10 +12,19 @@ import { RoomEventsService } from './room-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { RoomPackService } from '../room-pack/room-pack.service';
interface PlayerAction {
action: 'revealAnswer' | 'nextQuestion' | 'prevQuestion';
roomId: string;
roomCode: string;
userId: string;
participantId: string;
// Для revealAnswer:
questionId?: string; // UUID вопроса
answerId?: string; // UUID ответа
}
@WebSocketGateway({
cors: {
// Примечание: декоратор выполняется на этапе инициализации,
// ConfigModule.forRoot() уже загружает переменные в process.env
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
},
@ -51,11 +60,18 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return room?.hostId === userId;
}
private async isCurrentPlayer(roomId: string, participantId: string): Promise<boolean> {
const room = await this.prisma.room.findUnique({
where: { id: roomId },
select: { currentPlayerId: true },
});
return room?.currentPlayerId === participantId;
}
@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);
await this.broadcastFullState(payload.roomCode);
}
@SubscribeMessage('startGame')
@ -67,122 +83,253 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
}
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')
async handleRevealAnswer(client: Socket, payload: { roomCode: string; answerIndex: number; userId: string; roomId: string; questionIndex?: number }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can reveal answers' });
return;
}
this.server.to(payload.roomCode).emit('answerRevealed', payload);
}
@SubscribeMessage('hideAnswer')
async handleHideAnswer(client: Socket, payload: { roomCode: string; answerIndex: number; userId: string; roomId: string; questionIndex?: number }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can hide answers' });
return;
}
this.server.to(payload.roomCode).emit('answerHidden', payload);
}
@SubscribeMessage('showAllAnswers')
async handleShowAllAnswers(client: Socket, payload: { roomCode: string; userId: string; roomId: string; questionIndex?: number }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can show all answers' });
return;
}
this.server.to(payload.roomCode).emit('allAnswersShown', payload);
}
@SubscribeMessage('hideAllAnswers')
async handleHideAllAnswers(client: Socket, payload: { roomCode: string; userId: string; roomId: string; questionIndex?: number }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can hide all answers' });
return;
}
this.server.to(payload.roomCode).emit('allAnswersHidden', payload);
}
@SubscribeMessage('updateScore')
async handleUpdateScore(client: Socket, payload: { participantId: string; score: number; roomCode: string; userId: string; roomId: string }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can update scores' });
return;
}
await this.roomsService.updateParticipantScore(payload.participantId, payload.score);
this.server.to(payload.roomCode).emit('scoreUpdated', payload);
}
@SubscribeMessage('nextQuestion')
async handleNextQuestion(client: Socket, payload: { roomCode: string; userId: string; roomId: string }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can change questions' });
return;
}
// Инициализировать первый вопрос и игрока
const room = await this.prisma.room.findUnique({
where: { id: payload.roomId },
select: { currentQuestionIndex: true },
include: {
roomPack: true,
participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } }
}
});
if (room) {
const newIndex = (room.currentQuestionIndex || 0) + 1;
await this.prisma.room.update({
where: { id: payload.roomId },
data: { currentQuestionIndex: newIndex },
const questions = room.roomPack?.questions as any[] || [];
const firstQuestion = questions[0];
const firstParticipant = room.participants[0];
if (firstQuestion && firstParticipant) {
await this.prisma.room.update({
where: { id: payload.roomId },
data: {
currentQuestionId: firstQuestion.id,
currentPlayerId: firstParticipant.id,
}
});
}
}
await this.broadcastFullState(payload.roomCode);
}
@SubscribeMessage('playerAction')
async handlePlayerAction(client: Socket, payload: PlayerAction) {
// Получаем комнату с данными
const room = await this.prisma.room.findUnique({
where: { id: payload.roomId },
include: {
roomPack: true,
participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } }
},
});
if (!room) {
client.emit('error', { message: 'Room not found' });
return;
}
// Проверяем права
const isHost = room.hostId === payload.userId;
const isCurrentPlayer = room.currentPlayerId === payload.participantId;
if (!isHost && !isCurrentPlayer) {
client.emit('error', { message: 'Not your turn!' });
return;
}
// Выполняем действие
try {
switch (payload.action) {
case 'revealAnswer':
await this.handleRevealAnswerAction(payload, room);
break;
case 'nextQuestion':
await this.handleNextQuestionAction(payload, room);
break;
case 'prevQuestion':
await this.handlePrevQuestionAction(payload, room);
break;
}
// Отправляем полное состояние всем
await this.broadcastFullState(payload.roomCode);
} catch (error) {
console.error('Error handling player action:', error);
client.emit('error', { message: 'Failed to process action' });
}
}
private async handleRevealAnswerAction(payload: PlayerAction, room: any) {
const questions = room.roomPack?.questions as any[] || [];
const question = questions.find(q => q.id === payload.questionId);
if (!question) {
console.error('Question not found:', payload.questionId);
return;
}
const answer = question.answers?.find((a: any) => a.id === payload.answerId);
if (!answer) {
console.error('Answer not found:', payload.answerId);
return;
}
// Обновляем revealedAnswers
const revealed = (room.revealedAnswers as any) || {};
const currentRevealed: string[] = revealed[payload.questionId] || [];
if (!currentRevealed.includes(payload.answerId)) {
currentRevealed.push(payload.answerId);
revealed[payload.questionId] = currentRevealed;
// Начисляем очки
await this.prisma.participant.update({
where: { id: payload.participantId },
data: { score: { increment: answer.points } }
});
this.server.to(payload.roomCode).emit('questionChanged', {
...payload,
questionIndex: newIndex,
// Сохраняем revealedAnswers
await this.prisma.room.update({
where: { id: payload.roomId },
data: { revealedAnswers: revealed }
});
// Определяем следующего игрока
const participants = room.participants;
const currentIdx = participants.findIndex((p: any) => p.id === payload.participantId);
const nextIdx = (currentIdx + 1) % participants.length;
const nextPlayerId = participants[nextIdx]?.id;
// Проверяем, это последний ответ?
const isLastAnswer = currentRevealed.length >= question.answers.length;
if (!isLastAnswer) {
// Меняем игрока
await this.prisma.room.update({
where: { id: payload.roomId },
data: { currentPlayerId: nextPlayerId }
});
} else {
// Последний ответ - проверяем, последний ли вопрос
const currentQuestionIndex = questions.findIndex((q: any) => q.id === payload.questionId);
const isLastQuestion = currentQuestionIndex >= questions.length - 1;
if (isLastQuestion) {
// Конец игры
await this.prisma.room.update({
where: { id: payload.roomId },
data: {
status: 'FINISHED',
isGameOver: true,
finishedAt: new Date()
}
});
} else {
// Меняем игрока для следующего вопроса
await this.prisma.room.update({
where: { id: payload.roomId },
data: { currentPlayerId: nextPlayerId }
});
}
}
}
}
private async handleNextQuestionAction(payload: PlayerAction, room: any) {
const questions = room.roomPack?.questions as any[] || [];
const currentIdx = questions.findIndex((q: any) => q.id === room.currentQuestionId);
if (currentIdx < questions.length - 1) {
const nextQuestion = questions[currentIdx + 1];
await this.prisma.room.update({
where: { id: payload.roomId },
data: {
currentQuestionId: nextQuestion.id,
currentQuestionIndex: currentIdx + 1 // Для совместимости
}
});
}
}
@SubscribeMessage('previousQuestion')
async handlePreviousQuestion(client: Socket, payload: { roomCode: string; userId: string; roomId: string }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can change questions' });
return;
}
private async handlePrevQuestionAction(payload: PlayerAction, room: any) {
const questions = room.roomPack?.questions as any[] || [];
const currentIdx = questions.findIndex((q: any) => q.id === room.currentQuestionId);
const room = await this.prisma.room.findUnique({
where: { id: payload.roomId },
select: { currentQuestionIndex: true },
});
if (room && room.currentQuestionIndex > 0) {
const newIndex = room.currentQuestionIndex - 1;
if (currentIdx > 0) {
const prevQuestion = questions[currentIdx - 1];
await this.prisma.room.update({
where: { id: payload.roomId },
data: { currentQuestionIndex: newIndex },
});
this.server.to(payload.roomCode).emit('questionChanged', {
...payload,
questionIndex: newIndex,
data: {
currentQuestionId: prevQuestion.id,
currentQuestionIndex: currentIdx - 1 // Для совместимости
}
});
}
}
// КЛЮЧЕВОЙ МЕТОД: Отправка полного состояния
private async broadcastFullState(roomCode: string) {
const room = await this.prisma.room.findUnique({
where: { code: roomCode },
include: {
participants: {
where: { isActive: true },
orderBy: { joinedAt: 'asc' }
},
roomPack: true,
host: { select: { id: true, name: true } }
}
});
if (!room) return;
const questions = room.roomPack?.questions as any[] || [];
// Инициализация currentQuestionId если не установлен
let currentQuestionId = room.currentQuestionId;
if (!currentQuestionId && questions.length > 0) {
currentQuestionId = questions[0].id;
await this.prisma.room.update({
where: { id: room.id },
data: { currentQuestionId }
});
}
const fullState = {
roomId: room.id,
roomCode: room.code,
status: room.status,
currentQuestionId: currentQuestionId,
currentPlayerId: room.currentPlayerId,
revealedAnswers: room.revealedAnswers,
isGameOver: room.isGameOver,
hostId: room.hostId,
participants: room.participants.map(p => ({
id: p.id,
userId: p.userId,
name: p.name,
role: p.role,
score: p.score
})),
questions: questions.map((q: any) => ({
id: q.id,
text: q.text || q.question,
answers: (q.answers || []).map((a: any) => ({
id: a.id,
text: a.text,
points: a.points
}))
}))
};
this.server.to(roomCode).emit('gameStateUpdated', fullState);
}
@SubscribeMessage('requestFullState')
async handleRequestFullState(client: Socket, payload: { roomCode: string }) {
await this.broadcastFullState(payload.roomCode);
}
@SubscribeMessage('endGame')
async handleEndGame(client: Socket, payload: { roomId: string; roomCode: string; userId: string }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
@ -192,40 +339,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
}
await this.roomsService.updateRoomStatus(payload.roomId, 'FINISHED');
this.server.to(payload.roomCode).emit('gameEnded', payload);
}
@SubscribeMessage('setCurrentPlayer')
async handleSetCurrentPlayer(client: Socket, payload: { roomId: string; roomCode: string; userId: string; playerId: string }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can select the current player' });
return;
}
await this.prisma.room.update({
where: { id: payload.roomId },
data: { currentPlayerId: payload.playerId },
});
this.server.to(payload.roomCode).emit('currentPlayerChanged', { playerId: payload.playerId });
}
@SubscribeMessage('updateRoomSettings')
async handleUpdateRoomSettings(client: Socket, payload: { roomId: string; roomCode: string; userId: string; settings: any }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can update room settings' });
return;
}
await this.prisma.room.update({
where: { id: payload.roomId },
data: payload.settings,
});
const room = await this.roomsService.getRoomByCode(payload.roomCode);
this.server.to(payload.roomCode).emit('roomUpdate', room);
await this.broadcastFullState(payload.roomCode);
}
@SubscribeMessage('restartGame')
@ -236,13 +350,26 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return;
}
const room = await this.prisma.room.findUnique({
where: { id: payload.roomId },
include: {
roomPack: true,
participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } }
}
});
const questions = room?.roomPack?.questions as any[] || [];
const firstQuestion = questions[0];
const firstParticipant = room?.participants[0];
await this.prisma.room.update({
where: { id: payload.roomId },
data: {
status: 'WAITING',
currentQuestionIndex: 0,
currentQuestionId: firstQuestion?.id || null,
revealedAnswers: {},
currentPlayerId: null,
currentPlayerId: firstParticipant?.id || null,
isGameOver: false,
answeredQuestions: 0,
},
@ -253,14 +380,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
data: { score: 0 },
});
const room = await this.roomsService.getRoomByCode(payload.roomCode);
this.server.to(payload.roomCode).emit('gameRestarted', room);
}
@SubscribeMessage('updateCustomQuestions')
async handleUpdateCustomQuestions(client: Socket, payload: { roomId: string; roomCode: string; userId: string; questions: any }) {
// DEPRECATED: Use updateRoomPack instead
return this.handleUpdateRoomPack(client, payload);
await this.broadcastFullState(payload.roomCode);
}
@SubscribeMessage('updateRoomPack')
@ -271,8 +391,8 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return;
}
const room = await this.roomsService.updateRoomPack(payload.roomId, payload.questions);
this.server.to(payload.roomCode).emit('roomPackUpdated', room);
await this.roomsService.updateRoomPack(payload.roomId, payload.questions);
await this.broadcastFullState(payload.roomCode);
}
@SubscribeMessage('importQuestions')
@ -290,9 +410,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
}
await this.roomPackService.importQuestions(payload.roomId, payload.sourcePackId, payload.questionIndices);
const room = await this.roomsService.getRoomByCode(payload.roomCode);
this.server.to(payload.roomCode).emit('roomPackUpdated', room);
await this.broadcastFullState(payload.roomCode);
}
@SubscribeMessage('kickPlayer')
@ -308,7 +426,6 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
data: { isActive: false },
});
const room = await this.roomsService.getRoomByCode(payload.roomCode);
this.server.to(payload.roomCode).emit('playerKicked', { participantId: payload.participantId, room });
await this.broadcastFullState(payload.roomCode);
}
}

View file

@ -11,9 +11,13 @@
align-items: center;
justify-content: center;
min-height: 0;
height: 100%;
position: relative;
overflow: hidden;
overflow-y: auto;
max-height: clamp(120px, 20vh, 250px);
width: 100%;
/* Firefox scrollbar */
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.05);
}
/* Горизонтальный layout для узких кнопок */
@ -23,6 +27,7 @@
align-items: center;
justify-content: space-between;
gap: clamp(8px, 1.5vw, 15px);
max-height: clamp(100px, 30vh, 200px);
}
}
@ -95,29 +100,53 @@
transform: scale(1.1);
}
.answer-revealed-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: clamp(8px, 1.5vh, 12px);
width: 100%;
flex: 1;
min-height: 0;
}
.answer-revealed-footer {
display: flex;
align-items: center;
gap: clamp(8px, 1.5vw, 12px);
flex-shrink: 0;
}
.answer-text {
font-size: clamp(0.9rem, 1.8vw, 1.4rem);
color: #fff;
font-weight: bold;
margin-bottom: clamp(4px, 1vh, 8px);
text-align: center;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
word-wrap: break-word;
overflow-wrap: break-word;
flex-shrink: 1;
min-height: 0;
flex: 1;
width: 100%;
}
@media (max-width: 1000px) {
.answer-revealed-content {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: clamp(8px, 1.5vw, 15px);
}
.answer-revealed-footer {
flex-shrink: 0;
}
.answer-text {
margin-bottom: 0;
margin-right: clamp(8px, 1.5vw, 15px);
margin-right: 0;
text-align: left;
-webkit-line-clamp: 2;
flex: 1;
min-width: 0;
}
@ -137,4 +166,23 @@
}
}
/* Кастомный скроллбар для кнопок ответов */
.answer-button::-webkit-scrollbar {
width: 6px;
}
.answer-button::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
.answer-button::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
transition: background 0.3s ease;
}
.answer-button::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}

View file

@ -1,7 +1,7 @@
import VoicePlayer from './VoicePlayer'
import './Answer.css'
const Answer = ({ answer, index, onClick, isRevealed, roomId, questionId }) => {
const Answer = ({ answer, onClick, isRevealed, roomId, questionId }) => {
const getAnswerClass = () => {
if (!isRevealed) return 'answer-hidden'
return 'answer-revealed'
@ -32,23 +32,25 @@ const Answer = ({ answer, index, onClick, isRevealed, roomId, questionId }) => {
}
>
{isRevealed ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%' }}>
<div className="answer-revealed-content">
<span className="answer-text">{answer.text}</span>
<span
className="answer-points"
style={{ color: getPointsColor(answer.points) }}
>
{answer.points}
</span>
{roomId && questionId && answer.id && (
<VoicePlayer
roomId={roomId}
questionId={questionId}
contentType="answer"
answerId={answer.id}
showButton={true}
/>
)}
<div className="answer-revealed-footer">
<span
className="answer-points"
style={{ color: getPointsColor(answer.points) }}
>
{answer.points}
</span>
{roomId && questionId && answer.id && (
<VoicePlayer
roomId={roomId}
questionId={questionId}
contentType="answer"
answerId={answer.id}
showButton={true}
/>
)}
</div>
</div>
) : (
<span

View file

@ -1,426 +1,71 @@
import { useState, useImperativeHandle, forwardRef, useEffect } from 'react'
import { forwardRef, useImperativeHandle } from 'react'
import Question from './Question'
import Players from './Players'
import PlayersModal from './PlayersModal'
import QuestionsModal from './QuestionsModal'
import { getCookie, setCookie, deleteCookie } from '../utils/cookies'
import { useVoice } from '../hooks/useVoice'
import './Game.css'
const Game = forwardRef(({
questions = [],
currentQuestionIndex = 0,
onQuestionIndexChange,
onQuestionsChange,
roomParticipants = null, // Участники для онлайн игры
isOnlineMode = false, // Флаг онлайн режима
roomId = null, // Room ID для TTS (только для онлайн режима)
currentQuestion = null,
roomParticipants = [],
roomId = null,
revealedAnswers = [], // Массив UUID открытых ответов
playerScores = {}, // Объект {playerId: score}
currentPlayerId = null,
onAnswerClick = null,
onPreviousQuestion = null,
onNextQuestion = null,
}, ref) => {
const { playEffect } = useVoice();
// Нет локального state - всё из props!
// Нет useEffect - нет синхронизации!
// Нет cookies - не нужны!
// Для локальной игры используем cookies, для онлайн - props
const [players, setPlayers] = useState(() => {
if (isOnlineMode && roomParticipants) {
return roomParticipants.map(p => ({
id: p.id,
name: p.name,
}))
}
const savedPlayers = getCookie('gamePlayers')
return savedPlayers || []
})
const players = roomParticipants;
const [currentPlayerId, setCurrentPlayerId] = useState(() => {
if (isOnlineMode && roomParticipants && roomParticipants.length > 0) {
return roomParticipants[0].id
}
const savedId = getCookie('gameCurrentPlayerId')
return savedId !== null ? savedId : null
})
const [playerScores, setPlayerScores] = useState(() => {
const savedScores = getCookie('gamePlayerScores')
return savedScores || {}
})
const [gameOver, setGameOver] = useState(() => {
const savedGameOver = getCookie('gameOver')
return savedGameOver === true
})
const [revealedAnswers, setRevealedAnswers] = useState(() => {
const savedAnswers = getCookie('gameRevealedAnswers')
return savedAnswers || {}
})
const handleAnswerClick = (answerId, points) => {
if (!currentQuestion) return;
if (revealedAnswers.includes(answerId)) return; // Проверка по UUID
if (!currentPlayerId) return;
if (!onAnswerClick) return;
// Получаем открытые ответы для текущего вопроса
const getCurrentRevealedAnswers = () => {
return revealedAnswers[currentQuestionIndex] || []
}
// Обновляем открытые ответы для текущего вопроса
const updateRevealedAnswers = (newAnswers) => {
setRevealedAnswers({
...revealedAnswers,
[currentQuestionIndex]: newAnswers,
})
}
const [isPlayersModalOpen, setIsPlayersModalOpen] = useState(false)
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false)
// Сохраняем состояние в cookies при изменении
useEffect(() => {
if (players.length > 0) {
setCookie('gamePlayers', players)
} else {
deleteCookie('gamePlayers')
}
}, [players])
useEffect(() => {
if (currentPlayerId !== null) {
setCookie('gameCurrentPlayerId', currentPlayerId)
} else {
deleteCookie('gameCurrentPlayerId')
}
}, [currentPlayerId])
useEffect(() => {
if (Object.keys(playerScores).length > 0) {
setCookie('gamePlayerScores', playerScores)
} else {
deleteCookie('gamePlayerScores')
}
}, [playerScores])
useEffect(() => {
setCookie('gameRevealedAnswers', revealedAnswers)
// Уведомляем родительский компонент об изменении состояния открытых ответов
if (onQuestionIndexChange) {
// Это вызовет перерендер в App, который обновит состояние кнопки
}
}, [revealedAnswers, currentQuestionIndex])
useEffect(() => {
setCookie('gameOver', gameOver)
}, [gameOver])
// Обновляем игроков при изменении roomParticipants (для онлайн режима)
useEffect(() => {
if (isOnlineMode && roomParticipants) {
const updatedPlayers = roomParticipants.map(p => ({
id: p.id,
name: p.name,
}))
setPlayers(updatedPlayers)
// Устанавливаем текущего игрока, если его нет
if (!currentPlayerId && updatedPlayers.length > 0) {
setCurrentPlayerId(updatedPlayers[0].id)
}
// Обновляем scores для новых игроков
setPlayerScores(prev => {
const newScores = { ...prev }
updatedPlayers.forEach(player => {
if (!(player.id in newScores)) {
newScores[player.id] = 0
}
})
return newScores
})
}
}, [isOnlineMode, roomParticipants, currentPlayerId])
// Устанавливаем первого игрока текущим, если есть игроки, но нет текущего игрока (для локальной игры)
useEffect(() => {
if (!isOnlineMode && players.length > 0 && !currentPlayerId) {
setCurrentPlayerId(players[0].id)
}
}, [isOnlineMode, players, currentPlayerId])
const currentQuestion = questions[currentQuestionIndex]
const isLastQuestion = currentQuestionIndex === questions.length - 1
const handleShowAllAnswers = () => {
if (!currentQuestion) return
const currentRevealed = getCurrentRevealedAnswers()
const allAnswersRevealed = currentRevealed.length === currentQuestion.answers.length
if (allAnswersRevealed) {
// Если все открыты - скрываем все
updateRevealedAnswers([])
} else {
// Если не все открыты - открываем все
const allAnswerIndices = currentQuestion.answers.map((_, index) => index)
updateRevealedAnswers(allAnswerIndices)
}
}
const hasRevealedAnswers = () => {
const currentRevealed = getCurrentRevealedAnswers()
return currentRevealed.length > 0
}
const areAllAnswersRevealed = () => {
if (!currentQuestion) return false
const currentRevealed = getCurrentRevealedAnswers()
return currentRevealed.length === currentQuestion.answers.length
}
onAnswerClick(answerId, points); // Передаем UUID
};
// Expose methods для внешних компонентов (если нужно)
useImperativeHandle(ref, () => ({
openPlayersModal: () => setIsPlayersModalOpen(true),
openQuestionsModal: () => setIsQuestionsModalOpen(true),
newGame: () => {
setPlayers([])
setCurrentPlayerId(null)
setPlayerScores({})
setGameOver(false)
setRevealedAnswers({})
},
showAllAnswers: handleShowAllAnswers,
areAllAnswersRevealed: areAllAnswersRevealed,
}))
const handleAddPlayer = (name) => {
const newPlayer = {
id: Date.now(),
name: name,
}
const updatedPlayers = [...players, newPlayer]
setPlayers(updatedPlayers)
// Если это первый участник, делаем его текущим
if (updatedPlayers.length === 1) {
setCurrentPlayerId(newPlayer.id)
setPlayerScores({ [newPlayer.id]: 0 })
} else {
setPlayerScores({ ...playerScores, [newPlayer.id]: 0 })
}
}
const handleSelectPlayer = (playerId) => {
setCurrentPlayerId(playerId)
}
const handleRemovePlayer = (playerId) => {
const updatedPlayers = players.filter(p => p.id !== playerId)
setPlayers(updatedPlayers)
const updatedScores = { ...playerScores }
delete updatedScores[playerId]
setPlayerScores(updatedScores)
// Если удалили текущего участника, выбираем другого
if (currentPlayerId === playerId) {
if (updatedPlayers.length > 0) {
setCurrentPlayerId(updatedPlayers[0].id)
} else {
setCurrentPlayerId(null)
}
}
}
const getNextPlayerId = () => {
if (players.length === 0) return null
if (players.length === 1) return currentPlayerId
const currentIndex = players.findIndex(p => p.id === currentPlayerId)
const nextIndex = (currentIndex + 1) % players.length
return players[nextIndex].id
}
const handleAnswerClick = (answerIndex, points) => {
const currentRevealed = getCurrentRevealedAnswers()
if (currentRevealed.includes(answerIndex)) return
if (!currentPlayerId) return
if (!currentQuestion) return
const isLastAnswer = currentRevealed.length === currentQuestion.answers.length - 1
updateRevealedAnswers([...currentRevealed, answerIndex])
// Добавляем очки текущему участнику
setPlayerScores({
...playerScores,
[currentPlayerId]: (playerScores[currentPlayerId] || 0) + points,
})
// Play correct answer sound
playEffect('correct')
// Переходим к следующему участнику только если это не последний ответ
if (!isLastAnswer) {
const nextPlayerId = getNextPlayerId()
if (nextPlayerId) {
setTimeout(() => {
setCurrentPlayerId(nextPlayerId)
}, 500)
}
} else {
// Если это последний ответ, переходим к следующему участнику перед следующим вопросом
setTimeout(() => {
const nextPlayerId = getNextPlayerId()
if (nextPlayerId) {
setCurrentPlayerId(nextPlayerId)
}
if (isLastQuestion) {
setGameOver(true)
} else {
setTimeout(() => {
nextQuestion()
}, 500)
}
}, 2000)
}
}
const nextQuestion = () => {
if (onQuestionIndexChange) {
onQuestionIndexChange(currentQuestionIndex + 1)
}
// Не сбрасываем открытые ответы - они сохраняются для каждого вопроса отдельно
}
const restartGame = () => {
if (onQuestionIndexChange) {
onQuestionIndexChange(0)
}
setGameOver(false)
setRevealedAnswers({})
const initialScores = {}
players.forEach(player => {
initialScores[player.id] = 0
})
setPlayerScores(initialScores)
if (players.length > 0) {
setCurrentPlayerId(players[0].id)
}
}
const newGame = () => {
setPlayers([])
setCurrentPlayerId(null)
setPlayerScores({})
setGameOver(false)
setRevealedAnswers({})
if (onQuestionIndexChange) {
onQuestionIndexChange(0)
}
}
const handlePreviousQuestion = () => {
if (currentQuestionIndex > 0 && onQuestionIndexChange) {
onQuestionIndexChange(currentQuestionIndex - 1)
// Открытые ответы сохраняются для каждого вопроса отдельно
}
}
const handleNextQuestion = () => {
if (currentQuestionIndex < questions.length - 1 && onQuestionIndexChange) {
onQuestionIndexChange(currentQuestionIndex + 1)
// Открытые ответы сохраняются для каждого вопроса отдельно
}
}
if (gameOver) {
// Находим победителя(ей)
const scores = Object.values(playerScores)
const maxScore = scores.length > 0 ? Math.max(...scores) : 0
const winners = players.filter(p => playerScores[p.id] === maxScore)
// Play victory sound
useEffect(() => {
playEffect('victory')
}, [])
return (
<div className="game-over">
<div className="game-over-content">
<h2 className="game-over-title">🎉 Игра окончена! 🎉</h2>
<div className="final-scores">
<h3 className="final-scores-title">Итоговые результаты:</h3>
{players
.sort((a, b) => (playerScores[b.id] || 0) - (playerScores[a.id] || 0))
.map((player) => (
<div
key={player.id}
className={`final-score-item ${
winners.includes(player) ? 'final-score-winner' : ''
}`}
>
<span className="final-score-name">{player.name}</span>
<span className="final-score-value">
{playerScores[player.id] || 0} очков
</span>
</div>
))}
</div>
<button className="restart-button" onClick={restartGame}>
Играть снова
</button>
</div>
</div>
)
}
// Можно добавить методы если понадобится
}));
return (
<div className="game">
{!isOnlineMode && (
<PlayersModal
isOpen={isPlayersModalOpen}
onClose={() => setIsPlayersModalOpen(false)}
players={players}
onAddPlayer={handleAddPlayer}
onRemovePlayer={handleRemovePlayer}
/>
)}
{!isOnlineMode && (
<QuestionsModal
isOpen={isQuestionsModalOpen}
onClose={() => setIsQuestionsModalOpen(false)}
questions={questions}
onUpdateQuestions={onQuestionsChange}
/>
)}
<div className="game-header">
{players.length > 0 && (
<Players
players={players}
currentPlayerId={currentPlayerId}
playerScores={playerScores}
onSelectPlayer={handleSelectPlayer}
/>
)}
</div>
{players.length > 0 && currentPlayerId ? (
<div className="game-content">
{questions.length === 0 ? (
<div className="no-players-message">
<p>Добавьте вопросы, чтобы начать игру</p>
</div>
) : currentQuestion ? (
{currentQuestion ? (
<Question
question={currentQuestion}
questionNumber={currentQuestionIndex + 1}
onAnswerClick={handleAnswerClick}
revealedAnswers={getCurrentRevealedAnswers()}
onPreviousQuestion={handlePreviousQuestion}
onNextQuestion={handleNextQuestion}
canGoPrevious={currentQuestionIndex > 0}
canGoNext={currentQuestionIndex < questions.length - 1}
revealedAnswers={revealedAnswers}
onPreviousQuestion={onPreviousQuestion}
onNextQuestion={onNextQuestion}
roomId={roomId}
/>
) : (
<div className="no-players-message">
<p>Ошибка: вопрос не найден</p>
<p>Загрузка вопроса...</p>
</div>
)}
</div>
) : (
<div className="no-players-message">
<p>Добавьте участников, чтобы начать игру</p>
<p>Ожидание игроков...</p>
</div>
)}
</div>
@ -430,4 +75,3 @@ const Game = forwardRef(({
Game.displayName = 'Game'
export default Game

View file

@ -62,11 +62,11 @@ const GameManagementModal = ({
if (e.target === e.currentTarget) onClose()
}
const handleRevealAnswer = (index) => {
if (revealedAnswers.includes(index)) {
onHideAnswer(index)
const handleRevealAnswer = (answerId) => {
if (revealedAnswers.includes(answerId)) {
onHideAnswer(answerId)
} else {
onRevealAnswer(index)
onRevealAnswer(answerId)
}
}
@ -506,11 +506,11 @@ const GameManagementModal = ({
<div className="answers-grid">
{currentQuestion.answers.map((answer, index) => (
<button
key={index}
key={answer.id || index}
className={`answer-button ${
revealedAnswers.includes(index) ? 'revealed' : 'hidden'
revealedAnswers.includes(answer.id) ? 'revealed' : 'hidden'
}`}
onClick={() => handleRevealAnswer(index)}
onClick={() => handleRevealAnswer(answer.id)}
>
<span className="answer-num">{index + 1}</span>
<span className="answer-txt">{answer.text}</span>

View file

@ -127,11 +127,10 @@
.answers-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: minmax(0, 1fr);
grid-auto-rows: auto;
gap: clamp(6px, 1.2vw, 12px);
flex: 1;
min-height: 0;
overflow: hidden;
}
@media (max-width: 768px) {

View file

@ -4,24 +4,17 @@ import './Question.css'
const Question = ({
question,
questionNumber,
onAnswerClick,
revealedAnswers,
onShowAll,
revealedAnswers, // Массив UUID открытых ответов
onPreviousQuestion,
onNextQuestion,
canGoPrevious,
canGoNext,
roomId,
}) => {
const allAnswersRevealed = question.answers.every((_, index) => revealedAnswers.includes(index))
const hasUnrevealedAnswers = revealedAnswers.length < question.answers.length
return (
<div className="question-container">
<div className="question-box">
<div className="question-navigation">
{canGoPrevious && onPreviousQuestion && (
{onPreviousQuestion && (
<button
className="question-nav-button question-nav-button-prev"
onClick={onPreviousQuestion}
@ -40,7 +33,7 @@ const Question = ({
/>
)}
</div>
{canGoNext && onNextQuestion && (
{onNextQuestion && (
<button
className="question-nav-button question-nav-button-next"
onClick={onNextQuestion}
@ -52,13 +45,12 @@ const Question = ({
</div>
</div>
<div className="answers-grid">
{question.answers.map((answer, index) => (
{question.answers.map((answer) => (
<Answer
key={answer.id || index}
key={answer.id}
answer={answer}
index={index}
onClick={() => onAnswerClick(index, answer.points)}
isRevealed={revealedAnswers.includes(index)}
onClick={() => onAnswerClick(answer.id, answer.points)}
isRevealed={revealedAnswers.includes(answer.id)}
roomId={roomId}
questionId={question.id}
/>

View file

@ -53,51 +53,23 @@ export const useRoom = (roomCode, onGameStarted = null) => {
}
};
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);
setRoom((prevRoom) =>
prevRoom ? { ...prevRoom, status: 'FINISHED' } : null
);
};
const handleRoomPackUpdated = (updatedRoom) => {
console.log('Room pack updated:', updatedRoom);
setRoom(updatedRoom);
// Используем новое событие gameStateUpdated если нужно обновлять состояние
const handleGameStateUpdated = (state) => {
// Обновляем только базовую информацию о комнате
// Полное состояние игры управляется в GamePage
if (state.participants) {
setParticipants(state.participants);
}
};
socketService.on('roomUpdate', handleRoomUpdate);
socketService.on('gameStarted', handleGameStarted);
socketService.on('answerRevealed', handleAnswerRevealed);
socketService.on('scoreUpdated', handleScoreUpdated);
socketService.on('questionChanged', handleQuestionChanged);
socketService.on('gameEnded', handleGameEnded);
socketService.on('roomPackUpdated', handleRoomPackUpdated);
socketService.on('gameStateUpdated', handleGameStateUpdated);
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);
socketService.off('roomPackUpdated', handleRoomPackUpdated);
socketService.off('gameStateUpdated', handleGameStateUpdated);
};
}, [roomCode, onGameStarted, user?.id]);
@ -128,24 +100,6 @@ export const useRoom = (roomCode, onGameStarted = null) => {
}
}, [room, user]);
const revealAnswer = useCallback((answerIndex) => {
if (room && user) {
socketService.revealAnswer(room.code, room.id, user.id, answerIndex);
}
}, [room, user]);
const updateScore = useCallback((participantId, score) => {
if (room && user) {
socketService.updateScore(participantId, score, room.code, room.id, user.id);
}
}, [room, user]);
const nextQuestion = useCallback(() => {
if (room && user) {
socketService.nextQuestion(room.code, room.id, user.id);
}
}, [room, user]);
const endGame = useCallback(() => {
if (room && user) {
socketService.endGame(room.id, room.code, user.id);
@ -176,9 +130,6 @@ export const useRoom = (roomCode, onGameStarted = null) => {
createRoom,
joinRoom,
startGame,
revealAnswer,
updateScore,
nextQuestion,
endGame,
updateQuestionPack,
};

View file

@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useRoom } from '../hooks/useRoom';
import { questionsApi, roomsApi } from '../services/api';
import QRCode from 'qrcode';
import socketService from '../services/socket';
@ -16,72 +15,62 @@ const GamePage = () => {
const { roomCode } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const {
room,
participants,
loading,
error,
updateQuestionPack,
startGame,
endGame,
nextQuestion,
revealAnswer,
updateScore,
} = useRoom(roomCode);
const [questions, setQuestions] = useState([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [loadingQuestions, setLoadingQuestions] = useState(true);
const [questionPacks, setQuestionPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState('');
const [updatingPack, setUpdatingPack] = useState(false);
// ВСЁ состояние игры в одном объекте
const [gameState, setGameState] = useState({
roomId: null,
status: 'WAITING',
currentQuestionId: null,
currentPlayerId: null,
revealedAnswers: {},
participants: [],
questions: [],
hostId: null,
roomCode: null,
});
const [loading, setLoading] = useState(true);
const [isGameManagementModalOpen, setIsGameManagementModalOpen] = useState(false);
const [isQRModalOpen, setIsQRModalOpen] = useState(false);
const [qrCode, setQrCode] = useState('');
const [revealedAnswers, setRevealedAnswers] = useState([]);
const [questionPacks, setQuestionPacks] = useState([]);
// ЕДИНСТВЕННЫЙ обработчик состояния игры
useEffect(() => {
const loadQuestions = async () => {
if (!room) return;
if (!roomCode) return;
setLoadingQuestions(true);
try {
// Load from roomPack (always exists now)
if (room.roomPack) {
const questions = room.roomPack.questions;
setQuestions(Array.isArray(questions) ? questions : []);
} else {
// Fallback for legacy rooms without roomPack
if (room.questionPackId) {
if (room.questionPack && room.questionPack.questions) {
const packQuestions = room.questionPack.questions;
setQuestions(Array.isArray(packQuestions) ? packQuestions : []);
} else {
const response = await questionsApi.getPack(room.questionPackId);
setQuestions(
response.data?.questions && Array.isArray(response.data.questions)
? response.data.questions
: []
);
}
} else {
setQuestions([]);
}
}
} catch (error) {
console.error('Error loading questions:', error);
setQuestions([]);
} finally {
setLoadingQuestions(false);
const handleGameStateUpdated = (state) => {
console.log('📦 Game state updated:', state);
setGameState(state);
setLoading(false);
};
socketService.connect();
socketService.joinRoom(roomCode, user?.id);
socketService.on('gameStateUpdated', handleGameStateUpdated);
return () => {
socketService.off('gameStateUpdated', handleGameStateUpdated);
};
}, [roomCode, user?.id]);
// Переподключение - автоматически получаем состояние
useEffect(() => {
const handleReconnect = () => {
console.log('🔄 Reconnected, requesting state...');
if (roomCode) {
socketService.emit('requestFullState', { roomCode });
}
};
loadQuestions();
}, [room]);
socketService.onReconnect(handleReconnect);
return () => socketService.offReconnect(handleReconnect);
}, [roomCode]);
// Загрузка доступных паков для хоста
useEffect(() => {
const fetchPacks = async () => {
if (user && room && room.hostId === user.id) {
if (user && gameState.hostId === user.id) {
try {
const response = await questionsApi.getPacks(user.id);
setQuestionPacks(response.data);
@ -92,17 +81,9 @@ const GamePage = () => {
};
fetchPacks();
}, [user, room]);
}, [user, gameState.hostId]);
useEffect(() => {
if (room && room.questionPackId) {
setSelectedPackId(room.questionPackId);
} else {
setSelectedPackId('');
}
}, [room]);
// Generate QR code for room
// Генерация QR кода
useEffect(() => {
const generateQR = async () => {
try {
@ -126,120 +107,176 @@ const GamePage = () => {
}
}, [roomCode]);
// Listen for socket events
useEffect(() => {
if (!room) return;
// === Handlers для действий игрока ===
const handleAnswerRevealed = (data) => {
if (data.questionIndex === currentQuestionIndex) {
setRevealedAnswers((prev) => {
if (!prev.includes(data.answerIndex)) {
return [...prev, data.answerIndex];
}
return prev;
});
}
};
const handleAnswerClick = (answerId, points) => {
if (!gameState.roomId || !user) return;
const handleAnswerHidden = (data) => {
if (data.questionIndex === currentQuestionIndex) {
setRevealedAnswers((prev) =>
prev.filter((idx) => idx !== data.answerIndex)
);
}
};
const myParticipant = gameState.participants.find(p => p.userId === user.id);
if (!myParticipant) return;
const handleAllAnswersShown = (data) => {
if (data.questionIndex === currentQuestionIndex && questions[currentQuestionIndex]) {
setRevealedAnswers(
Array.from({ length: questions[currentQuestionIndex].answers.length }, (_, i) => i)
);
}
};
const handleAllAnswersHidden = (data) => {
if (data.questionIndex === currentQuestionIndex) {
setRevealedAnswers([]);
}
};
const handleQuestionChanged = (data) => {
if (data.questionIndex !== undefined) {
setCurrentQuestionIndex(data.questionIndex);
// Reset revealed answers when question changes
setRevealedAnswers([]);
}
};
socketService.on('answerRevealed', handleAnswerRevealed);
socketService.on('answerHidden', handleAnswerHidden);
socketService.on('allAnswersShown', handleAllAnswersShown);
socketService.on('allAnswersHidden', handleAllAnswersHidden);
socketService.on('questionChanged', handleQuestionChanged);
return () => {
socketService.off('answerRevealed', handleAnswerRevealed);
socketService.off('answerHidden', handleAnswerHidden);
socketService.off('allAnswersShown', handleAllAnswersShown);
socketService.off('allAnswersHidden', handleAllAnswersHidden);
socketService.off('questionChanged', handleQuestionChanged);
};
}, [room, currentQuestionIndex, questions]);
// Reset revealed answers when question changes
useEffect(() => {
setRevealedAnswers([]);
}, [currentQuestionIndex]);
const handleUpdateQuestionPack = async () => {
if (!selectedPackId) {
alert('Выберите пак вопросов');
// Проверка очереди
if (gameState.currentPlayerId !== myParticipant.id) {
alert('Сейчас не ваша очередь!');
return;
}
// Отправляем действие
socketService.emit('playerAction', {
action: 'revealAnswer',
roomId: gameState.roomId,
roomCode: gameState.roomCode,
userId: user.id,
participantId: myParticipant.id,
questionId: gameState.currentQuestionId,
answerId: answerId
});
};
const handleNextQuestion = () => {
if (!gameState.roomId || !user) return;
const myParticipant = gameState.participants.find(p => p.userId === user.id);
if (!myParticipant) return;
socketService.emit('playerAction', {
action: 'nextQuestion',
roomId: gameState.roomId,
roomCode: gameState.roomCode,
userId: user.id,
participantId: myParticipant.id
});
};
const handlePrevQuestion = () => {
if (!gameState.roomId || !user) return;
const myParticipant = gameState.participants.find(p => p.userId === user.id);
if (!myParticipant) return;
socketService.emit('playerAction', {
action: 'prevQuestion',
roomId: gameState.roomId,
roomCode: gameState.roomCode,
userId: user.id,
participantId: myParticipant.id
});
};
const handleStartGame = () => {
if (!gameState.roomId || !user) return;
socketService.emit('startGame', {
roomId: gameState.roomId,
roomCode: gameState.roomCode,
userId: user.id
});
};
const handleEndGame = () => {
if (window.confirm('Завершить игру?')) {
socketService.emit('endGame', {
roomId: gameState.roomId,
roomCode: gameState.roomCode,
userId: user.id
});
}
};
const handleRestartGame = () => {
if (window.confirm('Начать игру заново? Все очки будут сброшены.')) {
socketService.emit('restartGame', {
roomId: gameState.roomId,
roomCode: gameState.roomCode,
userId: user.id
});
}
};
const handleUpdateRoomQuestions = async (newQuestions) => {
if (!gameState.roomId) return;
try {
setUpdatingPack(true);
await updateQuestionPack(selectedPackId);
// Перезагружаем вопросы после обновления пака
const response = await questionsApi.getPack(selectedPackId);
if (response.data && response.data.questions) {
setQuestions(
Array.isArray(response.data.questions)
? response.data.questions
: [],
);
setCurrentQuestionIndex(0);
}
socketService.emit('updateRoomPack', {
roomId: gameState.roomId,
roomCode: gameState.roomCode,
userId: user.id,
questions: newQuestions
});
} catch (error) {
console.error('Error updating question pack:', error);
alert('Ошибка при обновлении пака вопросов');
} finally {
setUpdatingPack(false);
console.error('Error updating room pack:', error);
alert('Ошибка при сохранении вопросов');
}
};
const handleQuestionsChange = (newQuestions) => {
setQuestions(newQuestions);
if (currentQuestionIndex >= newQuestions.length) {
setCurrentQuestionIndex(0);
}
// Handlers для GameManagementModal (работают с UUID)
const handleRevealAnswer = (answerId) => {
if (!gameState.roomId || !user) return;
const myParticipant = gameState.participants.find(p => p.userId === user.id);
if (!myParticipant) return;
socketService.emit('playerAction', {
action: 'revealAnswer',
roomId: gameState.roomId,
roomCode: gameState.roomCode,
userId: user.id,
participantId: myParticipant.id,
questionId: gameState.currentQuestionId,
answerId: answerId
});
};
if (loading || loadingQuestions) {
const handleHideAnswer = (answerId) => {
// TODO: В новой архитектуре нет hideAnswer
// Ответы можно только открывать через playerAction
console.warn('hideAnswer not implemented in new architecture');
};
const handleShowAllAnswers = () => {
// TODO: Открыть все ответы через playerAction
console.warn('showAllAnswers not fully implemented');
// Можно сделать цикл по всем ответам и открыть их
};
const handleHideAllAnswers = () => {
// TODO: В новой архитектуре нет hideAllAnswers
console.warn('hideAllAnswers not implemented in new architecture');
};
const handleAwardPoints = (participantId, points) => {
// TODO: Начисление очков вручную
// Нужен отдельный backend endpoint или событие
console.warn('Manual point award not implemented yet');
};
// === Вычисляемые значения ===
const currentQuestion = gameState.questions.find(
q => q.id === gameState.currentQuestionId
);
const currentQuestionIndex = gameState.questions.findIndex(
q => q.id === gameState.currentQuestionId
);
const revealedForCurrentQ = gameState.revealedAnswers[gameState.currentQuestionId] || [];
const playerScores = gameState.participants.reduce(
(acc, p) => ({ ...acc, [p.id]: p.score }),
{}
);
const isHost = user && gameState.hostId === user.id;
const canGoPrev = currentQuestionIndex > 0;
const canGoNext = currentQuestionIndex < gameState.questions.length - 1;
// === Render ===
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) {
if (!gameState.roomId) {
return (
<div className="error-page">
<h1>Комната не найдена</h1>
@ -248,135 +285,9 @@ const GamePage = () => {
);
}
const isHost = user && room.hostId === user.id;
const handleUpdateRoomQuestions = async (newQuestions) => {
setQuestions(newQuestions);
if (room) {
try {
await roomsApi.updateRoomPack(room.id, newQuestions);
} catch (error) {
console.error('Error updating room pack:', error);
alert('Ошибка при сохранении вопросов');
}
}
};
// Game control handlers
const handleStartGame = () => {
startGame();
};
const handleEndGame = () => {
if (window.confirm('Завершить игру? Весь прогресс будет сохранен.')) {
endGame();
}
};
const handleNextQuestion = () => {
if (currentQuestionIndex < questions.length - 1) {
nextQuestion();
// The question index will be updated via socket event
}
};
const handlePreviousQuestion = () => {
if (room && user && currentQuestionIndex > 0) {
socketService.emit('previousQuestion', {
roomCode: room.code,
roomId: room.id,
userId: user.id,
});
// The question index will be updated via socket event (questionChanged)
}
};
const handleRevealAnswer = (answerIndex) => {
if (room && user) {
socketService.emit('revealAnswer', {
roomCode: room.code,
roomId: room.id,
userId: user.id,
answerIndex,
questionIndex: currentQuestionIndex,
});
// Also update local state immediately for better UX
setRevealedAnswers((prev) => {
if (!prev.includes(answerIndex)) {
return [...prev, answerIndex];
}
return prev;
});
}
};
const handleHideAnswer = (answerIndex) => {
if (room && user) {
socketService.emit('hideAnswer', {
roomCode: room.code,
roomId: room.id,
userId: user.id,
answerIndex,
questionIndex: currentQuestionIndex,
});
// Also update local state immediately for better UX
setRevealedAnswers((prev) =>
prev.filter((idx) => idx !== answerIndex)
);
}
};
const handleShowAllAnswers = () => {
if (room && user && questions[currentQuestionIndex]) {
socketService.emit('showAllAnswers', {
roomCode: room.code,
roomId: room.id,
userId: user.id,
questionIndex: currentQuestionIndex,
});
// Also update local state immediately for better UX
setRevealedAnswers(
Array.from({ length: questions[currentQuestionIndex].answers.length }, (_, i) => i)
);
}
};
const handleHideAllAnswers = () => {
if (room && user) {
socketService.emit('hideAllAnswers', {
roomCode: room.code,
roomId: room.id,
userId: user.id,
questionIndex: currentQuestionIndex,
});
// Also update local state immediately for better UX
setRevealedAnswers([]);
}
};
const handleAwardPoints = (participantId, points) => {
if (room && user) {
const participant = participants.find((p) => p.id === participantId);
if (participant) {
const newScore = (participant.score || 0) + points;
updateScore(participantId, newScore);
}
}
};
const handlePenalty = (participantId) => {
if (room && user) {
const participant = participants.find((p) => p.id === participantId);
if (participant) {
const newScore = Math.max(0, (participant.score || 0) - 10);
updateScore(participantId, newScore);
}
}
};
return (
<div className="game-page">
{/* Control bar - only for host */}
{/* Control bar - только для хоста */}
{isHost && (
<div className="game-control-bar">
<div className="game-control-left">
@ -399,9 +310,9 @@ const GamePage = () => {
</div>
<div className="game-control-right">
{questions.length > 0 && (
{gameState.questions.length > 0 && (
<div className="question-counter">
{currentQuestionIndex + 1}/{questions.length}
{currentQuestionIndex + 1}/{gameState.questions.length}
</div>
)}
</div>
@ -409,7 +320,7 @@ const GamePage = () => {
)}
<div className="game-container">
{questions.length === 0 && (
{gameState.questions.length === 0 && (
<div className="no-questions-banner">
<p>
Вопросы не загружены.
@ -421,13 +332,15 @@ const GamePage = () => {
)}
<Game
questions={questions}
currentQuestionIndex={currentQuestionIndex}
onQuestionIndexChange={setCurrentQuestionIndex}
onQuestionsChange={handleQuestionsChange}
roomParticipants={participants}
isOnlineMode={true}
roomId={room?.id}
currentQuestion={currentQuestion}
roomParticipants={gameState.participants}
roomId={gameState.roomId}
revealedAnswers={revealedForCurrentQ}
playerScores={playerScores}
currentPlayerId={gameState.currentPlayerId}
onAnswerClick={handleAnswerClick}
onPreviousQuestion={canGoPrev ? handlePrevQuestion : null}
onNextQuestion={canGoNext ? handleNextQuestion : null}
/>
</div>
@ -444,25 +357,30 @@ const GamePage = () => {
<GameManagementModal
isOpen={isGameManagementModalOpen}
onClose={() => setIsGameManagementModalOpen(false)}
room={room}
participants={participants}
currentQuestion={questions[currentQuestionIndex]}
room={{
id: gameState.roomId,
code: gameState.roomCode,
status: gameState.status,
hostId: gameState.hostId
}}
participants={gameState.participants}
currentQuestion={currentQuestion}
currentQuestionIndex={currentQuestionIndex}
totalQuestions={questions.length}
revealedAnswers={revealedAnswers}
questions={questions}
totalQuestions={gameState.questions.length}
revealedAnswers={revealedForCurrentQ}
questions={gameState.questions}
onUpdateQuestions={handleUpdateRoomQuestions}
availablePacks={questionPacks}
onStartGame={handleStartGame}
onEndGame={handleEndGame}
onRestartGame={handleRestartGame}
onNextQuestion={handleNextQuestion}
onPreviousQuestion={handlePreviousQuestion}
onPreviousQuestion={handlePrevQuestion}
onRevealAnswer={handleRevealAnswer}
onHideAnswer={handleHideAnswer}
onShowAllAnswers={handleShowAllAnswers}
onHideAllAnswers={handleHideAllAnswers}
onAwardPoints={handleAwardPoints}
onPenalty={handlePenalty}
/>
</>
)}

View file

@ -82,22 +82,14 @@ class SocketService {
this.emit('startGame', { roomId, roomCode, userId });
}
revealAnswer(roomCode, roomId, userId, answerIndex) {
this.emit('revealAnswer', { roomCode, roomId, userId, answerIndex });
}
updateScore(participantId, score, roomCode, roomId, userId) {
this.emit('updateScore', { participantId, score, roomCode, roomId, userId });
}
nextQuestion(roomCode, roomId, userId) {
this.emit('nextQuestion', { roomCode, roomId, userId });
}
endGame(roomId, roomCode, userId) {
this.emit('endGame', { roomId, roomCode, userId });
}
// Note: Game actions now use 'playerAction' event with UUID
// Direct emit is preferred over these helper methods
// Example: socketService.emit('playerAction', { action: 'revealAnswer', ... })
updateRoomPack(roomId, roomCode, userId, questions) {
this.emit('updateRoomPack', { roomId, roomCode, userId, questions });
}