stiff
This commit is contained in:
parent
403ea8ac24
commit
06e95fb432
11 changed files with 186 additions and 226 deletions
|
|
@ -29,12 +29,11 @@ export interface ThemeSettings {
|
||||||
particleSymbol?: string
|
particleSymbol?: string
|
||||||
particleColor?: string
|
particleColor?: string
|
||||||
particleGlow?: string
|
particleGlow?: string
|
||||||
particleTargetCount?: number
|
particleSpawnCount?: number
|
||||||
particleUpdateInterval?: number
|
particleUpdateInterval?: number
|
||||||
particleDurationMin?: number
|
particleDurationMin?: number
|
||||||
particleDurationMax?: number
|
particleDurationMax?: number
|
||||||
particleInitialDelayMax?: number
|
particleInitialDelayMax?: number
|
||||||
particleDensity?: number
|
|
||||||
// Finish Screen Settings
|
// Finish Screen Settings
|
||||||
finishScreenTitle?: string
|
finishScreenTitle?: string
|
||||||
finishScreenSubtitle?: string
|
finishScreenSubtitle?: string
|
||||||
|
|
@ -313,12 +312,11 @@ export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
|
||||||
particleSymbol: '❄',
|
particleSymbol: '❄',
|
||||||
particleColor: '#ffffff',
|
particleColor: '#ffffff',
|
||||||
particleGlow: 'rgba(255, 255, 255, 0.8)',
|
particleGlow: 'rgba(255, 255, 255, 0.8)',
|
||||||
particleTargetCount: 200,
|
particleSpawnCount: 0.1,
|
||||||
particleUpdateInterval: 1000,
|
particleUpdateInterval: 1000,
|
||||||
particleDurationMin: 7,
|
particleDurationMin: 7,
|
||||||
particleDurationMax: 10,
|
particleDurationMax: 10,
|
||||||
particleInitialDelayMax: 10,
|
particleInitialDelayMax: 10,
|
||||||
particleDensity: 100,
|
|
||||||
finishScreenTitle: 'Игра завершена!',
|
finishScreenTitle: 'Игра завершена!',
|
||||||
finishScreenSubtitle: '',
|
finishScreenSubtitle: '',
|
||||||
finishScreenBgColor: 'rgba(0, 0, 0, 0.5)',
|
finishScreenBgColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
|
|
||||||
|
|
@ -598,46 +598,25 @@ export function ThemeEditorDialog({
|
||||||
<h4 className="text-md font-medium">Animation Settings (Настройки анимации)</h4>
|
<h4 className="text-md font-medium">Animation Settings (Настройки анимации)</h4>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="particleTargetCount">
|
<Label htmlFor="particleSpawnCount">
|
||||||
Target Count (Целевое количество)
|
Spawn Count (частиц/пиксель ширины)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="particleTargetCount"
|
id="particleSpawnCount"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="0.01"
|
||||||
max="1000"
|
max="10"
|
||||||
value={settings.particleTargetCount ?? DEFAULT_THEME_SETTINGS.particleTargetCount ?? 200}
|
step="0.01"
|
||||||
|
value={settings.particleSpawnCount ?? DEFAULT_THEME_SETTINGS.particleSpawnCount ?? 0.1}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = parseInt(e.target.value, 10)
|
const value = parseFloat(e.target.value)
|
||||||
if (!isNaN(value) && value > 0) {
|
if (!isNaN(value) && value > 0) {
|
||||||
updateSetting('particleTargetCount', value)
|
updateSetting('particleSpawnCount', value)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Целевое количество снежинок на экране (используется, если плотность не задана)
|
Количество частиц на единицу ширины экрана. Обеспечивает одинаковую визуальную плотность на разных устройствах
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="particleDensity">
|
|
||||||
Density (Плотность, частиц/Мп)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="particleDensity"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="500"
|
|
||||||
step="1"
|
|
||||||
value={settings.particleDensity ?? DEFAULT_THEME_SETTINGS.particleDensity ?? 100}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = parseInt(e.target.value, 10)
|
|
||||||
if (!isNaN(value) && value > 0) {
|
|
||||||
updateSetting('particleDensity', value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Плотность частиц (количество на 1Мп площади экрана). Учитывает размер экрана для одинаковой визуальной плотности на разных устройствах
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -287,7 +287,7 @@ async function main() {
|
||||||
particleSymbol: '❄',
|
particleSymbol: '❄',
|
||||||
particleColor: '#ffffff',
|
particleColor: '#ffffff',
|
||||||
particleGlow: 'rgba(255, 215, 0, 0.8)',
|
particleGlow: 'rgba(255, 215, 0, 0.8)',
|
||||||
particleTargetCount: 200,
|
particleSpawnCount: 0.1,
|
||||||
particleUpdateInterval: 1000,
|
particleUpdateInterval: 1000,
|
||||||
particleDurationMin: 7,
|
particleDurationMin: 7,
|
||||||
particleDurationMax: 10,
|
particleDurationMax: 10,
|
||||||
|
|
@ -327,7 +327,7 @@ async function main() {
|
||||||
particleSymbol: '🌸',
|
particleSymbol: '🌸',
|
||||||
particleColor: '#2d3748',
|
particleColor: '#2d3748',
|
||||||
particleGlow: 'rgba(47, 128, 237, 0.6)',
|
particleGlow: 'rgba(47, 128, 237, 0.6)',
|
||||||
particleTargetCount: 200,
|
particleSpawnCount: 0.1,
|
||||||
particleUpdateInterval: 1000,
|
particleUpdateInterval: 1000,
|
||||||
particleDurationMin: 7,
|
particleDurationMin: 7,
|
||||||
particleDurationMax: 10,
|
particleDurationMax: 10,
|
||||||
|
|
@ -367,7 +367,7 @@ async function main() {
|
||||||
particleSymbol: '🎉',
|
particleSymbol: '🎉',
|
||||||
particleColor: '#ffffff',
|
particleColor: '#ffffff',
|
||||||
particleGlow: 'rgba(255, 87, 108, 0.8)',
|
particleGlow: 'rgba(255, 87, 108, 0.8)',
|
||||||
particleTargetCount: 200,
|
particleSpawnCount: 0.1,
|
||||||
particleUpdateInterval: 1000,
|
particleUpdateInterval: 1000,
|
||||||
particleDurationMin: 7,
|
particleDurationMin: 7,
|
||||||
particleDurationMax: 10,
|
particleDurationMax: 10,
|
||||||
|
|
@ -407,7 +407,7 @@ async function main() {
|
||||||
particleSymbol: '✨',
|
particleSymbol: '✨',
|
||||||
particleColor: '#e0e0e0',
|
particleColor: '#e0e0e0',
|
||||||
particleGlow: 'rgba(100, 255, 218, 0.6)',
|
particleGlow: 'rgba(100, 255, 218, 0.6)',
|
||||||
particleTargetCount: 200,
|
particleSpawnCount: 0.1,
|
||||||
particleUpdateInterval: 1000,
|
particleUpdateInterval: 1000,
|
||||||
particleDurationMin: 7,
|
particleDurationMin: 7,
|
||||||
particleDurationMax: 10,
|
particleDurationMax: 10,
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export class ThemeSettingsDto {
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
particleTargetCount?: number;
|
particleSpawnCount?: number;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|
@ -112,11 +112,6 @@ export class ThemeSettingsDto {
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
particleInitialDelayMax?: number;
|
particleInitialDelayMax?: number;
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
particleDensity?: number;
|
|
||||||
|
|
||||||
// Finish Screen Settings
|
// Finish Screen Settings
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
|
|
||||||
@SubscribeMessage('joinRoom')
|
@SubscribeMessage('joinRoom')
|
||||||
async handleJoinRoom(client: Socket, payload: { roomCode: string; userId: string }) {
|
async handleJoinRoom(client: Socket, payload: { roomCode: string; userId: string }) {
|
||||||
|
console.log(`🔌 Client ${client.id} joining WebSocket room ${payload.roomCode}, userId: ${payload.userId}`);
|
||||||
client.join(payload.roomCode);
|
client.join(payload.roomCode);
|
||||||
|
|
||||||
// Получаем полное состояние для отправки присоединившемуся клиенту
|
// Получаем полное состояние для отправки присоединившемуся клиенту
|
||||||
|
|
@ -573,6 +574,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log(`📡 Broadcasting gameStateUpdated to room ${roomCode} with ${room.participants.length} participants`);
|
||||||
this.server.to(roomCode).emit('gameStateUpdated', fullState);
|
this.server.to(roomCode).emit('gameStateUpdated', fullState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ export class RoomsService {
|
||||||
// WebSocket joinRoom может выполняться параллельно с REST API joinRoom
|
// WebSocket joinRoom может выполняться параллельно с REST API joinRoom
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
console.log(`📤 Broadcasting room update for ${updatedRoom.code} with ${updatedRoom.participants.length} participants`);
|
||||||
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
|
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
|
||||||
// Также отправляем gameStateUpdated через broadcastFullState
|
// Также отправляем gameStateUpdated через broadcastFullState
|
||||||
await this.gameGateway.broadcastFullState(updatedRoom.code);
|
await this.gameGateway.broadcastFullState(updatedRoom.code);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||||
import { useTheme } from '../context/ThemeContext'
|
import { useTheme } from '../context/ThemeContext'
|
||||||
|
|
||||||
// Default values for particle animation settings
|
// Default values for particle animation settings
|
||||||
const DEFAULT_TARGET_COUNT = 200
|
const DEFAULT_SPAWN_COUNT = 0.1
|
||||||
const DEFAULT_UPDATE_INTERVAL = 1000
|
const DEFAULT_UPDATE_INTERVAL = 1000
|
||||||
const DEFAULT_DURATION_MIN = 7
|
const DEFAULT_DURATION_MIN = 7
|
||||||
const DEFAULT_DURATION_MAX = 10
|
const DEFAULT_DURATION_MAX = 10
|
||||||
|
|
@ -53,29 +53,16 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => {
|
||||||
return currentThemeData?.settings?.particleSymbol || '❄'
|
return currentThemeData?.settings?.particleSymbol || '❄'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get particle density from theme settings
|
// Get particle spawn count from theme settings (particles per pixel width)
|
||||||
const getParticleDensity = () => {
|
const getParticleSpawnCount = () => {
|
||||||
return currentThemeData?.settings?.particleDensity
|
return currentThemeData?.settings?.particleSpawnCount ?? DEFAULT_SPAWN_COUNT
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate target count based on density and screen size, or use fixed target count
|
// Calculate target count based on spawn count and screen width
|
||||||
const calculateTargetCount = () => {
|
const calculateTargetCount = () => {
|
||||||
const density = getParticleDensity()
|
const spawnCount = getParticleSpawnCount()
|
||||||
|
const count = Math.round(spawnCount * windowSize.width)
|
||||||
// If density is set, calculate based on screen area (particles per 1Mp)
|
return Math.max(1, count) // Ensure at least 1 particle
|
||||||
if (density !== undefined && density > 0) {
|
|
||||||
const area = windowSize.width * windowSize.height
|
|
||||||
const count = Math.round((density * area) / 1000000)
|
|
||||||
return Math.max(1, count) // Ensure at least 1 particle
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to fixed target count
|
|
||||||
return currentThemeData?.settings?.particleTargetCount ?? DEFAULT_TARGET_COUNT
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get particle animation settings from theme with defaults
|
|
||||||
const getParticleTargetCount = () => {
|
|
||||||
return calculateTargetCount()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getParticleUpdateInterval = () => {
|
const getParticleUpdateInterval = () => {
|
||||||
|
|
@ -104,7 +91,7 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => {
|
||||||
|
|
||||||
const particlesEnabled = getParticlesEnabled()
|
const particlesEnabled = getParticlesEnabled()
|
||||||
const particleSymbol = getParticleSymbol()
|
const particleSymbol = getParticleSymbol()
|
||||||
const targetCount = getParticleTargetCount()
|
const targetCount = calculateTargetCount()
|
||||||
const updateInterval = getParticleUpdateInterval()
|
const updateInterval = getParticleUpdateInterval()
|
||||||
const durationRange = getParticleDurationRange()
|
const durationRange = getParticleDurationRange()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,23 @@ export const ThemeProvider = ({ children }) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pendingThemeId, setPendingThemeId] = useState(null);
|
const [pendingThemeId, setPendingThemeId] = useState(null);
|
||||||
const [currentTheme, setCurrentTheme] = useState(() => {
|
const [currentTheme, setCurrentTheme] = useState(() => {
|
||||||
|
// Загружаем полный объект темы из localStorage
|
||||||
const saved = localStorage.getItem('app-theme');
|
const saved = localStorage.getItem('app-theme');
|
||||||
return saved || null;
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const themeData = JSON.parse(saved);
|
||||||
|
// Если это объект темы, возвращаем его ID
|
||||||
|
if (themeData && typeof themeData === 'object' && themeData.id) {
|
||||||
|
return themeData.id;
|
||||||
|
}
|
||||||
|
// Если это просто строка ID (старый формат), возвращаем её
|
||||||
|
return themeData;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse theme from localStorage:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load themes from API
|
// Load themes from API
|
||||||
|
|
@ -38,9 +53,16 @@ export const ThemeProvider = ({ children }) => {
|
||||||
// Find theme marked as default, or use first theme as fallback
|
// Find theme marked as default, or use first theme as fallback
|
||||||
const defaultTheme = data.find((t) => t.isDefault === true) || data[0];
|
const defaultTheme = data.find((t) => t.isDefault === true) || data[0];
|
||||||
if (defaultTheme) {
|
if (defaultTheme) {
|
||||||
localStorage.setItem('app-theme', defaultTheme.id);
|
// Сохраняем полный объект темы
|
||||||
|
localStorage.setItem('app-theme', JSON.stringify(defaultTheme));
|
||||||
return defaultTheme.id;
|
return defaultTheme.id;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Обновляем сохраненную тему актуальными данными с сервера
|
||||||
|
const currentThemeData = data.find((t) => t.id === prevTheme);
|
||||||
|
if (currentThemeData) {
|
||||||
|
localStorage.setItem('app-theme', JSON.stringify(currentThemeData));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return prevTheme;
|
return prevTheme;
|
||||||
});
|
});
|
||||||
|
|
@ -69,11 +91,38 @@ export const ThemeProvider = ({ children }) => {
|
||||||
|
|
||||||
// Apply theme when currentTheme or themes change
|
// Apply theme when currentTheme or themes change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentTheme || themes.length === 0) return;
|
// Сначала пытаемся применить тему из localStorage для мгновенного отображения
|
||||||
|
const applyThemeFromCache = () => {
|
||||||
|
const savedThemeStr = localStorage.getItem('app-theme');
|
||||||
|
if (savedThemeStr) {
|
||||||
|
try {
|
||||||
|
const savedTheme = JSON.parse(savedThemeStr);
|
||||||
|
if (savedTheme && typeof savedTheme === 'object' && savedTheme.colors && savedTheme.settings) {
|
||||||
|
applyThemeStyles(savedTheme);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to apply cached theme:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const theme = themes.find((t) => t.id === currentTheme);
|
// Применяем кэшированную тему сразу
|
||||||
if (!theme) return;
|
const cacheApplied = applyThemeFromCache();
|
||||||
|
|
||||||
|
// Затем применяем актуальную тему из загруженного списка (если она отличается)
|
||||||
|
if (currentTheme && themes.length > 0) {
|
||||||
|
const theme = themes.find((t) => t.id === currentTheme);
|
||||||
|
if (theme) {
|
||||||
|
applyThemeStyles(theme);
|
||||||
|
// Сохраняем полный объект темы
|
||||||
|
localStorage.setItem('app-theme', JSON.stringify(theme));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentTheme, themes]);
|
||||||
|
|
||||||
|
const applyThemeStyles = (theme) => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
// Remove data-theme attribute (for built-in CSS themes)
|
// Remove data-theme attribute (for built-in CSS themes)
|
||||||
|
|
@ -92,7 +141,7 @@ export const ThemeProvider = ({ children }) => {
|
||||||
// Skip boolean and number values - they are handled separately (numbers for JS, booleans for logic)
|
// Skip boolean and number values - they are handled separately (numbers for JS, booleans for logic)
|
||||||
// Only string values are used as CSS variables (except particle animation numbers)
|
// Only string values are used as CSS variables (except particle animation numbers)
|
||||||
const isParticleNumber = [
|
const isParticleNumber = [
|
||||||
'particleTargetCount',
|
'particleSpawnCount',
|
||||||
'particleUpdateInterval',
|
'particleUpdateInterval',
|
||||||
'particleDurationMin',
|
'particleDurationMin',
|
||||||
'particleDurationMax',
|
'particleDurationMax',
|
||||||
|
|
@ -117,9 +166,7 @@ export const ThemeProvider = ({ children }) => {
|
||||||
root.style.setProperty('--particle-color', particleColor);
|
root.style.setProperty('--particle-color', particleColor);
|
||||||
root.style.setProperty('--particle-glow', particleGlow);
|
root.style.setProperty('--particle-glow', particleGlow);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
localStorage.setItem('app-theme', currentTheme);
|
|
||||||
}, [currentTheme, themes]);
|
|
||||||
|
|
||||||
const changeTheme = (themeId) => {
|
const changeTheme = (themeId) => {
|
||||||
// Если темы еще не загружены, сохраняем themeId для применения после загрузки
|
// Если темы еще не загружены, сохраняем themeId для применения после загрузки
|
||||||
|
|
@ -129,8 +176,11 @@ export const ThemeProvider = ({ children }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если тема существует в списке, применяем её
|
// Если тема существует в списке, применяем её
|
||||||
if (themes.find((t) => t.id === themeId)) {
|
const theme = themes.find((t) => t.id === themeId);
|
||||||
|
if (theme) {
|
||||||
setCurrentTheme(themeId);
|
setCurrentTheme(themeId);
|
||||||
|
// Сохраняем полный объект темы
|
||||||
|
localStorage.setItem('app-theme', JSON.stringify(theme));
|
||||||
setPendingThemeId(null);
|
setPendingThemeId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,83 @@ import socketService from '../services/socket';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
||||||
const { user } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const [room, setRoom] = useState(null);
|
const [room, setRoom] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [participants, setParticipants] = useState([]);
|
const [participants, setParticipants] = useState([]);
|
||||||
const [requiresPassword, setRequiresPassword] = useState(false);
|
const [requiresPassword, setRequiresPassword] = useState(false);
|
||||||
|
|
||||||
|
// Обработчики событий вынесены наружу useEffect, чтобы они регистрировались только один раз
|
||||||
|
// и не зависели от изменений зависимостей
|
||||||
|
const handleRoomUpdate = useCallback((updatedRoom) => {
|
||||||
|
console.log('📨 roomUpdate received:', updatedRoom.participants?.length, 'participants');
|
||||||
|
setRoom(updatedRoom);
|
||||||
|
setParticipants(updatedRoom.participants || []);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGameStarted = useCallback((updatedRoom) => {
|
||||||
|
console.log('🎮 gameStarted received');
|
||||||
|
setRoom(updatedRoom);
|
||||||
|
// Вызываем callback для навигации на страницу игры
|
||||||
|
if (onGameStarted) {
|
||||||
|
onGameStarted(updatedRoom);
|
||||||
|
}
|
||||||
|
}, [onGameStarted]);
|
||||||
|
|
||||||
|
const handleGameStateUpdated = useCallback((state) => {
|
||||||
|
console.log('🔄 gameStateUpdated received:', state.participants?.length, 'participants');
|
||||||
|
// Обновляем только базовую информацию о комнате
|
||||||
|
// Полное состояние игры управляется в GamePage
|
||||||
|
if (state.participants) {
|
||||||
|
setParticipants(state.participants);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем базовую информацию о комнате из состояния
|
||||||
|
setRoom(prevRoom => {
|
||||||
|
if (!prevRoom) return prevRoom;
|
||||||
|
|
||||||
|
const updatedRoom = { ...prevRoom };
|
||||||
|
|
||||||
|
if (state.status) {
|
||||||
|
updatedRoom.status = state.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.themeId !== undefined) {
|
||||||
|
updatedRoom.themeId = state.themeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.voiceMode !== undefined) {
|
||||||
|
updatedRoom.voiceMode = state.voiceMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedRoom;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если игра началась, вызываем callback
|
||||||
|
if (state.status === 'PLAYING' && onGameStarted) {
|
||||||
|
onGameStarted(state);
|
||||||
|
}
|
||||||
|
}, [onGameStarted]);
|
||||||
|
|
||||||
|
const handleRoomPackUpdated = useCallback((updatedRoom) => {
|
||||||
|
setRoom(updatedRoom);
|
||||||
|
if (updatedRoom.participants) {
|
||||||
|
setParticipants(updatedRoom.participants);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roomCode) {
|
if (!roomCode) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ждем загрузки пользователя из куки перед запросом комнаты
|
||||||
|
if (authLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchRoom = async () => {
|
const fetchRoom = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -36,8 +100,8 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setRequiresPassword(false);
|
setRequiresPassword(false);
|
||||||
|
|
||||||
// Сохраняем пароль, если вход успешен
|
// Сохраняем пароль, если вход успешен И пользователь не является хостом
|
||||||
if (roomPassword) {
|
if (roomPassword && response.data.hostId !== user?.id) {
|
||||||
localStorage.setItem(`room-password-${roomCode}`, roomPassword);
|
localStorage.setItem(`room-password-${roomCode}`, roomPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,63 +128,7 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
||||||
|
|
||||||
fetchRoom();
|
fetchRoom();
|
||||||
|
|
||||||
// Listen for room updates (регистрируются всегда, но не подключаются если requiresPassword)
|
// Регистрируем обработчики событий
|
||||||
const handleRoomUpdate = (updatedRoom) => {
|
|
||||||
setRoom(updatedRoom);
|
|
||||||
setParticipants(updatedRoom.participants || []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGameStarted = (updatedRoom) => {
|
|
||||||
setRoom(updatedRoom);
|
|
||||||
// Вызываем callback для навигации на страницу игры
|
|
||||||
if (onGameStarted) {
|
|
||||||
onGameStarted(updatedRoom);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Используем новое событие gameStateUpdated если нужно обновлять состояние
|
|
||||||
const handleGameStateUpdated = (state) => {
|
|
||||||
// Обновляем только базовую информацию о комнате
|
|
||||||
// Полное состояние игры управляется в GamePage
|
|
||||||
if (state.participants) {
|
|
||||||
setParticipants(state.participants);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем базовую информацию о комнате из состояния
|
|
||||||
setRoom(prevRoom => {
|
|
||||||
if (!prevRoom) return prevRoom;
|
|
||||||
|
|
||||||
const updatedRoom = { ...prevRoom };
|
|
||||||
|
|
||||||
if (state.status) {
|
|
||||||
updatedRoom.status = state.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.themeId !== undefined) {
|
|
||||||
updatedRoom.themeId = state.themeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.voiceMode !== undefined) {
|
|
||||||
updatedRoom.voiceMode = state.voiceMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedRoom;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Если игра началась, вызываем callback
|
|
||||||
if (state.status === 'PLAYING' && onGameStarted) {
|
|
||||||
onGameStarted(state);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Обработчик обновления вопросов комнаты
|
|
||||||
const handleRoomPackUpdated = (updatedRoom) => {
|
|
||||||
setRoom(updatedRoom);
|
|
||||||
if (updatedRoom.participants) {
|
|
||||||
setParticipants(updatedRoom.participants);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socketService.on('roomUpdate', handleRoomUpdate);
|
socketService.on('roomUpdate', handleRoomUpdate);
|
||||||
socketService.on('gameStarted', handleGameStarted);
|
socketService.on('gameStarted', handleGameStarted);
|
||||||
socketService.on('gameStateUpdated', handleGameStateUpdated);
|
socketService.on('gameStateUpdated', handleGameStateUpdated);
|
||||||
|
|
@ -132,7 +140,7 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
||||||
socketService.off('gameStateUpdated', handleGameStateUpdated);
|
socketService.off('gameStateUpdated', handleGameStateUpdated);
|
||||||
socketService.off('roomPackUpdated', handleRoomPackUpdated);
|
socketService.off('roomPackUpdated', handleRoomPackUpdated);
|
||||||
};
|
};
|
||||||
}, [roomCode, password, onGameStarted, user?.id]);
|
}, [roomCode, password, user?.id, authLoading, handleRoomUpdate, handleGameStarted, handleGameStateUpdated, handleRoomPackUpdated]);
|
||||||
|
|
||||||
const createRoom = useCallback(async (hostId, questionPackId, settings = {}, hostName) => {
|
const createRoom = useCallback(async (hostId, questionPackId, settings = {}, hostName) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -203,8 +211,10 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setRequiresPassword(false);
|
setRequiresPassword(false);
|
||||||
|
|
||||||
// Сохраняем пароль в localStorage при успешном входе
|
// Сохраняем пароль в localStorage при успешном входе, НО НЕ для хоста
|
||||||
localStorage.setItem(`room-password-${roomCode}`, roomPassword);
|
if (response.data.hostId !== user?.id) {
|
||||||
|
localStorage.setItem(`room-password-${roomCode}`, roomPassword);
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ После успешной загрузки комнаты с паролем подключаемся к WebSocket
|
// ✅ После успешной загрузки комнаты с паролем подключаемся к WebSocket
|
||||||
socketService.connect();
|
socketService.connect();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useRoom } from '../hooks/useRoom';
|
import { useRoom } from '../hooks/useRoom';
|
||||||
import NameInputModal from '../components/NameInputModal';
|
|
||||||
|
|
||||||
const CreateRoom = () => {
|
const CreateRoom = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, loginAnonymous, loading: authLoading } = useAuth();
|
const { user } = useAuth();
|
||||||
const { createRoom, loading: roomLoading } = useRoom();
|
const { createRoom, loading: roomLoading } = useRoom();
|
||||||
|
|
||||||
const [settings, setSettings] = useState({
|
const [settings, setSettings] = useState({
|
||||||
|
|
@ -14,42 +13,14 @@ const CreateRoom = () => {
|
||||||
allowSpectators: true,
|
allowSpectators: true,
|
||||||
password: '',
|
password: '',
|
||||||
});
|
});
|
||||||
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
|
|
||||||
const [isHostNameModalOpen, setIsHostNameModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// Проверка авторизации и показ модального окна для ввода имени
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authLoading && !user) {
|
|
||||||
setIsNameModalOpen(true);
|
|
||||||
} else if (user) {
|
|
||||||
setIsNameModalOpen(false);
|
|
||||||
}
|
|
||||||
}, [authLoading, user]);
|
|
||||||
|
|
||||||
// Обработка ввода имени и авторизация
|
|
||||||
const handleNameSubmit = async (name) => {
|
|
||||||
try {
|
|
||||||
await loginAnonymous(name);
|
|
||||||
setIsNameModalOpen(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
alert('Ошибка при авторизации. Попробуйте еще раз.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateRoom = async () => {
|
const handleCreateRoom = async () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setIsNameModalOpen(true);
|
alert('Пожалуйста, задайте имя на главном экране');
|
||||||
|
navigate('/');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Всегда спрашиваем имя хоста перед созданием комнаты
|
|
||||||
setIsHostNameModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHostNameSubmit = async (name) => {
|
|
||||||
setIsHostNameModalOpen(false);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Очищаем пустой пароль перед отправкой
|
// Очищаем пустой пароль перед отправкой
|
||||||
const cleanSettings = { ...settings };
|
const cleanSettings = { ...settings };
|
||||||
|
|
@ -63,7 +34,7 @@ const CreateRoom = () => {
|
||||||
user.id,
|
user.id,
|
||||||
undefined,
|
undefined,
|
||||||
cleanSettings,
|
cleanSettings,
|
||||||
name.trim(),
|
user.name,
|
||||||
);
|
);
|
||||||
navigate(`/room/${room.code}`);
|
navigate(`/room/${room.code}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -121,7 +92,7 @@ const CreateRoom = () => {
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateRoom}
|
onClick={handleCreateRoom}
|
||||||
disabled={roomLoading}
|
disabled={roomLoading || !user}
|
||||||
className="primary"
|
className="primary"
|
||||||
>
|
>
|
||||||
{roomLoading ? 'Создание...' : 'Создать комнату'}
|
{roomLoading ? 'Создание...' : 'Создать комнату'}
|
||||||
|
|
@ -129,20 +100,6 @@ const CreateRoom = () => {
|
||||||
<button onClick={() => navigate('/')}>Назад</button>
|
<button onClick={() => navigate('/')}>Назад</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NameInputModal
|
|
||||||
isOpen={isNameModalOpen}
|
|
||||||
onSubmit={handleNameSubmit}
|
|
||||||
onCancel={null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NameInputModal
|
|
||||||
isOpen={isHostNameModalOpen}
|
|
||||||
onSubmit={handleHostNameSubmit}
|
|
||||||
onCancel={() => setIsHostNameModalOpen(false)}
|
|
||||||
title="Введите ваше имя как ведущего"
|
|
||||||
description="Чтобы создать комнату, введите ваше имя как ведущего"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { questionsApi } from '../services/api';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import socketService from '../services/socket';
|
import socketService from '../services/socket';
|
||||||
import QRModal from '../components/QRModal';
|
import QRModal from '../components/QRModal';
|
||||||
import NameInputModal from '../components/NameInputModal';
|
|
||||||
import PasswordModal from '../components/PasswordModal';
|
import PasswordModal from '../components/PasswordModal';
|
||||||
import RoleSelectionModal from '../components/RoleSelectionModal';
|
import RoleSelectionModal from '../components/RoleSelectionModal';
|
||||||
import GameManagementModal from '../components/GameManagementModal';
|
import GameManagementModal from '../components/GameManagementModal';
|
||||||
|
|
@ -15,7 +14,7 @@ import GameManagementModal from '../components/GameManagementModal';
|
||||||
const RoomPage = () => {
|
const RoomPage = () => {
|
||||||
const { roomCode } = useParams();
|
const { roomCode } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, loginAnonymous, loading: authLoading } = useAuth();
|
const { user } = useAuth();
|
||||||
const { changeTheme } = useTheme();
|
const { changeTheme } = useTheme();
|
||||||
|
|
||||||
// Храним предыдущий themeId комнаты для отслеживания изменений
|
// Храним предыдущий themeId комнаты для отслеживания изменений
|
||||||
|
|
@ -41,7 +40,6 @@ const RoomPage = () => {
|
||||||
const [qrCode, setQrCode] = useState('');
|
const [qrCode, setQrCode] = useState('');
|
||||||
const [joined, setJoined] = useState(false);
|
const [joined, setJoined] = useState(false);
|
||||||
const [isQRModalOpen, setIsQRModalOpen] = useState(false);
|
const [isQRModalOpen, setIsQRModalOpen] = useState(false);
|
||||||
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
|
|
||||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
||||||
const [isRoleSelectionModalOpen, setIsRoleSelectionModalOpen] = useState(false);
|
const [isRoleSelectionModalOpen, setIsRoleSelectionModalOpen] = useState(false);
|
||||||
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false);
|
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false);
|
||||||
|
|
@ -53,6 +51,11 @@ const RoomPage = () => {
|
||||||
// Ref для отслеживания попытки присоединения (защита от двойного запроса)
|
// Ref для отслеживания попытки присоединения (защита от двойного запроса)
|
||||||
const joinAttemptedRef = useRef(false);
|
const joinAttemptedRef = useRef(false);
|
||||||
|
|
||||||
|
// Логирование изменений participants для отладки
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('👥 RoomPage participants updated:', participants.length, participants.map(p => p.name));
|
||||||
|
}, [participants]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generateQR = async () => {
|
const generateQR = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -78,24 +81,12 @@ const RoomPage = () => {
|
||||||
}, [roomCode]);
|
}, [roomCode]);
|
||||||
|
|
||||||
// Проверка пароля: показываем модальное окно, если требуется пароль
|
// Проверка пароля: показываем модальное окно, если требуется пароль
|
||||||
// Показываем независимо от авторизации - пароль проверяется первым
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (requiresPassword && !isPasswordModalOpen && !loading) {
|
if (requiresPassword && !isPasswordModalOpen && !loading) {
|
||||||
// Показывать модальное окно пароля независимо от авторизации
|
|
||||||
setIsPasswordModalOpen(true);
|
setIsPasswordModalOpen(true);
|
||||||
}
|
}
|
||||||
}, [requiresPassword, isPasswordModalOpen, loading]);
|
}, [requiresPassword, isPasswordModalOpen, loading]);
|
||||||
|
|
||||||
// Проверка авторизации и показ модального окна для ввода имени
|
|
||||||
// Показывать только если НЕТ пароля - пароль приоритетнее
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authLoading && !user && room && !loading && !requiresPassword) {
|
|
||||||
setIsNameModalOpen(true);
|
|
||||||
} else if (user) {
|
|
||||||
setIsNameModalOpen(false);
|
|
||||||
}
|
|
||||||
}, [authLoading, user, room, loading, requiresPassword]);
|
|
||||||
|
|
||||||
// Обработка ввода пароля
|
// Обработка ввода пароля
|
||||||
const handlePasswordSubmit = async (enteredPassword) => {
|
const handlePasswordSubmit = async (enteredPassword) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -113,22 +104,18 @@ const RoomPage = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработка ввода имени и авторизация
|
|
||||||
const handleNameSubmit = async (name) => {
|
|
||||||
try {
|
|
||||||
await loginAnonymous(name);
|
|
||||||
setIsNameModalOpen(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
alert('Ошибка при авторизации. Попробуйте еще раз.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Единая логика присоединения к комнате
|
// Единая логика присоединения к комнате
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleJoin = async () => {
|
const handleJoin = async () => {
|
||||||
// Пропускаем если нет комнаты, пользователя или уже присоединились
|
// Перенаправляем на главный экран, если нет пользователя
|
||||||
if (!room || !user || joined) return;
|
if (!user) {
|
||||||
|
alert('Пожалуйста, задайте имя на главном экране');
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пропускаем если нет комнаты или уже присоединились
|
||||||
|
if (!room || joined) return;
|
||||||
|
|
||||||
// Проверяем, не является ли пользователь уже участником
|
// Проверяем, не является ли пользователь уже участником
|
||||||
const isParticipant = participants.some((p) => p.userId === user.id);
|
const isParticipant = participants.some((p) => p.userId === user.id);
|
||||||
|
|
@ -169,7 +156,7 @@ const RoomPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleJoin();
|
handleJoin();
|
||||||
}, [room, user, participants, joined, joinRoom, isRoleSelectionModalOpen]);
|
}, [room, user, joined, participants, joinRoom, navigate, isRoleSelectionModalOpen]);
|
||||||
|
|
||||||
// Обработка выбора роли
|
// Обработка выбора роли
|
||||||
// Присоединение разрешено независимо от статуса игры (WAITING, PLAYING, FINISHED)
|
// Присоединение разрешено независимо от статуса игры (WAITING, PLAYING, FINISHED)
|
||||||
|
|
@ -437,12 +424,6 @@ const RoomPage = () => {
|
||||||
roomCode={roomCode}
|
roomCode={roomCode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NameInputModal
|
|
||||||
isOpen={isNameModalOpen}
|
|
||||||
onSubmit={handleNameSubmit}
|
|
||||||
onCancel={null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PasswordModal
|
<PasswordModal
|
||||||
isOpen={isPasswordModalOpen}
|
isOpen={isPasswordModalOpen}
|
||||||
onSubmit={handlePasswordSubmit}
|
onSubmit={handlePasswordSubmit}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue