diff --git a/README.md b/README.md index 6dc77f3..92f6397 100644 --- a/README.md +++ b/README.md @@ -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-коды для присоединения - 📊 Статистика и история игр diff --git a/admin/src/api/themes.ts b/admin/src/api/themes.ts index ae9fa36..3390e55 100644 --- a/admin/src/api/themes.ts +++ b/admin/src/api/themes.ts @@ -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)', } diff --git a/admin/src/components/ThemeEditorDialog.tsx b/admin/src/components/ThemeEditorDialog.tsx index 4820af9..8862a90 100644 --- a/admin/src/components/ThemeEditorDialog.tsx +++ b/admin/src/components/ThemeEditorDialog.tsx @@ -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({

+ + {/* Particles Section */} +
+

Particles (Частицы)

+
+
+ updateSetting('particlesEnabled', checked)} + /> + +
+
+ + updateSetting('particleSymbol', e.target.value)} + placeholder="❄" + maxLength={2} + /> +

+ Символ для частиц. Рекомендуется использовать эмодзи (например: ❄, 🌸, 🎉, ✨) +

+
+ updateSetting('particleColor', v)} + description="Цвет частиц. По умолчанию используется Text Primary цвет" + /> + updateSetting('particleGlow', v)} + description="Цвет свечения частиц. По умолчанию используется Text Glow цвет" + /> +
+
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 62ce900..265f525 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -46,6 +46,7 @@ model Room { questionPackId String? autoAdvance Boolean @default(false) voiceMode Boolean @default(false) // Голосовой режим + particlesEnabled Boolean? // null = использовать настройку из темы, true/false = override password String? // Пароль для доступа к комнате // Админские комнаты diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 2514f8f..f23511f 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -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)', }, }, ]; diff --git a/backend/src/admin/themes/dto/create-theme.dto.ts b/backend/src/admin/themes/dto/create-theme.dto.ts index 21abf9e..91ba427 100644 --- a/backend/src/admin/themes/dto/create-theme.dto.ts +++ b/backend/src/admin/themes/dto/create-theme.dto.ts @@ -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 { diff --git a/backend/src/game/game.gateway.ts b/backend/src/game/game.gateway.ts index 4d55a5a..9945d26 100644 --- a/backend/src/game/game.gateway.ts +++ b/backend/src/game/game.gateway.ts @@ -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 { - 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 { @@ -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' + }); + } + } } diff --git a/backend/src/rooms/rooms.controller.ts b/backend/src/rooms/rooms.controller.ts index 6a68689..00a4500 100644 --- a/backend/src/rooms/rooms.controller.ts +++ b/backend/src/rooms/rooms.controller.ts @@ -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, + ); + } } diff --git a/backend/src/rooms/rooms.service.ts b/backend/src/rooms/rooms.service.ts index 4a4d273..10f4663 100644 --- a/backend/src/rooms/rooms.service.ts +++ b/backend/src/rooms/rooms.service.ts @@ -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; + } } diff --git a/src/App.jsx b/src/App.jsx index 19293b3..4f55942 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { <> - + {/* Snowflakes for non-game pages (uses theme settings only) */} + } /> } /> } /> } /> } /> - } /> diff --git a/src/components/GameManagementModal.css b/src/components/GameManagementModal.css index 378a594..545c915 100644 --- a/src/components/GameManagementModal.css +++ b/src/components/GameManagementModal.css @@ -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; diff --git a/src/components/GameManagementModal.jsx b/src/components/GameManagementModal.jsx index 758dd69..5b6a2c6 100644 --- a/src/components/GameManagementModal.jsx +++ b/src/components/GameManagementModal.jsx @@ -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 = ({ )} - - {participant.role === 'HOST' && '👑 Ведущий'} - {participant.role === 'SPECTATOR' && '👀 Зритель'} - +
+ {onChangeParticipantRole ? ( + + ) : ( + + {participant.role === 'HOST' && '👑 Ведущий'} + {participant.role === 'SPECTATOR' && '👀 Зритель'} + + )} +
{editingPlayerId === participant.id && editMode === 'score' ? ( @@ -682,6 +743,39 @@ const GameManagementModal = ({
)} + + {/* Visual Effects Section */} + {onToggleParticles && ( +
+

🎨 Визуальные эффекты

+
+ +

+ {hasRoomOverride + ? 'Вы переопределили настройку из темы. Переключение изменит настройку комнаты.' + : 'Текущее состояние берется из настроек темы. Переключение создаст переопределение для этой комнаты.' + } +

+
+
+ )} )} diff --git a/src/components/LocalGameApp.jsx b/src/components/LocalGameApp.jsx deleted file mode 100644 index 8b501cc..0000000 --- a/src/components/LocalGameApp.jsx +++ /dev/null @@ -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 ( -
-
-
-
- - - - - -
- -

- 100 - к - 1 -

- - {questions.length > 0 && currentQuestion && ( -
-
- {currentQuestionIndex + 1}/{questions.length} -
- -
- )} -
- - setIsQuestionsModalOpen(false)} - questions={questions} - onUpdateQuestions={handleUpdateQuestions} - /> - - -
-
- ) -} - -export default LocalGameApp diff --git a/src/components/QuestionsModal.jsx b/src/components/QuestionsModal.jsx deleted file mode 100644 index 277f657..0000000 --- a/src/components/QuestionsModal.jsx +++ /dev/null @@ -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 ( -
-
-
-

Управление вопросами

- -
- -
- - - {availablePacks.length > 0 && ( - - )} -
- - {jsonError && ( -
{jsonError}
- )} - - {showPackImport && availablePacks.length > 0 && ( -
-

Импорт вопросов из пака

- - - {packQuestions.length > 0 && ( -
- {/* Поиск */} -
- setSearchQuery(e.target.value)} - placeholder="🔍 Поиск вопросов..." - className="pack-search-input" - /> -
- -
-
- Выберите вопросы для импорта: -
- {filteredPackQuestions.length > 0 && ( - <> - {areAllVisibleSelected() ? ( - - ) : ( - - )} - - )} -
-
- -
- -
- {filteredPackQuestions.length === 0 ? ( -
- {searchQuery ? 'Вопросы не найдены' : 'Нет вопросов в паке'} -
- ) : ( - filteredPackQuestions.map((q, filteredIdx) => { - const originalIndex = packQuestions.findIndex(pq => pq === q) - return ( -
- handleToggleQuestion(originalIndex)} - /> -
- {q.text || q.question} - - {q.answers?.length || 0} ответов - -
- -
- ) - }) - )} -
-
- )} - - {/* Модальное окно просмотра вопроса */} - {viewingQuestion && ( -
-
e.stopPropagation()}> -
-

Просмотр вопроса

- -
-
-
- {viewingQuestion.text || viewingQuestion.question} -
- - {showAnswers && ( -
- {viewingQuestion.answers?.map((answer, idx) => ( -
- {answer.text} - {answer.points} очков -
- ))} -
- )} -
-
-
- )} -
- )} - -
- setQuestionText(e.target.value)} - placeholder="Введите текст вопроса" - className="questions-modal-input" - /> - -
-
- Ответы: - -
- - {answers.map((answer, index) => ( -
- handleAnswerChange(index, 'text', e.target.value)} - placeholder={`Ответ ${index + 1}`} - className="questions-modal-answer-input" - /> - handleAnswerChange(index, 'points', e.target.value)} - className="questions-modal-points-input" - min="0" - /> - {answers.length > 1 && ( - - )} -
- ))} -
- -
- - {editingQuestion && ( - - )} -
-
- -
-

- Вопросы ({questions.length}) -

- {questions.length === 0 ? ( -

Нет вопросов. Добавьте вопросы для игры.

- ) : ( -
- {questions.map((question) => ( -
-
-
{question.text}
-
- {question.answers.length} ответов -
-
-
- - -
-
- ))} -
- )} -
-
-
- ) -} - -export default QuestionsModal - - - - - diff --git a/src/components/RoleSelectionModal.jsx b/src/components/RoleSelectionModal.jsx new file mode 100644 index 0000000..0e6217c --- /dev/null +++ b/src/components/RoleSelectionModal.jsx @@ -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 ( +
+
+
+

{title}

+ {onCancel && ( + + )} +
+ +
+
+

+ {description} +

+ +
+
+ + + {allowSpectators && ( + + )} +
+ {error && ( +

{error}

+ )} +
+
+ +
+ + {onCancel && ( + + )} +
+
+
+
+ ); +}; + +export default RoleSelectionModal; + diff --git a/src/components/Snowflakes.jsx b/src/components/Snowflakes.jsx index fd51ba1..eee80b2 100644 --- a/src/components/Snowflakes.jsx +++ b/src/components/Snowflakes.jsx @@ -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 (
@@ -65,7 +104,7 @@ const Snowflakes = () => { opacity: snowflake.opacity, }} > - ❄ + {particleSymbol}
))} diff --git a/src/components/ThemeSwitcher.jsx b/src/components/ThemeSwitcher.jsx index 76719b1..c5333ca 100644 --- a/src/components/ThemeSwitcher.jsx +++ b/src/components/ThemeSwitcher.jsx @@ -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); }; diff --git a/src/context/ThemeContext.jsx b/src/context/ThemeContext.jsx index f57b37b..f897e94 100644 --- a/src/context/ThemeContext.jsx +++ b/src/context/ThemeContext.jsx @@ -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); } }; diff --git a/src/data/questions.js b/src/data/questions.js deleted file mode 100644 index 87f25b9..0000000 --- a/src/data/questions.js +++ /dev/null @@ -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 }, - ], - } -] diff --git a/src/hooks/useRoom.js b/src/hooks/useRoom.js index b081480..ecb49b0 100644 --- a/src/hooks/useRoom.js +++ b/src/hooks/useRoom.js @@ -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]); diff --git a/src/index.css b/src/index.css index 2fa6d12..c4cc511 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/pages/CreateRoom.jsx b/src/pages/CreateRoom.jsx index d1015a5..c1da342 100644 --- a/src/pages/CreateRoom.jsx +++ b/src/pages/CreateRoom.jsx @@ -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
Загрузка...
; - } - return (

Создать комнату

-
- - -
-
{ 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 (
+ {/* Particles with room override */} + + {/* Control bar - только для хоста */} {isHost && (
- +
)} + {isSpectator && ( +
+

+ 👀 Вы в роли зрителя. Вы можете наблюдать за игрой, но не можете отвечать на вопросы. +

+
+ )} + { 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} /> )} diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 14b8045..66a67ac 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -29,10 +29,6 @@ const Home = () => { navigate('/join-room'); }; - const handleLocalGame = () => { - navigate('/local-game'); - }; - return (
@@ -52,10 +48,6 @@ const Home = () => { - -
{user && ( diff --git a/src/pages/LocalGame.jsx b/src/pages/LocalGame.jsx deleted file mode 100644 index 99f5f59..0000000 --- a/src/pages/LocalGame.jsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import LocalGameApp from '../components/LocalGameApp'; - -const LocalGame = () => { - return ; -}; - -export default LocalGame; diff --git a/src/pages/RoomPage.jsx b/src/pages/RoomPage.jsx index 1282e77..4d15da3 100644 --- a/src/pages/RoomPage.jsx +++ b/src/pages/RoomPage.jsx @@ -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
Загрузка комнаты...
; @@ -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 = () => {
-

Пак вопросов:

- {room.questionPack ? ( -
-

- {room.questionPack.name} ( - {room.questionPack.questionCount || 0} вопросов) -

- {isHost && ( -

- Можете изменить пак вопросов в любой момент -

- )} -
- ) : ( -
+

Вопросы:

+
+

+ Вопросов в комнате: {roomQuestions.length} +

+ {isHost && (

- Пак вопросов не выбран. Вы можете начать игру без пака и - добавить его позже. + Вы можете настроить вопросы перед началом игры

-
- )} + )} +
{isHost && ( -
- - -
+ )}
@@ -353,6 +405,40 @@ const RoomPage = () => { onCancel={() => navigate('/')} error={passwordError} /> + + navigate('/')} + allowSpectators={room?.allowSpectators} + title="Выберите роль" + description={room?.allowSpectators + ? "Выберите роль для присоединения к комнате" + : "Присоединиться как игрок"} + /> + + {isHost && room && ( + 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} + /> + )}
); }; diff --git a/src/services/socket.js b/src/services/socket.js index b88656b..ad7127f 100644 --- a/src/services/socket.js +++ b/src/services/socket.js @@ -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();