This commit is contained in:
Dmitry 2026-01-10 18:51:33 +03:00
parent e036011998
commit 6e940aceb0
27 changed files with 1033 additions and 1317 deletions

View file

@ -1,22 +1,16 @@
# 100 к 1 - Multiplayer Game
Интерактивная веб-игра "100 к 1" с поддержкой мультиплеера и локальной игры.
Интерактивная веб-игра "100 к 1" с поддержкой мультиплеера.
## 🎮 Возможности
### 🌐 Мультиплеер (NEW!)
### 🌐 Мультиплеер
- **Игровые комнаты** с уникальными кодами
- **QR-коды** для быстрого присоединения
- **Real-time синхронизация** через WebSocket
- **Роли**: Ведущий, Игрок, Зритель
- **Статистика игр** с историей
### 🏠 Локальная игра
- Оригинальная версия для одного устройства
- Управление участниками
- Редактирование вопросов
- Автосохранение прогресса
## 🛠 Технологический стек
### Frontend
@ -56,7 +50,7 @@ sto_k_odnomu/
│ │ ├── CreateRoom.jsx # Создание комнаты
│ │ ├── JoinRoom.jsx # Присоединение
│ │ ├── RoomPage.jsx # Лобби комнаты
│ │ └── LocalGame.jsx # Локальная игра
│ │ └── GamePage.jsx # Игровая страница
│ ├── services/ # API & WebSocket
│ ├── context/ # React Context
│ ├── hooks/ # Custom hooks
@ -125,12 +119,6 @@ Frontend: http://localhost:5173
4. **Начать игру** (ведущий)
5. Игроки открывают ответы в реальном времени
### Локальная игра
1. Главная → **Локальная игра**
2. Добавьте участников (👥)
3. Играйте на одном устройстве
## 📊 API Endpoints
### REST API
@ -286,7 +274,6 @@ npm run preview # Preview build
- ❄️ Новогодняя анимация снежинок
- 🎨 Адаптивный дизайн
- 💾 Автосохранение прогресса
- 🔄 Real-time синхронизация
- 📱 QR-коды для присоединения
- 📊 Статистика и история игр

View file

@ -25,6 +25,10 @@ export interface ThemeSettings {
borderRadiusMd: string
borderRadiusLg: string
animationSpeed: string
particlesEnabled?: boolean
particleSymbol?: string
particleColor?: string
particleGlow?: string
}
export interface Theme {
@ -243,4 +247,8 @@ export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
borderRadiusMd: '8px',
borderRadiusLg: '12px',
animationSpeed: '0.3s',
particlesEnabled: true,
particleSymbol: '❄',
particleColor: '#ffffff',
particleGlow: 'rgba(255, 255, 255, 0.8)',
}

View file

@ -218,7 +218,7 @@ export function ThemeEditorDialog({
setColors((prev) => ({ ...prev, [key]: value }))
}
const updateSetting = (key: keyof ThemeSettings, value: string) => {
const updateSetting = (key: keyof ThemeSettings, value: string | boolean) => {
setSettings((prev) => ({ ...prev, [key]: value }))
}
@ -554,6 +554,47 @@ export function ThemeEditorDialog({
</p>
</div>
</div>
{/* Particles Section */}
<div className="space-y-4 pt-4 border-t">
<h3 className="text-lg font-semibold">Particles (Частицы)</h3>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id="particlesEnabled"
checked={settings.particlesEnabled ?? true}
onCheckedChange={(checked) => updateSetting('particlesEnabled', checked)}
/>
<Label htmlFor="particlesEnabled" className="cursor-pointer">
Enable Particles (Включить частицы)
</Label>
</div>
<div className="space-y-2">
<Label>Particle Symbol (Emoji)</Label>
<Input
value={settings.particleSymbol || '❄'}
onChange={(e) => updateSetting('particleSymbol', e.target.value)}
placeholder="❄"
maxLength={2}
/>
<p className="text-xs text-muted-foreground">
Символ для частиц. Рекомендуется использовать эмодзи (например: , 🌸, 🎉, )
</p>
</div>
<ColorField
label="Particle Color"
value={settings.particleColor || colors.textPrimary}
onChange={(v) => updateSetting('particleColor', v)}
description="Цвет частиц. По умолчанию используется Text Primary цвет"
/>
<ColorField
label="Particle Glow"
value={settings.particleGlow || colors.textGlow}
onChange={(v) => updateSetting('particleGlow', v)}
description="Цвет свечения частиц. По умолчанию используется Text Glow цвет"
/>
</div>
</div>
</TabsContent>
</Tabs>
</div>

View file

@ -46,6 +46,7 @@ model Room {
questionPackId String?
autoAdvance Boolean @default(false)
voiceMode Boolean @default(false) // Голосовой режим
particlesEnabled Boolean? // null = использовать настройку из темы, true/false = override
password String? // Пароль для доступа к комнате
// Админские комнаты

View file

@ -286,6 +286,10 @@ async function main() {
borderRadiusMd: '15px',
borderRadiusLg: '20px',
animationSpeed: '0.3s',
particlesEnabled: true,
particleSymbol: '❄',
particleColor: '#ffffff',
particleGlow: 'rgba(255, 215, 0, 0.8)',
},
},
{
@ -317,6 +321,10 @@ async function main() {
borderRadiusMd: '15px',
borderRadiusLg: '20px',
animationSpeed: '0.3s',
particlesEnabled: true,
particleSymbol: '🌸',
particleColor: '#2d3748',
particleGlow: 'rgba(47, 128, 237, 0.6)',
},
},
{
@ -348,6 +356,10 @@ async function main() {
borderRadiusMd: '15px',
borderRadiusLg: '20px',
animationSpeed: '0.2s',
particlesEnabled: true,
particleSymbol: '🎉',
particleColor: '#ffffff',
particleGlow: 'rgba(255, 87, 108, 0.8)',
},
},
{
@ -379,6 +391,10 @@ async function main() {
borderRadiusMd: '15px',
borderRadiusLg: '20px',
animationSpeed: '0.3s',
particlesEnabled: true,
particleSymbol: '✨',
particleColor: '#e0e0e0',
particleGlow: 'rgba(100, 255, 218, 0.6)',
},
},
];

View file

@ -69,6 +69,22 @@ export class ThemeSettingsDto {
@IsString()
animationSpeed: string;
@IsBoolean()
@IsOptional()
particlesEnabled?: boolean;
@IsString()
@IsOptional()
particleSymbol?: string;
@IsString()
@IsOptional()
particleColor?: string;
@IsString()
@IsOptional()
particleGlow?: string;
}
export class CreateThemeDto {

View file

@ -39,6 +39,7 @@ type RoomWithPack = Prisma.RoomGetPayload<{
include: {
roomPack: true;
participants: true;
theme: true;
}
}> & {
currentQuestionId?: string | null;
@ -78,11 +79,16 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
}
private async isHost(roomId: string, userId: string): Promise<boolean> {
const room = await this.prisma.room.findUnique({
where: { id: roomId },
select: { hostId: true },
// Проверяем роль участника (role === 'HOST') для поддержки нескольких хостов
const participant = await this.prisma.participant.findFirst({
where: {
roomId,
userId,
role: 'HOST',
isActive: true,
},
});
return room?.hostId === userId;
return !!participant;
}
private async isCurrentPlayer(roomId: string, participantId: string): Promise<boolean> {
@ -169,8 +175,22 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return;
}
// Получаем участника для проверки роли
const participant = room.participants.find(p => p.id === payload.participantId);
if (!participant) {
client.emit('error', { message: 'Participant not found' });
return;
}
// Проверяем роль участника - зрители не могут выполнять действия игрока
if (participant.role === 'SPECTATOR') {
client.emit('error', { message: 'Spectators cannot perform player actions' });
return;
}
// Проверяем права
const isHost = room.hostId === payload.userId;
const isHost = await this.isHost(payload.roomId, payload.userId);
const isCurrentPlayer = room.currentPlayerId === payload.participantId;
if (!isHost && !isCurrentPlayer) {
@ -323,7 +343,8 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
orderBy: { joinedAt: 'asc' }
},
roomPack: true,
host: { select: { id: true, name: true } }
host: { select: { id: true, name: true } },
theme: true
} as Prisma.RoomInclude,
})) as unknown as RoomWithPack | null;
@ -402,6 +423,8 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
revealedAnswers: room.revealedAnswers as RevealedAnswers,
isGameOver: room.isGameOver,
hostId: room.hostId,
themeId: (room as any).themeId || null,
particlesEnabled: (room as any).particlesEnabled !== undefined ? (room as any).particlesEnabled : null,
participants: room.participants.map((p) => ({
id: p.id,
userId: p.userId,
@ -528,6 +551,38 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
await this.broadcastFullState(payload.roomCode);
}
@SubscribeMessage('changeRoomTheme')
async handleChangeRoomTheme(client: Socket, payload: { roomId: string; roomCode: string; userId: string; themeId: string | null }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can change room theme' });
return;
}
await this.prisma.room.update({
where: { id: payload.roomId },
data: { themeId: payload.themeId } as Prisma.RoomUpdateInput
});
await this.broadcastFullState(payload.roomCode);
}
@SubscribeMessage('toggleParticles')
async handleToggleParticles(client: Socket, payload: { roomId: string; roomCode: string; userId: string; particlesEnabled: boolean }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can toggle particles' });
return;
}
await this.prisma.room.update({
where: { id: payload.roomId },
data: { particlesEnabled: payload.particlesEnabled } as any
});
await this.broadcastFullState(payload.roomCode);
}
@SubscribeMessage('updateRoomPack')
async handleUpdateRoomPack(client: Socket, payload: { roomId: string; roomCode: string; userId: string; questions: any[] }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
@ -660,7 +715,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
}
// Запрещаем удаление хоста
if (participant.role === 'HOST' || participant.userId === room.hostId) {
if (participant.role === 'HOST') {
client.emit('error', { message: 'Cannot kick the host' });
return;
}
@ -783,4 +838,35 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
await this.broadcastFullState(payload.roomCode);
}
@SubscribeMessage('changeParticipantRole')
async handleChangeParticipantRole(client: Socket, payload: {
roomId: string;
roomCode: string;
userId: string;
participantId: string;
newRole: 'HOST' | 'PLAYER' | 'SPECTATOR';
}) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only hosts can change participant roles' });
return;
}
try {
const room = await this.roomsService.updateParticipantRole(
payload.roomId,
payload.participantId,
payload.newRole,
payload.userId,
);
await this.broadcastFullState(payload.roomCode);
} catch (error) {
console.error('Error changing participant role:', error);
client.emit('error', {
message: error.message || 'Failed to change participant role'
});
}
}
}

View file

@ -84,4 +84,18 @@ export class RoomsController {
) {
return this.roomsService.kickPlayer(roomId, participantId);
}
@Patch(':roomId/participants/:participantId/role')
async updateParticipantRole(
@Param('roomId') roomId: string,
@Param('participantId') participantId: string,
@Body() dto: { role: 'HOST' | 'PLAYER' | 'SPECTATOR'; requestedByUserId: string }
) {
return this.roomsService.updateParticipantRole(
roomId,
participantId,
dto.role,
dto.requestedByUserId,
);
}
}

View file

@ -107,6 +107,20 @@ export class RoomsService {
}
async joinRoom(roomId: string, userId: string, name: string, role: 'PLAYER' | 'SPECTATOR') {
// Получаем комнату для проверки настроек
const room = await this.prisma.room.findUnique({
where: { id: roomId },
});
if (!room) {
throw new NotFoundException('Room not found');
}
// Проверяем, разрешены ли зрители
if (role === 'SPECTATOR' && !room.allowSpectators) {
throw new BadRequestException('Spectators are not allowed in this room');
}
const participant = await this.prisma.participant.create({
data: {
userId,
@ -117,7 +131,7 @@ export class RoomsService {
});
// Получаем обновленную комнату со всеми участниками
const room = await this.prisma.room.findUnique({
const updatedRoom = await this.prisma.room.findUnique({
where: { id: roomId },
include: {
host: true,
@ -129,8 +143,8 @@ export class RoomsService {
});
// Отправляем событие roomUpdate всем клиентам в комнате
if (room) {
this.roomEventsService.emitRoomUpdate(room.code, room);
if (updatedRoom) {
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
}
return participant;
@ -337,4 +351,92 @@ export class RoomsService {
return room;
}
async updateParticipantRole(
roomId: string,
participantId: string,
newRole: 'HOST' | 'PLAYER' | 'SPECTATOR',
requestedByUserId: string,
) {
// Проверяем права: запрашивающий должен быть хостом
const requester = await this.prisma.participant.findFirst({
where: {
roomId,
userId: requestedByUserId,
role: 'HOST',
isActive: true,
},
});
if (!requester) {
throw new UnauthorizedException('Only hosts can change participant roles');
}
// Получаем участника, которому меняем роль
const participant = await this.prisma.participant.findUnique({
where: { id: participantId },
include: { room: true },
});
if (!participant || participant.roomId !== roomId) {
throw new NotFoundException('Participant not found');
}
if (!participant.isActive) {
throw new BadRequestException('Cannot change role of inactive participant');
}
// Проверяем настройки комнаты для зрителей
if (newRole === 'SPECTATOR' && !participant.room.allowSpectators) {
throw new BadRequestException('Spectators are not allowed in this room');
}
// Проверяем, что не изменяем роль последнего хоста
if (participant.role === 'HOST' && newRole !== 'HOST') {
const hostCount = await this.prisma.participant.count({
where: {
roomId,
role: 'HOST',
isActive: true,
},
});
if (hostCount <= 1) {
throw new BadRequestException('Cannot remove the last host');
}
}
// Если игра идет и меняем роль текущего игрока на SPECTATOR, запрещаем
if (
participant.room.status === 'PLAYING' &&
participant.room.currentPlayerId === participantId &&
newRole === 'SPECTATOR'
) {
throw new BadRequestException('Cannot change current player role to SPECTATOR during the game');
}
// Обновляем роль
await this.prisma.participant.update({
where: { id: participantId },
data: { role: newRole },
});
// Получаем обновленную комнату со всеми участниками
const room = await this.prisma.room.findUnique({
where: { id: roomId },
include: {
host: true,
participants: {
include: { user: true },
},
questionPack: true,
},
});
if (room) {
this.roomEventsService.emitRoomUpdate(room.code, room);
}
return room;
}
}

View file

@ -8,7 +8,6 @@ import CreateRoom from './pages/CreateRoom';
import JoinRoom from './pages/JoinRoom';
import RoomPage from './pages/RoomPage';
import GamePage from './pages/GamePage';
import LocalGame from './pages/LocalGame';
import './App.css';
function App() {
@ -17,14 +16,14 @@ function App() {
<AuthProvider>
<Router>
<>
<Snowflakes />
{/* Snowflakes for non-game pages (uses theme settings only) */}
<Snowflakes roomParticlesEnabled={null} />
<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="/game/:roomCode" element={<GamePage />} />
<Route path="/local-game" element={<LocalGame />} />
</Routes>
</>
</Router>

View file

@ -315,6 +315,64 @@
color: var(--accent-primary, #ffd700);
}
/* Visual Effects Section */
.visual-effects-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 2px solid var(--border-color, rgba(255, 255, 255, 0.2));
}
.visual-effects-section h3 {
margin: 0 0 1rem 0;
color: var(--text-primary, #ffffff);
font-size: 1.2rem;
}
.visual-effects-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.toggle-label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2));
border-radius: var(--border-radius-sm, 8px);
transition: all 0.2s;
}
.toggle-label:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--accent-primary, #ffd700);
}
.toggle-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--accent-primary, #ffd700);
}
.toggle-text {
flex: 1;
color: var(--text-primary, #ffffff);
font-weight: 500;
user-select: none;
}
.visual-effects-description {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
font-style: italic;
}
/* Answers control section */
.answers-control-section {
margin-top: 1.5rem;

View file

@ -1,5 +1,6 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { questionsApi } from '../services/api'
import { useTheme } from '../context/ThemeContext'
import './GameManagementModal.css'
import './QuestionsModal.css'
@ -28,10 +29,27 @@ const GameManagementModal = ({
onUpdatePlayerName,
onUpdatePlayerScore,
onKickPlayer,
onChangeParticipantRole,
particlesEnabled = null,
onToggleParticles,
initialTab = 'players',
}) => {
const [activeTab, setActiveTab] = useState('players') // players | game | scoring | questions
const { currentThemeData } = useTheme()
const [activeTab, setActiveTab] = useState(initialTab) // players | game | scoring | questions
const [selectedPlayer, setSelectedPlayer] = useState(null)
const [customPoints, setCustomPoints] = useState(10)
// Determine actual particles enabled state (room override or theme default)
const getActualParticlesEnabled = () => {
if (particlesEnabled === true || particlesEnabled === false) {
return particlesEnabled
}
// If room override is null, use theme setting
return currentThemeData?.settings?.particlesEnabled ?? true
}
const actualParticlesEnabled = getActualParticlesEnabled()
const hasRoomOverride = particlesEnabled !== null && particlesEnabled !== undefined
// Player editing state
const [editingPlayerId, setEditingPlayerId] = useState(null)
@ -59,6 +77,13 @@ const GameManagementModal = ({
const [viewingQuestion, setViewingQuestion] = useState(null)
const [showAnswers, setShowAnswers] = useState(false)
// Сбрасываем вкладку на initialTab при открытии модального окна
useEffect(() => {
if (isOpen) {
setActiveTab(initialTab)
}
}, [isOpen, initialTab])
if (!isOpen) return null
const gameStatus = room?.status || 'WAITING'
@ -538,10 +563,46 @@ const GameManagementModal = ({
</button>
</span>
)}
<span className="player-role">
{participant.role === 'HOST' && '👑 Ведущий'}
{participant.role === 'SPECTATOR' && '👀 Зритель'}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{onChangeParticipantRole ? (
<select
value={participant.role}
onChange={(e) => {
const newRole = e.target.value;
// Проверка: нельзя изменить роль последнего хоста на не-HOST
if (participant.role === 'HOST' && newRole !== 'HOST') {
const hostCount = participants.filter(p =>
p.role === 'HOST' && (p.isActive !== false)
).length;
if (hostCount <= 1) {
alert('Нельзя изменить роль последнего хоста');
return;
}
}
onChangeParticipantRole(participant.id, newRole);
}}
style={{
padding: '5px 10px',
background: 'rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 215, 0, 0.3)',
borderRadius: '6px',
color: 'var(--text-primary)',
fontSize: '0.9rem',
cursor: 'pointer',
}}
title="Изменить роль участника"
>
<option value="HOST">👑 Ведущий</option>
<option value="PLAYER">🎮 Игрок</option>
<option value="SPECTATOR">👀 Зритель</option>
</select>
) : (
<span className="player-role">
{participant.role === 'HOST' && '👑 Ведущий'}
{participant.role === 'SPECTATOR' && '👀 Зритель'}
</span>
)}
</div>
</div>
<div className="player-score-section">
{editingPlayerId === participant.id && editMode === 'score' ? (
@ -682,6 +743,39 @@ const GameManagementModal = ({
</div>
)}
</div>
{/* Visual Effects Section */}
{onToggleParticles && (
<div className="visual-effects-section">
<h3>🎨 Визуальные эффекты</h3>
<div className="visual-effects-controls">
<label className="toggle-label">
<input
type="checkbox"
checked={actualParticlesEnabled}
onChange={(e) => {
if (onToggleParticles) {
onToggleParticles(e.target.checked)
}
}}
className="toggle-checkbox"
/>
<span className="toggle-text">
{hasRoomOverride
? (particlesEnabled ? 'Частицы включены (переопределено)' : 'Частицы выключены (переопределено)')
: `Частицы ${actualParticlesEnabled ? 'включены' : 'выключены'} (по умолчанию из темы)`
}
</span>
</label>
<p className="visual-effects-description">
{hasRoomOverride
? 'Вы переопределили настройку из темы. Переключение изменит настройку комнаты.'
: 'Текущее состояние берется из настроек темы. Переключение создаст переопределение для этой комнаты.'
}
</p>
</div>
</div>
)}
</div>
)}

View file

@ -1,163 +0,0 @@
import { useState, useRef, useEffect } from 'react'
import Game from './Game'
import QuestionsModal from './QuestionsModal'
import ThemeSwitcher from './ThemeSwitcher'
import VoiceSettings from './VoiceSettings'
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">
<div className="app-content">
<div className="app-title-bar">
<div className="app-control-buttons">
<ThemeSwitcher />
<VoiceSettings />
<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

View file

@ -1,611 +0,0 @@
import { useState } from 'react'
import { questionsApi } from '../services/api'
import './QuestionsModal.css'
const QuestionsModal = ({
isOpen,
onClose,
questions,
onUpdateQuestions,
isOnlineMode = false,
roomId = null,
availablePacks = [],
}) => {
const [editingQuestion, setEditingQuestion] = useState(null)
const [questionText, setQuestionText] = useState('')
const [answers, setAnswers] = useState([
{ text: '', points: 100 },
{ text: '', points: 80 },
{ text: '', points: 60 },
{ text: '', points: 40 },
{ text: '', points: 20 },
{ text: '', points: 10 },
])
const [jsonError, setJsonError] = useState('')
const [showPackImport, setShowPackImport] = useState(false)
const [selectedPack, setSelectedPack] = useState(null)
const [packQuestions, setPackQuestions] = useState([])
const [selectedQuestionIndices, setSelectedQuestionIndices] = useState(new Set())
const [savingToRoom, setSavingToRoom] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [viewingQuestion, setViewingQuestion] = useState(null)
const [showAnswers, setShowAnswers] = useState(false)
if (!isOpen) return null
const resetForm = () => {
setEditingQuestion(null)
setQuestionText('')
setAnswers([
{ text: '', points: 100 },
{ text: '', points: 80 },
{ text: '', points: 60 },
{ text: '', points: 40 },
{ text: '', points: 20 },
{ text: '', points: 10 },
])
setJsonError('')
}
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
onClose()
resetForm()
}
}
const handleClose = () => {
onClose()
resetForm()
}
const handleEdit = (question) => {
setEditingQuestion(question)
setQuestionText(question.text)
setAnswers([...question.answers])
setJsonError('')
}
const handleCancelEdit = () => {
resetForm()
}
const handleAnswerChange = (index, field, value) => {
const updatedAnswers = [...answers]
if (field === 'text') {
updatedAnswers[index].text = value
} else if (field === 'points') {
updatedAnswers[index].points = parseInt(value) || 0
}
setAnswers(updatedAnswers)
}
const handleAddAnswer = () => {
const minPoints = Math.min(...answers.map(a => a.points))
setAnswers([...answers, { text: '', points: Math.max(0, minPoints - 10) }])
}
const handleRemoveAnswer = (index) => {
if (answers.length > 1) {
setAnswers(answers.filter((_, i) => i !== index))
}
}
const validateForm = () => {
if (!questionText.trim()) {
setJsonError('Введите текст вопроса')
return false
}
if (answers.length === 0) {
setJsonError('Добавьте хотя бы один ответ')
return false
}
const hasEmptyAnswers = answers.some(a => !a.text.trim())
if (hasEmptyAnswers) {
setJsonError('Заполните все ответы')
return false
}
return true
}
const handleSave = () => {
if (!validateForm()) return
const questionData = {
id: editingQuestion ? editingQuestion.id : Date.now(),
text: questionText.trim(),
answers: answers
.filter(a => a.text.trim())
.map(a => ({
text: a.text.trim(),
points: a.points,
})),
}
let updatedQuestions
if (editingQuestion) {
updatedQuestions = questions.map(q =>
q.id === editingQuestion.id ? questionData : q
)
} else {
updatedQuestions = [...questions, questionData]
}
onUpdateQuestions(updatedQuestions)
resetForm()
}
const handleDelete = (questionId) => {
if (window.confirm('Вы уверены, что хотите удалить этот вопрос?')) {
const updatedQuestions = questions.filter(q => q.id !== questionId)
onUpdateQuestions(updatedQuestions)
if (editingQuestion && editingQuestion.id === questionId) {
resetForm()
}
}
}
const handleExportJson = () => {
try {
const jsonString = JSON.stringify(questions, null, 2)
const blob = new Blob([jsonString], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'questions.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
setJsonError('')
} catch (error) {
setJsonError('Ошибка при экспорте: ' + error.message)
}
}
const handleImportJson = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = (e) => {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
try {
const jsonContent = JSON.parse(event.target.result)
if (!Array.isArray(jsonContent)) {
setJsonError('JSON должен содержать массив вопросов')
return
}
// Валидация структуры
const isValid = jsonContent.every(q =>
q.id &&
typeof q.text === 'string' &&
Array.isArray(q.answers) &&
q.answers.every(a => a.text && typeof a.points === 'number')
)
if (!isValid) {
setJsonError('Неверный формат JSON. Ожидается массив объектов с полями: id, text, answers')
return
}
onUpdateQuestions(jsonContent)
setJsonError('')
alert(`Успешно импортировано ${jsonContent.length} вопросов`)
} catch (error) {
setJsonError('Ошибка при импорте: ' + error.message)
}
}
reader.readAsText(file)
}
input.click()
}
const handleSelectPack = async (packId) => {
if (!packId) {
setPackQuestions([])
setSelectedPack(null)
setSearchQuery('')
setViewingQuestion(null)
setShowAnswers(false)
return
}
try {
const response = await questionsApi.getPack(packId)
setPackQuestions(response.data.questions || [])
setSelectedPack(packId)
setSelectedQuestionIndices(new Set())
setSearchQuery('')
setViewingQuestion(null)
setShowAnswers(false)
} catch (error) {
console.error('Error fetching pack:', error)
setJsonError('Ошибка загрузки пака вопросов')
}
}
// Фильтрация вопросов по поисковому запросу
const filteredPackQuestions = packQuestions.filter((q) => {
if (!searchQuery.trim()) return true
const questionText = (q.text || q.question || '').toLowerCase()
return questionText.includes(searchQuery.toLowerCase())
})
// Выбор всех видимых вопросов
const handleSelectAll = () => {
const allVisibleIndices = new Set(
filteredPackQuestions.map((q) => {
const originalIndex = packQuestions.findIndex(pq => pq === q)
return originalIndex
}).filter(idx => idx !== -1)
)
const newSelected = new Set(selectedQuestionIndices)
allVisibleIndices.forEach(idx => newSelected.add(idx))
setSelectedQuestionIndices(newSelected)
}
// Снятие выбора со всех видимых вопросов
const handleDeselectAll = () => {
const visibleIndices = new Set(
filteredPackQuestions.map((q) => {
const originalIndex = packQuestions.findIndex(pq => pq === q)
return originalIndex
}).filter(idx => idx !== -1)
)
const newSelected = new Set(selectedQuestionIndices)
visibleIndices.forEach(idx => newSelected.delete(idx))
setSelectedQuestionIndices(newSelected)
}
// Проверка, выбраны ли все видимые вопросы
const areAllVisibleSelected = () => {
if (filteredPackQuestions.length === 0) return false
const visibleIndices = filteredPackQuestions.map((q) => {
const originalIndex = packQuestions.findIndex(pq => pq === q)
return originalIndex
}).filter(idx => idx !== -1)
return visibleIndices.every(idx => selectedQuestionIndices.has(idx))
}
// Просмотр вопроса
const handleViewQuestion = (question) => {
setViewingQuestion(question)
setShowAnswers(false)
}
// Закрытие просмотра вопроса
const handleCloseViewer = () => {
setViewingQuestion(null)
setShowAnswers(false)
}
const handleToggleQuestion = (index) => {
const newSelected = new Set(selectedQuestionIndices)
if (newSelected.has(index)) {
newSelected.delete(index)
} else {
newSelected.add(index)
}
setSelectedQuestionIndices(newSelected)
}
const handleImportSelected = () => {
const indices = Array.from(selectedQuestionIndices)
const questionsToImport = indices.map(idx => packQuestions[idx]).filter(Boolean)
// Create deep copies
const copiedQuestions = questionsToImport.map((q, idx) => ({
id: Date.now() + Math.random() + idx, // Generate new ID
text: q.text || q.question || '',
answers: (q.answers || []).map(a => ({ text: a.text, points: a.points })),
}))
const updatedQuestions = [...questions, ...copiedQuestions]
onUpdateQuestions(updatedQuestions)
// Reset
setSelectedQuestionIndices(new Set())
setSearchQuery('')
setShowPackImport(false)
setJsonError('')
alert(`Импортировано ${copiedQuestions.length} вопросов`)
}
return (
<div className="questions-modal-backdrop" onClick={handleBackdropClick}>
<div className="questions-modal-content">
<div className="questions-modal-header">
<h2 className="questions-modal-title">Управление вопросами</h2>
<button className="questions-modal-close" onClick={handleClose}>
×
</button>
</div>
<div className="questions-modal-actions">
<button
className="questions-modal-export-button"
onClick={handleExportJson}
>
📥 Экспорт JSON
</button>
<button
className="questions-modal-import-button"
onClick={handleImportJson}
>
📤 Импорт JSON
</button>
{availablePacks.length > 0 && (
<button
className="questions-modal-pack-import-button"
onClick={() => setShowPackImport(!showPackImport)}
>
📦 {showPackImport ? 'Скрыть импорт' : 'Импорт из пака'}
</button>
)}
</div>
{jsonError && (
<div className="questions-modal-error">{jsonError}</div>
)}
{showPackImport && availablePacks.length > 0 && (
<div className="pack-import-section">
<h3>Импорт вопросов из пака</h3>
<select
value={selectedPack || ''}
onChange={(e) => handleSelectPack(e.target.value)}
className="pack-import-select"
>
<option value="">-- Выберите пак --</option>
{availablePacks.map(pack => (
<option key={pack.id} value={pack.id}>
{pack.name} ({pack.questionCount} вопросов)
</option>
))}
</select>
{packQuestions.length > 0 && (
<div className="pack-questions-list">
{/* Поиск */}
<div className="pack-search-container">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="🔍 Поиск вопросов..."
className="pack-search-input"
/>
</div>
<div className="pack-questions-header">
<div className="pack-questions-header-left">
<span>Выберите вопросы для импорта:</span>
<div className="pack-select-all-buttons">
{filteredPackQuestions.length > 0 && (
<>
{areAllVisibleSelected() ? (
<button
onClick={handleDeselectAll}
className="pack-select-all-button"
>
Снять выбор
</button>
) : (
<button
onClick={handleSelectAll}
className="pack-select-all-button"
>
Выбрать все ({filteredPackQuestions.length})
</button>
)}
</>
)}
</div>
</div>
<button
onClick={handleImportSelected}
disabled={selectedQuestionIndices.size === 0}
className="pack-import-confirm-button"
>
Импортировать ({selectedQuestionIndices.size})
</button>
</div>
<div className="pack-questions-items">
{filteredPackQuestions.length === 0 ? (
<div className="pack-no-results">
{searchQuery ? 'Вопросы не найдены' : 'Нет вопросов в паке'}
</div>
) : (
filteredPackQuestions.map((q, filteredIdx) => {
const originalIndex = packQuestions.findIndex(pq => pq === q)
return (
<div key={originalIndex} className="pack-question-item">
<input
type="checkbox"
checked={selectedQuestionIndices.has(originalIndex)}
onChange={() => handleToggleQuestion(originalIndex)}
/>
<div className="pack-question-content">
<strong>{q.text || q.question}</strong>
<span className="pack-question-info">
{q.answers?.length || 0} ответов
</span>
</div>
<button
onClick={() => handleViewQuestion(q)}
className="pack-view-question-button"
title="Просмотр вопроса"
>
👁
</button>
</div>
)
})
)}
</div>
</div>
)}
{/* Модальное окно просмотра вопроса */}
{viewingQuestion && (
<div className="pack-question-viewer-backdrop" onClick={handleCloseViewer}>
<div className="pack-question-viewer" onClick={(e) => e.stopPropagation()}>
<div className="pack-question-viewer-header">
<h4>Просмотр вопроса</h4>
<button
className="pack-question-viewer-close"
onClick={handleCloseViewer}
>
×
</button>
</div>
<div className="pack-question-viewer-content">
<div className="pack-question-viewer-text">
{viewingQuestion.text || viewingQuestion.question}
</div>
<button
className="pack-show-answers-button"
onClick={() => setShowAnswers(!showAnswers)}
>
{showAnswers ? '🙈 Скрыть ответы' : '👁 Показать ответы'}
</button>
{showAnswers && (
<div className="pack-question-answers">
{viewingQuestion.answers?.map((answer, idx) => (
<div key={idx} className="pack-answer-item">
<span className="pack-answer-text">{answer.text}</span>
<span className="pack-answer-points">{answer.points} очков</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
)}
<div className="questions-modal-form">
<input
type="text"
value={questionText}
onChange={(e) => setQuestionText(e.target.value)}
placeholder="Введите текст вопроса"
className="questions-modal-input"
/>
<div className="questions-modal-answers">
<div className="questions-modal-answers-header">
<span>Ответы:</span>
<button
className="questions-modal-add-answer-button"
onClick={handleAddAnswer}
type="button"
>
+ Добавить ответ
</button>
</div>
{answers.map((answer, index) => (
<div key={index} className="questions-modal-answer-row">
<input
type="text"
value={answer.text}
onChange={(e) => handleAnswerChange(index, 'text', e.target.value)}
placeholder={`Ответ ${index + 1}`}
className="questions-modal-answer-input"
/>
<input
type="number"
value={answer.points}
onChange={(e) => handleAnswerChange(index, 'points', e.target.value)}
className="questions-modal-points-input"
min="0"
/>
{answers.length > 1 && (
<button
className="questions-modal-remove-answer-button"
onClick={() => handleRemoveAnswer(index)}
type="button"
>
×
</button>
)}
</div>
))}
</div>
<div className="questions-modal-form-buttons">
<button
className="questions-modal-save-button"
onClick={handleSave}
>
{editingQuestion ? 'Сохранить изменения' : 'Добавить вопрос'}
</button>
{editingQuestion && (
<button
className="questions-modal-cancel-button"
onClick={handleCancelEdit}
>
Отмена
</button>
)}
</div>
</div>
<div className="questions-modal-list">
<h3 className="questions-modal-list-title">
Вопросы ({questions.length})
</h3>
{questions.length === 0 ? (
<p className="questions-modal-empty">Нет вопросов. Добавьте вопросы для игры.</p>
) : (
<div className="questions-modal-items">
{questions.map((question) => (
<div key={question.id} className="questions-modal-item">
<div className="questions-modal-item-content">
<div className="questions-modal-item-text">{question.text}</div>
<div className="questions-modal-item-info">
{question.answers.length} ответов
</div>
</div>
<div className="questions-modal-item-actions">
<button
className="questions-modal-edit-button"
onClick={() => handleEdit(question)}
title="Редактировать"
>
</button>
<button
className="questions-modal-delete-button"
onClick={() => handleDelete(question.id)}
title="Удалить"
>
×
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
export default QuestionsModal

View file

@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react';
import './NameInputModal.css';
const RoleSelectionModal = ({
isOpen,
onSubmit,
onCancel,
allowSpectators = true,
title = 'Выберите роль',
description = 'Выберите роль для присоединения к комнате'
}) => {
const [selectedRole, setSelectedRole] = useState('PLAYER');
const [error, setError] = useState('');
// Сброс формы при открытии модального окна
useEffect(() => {
if (isOpen) {
setSelectedRole('PLAYER');
setError('');
}
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = (e) => {
e.preventDefault();
if (!selectedRole) {
setError('Выберите роль');
return;
}
// Проверка, разрешены ли зрители
if (selectedRole === 'SPECTATOR' && !allowSpectators) {
setError('Зрители не разрешены в этой комнате');
return;
}
setError('');
onSubmit(selectedRole);
};
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget && onCancel) {
onCancel();
}
};
return (
<div className="name-input-modal-backdrop" onClick={handleBackdropClick}>
<div className="name-input-modal-content">
<div className="name-input-modal-header">
<h2 className="name-input-modal-title">{title}</h2>
{onCancel && (
<button className="name-input-modal-close" onClick={onCancel}>
×
</button>
)}
</div>
<form className="name-input-modal-form" onSubmit={handleSubmit}>
<div className="name-input-modal-body">
<p className="name-input-modal-description">
{description}
</p>
<div className="name-input-group">
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '15px 20px',
background: selectedRole === 'PLAYER' ? 'rgba(255, 215, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)',
border: `2px solid ${selectedRole === 'PLAYER' ? 'rgba(255, 215, 0, 0.5)' : 'rgba(255, 255, 255, 0.3)'}`,
borderRadius: '12px',
cursor: 'pointer',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
if (selectedRole !== 'PLAYER') {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}
}}
onMouseLeave={(e) => {
if (selectedRole !== 'PLAYER') {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
}
}}
>
<input
type="radio"
name="role"
value="PLAYER"
checked={selectedRole === 'PLAYER'}
onChange={(e) => {
setSelectedRole(e.target.value);
setError('');
}}
style={{ cursor: 'pointer' }}
/>
<span style={{ color: 'var(--text-primary)', fontSize: '1.1rem', fontWeight: '500' }}>
🎮 Игрок
</span>
</label>
{allowSpectators && (
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '15px 20px',
background: selectedRole === 'SPECTATOR' ? 'rgba(255, 215, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)',
border: `2px solid ${selectedRole === 'SPECTATOR' ? 'rgba(255, 215, 0, 0.5)' : 'rgba(255, 255, 255, 0.3)'}`,
borderRadius: '12px',
cursor: 'pointer',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
if (selectedRole !== 'SPECTATOR') {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}
}}
onMouseLeave={(e) => {
if (selectedRole !== 'SPECTATOR') {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
}
}}
>
<input
type="radio"
name="role"
value="SPECTATOR"
checked={selectedRole === 'SPECTATOR'}
onChange={(e) => {
setSelectedRole(e.target.value);
setError('');
}}
style={{ cursor: 'pointer' }}
/>
<span style={{ color: 'var(--text-primary)', fontSize: '1.1rem', fontWeight: '500' }}>
👀 Зритель
</span>
</label>
)}
</div>
{error && (
<p className="name-input-error">{error}</p>
)}
</div>
</div>
<div className="name-input-modal-footer">
<button
type="submit"
className="name-input-submit-button primary"
disabled={!selectedRole}
>
Продолжить
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="name-input-cancel-button secondary"
>
Отмена
</button>
)}
</div>
</form>
</div>
</div>
);
};
export default RoleSelectionModal;

View file

@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react'
import { useEffect, useState } from 'react'
import { useTheme } from '../context/ThemeContext'
const SNOWFLAKE_LIFETIME = 15000 // 15 seconds max lifetime
const TARGET_COUNT = 30 // Target number of snowflakes
@ -16,17 +17,50 @@ function createSnowflake(id) {
}
}
const Snowflakes = () => {
const Snowflakes = ({ roomParticlesEnabled = null }) => {
const { currentThemeData } = useTheme()
const [snowflakes, setSnowflakes] = useState([])
// Initialize snowflakes
// Determine if particles should be enabled
// Priority: room override (if explicitly set to true/false) > theme setting > default (true)
const getParticlesEnabled = () => {
// Check room override first (if explicitly set to true or false, not null)
if (roomParticlesEnabled === true || roomParticlesEnabled === false) {
return roomParticlesEnabled
}
// If room override is null/undefined, use theme setting if available
if (currentThemeData?.settings?.particlesEnabled !== undefined) {
return currentThemeData.settings.particlesEnabled
}
// Default: enabled
return true
}
// Get particle symbol from theme or default
const getParticleSymbol = () => {
return currentThemeData?.settings?.particleSymbol || '❄'
}
const particlesEnabled = getParticlesEnabled()
const particleSymbol = getParticleSymbol()
// Initialize snowflakes only if particles are enabled
// Also re-initialize when theme changes (particleSymbol might change)
useEffect(() => {
if (!particlesEnabled) {
setSnowflakes([])
return
}
const initial = Array.from({ length: TARGET_COUNT }, (_, i) => createSnowflake(i))
setSnowflakes(initial)
}, [])
}, [particlesEnabled, particleSymbol, currentThemeData])
// Update cycle - remove old snowflakes and add new ones
useEffect(() => {
if (!particlesEnabled) {
return
}
const interval = setInterval(() => {
setSnowflakes((prev) => {
const now = Date.now()
@ -49,7 +83,12 @@ const Snowflakes = () => {
}, UPDATE_INTERVAL)
return () => clearInterval(interval)
}, [])
}, [particlesEnabled])
// Don't render if particles are disabled
if (!particlesEnabled) {
return null
}
return (
<div className="snowflakes-container">
@ -65,7 +104,7 @@ const Snowflakes = () => {
opacity: snowflake.opacity,
}}
>
{particleSymbol}
</div>
))}
</div>

View file

@ -1,13 +1,23 @@
import React, { useState } from 'react';
import { useTheme } from '../context/ThemeContext';
import socketService from '../services/socket';
import './ThemeSwitcher.css';
const ThemeSwitcher = () => {
const ThemeSwitcher = ({ roomId, roomCode, isHost, userId }) => {
const { currentTheme, currentThemeData, themes, changeTheme, loading } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const handleThemeChange = (themeId) => {
changeTheme(themeId);
// Если это комната и пользователь хост, синхронизируем тему через сервер
if (roomId && roomCode && isHost && userId) {
// Применяем тему локально сразу для мгновенного отклика
changeTheme(themeId);
// Отправляем событие на сервер для синхронизации со всеми игроками
socketService.changeRoomTheme(roomId, roomCode, userId, themeId);
} else {
// Обычное локальное изменение темы
changeTheme(themeId);
}
setIsOpen(false);
};

View file

@ -16,6 +16,7 @@ export const useTheme = () => {
export const ThemeProvider = ({ children }) => {
const [themes, setThemes] = useState([]);
const [loading, setLoading] = useState(true);
const [pendingThemeId, setPendingThemeId] = useState(null);
const [currentTheme, setCurrentTheme] = useState(() => {
const saved = localStorage.getItem('app-theme');
return saved || null;
@ -53,6 +54,18 @@ export const ThemeProvider = ({ children }) => {
loadThemes();
}, []);
// Применяем pending theme после загрузки тем
useEffect(() => {
if (!loading && themes.length > 0 && pendingThemeId) {
if (themes.find((t) => t.id === pendingThemeId)) {
setCurrentTheme(pendingThemeId);
setPendingThemeId(null);
} else {
setPendingThemeId(null);
}
}
}, [loading, themes, pendingThemeId]);
// Apply theme when currentTheme or themes change
useEffect(() => {
if (!currentTheme || themes.length === 0) return;
@ -75,16 +88,35 @@ export const ThemeProvider = ({ children }) => {
// Apply theme settings
if (theme.settings) {
Object.entries(theme.settings).forEach(([key, value]) => {
root.style.setProperty(`--${camelToKebab(key)}`, value);
// Skip boolean values (like particlesEnabled) - they are handled separately
if (typeof value !== 'boolean' && value !== null && value !== undefined) {
root.style.setProperty(`--${camelToKebab(key)}`, String(value));
}
});
}
// Apply particle CSS variables
if (theme.settings) {
const particleColor = theme.settings.particleColor || theme.colors?.textPrimary || '#ffffff';
const particleGlow = theme.settings.particleGlow || theme.colors?.textGlow || 'rgba(255, 255, 255, 0.8)';
root.style.setProperty('--particle-color', particleColor);
root.style.setProperty('--particle-glow', particleGlow);
}
localStorage.setItem('app-theme', currentTheme);
}, [currentTheme, themes]);
const changeTheme = (themeId) => {
// Если темы еще не загружены, сохраняем themeId для применения после загрузки
if (loading || themes.length === 0) {
setPendingThemeId(themeId);
return;
}
// Если тема существует в списке, применяем её
if (themes.find((t) => t.id === themeId)) {
setCurrentTheme(themeId);
setPendingThemeId(null);
}
};

View file

@ -1,343 +0,0 @@
export const questions = [
{
id: 18,
text: 'Что дед мороз делает летом?',
answers: [
{ text: 'Отдыхает', points: 100 },
{ text: 'Готовит подарки', points: 80 },
{ text: 'Спит', points: 60 },
{ text: 'Путешествует', points: 40 },
{ text: 'Загорает', points: 20 },
{ text: 'Работает', points: 10 },
],
},
{
id: 30,
text: 'Что намазывают на хлеб?',
answers: [
{ text: 'Масло', points: 100 },
{ text: 'Икру', points: 80 },
{ text: 'Варенье', points: 60 },
{ text: 'Паштет', points: 40 },
{ text: 'Майонез', points: 20 },
{ text: 'Горчицу', points: 10 },
],
},
{
id: 20,
text: 'Кто работает в новый год?',
answers: [
{ text: 'Дед Мороз', points: 100 },
{ text: 'Снегурочка', points: 80 },
{ text: 'Врач', points: 60 },
{ text: 'Полицейский', points: 40 },
{ text: 'Таксист', points: 20 },
{ text: 'Продавец', points: 10 },
],
},
{
id: 19,
text: 'Почему лошадь не курит?',
answers: [
{ text: 'Боится умереть', points: 100 },
{ text: 'Неудобно (копыта мешают)', points: 80 },
{ text: 'Не хочет', points: 60 },
{ text: 'Не продают', points: 40 },
],
},
{
id: 31,
text: 'Какая самая "лошадиная" фамилия?',
answers: [
{ text: 'Овсов', points: 100 },
{ text: 'Лошадкин', points: 80 },
{ text: 'Конев', points: 60 },
{ text: 'Жеребцов', points: 40 },
{ text: 'Скакунов', points: 20 },
{ text: 'Рысаков', points: 10 },
],
},
{
id: 10,
text: 'Кто больше всех ест на Новый год?',
answers: [
{ 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 },
],
},
{
id: 14,
text: 'Что любят лошади?',
answers: [
{ text: 'Яблоки', points: 100 },
{ text: 'Морковь', points: 80 },
{ text: 'Свежую траву', points: 60 },
{ text: 'Сахар', points: 40 },
{ text: 'Когда их гладят', points: 20 },
{ text: 'Овёс', points: 10 },
],
},
{
id: 21,
text: 'Что может быть вязаным?',
answers: [
{ text: 'Шарф', points: 100 },
{ text: 'Свитер', points: 80 },
{ text: 'Носки', points: 60 },
{ text: 'Шапка', points: 40 },
{ text: 'Варежки', points: 20 },
{ text: 'Жилет', points: 10 },
],
},
{
id: 25,
text: 'Что бы вы хотели выиграть в лотерею?',
answers: [
{ text: 'Деньги', points: 100 },
{ text: 'Машину', points: 80 },
{ text: 'Квартиру', points: 60 },
{ text: 'Путешествие', points: 40 },
{ text: 'Технику', points: 20 },
{ text: 'Дом', points: 10 },
],
},
{
id: 8,
text: 'Кто дольше всех собирается за стол?',
answers: [
{ 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: 50 },
{ text: 'Не тут', points: 50 },
],
},
{
id: 29,
text: 'Кому очень холодно зимой?',
answers: [
{ text: 'Елочке', points: 100 },
{ text: 'Людям', points: 80 },
{ text: 'Птицам', points: 60 },
{ text: 'Собаке', points: 40 },
{ text: 'Зайцу', points: 20 },
{ text: 'Деду Морозу', points: 10 },
],
},
{
id: 5,
text: 'Что обычно остаётся на утро после праздника?',
answers: [
{ text: 'Посуда', points: 100 },
{ text: 'Остатки еды', points: 80 },
{ text: 'Усталость', points: 60 },
{ text: 'Хлопушки', points: 40 },
{ text: 'Мишура', points: 20 },
{ text: 'Украшения', points: 10 },
],
},
{
id: 16,
text: 'Зачем деду морозу посох?',
answers: [
{ text: 'Для опоры', points: 100 },
{ text: 'Для волшебства', points: 80 },
{ text: 'Для красоты', points: 60 },
{ text: 'Для заморозки', points: 40 },
{ text: 'По традиции', points: 20 },
{ text: 'Для защиты', points: 10 },
],
},
{
id: 2,
text: 'Что обещают себе с 1 января?',
answers: [
{ text: 'Похудеть', points: 100 },
{ text: 'Раньше ложиться спать', points: 80 },
{ text: 'Начать заниматься спортом', points: 60 },
{ text: 'Больше зарабатывать', points: 40 },
{ text: 'Выучить язык', points: 20 },
{ text: 'Больше читать', points: 10 },
],
},
{
id: 3,
text: 'Что чаще всего забывают купить перед Новым годом?',
answers: [
{ text: 'Шампанское', points: 100 },
{ text: 'Майонез', points: 80 },
{ text: 'Мандарины', points: 60 },
{ text: 'Петарды', points: 40 },
{ text: 'Салфетки', points: 20 },
{ 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: 'Чем обычно заканчивается новогодняя ночь?',
answers: [
{ text: 'Сном', points: 100 },
{ text: 'Тостом', points: 80 },
{ text: 'Фейерверком', points: 60 },
{ text: 'Песнями', points: 40 },
{ text: 'Танцами', points: 20 },
{ text: 'Играми', points: 10 },
],
},
{
id: 15,
text: 'С чем у людей чаще всего ассоциируется лошадь?',
answers: [
{ text: 'Скачки / бег', points: 100 },
{ text: 'Свобода', points: 80 },
{ text: 'Сила', points: 60 },
{ text: 'Деревня / поле', points: 40 },
{ text: 'Ковбои', points: 20 },
{ text: 'Красота', points: 10 },
],
},
{
id: 22,
text: 'Что нужно иметь покорителю Северного полюса?',
answers: [
{ text: 'Компас', points: 100 },
{ text: 'Лыжи', points: 80 },
{ text: 'Теплую одежду', points: 60 },
{ text: 'Еду', points: 40 },
{ text: 'Флаг', points: 20 },
{ text: 'Обувь', points: 10 },
],
},
{
id: 17,
text: 'Где дед мороз берет подарки?',
answers: [
{ text: 'В мастерской', points: 100 },
{ text: 'Покупает', points: 80 },
{ text: 'Делает сам', points: 60 },
{ text: 'В магазине', points: 40 },
{ text: 'У эльфов', points: 20 },
{ text: 'Волшебством', points: 10 },
],
},
{
id: 26,
text: 'Во что упаковывают подарок?',
answers: [
{ text: 'В коробку', points: 100 },
{ text: 'В пакет', points: 80 },
{ text: 'В бумагу', points: 60 },
{ text: 'В фольгу', points: 40 },
{ text: 'В упаковку', points: 20 },
{ text: 'В газету', points: 10 },
],
},
{
id: 1,
text: 'Что чаще всего стоит на новогоднем столе?',
answers: [
{ text: 'Оливье', points: 100 },
{ text: 'Шампанское', points: 80 },
{ text: 'Мандарины', points: 60 },
{ text: 'Селедка под шубой', points: 40 },
{ text: 'Икра', points: 20 },
{ text: 'Торт', points: 10 },
],
},
{
id: 6,
text: 'Самая популярная новогодняя традиция',
answers: [
{ text: 'Загадывать желание', points: 100 },
{ text: 'Смотреть "Иронию судьбы"', points: 80 },
{ text: 'Дарить подарки', points: 60 },
{ text: 'Наряжать ёлку', points: 40 },
{ text: 'Запускать фейерверки', points: 20 },
{ text: 'Встречать с семьёй', points: 10 },
],
},
{
id: 23,
text: 'Кто живёт в Антарктиде?',
answers: [
{ text: 'Пингвины', points: 100 },
{ text: 'Медведи', points: 80 },
{ text: 'Полярники', points: 60 },
{ text: 'Моржи', points: 40 },
{ text: 'Тюлени', points: 20 },
{ text: 'Морские котики', points: 10 },
],
},
{
id: 28,
text: 'Какое слово очень холодное?',
answers: [
{ text: 'Снег', points: 100 },
{ text: 'Мороз', points: 80 },
{ text: 'Лед', points: 60 },
{ text: 'Зима', points: 40 },
{ text: 'Мороженое', points: 20 },
{ text: 'Холод', points: 10 },
],
},
{
id: 7,
text: 'Что чаще всего дарят взрослым на Новый год?',
answers: [
{ text: 'Деньги', points: 100 },
{ text: 'Парфюм', points: 80 },
{ text: 'Книги', points: 60 },
{ text: 'Одежду', points: 40 },
{ text: 'Сладости', points: 20 },
{ text: 'Цветы', points: 10 },
],
},
{
id: 27,
text: 'Кто стучится в дверь ко мне?',
answers: [
{ text: 'Почтальон', points: 100 },
{ text: 'Сосед', points: 80 },
{ text: 'Гость', points: 60 },
{ text: 'Друг', points: 40 },
{ text: 'Дед мороз', points: 20 },
{ text: 'Полиция', points: 10 },
],
}
]

View file

@ -81,14 +81,24 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
}
};
// Обработчик обновления вопросов комнаты
const handleRoomPackUpdated = (updatedRoom) => {
setRoom(updatedRoom);
if (updatedRoom.participants) {
setParticipants(updatedRoom.participants);
}
};
socketService.on('roomUpdate', handleRoomUpdate);
socketService.on('gameStarted', handleGameStarted);
socketService.on('gameStateUpdated', handleGameStateUpdated);
socketService.on('roomPackUpdated', handleRoomPackUpdated);
return () => {
socketService.off('roomUpdate', handleRoomUpdate);
socketService.off('gameStarted', handleGameStarted);
socketService.off('gameStateUpdated', handleGameStateUpdated);
socketService.off('roomPackUpdated', handleRoomPackUpdated);
};
}, [roomCode, password, onGameStarted, user?.id]);

View file

@ -125,10 +125,10 @@ body {
.snowflake {
position: absolute;
top: -20px;
color: var(--text-primary);
color: var(--particle-color, var(--text-primary, #ffffff));
font-size: 1em;
font-family: Arial;
text-shadow: 0 0 5px rgba(255, 255, 255, 0.8);
text-shadow: 0 0 5px var(--particle-glow, rgba(255, 255, 255, 0.8));
animation: snow linear forwards;
pointer-events: none;
will-change: transform, opacity;

View file

@ -2,7 +2,6 @@ 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';
import NameInputModal from '../components/NameInputModal';
const CreateRoom = () => {
@ -10,8 +9,6 @@ const CreateRoom = () => {
const { user, loginAnonymous, loading: authLoading } = useAuth();
const { createRoom, loading: roomLoading } = useRoom();
const [questionPacks, setQuestionPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState('');
const [settings, setSettings] = useState({
maxPlayers: 10,
allowSpectators: true,
@ -19,7 +16,6 @@ const CreateRoom = () => {
timerDuration: 30,
password: '',
});
const [loading, setLoading] = useState(true);
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
const [isHostNameModalOpen, setIsHostNameModalOpen] = useState(false);
@ -43,25 +39,6 @@ const CreateRoom = () => {
}
};
useEffect(() => {
const fetchPacks = async () => {
try {
const response = await questionsApi.getPacks(user?.id);
setQuestionPacks(response.data);
} catch (error) {
console.error('Error fetching question packs:', error);
} finally {
setLoading(false);
}
};
if (user) {
fetchPacks();
} else {
setLoading(false);
}
}, [user]);
const handleCreateRoom = async () => {
if (!user) {
setIsNameModalOpen(true);
@ -86,7 +63,7 @@ const CreateRoom = () => {
const room = await createRoom(
user.id,
selectedPackId || undefined,
undefined,
cleanSettings,
name.trim(),
);
@ -97,30 +74,11 @@ const CreateRoom = () => {
}
};
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)}
>
<option value="">Без пака вопросов</option>
{questionPacks.map((pack) => (
<option key={pack.id} value={pack.id}>
{pack.name} ({pack.questionCount} вопросов)
</option>
))}
</select>
</div>
<div className="form-group">
<label>Максимум игроков:</label>
<input

View file

@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext';
import { questionsApi, roomsApi } from '../services/api';
import QRCode from 'qrcode';
import socketService from '../services/socket';
@ -9,12 +10,14 @@ import QRModal from '../components/QRModal';
import GameManagementModal from '../components/GameManagementModal';
import ThemeSwitcher from '../components/ThemeSwitcher';
import VoiceSettings from '../components/VoiceSettings';
import Snowflakes from '../components/Snowflakes';
import './GamePage.css';
const GamePage = () => {
const { roomCode } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const { changeTheme } = useTheme();
// ВСЁ состояние игры в одном объекте
const [gameState, setGameState] = useState({
@ -27,6 +30,8 @@ const GamePage = () => {
questions: [],
hostId: null,
roomCode: null,
themeId: null,
particlesEnabled: null, // null = использовать настройку из темы, true/false = override
});
const [loading, setLoading] = useState(true);
@ -37,6 +42,8 @@ const GamePage = () => {
// Храним participantId текущего пользователя для проверки удаления
const currentUserParticipantIdRef = useRef(null);
// Храним предыдущий themeId комнаты для отслеживания изменений
const previousThemeIdRef = useRef(null);
// ЕДИНСТВЕННЫЙ обработчик состояния игры
useEffect(() => {
@ -54,6 +61,15 @@ const GamePage = () => {
);
currentUserParticipantIdRef.current = currentUserParticipant?.id || null;
}
// Применяем тему комнаты, если она изменилась
const currentThemeId = state.themeId || null;
if (currentThemeId !== previousThemeIdRef.current) {
previousThemeIdRef.current = currentThemeId;
if (currentThemeId) {
changeTheme(currentThemeId);
}
}
};
socketService.connect();
@ -63,7 +79,7 @@ const GamePage = () => {
return () => {
socketService.off('gameStateUpdated', handleGameStateUpdated);
};
}, [roomCode, user?.id]);
}, [roomCode, user?.id, changeTheme]);
// Обработка события удаления игрока
useEffect(() => {
@ -107,7 +123,13 @@ const GamePage = () => {
// Загрузка доступных паков для хоста
useEffect(() => {
const fetchPacks = async () => {
if (user && gameState.hostId === user.id) {
// Проверяем роль участника для поддержки нескольких хостов
const currentUserParticipant = user
? gameState.participants.find(p => p.userId === user.id)
: null;
const isHost = currentUserParticipant?.role === 'HOST';
if (user && isHost) {
try {
const response = await questionsApi.getPacks(user.id);
setQuestionPacks(response.data);
@ -118,7 +140,7 @@ const GamePage = () => {
};
fetchPacks();
}, [user, gameState.hostId]);
}, [user, gameState.participants]);
// Генерация QR кода
useEffect(() => {
@ -147,11 +169,17 @@ const GamePage = () => {
// === Handlers для действий игрока ===
const handleAnswerClick = (answerId, points) => {
if (!gameState.roomId || !user) return;
if (!gameState.roomId || !user || !canPerformActions) return;
const myParticipant = gameState.participants.find(p => p.userId === user.id);
if (!myParticipant) return;
// Зрители не могут отвечать на вопросы
if (isSpectator) {
alert('Зрители не могут отвечать на вопросы');
return;
}
// Проверка очереди (только для не-хостов)
if (!isHost && gameState.currentPlayerId !== myParticipant.id) {
alert('Сейчас не ваша очередь!');
@ -171,11 +199,14 @@ const GamePage = () => {
};
const handleNextQuestion = () => {
if (!gameState.roomId || !user) return;
if (!gameState.roomId || !user || !canPerformActions) return;
const myParticipant = gameState.participants.find(p => p.userId === user.id);
if (!myParticipant) return;
// Зрители не могут переключать вопросы
if (isSpectator) return;
socketService.emit('playerAction', {
action: 'nextQuestion',
roomId: gameState.roomId,
@ -186,11 +217,14 @@ const GamePage = () => {
};
const handlePrevQuestion = () => {
if (!gameState.roomId || !user) return;
if (!gameState.roomId || !user || !canPerformActions) return;
const myParticipant = gameState.participants.find(p => p.userId === user.id);
if (!myParticipant) return;
// Зрители не могут переключать вопросы
if (isSpectator) return;
socketService.emit('playerAction', {
action: 'prevQuestion',
roomId: gameState.roomId,
@ -318,6 +352,17 @@ const GamePage = () => {
});
};
const handleChangeParticipantRole = (participantId, newRole) => {
if (!gameState.roomId || !user) return;
socketService.changeParticipantRole(
gameState.roomId,
gameState.roomCode,
user.id,
participantId,
newRole
);
};
const handleSelectPlayer = (participantId) => {
if (!gameState.roomId || !user) return;
if (!isHost) return; // Только хост может выбирать игрока
@ -360,9 +405,21 @@ const GamePage = () => {
{}
);
const isHost = user && gameState.hostId === user.id;
// Определяем роль текущего пользователя
const currentUserParticipant = user
? gameState.participants.find(p => p.userId === user.id)
: null;
const userRole = currentUserParticipant?.role || null;
const isHost = userRole === 'HOST'; // Проверяем роль участника для поддержки нескольких хостов
const isSpectator = userRole === 'SPECTATOR';
const isPlayer = userRole === 'PLAYER';
const canGoPrev = currentQuestionIndex > 0;
const canGoNext = currentQuestionIndex < gameState.questions.length - 1;
// Зрители не могут выполнять действия игрока
const canPerformActions = isHost || isPlayer;
// === Render ===
@ -381,11 +438,19 @@ const GamePage = () => {
return (
<div className="game-page">
{/* Particles with room override */}
<Snowflakes roomParticlesEnabled={gameState.particlesEnabled} />
{/* Control bar - только для хоста */}
{isHost && (
<div className="game-control-bar">
<div className="game-control-left">
<ThemeSwitcher />
<ThemeSwitcher
roomId={gameState.roomId}
roomCode={gameState.roomCode}
isHost={isHost}
userId={user?.id}
/>
<VoiceSettings />
<button
className="control-button control-button-qr"
@ -425,6 +490,22 @@ const GamePage = () => {
</div>
)}
{isSpectator && (
<div className="spectator-info" style={{
padding: '20px',
margin: '20px',
background: 'rgba(255, 215, 0, 0.1)',
border: '2px solid rgba(255, 215, 0, 0.3)',
borderRadius: '12px',
textAlign: 'center',
color: 'var(--text-primary)'
}}>
<p style={{ fontSize: '1.1rem', margin: 0 }}>
👀 Вы в роли зрителя. Вы можете наблюдать за игрой, но не можете отвечать на вопросы.
</p>
</div>
)}
<Game
currentQuestion={currentQuestion}
roomParticipants={gameState.participants}
@ -432,7 +513,7 @@ const GamePage = () => {
revealedAnswers={revealedForCurrentQ}
playerScores={playerScores}
currentPlayerId={gameState.currentPlayerId}
onAnswerClick={handleAnswerClick}
onAnswerClick={canPerformActions ? handleAnswerClick : null}
onPreviousQuestion={isHost && canGoPrev ? handlePrevQuestion : null}
onNextQuestion={isHost && canGoNext ? handleNextQuestion : null}
onSelectPlayer={isHost ? handleSelectPlayer : null}
@ -479,6 +560,9 @@ const GamePage = () => {
onUpdatePlayerName={handleUpdatePlayerName}
onUpdatePlayerScore={handleUpdatePlayerScore}
onKickPlayer={handleKickPlayer}
onChangeParticipantRole={handleChangeParticipantRole}
particlesEnabled={gameState.particlesEnabled}
onToggleParticles={handleToggleParticles}
/>
</>
)}

View file

@ -29,10 +29,6 @@ const Home = () => {
navigate('/join-room');
};
const handleLocalGame = () => {
navigate('/local-game');
};
return (
<div className="home-page">
<div className="home-theme-switcher-wrapper">
@ -52,10 +48,6 @@ const Home = () => {
<button onClick={handleJoinRoom} className="menu-button">
Присоединиться к комнате
</button>
<button onClick={handleLocalGame} className="menu-button">
Локальная игра
</button>
</div>
{user && (

View file

@ -1,8 +0,0 @@
import React from 'react';
import LocalGameApp from '../components/LocalGameApp';
const LocalGame = () => {
return <LocalGameApp />;
};
export default LocalGame;

View file

@ -4,9 +4,12 @@ import { useAuth } from '../context/AuthContext';
import { useRoom } from '../hooks/useRoom';
import { questionsApi } from '../services/api';
import QRCode from 'qrcode';
import socketService from '../services/socket';
import QRModal from '../components/QRModal';
import NameInputModal from '../components/NameInputModal';
import PasswordModal from '../components/PasswordModal';
import RoleSelectionModal from '../components/RoleSelectionModal';
import GameManagementModal from '../components/GameManagementModal';
const RoomPage = () => {
const { roomCode } = useParams();
@ -28,18 +31,18 @@ const RoomPage = () => {
fetchRoomWithPassword,
joinRoom,
startGame,
updateQuestionPack,
} = useRoom(roomCode, handleGameStartedEvent, password);
const [qrCode, setQrCode] = useState('');
const [joined, setJoined] = useState(false);
const [isQRModalOpen, setIsQRModalOpen] = useState(false);
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const [isRoleSelectionModalOpen, setIsRoleSelectionModalOpen] = useState(false);
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false);
const [passwordError, setPasswordError] = useState(null);
const [joinError, setJoinError] = useState(null);
const [selectedRole, setSelectedRole] = useState('PLAYER');
const [questionPacks, setQuestionPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState('');
const [loadingPacks, setLoadingPacks] = useState(false);
const [updatingPack, setUpdatingPack] = useState(false);
useEffect(() => {
const generateQR = async () => {
@ -116,16 +119,38 @@ const RoomPage = () => {
}
};
// Показываем модальное окно выбора роли, если allowSpectators === true и пользователь авторизован
useEffect(() => {
if (
room &&
user &&
!joined &&
!isRoleSelectionModalOpen &&
room.allowSpectators &&
!participants.some((p) => p.userId === user.id)
) {
setIsRoleSelectionModalOpen(true);
}
}, [room, user, joined, participants, isRoleSelectionModalOpen]);
// Автоматическое присоединение как PLAYER, если зрители не разрешены
useEffect(() => {
const handleJoin = async () => {
if (room && user && !joined) {
if (room && user && !joined && !isRoleSelectionModalOpen) {
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);
// Если зрители не разрешены, присоединяемся как PLAYER автоматически
if (!room.allowSpectators) {
try {
setJoinError(null);
await joinRoom(room.id, user.id, user.name || 'Гость', 'PLAYER');
setJoined(true);
} catch (error) {
console.error('Join error:', error);
const errorMessage = error.response?.data?.message || error.message || 'Ошибка при присоединении к комнате';
setJoinError(errorMessage);
alert(errorMessage);
}
}
} else {
setJoined(true);
@ -134,35 +159,50 @@ const RoomPage = () => {
};
handleJoin();
}, [room, user, participants, joined, joinRoom]);
}, [room, user, participants, joined, joinRoom, isRoleSelectionModalOpen]);
// Обработка выбора роли
const handleRoleSubmit = async (role) => {
if (!room || !user) return;
try {
setJoinError(null);
setIsRoleSelectionModalOpen(false);
await joinRoom(room.id, user.id, user.name || 'Гость', role);
setSelectedRole(role);
setJoined(true);
} catch (error) {
console.error('Join error:', error);
const errorMessage = error.response?.data?.message || error.message || 'Ошибка при присоединении к комнате';
setJoinError(errorMessage);
alert(errorMessage);
// Открываем модальное окно снова при ошибке
setIsRoleSelectionModalOpen(true);
}
};
useEffect(() => {
const fetchPacks = async () => {
if (user) {
try {
setLoadingPacks(true);
const response = await questionsApi.getPacks(user.id);
setQuestionPacks(response.data);
} catch (error) {
console.error('Error fetching question packs:', error);
} finally {
setLoadingPacks(false);
}
}
};
if (room && user && room.hostId === user.id) {
// Проверяем роль участника для поддержки нескольких хостов
const currentUserParticipant = user && participants
? participants.find(p => p.userId === user.id)
: null;
const isHost = currentUserParticipant?.role === 'HOST';
if (room && user && isHost) {
fetchPacks();
}
}, [room, user]);
useEffect(() => {
if (room && room.questionPackId) {
setSelectedPackId(room.questionPackId);
} else {
setSelectedPackId('');
}
}, [room]);
}, [room, user, participants]);
// Автоматически перенаправляем на страницу игры, если игра уже началась
useEffect(() => {
@ -176,23 +216,61 @@ const RoomPage = () => {
navigate(`/game/${roomCode}`);
};
const handleUpdateQuestionPack = async () => {
if (!selectedPackId) {
alert('Выберите пак вопросов');
return;
}
try {
setUpdatingPack(true);
await updateQuestionPack(selectedPackId);
alert('Пак вопросов успешно добавлен');
} catch (error) {
console.error('Error updating question pack:', error);
alert('Ошибка при обновлении пака вопросов');
} finally {
setUpdatingPack(false);
// Получаем вопросы из roomPack (может быть JSON строкой или массивом)
const getRoomQuestions = () => {
if (!room?.roomPack?.questions) return [];
const questions = room.roomPack.questions;
if (typeof questions === 'string') {
try {
return JSON.parse(questions);
} catch {
return [];
}
}
return Array.isArray(questions) ? questions : [];
};
const roomQuestions = getRoomQuestions();
// Обновление вопросов через WebSocket
const handleUpdateRoomQuestions = useCallback(
async (newQuestions) => {
if (!room?.id || !user) return;
try {
socketService.updateRoomPack(
room.id,
room.code,
user.id,
newQuestions
);
} catch (error) {
console.error('Error updating room questions:', error);
alert('Ошибка при сохранении вопросов');
}
},
[room, user]
);
// Изменение роли участника через WebSocket
const handleChangeParticipantRole = useCallback(
(participantId, newRole) => {
if (!room?.id || !user) return;
try {
socketService.changeParticipantRole(
room.id,
room.code,
user.id,
participantId,
newRole
);
} catch (error) {
console.error('Error changing participant role:', error);
alert('Ошибка при изменении роли участника');
}
},
[room, user]
);
if (loading) {
return <div className="loading">Загрузка комнаты...</div>;
@ -218,7 +296,11 @@ const RoomPage = () => {
);
}
const isHost = user && room && room.hostId === user.id;
// Проверяем роль участника для поддержки нескольких хостов
const currentUserParticipant = user && participants
? participants.find(p => p.userId === user.id)
: null;
const isHost = currentUserParticipant?.role === 'HOST';
// Если требуется пароль, показываем только модальное окно
if (requiresPassword && !room) {
@ -258,55 +340,25 @@ const RoomPage = () => {
</div>
<div className="question-pack-section">
<h3>Пак вопросов:</h3>
{room.questionPack ? (
<div className="pack-info">
<p>
<strong>{room.questionPack.name}</strong> (
{room.questionPack.questionCount || 0} вопросов)
</p>
{isHost && (
<p className="pack-hint">
Можете изменить пак вопросов в любой момент
</p>
)}
</div>
) : (
<div className="pack-info">
<h3>Вопросы:</h3>
<div className="pack-info">
<p>
Вопросов в комнате: <strong>{roomQuestions.length}</strong>
</p>
{isHost && (
<p className="pack-hint">
Пак вопросов не выбран. Вы можете начать игру без пака и
добавить его позже.
Вы можете настроить вопросы перед началом игры
</p>
</div>
)}
)}
</div>
{isHost && (
<div className="pack-selector">
<select
value={selectedPackId}
onChange={(e) => setSelectedPackId(e.target.value)}
disabled={loadingPacks || updatingPack}
>
<option value="">Выберите пак вопросов</option>
{questionPacks.map((pack) => (
<option key={pack.id} value={pack.id}>
{pack.name} ({pack.questionCount} вопросов)
</option>
))}
</select>
<button
onClick={handleUpdateQuestionPack}
disabled={
!selectedPackId ||
selectedPackId === room.questionPackId ||
updatingPack ||
loadingPacks
}
className="secondary"
>
{updatingPack ? 'Сохранение...' : 'Сохранить пак'}
</button>
</div>
<button
onClick={() => setIsQuestionsModalOpen(true)}
className="secondary"
>
Настроить вопросы
</button>
)}
</div>
@ -353,6 +405,40 @@ const RoomPage = () => {
onCancel={() => navigate('/')}
error={passwordError}
/>
<RoleSelectionModal
isOpen={isRoleSelectionModalOpen}
onSubmit={handleRoleSubmit}
onCancel={() => navigate('/')}
allowSpectators={room?.allowSpectators}
title="Выберите роль"
description={room?.allowSpectators
? "Выберите роль для присоединения к комнате"
: "Присоединиться как игрок"}
/>
{isHost && room && (
<GameManagementModal
isOpen={isQuestionsModalOpen}
onClose={() => setIsQuestionsModalOpen(false)}
initialTab="questions"
room={{
id: room.id,
code: room.code,
status: room.status,
hostId: room.hostId,
}}
participants={participants}
currentQuestion={null}
currentQuestionIndex={0}
totalQuestions={roomQuestions.length}
revealedAnswers={[]}
questions={roomQuestions}
onUpdateQuestions={handleUpdateRoomQuestions}
availablePacks={questionPacks}
onChangeParticipantRole={handleChangeParticipantRole}
/>
)}
</div>
);
};

View file

@ -137,6 +137,34 @@ class SocketService {
newScore,
});
}
changeRoomTheme(roomId, roomCode, userId, themeId) {
this.emit('changeRoomTheme', {
roomId,
roomCode,
userId,
themeId,
});
}
changeParticipantRole(roomId, roomCode, userId, participantId, newRole) {
this.emit('changeParticipantRole', {
roomId,
roomCode,
userId,
participantId,
newRole,
});
}
toggleParticles(roomId, roomCode, userId, particlesEnabled) {
this.emit('toggleParticles', {
roomId,
roomCode,
userId,
particlesEnabled,
});
}
}
export default new SocketService();