diff --git a/admin/src/api/themes.ts b/admin/src/api/themes.ts
index fec2e16..1a04837 100644
--- a/admin/src/api/themes.ts
+++ b/admin/src/api/themes.ts
@@ -34,6 +34,7 @@ export interface ThemeSettings {
particleDurationMin?: number
particleDurationMax?: number
particleInitialDelayMax?: number
+ particleDensity?: number
// Finish Screen Settings
finishScreenTitle?: string
finishScreenSubtitle?: string
@@ -317,6 +318,7 @@ export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
particleDurationMin: 7,
particleDurationMax: 10,
particleInitialDelayMax: 10,
+ particleDensity: 100,
finishScreenTitle: 'Игра завершена!',
finishScreenSubtitle: '',
finishScreenBgColor: 'rgba(0, 0, 0, 0.5)',
diff --git a/admin/src/components/ThemeEditorDialog.tsx b/admin/src/components/ThemeEditorDialog.tsx
index 758a18c..1f92965 100644
--- a/admin/src/components/ThemeEditorDialog.tsx
+++ b/admin/src/components/ThemeEditorDialog.tsx
@@ -615,7 +615,29 @@ export function ThemeEditorDialog({
}}
/>
- Целевое количество снежинок на экране
+ Целевое количество снежинок на экране (используется, если плотность не задана)
+
+
+
+
+
{
+ const value = parseInt(e.target.value, 10)
+ if (!isNaN(value) && value > 0) {
+ updateSetting('particleDensity', value)
+ }
+ }}
+ />
+
+ Плотность частиц (количество на 1Мп площади экрана). Учитывает размер экрана для одинаковой визуальной плотности на разных устройствах
diff --git a/backend/src/admin/themes/dto/create-theme.dto.ts b/backend/src/admin/themes/dto/create-theme.dto.ts
index 70cbcc6..aede48a 100644
--- a/backend/src/admin/themes/dto/create-theme.dto.ts
+++ b/backend/src/admin/themes/dto/create-theme.dto.ts
@@ -112,6 +112,11 @@ export class ThemeSettingsDto {
@Type(() => Number)
particleInitialDelayMax?: number;
+ @IsNumber()
+ @IsOptional()
+ @Type(() => Number)
+ particleDensity?: number;
+
// Finish Screen Settings
@IsString()
@IsOptional()
diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts
index b118b13..d18e55a 100644
--- a/backend/src/auth/auth.controller.ts
+++ b/backend/src/auth/auth.controller.ts
@@ -1,9 +1,13 @@
-import { Controller, Post, Body } from '@nestjs/common';
+import { Controller, Post, Body, Patch, Headers, UnauthorizedException } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
- constructor(private authService: AuthService) {}
+ constructor(
+ private authService: AuthService,
+ private jwtService: JwtService,
+ ) {}
@Post('anonymous')
async createAnonymous(@Body('name') name?: string) {
@@ -19,4 +23,34 @@ export class AuthController {
async login(@Body() dto: { email: string; password: string }) {
return this.authService.login(dto.email, dto.password);
}
+
+ @Patch('user')
+ async updateUserName(
+ @Headers('authorization') authorization: string,
+ @Body('name') name: string,
+ ) {
+ if (!authorization) {
+ throw new UnauthorizedException('Authorization header is required');
+ }
+
+ const token = authorization.replace('Bearer ', '');
+
+ try {
+ const payload = this.jwtService.verify(token);
+ const userId = payload.sub;
+
+ if (!name || name.trim().length === 0) {
+ throw new Error('Name cannot be empty');
+ }
+
+ if (name.trim().length > 50) {
+ throw new Error('Name is too long (max 50 characters)');
+ }
+
+ const user = await this.authService.updateUserName(userId, name.trim());
+ return { user };
+ } catch (error) {
+ throw new UnauthorizedException('Invalid token');
+ }
+ }
}
diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts
index 7e53a93..ba2afb0 100644
--- a/backend/src/auth/auth.service.ts
+++ b/backend/src/auth/auth.service.ts
@@ -46,4 +46,30 @@ export class AuthService {
async validateUser(userId: string) {
return this.prisma.user.findUnique({ where: { id: userId } });
}
+
+ async updateUserName(userId: string, name: string) {
+ const user = await this.prisma.user.findUnique({
+ where: { id: userId },
+ });
+
+ if (!user) {
+ throw new Error('User not found');
+ }
+
+ return this.prisma.user.update({
+ where: { id: userId },
+ data: { name },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ role: true,
+ telegramId: true,
+ createdAt: true,
+ gamesPlayed: true,
+ gamesWon: true,
+ totalPoints: true,
+ },
+ });
+ }
}
diff --git a/backend/src/rooms/rooms.service.ts b/backend/src/rooms/rooms.service.ts
index b695f25..868b741 100644
--- a/backend/src/rooms/rooms.service.ts
+++ b/backend/src/rooms/rooms.service.ts
@@ -153,6 +153,10 @@ export class RoomsService {
// Отправляем событие roomUpdate всем клиентам в комнате
if (updatedRoom) {
+ // Небольшая задержка, чтобы дать время новому игроку присоединиться к WebSocket комнате
+ // WebSocket joinRoom может выполняться параллельно с REST API joinRoom
+ await new Promise(resolve => setTimeout(resolve, 50));
+
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
// Также отправляем gameStateUpdated через broadcastFullState
await this.gameGateway.broadcastFullState(updatedRoom.code);
@@ -243,6 +247,9 @@ export class RoomsService {
// Отправляем событие roomUpdate всем клиентам в комнате
if (updatedRoom) {
+ // Небольшая задержка, чтобы дать время новому игроку присоединиться к WebSocket комнате
+ await new Promise(resolve => setTimeout(resolve, 50));
+
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
// Также отправляем gameStateUpdated через broadcastFullState
await this.gameGateway.broadcastFullState(updatedRoom.code);
diff --git a/src/components/Snowflakes.jsx b/src/components/Snowflakes.jsx
index 5ddbd76..5e185ad 100644
--- a/src/components/Snowflakes.jsx
+++ b/src/components/Snowflakes.jsx
@@ -6,14 +6,11 @@ const DEFAULT_TARGET_COUNT = 200
const DEFAULT_UPDATE_INTERVAL = 1000
const DEFAULT_DURATION_MIN = 7
const DEFAULT_DURATION_MAX = 10
-const DEFAULT_INITIAL_DELAY_MAX = 10
function createSnowflake(id, options = {}) {
const {
durationMin = DEFAULT_DURATION_MIN,
durationMax = DEFAULT_DURATION_MAX,
- initialDelayMax = DEFAULT_INITIAL_DELAY_MAX,
- isInitial = false,
} = options
const durationRange = durationMax - durationMin
@@ -21,7 +18,7 @@ function createSnowflake(id, options = {}) {
id: id || crypto.randomUUID(),
left: Math.random() * 100,
duration: Math.random() * durationRange + durationMin,
- delay: isInitial ? Math.random() * initialDelayMax : 0,
+ delay: 0, // Частицы появляются сразу
size: Math.random() * 10 + 10, // 10-20px
opacity: Math.random() * 0.5 + 0.5, // 0.5-1
createdAt: Date.now(),
@@ -31,6 +28,10 @@ function createSnowflake(id, options = {}) {
const Snowflakes = ({ roomParticlesEnabled = null }) => {
const { currentThemeData } = useTheme()
const [snowflakes, setSnowflakes] = useState([])
+ const [windowSize, setWindowSize] = useState({
+ width: typeof window !== 'undefined' ? window.innerWidth : 1920,
+ height: typeof window !== 'undefined' ? window.innerHeight : 1080,
+ })
// Determine if particles should be enabled
// Priority: room override (if explicitly set to true/false) > theme setting > default (true)
@@ -52,9 +53,29 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => {
return currentThemeData?.settings?.particleSymbol || '❄'
}
+ // Get particle density from theme settings
+ const getParticleDensity = () => {
+ return currentThemeData?.settings?.particleDensity
+ }
+
+ // Calculate target count based on density and screen size, or use fixed target count
+ const calculateTargetCount = () => {
+ const density = getParticleDensity()
+
+ // If density is set, calculate based on screen area (particles per 1Mp)
+ 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 currentThemeData?.settings?.particleTargetCount ?? DEFAULT_TARGET_COUNT
+ return calculateTargetCount()
}
const getParticleUpdateInterval = () => {
@@ -68,19 +89,27 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => {
}
}
- const getParticleInitialDelayMax = () => {
- return currentThemeData?.settings?.particleInitialDelayMax ?? DEFAULT_INITIAL_DELAY_MAX
- }
+ // Handle window resize to recalculate particle count
+ useEffect(() => {
+ const handleResize = () => {
+ setWindowSize({
+ width: window.innerWidth,
+ height: window.innerHeight,
+ })
+ }
+
+ window.addEventListener('resize', handleResize)
+ return () => window.removeEventListener('resize', handleResize)
+ }, [])
const particlesEnabled = getParticlesEnabled()
const particleSymbol = getParticleSymbol()
const targetCount = getParticleTargetCount()
const updateInterval = getParticleUpdateInterval()
const durationRange = getParticleDurationRange()
- const initialDelayMax = getParticleInitialDelayMax()
// Initialize snowflakes only if particles are enabled
- // Also re-initialize when theme changes (particle settings might change)
+ // Also re-initialize when theme changes or window size changes (particle settings might change)
useEffect(() => {
if (!particlesEnabled) {
setSnowflakes([])
@@ -90,12 +119,10 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => {
createSnowflake(i, {
durationMin: durationRange.min,
durationMax: durationRange.max,
- initialDelayMax,
- isInitial: true,
})
)
setSnowflakes(initial)
- }, [particlesEnabled, particleSymbol, targetCount, durationRange.min, durationRange.max, initialDelayMax, currentThemeData])
+ }, [particlesEnabled, particleSymbol, targetCount, durationRange.min, durationRange.max])
// Update cycle - remove old snowflakes and add new ones
useEffect(() => {
@@ -121,8 +148,6 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => {
createSnowflake(null, {
durationMin: durationRange.min,
durationMax: durationRange.max,
- initialDelayMax,
- isInitial: false,
})
)
}
@@ -132,7 +157,7 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => {
}, updateInterval)
return () => clearInterval(interval)
- }, [particlesEnabled, targetCount, updateInterval, durationRange.min, durationRange.max, initialDelayMax])
+ }, [particlesEnabled, targetCount, updateInterval, durationRange.min, durationRange.max])
// Don't render if particles are disabled
if (!particlesEnabled) {
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
index ef55547..1568fa4 100644
--- a/src/context/AuthContext.jsx
+++ b/src/context/AuthContext.jsx
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { authApi } from '../services/api';
+import { getCookie, setCookie, deleteCookie } from '../utils/cookies';
const AuthContext = createContext();
@@ -17,12 +18,37 @@ export const AuthProvider = ({ children }) => {
const [loading, setLoading] = useState(true);
useEffect(() => {
- const storedUser = localStorage.getItem('user');
- const storedToken = localStorage.getItem('token');
+ // Миграция с localStorage на куки (обратная совместимость)
+ const storedUserLS = localStorage.getItem('user');
+ const storedTokenLS = localStorage.getItem('token');
+
+ if (storedUserLS && storedTokenLS) {
+ // Мигрируем в куки
+ const userData = JSON.parse(storedUserLS);
+ setCookie('player-id', userData.id);
+ setCookie('player-name', userData.name);
+ setCookie('player-token', storedTokenLS);
+
+ setUser(userData);
+ setToken(storedTokenLS);
+
+ // Очищаем localStorage (но оставляем app-theme и room-password-*)
+ localStorage.removeItem('user');
+ localStorage.removeItem('token');
+ } else {
+ // Загружаем из куков
+ const storedUserId = getCookie('player-id');
+ const storedUserName = getCookie('player-name');
+ const storedToken = getCookie('player-token');
- if (storedUser && storedToken) {
- setUser(JSON.parse(storedUser));
- setToken(storedToken);
+ if (storedUserId && storedUserName && storedToken) {
+ const userData = {
+ id: storedUserId,
+ name: storedUserName,
+ };
+ setUser(userData);
+ setToken(storedToken);
+ }
}
setLoading(false);
@@ -36,8 +62,9 @@ export const AuthProvider = ({ children }) => {
setUser(newUser);
setToken(newToken);
- localStorage.setItem('user', JSON.stringify(newUser));
- localStorage.setItem('token', newToken);
+ setCookie('player-id', newUser.id);
+ setCookie('player-name', newUser.name);
+ setCookie('player-token', newToken);
return { user: newUser, token: newToken };
} catch (error) {
@@ -54,8 +81,9 @@ export const AuthProvider = ({ children }) => {
setUser(newUser);
setToken(newToken);
- localStorage.setItem('user', JSON.stringify(newUser));
- localStorage.setItem('token', newToken);
+ setCookie('player-id', newUser.id);
+ setCookie('player-name', newUser.name);
+ setCookie('player-token', newToken);
return { user: newUser, token: newToken };
} catch (error) {
@@ -72,8 +100,9 @@ export const AuthProvider = ({ children }) => {
setUser(newUser);
setToken(newToken);
- localStorage.setItem('user', JSON.stringify(newUser));
- localStorage.setItem('token', newToken);
+ setCookie('player-id', newUser.id);
+ setCookie('player-name', newUser.name);
+ setCookie('player-token', newToken);
return { user: newUser, token: newToken };
} catch (error) {
@@ -85,8 +114,29 @@ export const AuthProvider = ({ children }) => {
const logout = () => {
setUser(null);
setToken(null);
- localStorage.removeItem('user');
- localStorage.removeItem('token');
+ deleteCookie('player-id');
+ deleteCookie('player-name');
+ deleteCookie('player-token');
+ };
+
+ const updateUserName = async (name) => {
+ if (!token || !user) {
+ throw new Error('Not authenticated');
+ }
+
+ try {
+ const response = await authApi.updateName(token, name);
+ const { user: updatedUser } = response.data;
+
+ setUser(updatedUser);
+ setCookie('player-id', updatedUser.id);
+ setCookie('player-name', updatedUser.name);
+
+ return updatedUser;
+ } catch (error) {
+ console.error('Update name error:', error);
+ throw error;
+ }
};
const value = {
@@ -97,6 +147,7 @@ export const AuthProvider = ({ children }) => {
register,
login,
logout,
+ updateUserName,
isAuthenticated: !!user,
};
diff --git a/src/hooks/useRoom.js b/src/hooks/useRoom.js
index 16488f0..afcf926 100644
--- a/src/hooks/useRoom.js
+++ b/src/hooks/useRoom.js
@@ -20,12 +20,27 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
const fetchRoom = async () => {
try {
setLoading(true);
- const response = await roomsApi.getByCode(roomCode, password, user?.id);
+
+ // Проверяем, есть ли сохраненный пароль для этой комнаты
+ let roomPassword = password;
+ if (!roomPassword) {
+ const savedPassword = localStorage.getItem(`room-password-${roomCode}`);
+ if (savedPassword) {
+ roomPassword = savedPassword;
+ }
+ }
+
+ const response = await roomsApi.getByCode(roomCode, roomPassword, user?.id);
setRoom(response.data);
setParticipants(response.data.participants || []);
setError(null);
setRequiresPassword(false);
+ // Сохраняем пароль, если вход успешен
+ if (roomPassword) {
+ localStorage.setItem(`room-password-${roomCode}`, roomPassword);
+ }
+
// ✅ Подключаться к WebSocket только после успешной загрузки комнаты
socketService.connect();
socketService.joinRoom(roomCode, user?.id);
@@ -34,6 +49,8 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
if (err.response?.status === 401) {
setRequiresPassword(true);
setError('Room password required');
+ // Удаляем неверный сохраненный пароль
+ localStorage.removeItem(`room-password-${roomCode}`);
// ❌ НЕ подключаться к WebSocket, если требуется пароль
} else {
setError(err.response?.data?.message || err.message);
@@ -186,6 +203,9 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
setError(null);
setRequiresPassword(false);
+ // Сохраняем пароль в localStorage при успешном входе
+ localStorage.setItem(`room-password-${roomCode}`, roomPassword);
+
// ✅ После успешной загрузки комнаты с паролем подключаемся к WebSocket
socketService.connect();
socketService.joinRoom(roomCode, user?.id);
@@ -195,6 +215,8 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
if (err.response?.status === 401) {
setRequiresPassword(true);
setError('Incorrect password');
+ // Удаляем неверный пароль
+ localStorage.removeItem(`room-password-${roomCode}`);
} else {
setError(err.response?.data?.message || err.message);
}
diff --git a/src/index.css b/src/index.css
index c4cc511..880fd79 100644
--- a/src/index.css
+++ b/src/index.css
@@ -186,6 +186,48 @@ body {
font-size: 1.2rem;
}
+.name-editable {
+ color: gold;
+ cursor: pointer;
+ position: relative;
+ padding: 0 0.25rem;
+ border-radius: 4px;
+ transition: all 0.3s ease;
+ display: inline-block;
+}
+
+.name-editable:hover {
+ background: rgba(255, 215, 0, 0.1);
+ text-shadow: 0 0 8px rgba(255, 215, 0, 0.5);
+}
+
+.name-input-inline {
+ background: rgba(255, 215, 0, 0.15);
+ border: 2px solid rgba(255, 215, 0, 0.5);
+ border-radius: 6px;
+ padding: 0.2rem 0.5rem;
+ color: gold;
+ font-size: 1.2rem;
+ font-weight: inherit;
+ font-family: inherit;
+ text-align: center;
+ min-width: 100px;
+ max-width: 300px;
+ outline: none;
+ transition: all 0.3s ease;
+}
+
+.name-input-inline:focus {
+ border-color: gold;
+ background: rgba(255, 215, 0, 0.2);
+ box-shadow: 0 0 10px rgba(255, 215, 0, 0.4);
+}
+
+.name-input-inline:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
.menu-buttons {
display: flex;
flex-direction: column;
diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx
index 66a67ac..2a08498 100644
--- a/src/pages/Home.jsx
+++ b/src/pages/Home.jsx
@@ -1,11 +1,14 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import ThemeSwitcher from '../components/ThemeSwitcher';
const Home = () => {
const navigate = useNavigate();
- const { user, loginAnonymous, isAuthenticated } = useAuth();
+ const { user, loginAnonymous, isAuthenticated, updateUserName } = useAuth();
+ const [editingName, setEditingName] = useState(false);
+ const [nameValue, setNameValue] = useState('');
+ const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
const initAuth = async () => {
@@ -21,6 +24,44 @@ const Home = () => {
initAuth();
}, [isAuthenticated, loginAnonymous]);
+ useEffect(() => {
+ if (user) {
+ setNameValue(user.name || '');
+ }
+ }, [user]);
+
+ const handleNameClick = () => {
+ setEditingName(true);
+ };
+
+ const handleNameChange = (e) => {
+ setNameValue(e.target.value);
+ };
+
+ const handleNameBlur = async () => {
+ setEditingName(false);
+
+ if (nameValue.trim() && nameValue.trim() !== user?.name) {
+ setIsUpdating(true);
+ try {
+ await updateUserName(nameValue.trim());
+ } catch (error) {
+ console.error('Failed to update name:', error);
+ setNameValue(user?.name || '');
+ } finally {
+ setIsUpdating(false);
+ }
+ } else {
+ setNameValue(user?.name || '');
+ }
+ };
+
+ const handleNameKeyPress = (e) => {
+ if (e.key === 'Enter') {
+ e.target.blur();
+ }
+ };
+
const handleCreateRoom = () => {
navigate('/create-room');
};
@@ -36,9 +77,37 @@ const Home = () => {
100 к 1
-
- {user ? `Привет, ${user.name}!` : 'Добро пожаловать!'}
-
+
+ {user ? (
+ <>
+ Привет,{' '}
+ {editingName ? (
+
+ ) : (
+
+ {user.name}
+
+ )}
+ !
+ >
+ ) : (
+ 'Добро пожаловать!'
+ )}
+