diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3a6afb0 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Star Wars Hearthstone - Environment Variables + +# Использовать Redis (true/false) +# Если false или не установлен, приложение работает без БД +USE_REDIS=true + +# URL Redis (если USE_REDIS=true) +# Для Docker: redis://redis:6379 +# Для локального: redis://localhost:6379 +REDIS_URL=redis://redis:6379 + +# Секретный ключ для сессий (измените в продакшене!) +SESSION_SECRET=star-wars-hearthstone-secret-key-change-in-production + +# Порт сервера +PORT=3542 + +# Окружение (production/development) +NODE_ENV=production diff --git a/AUTH_README.md b/AUTH_README.md new file mode 100644 index 0000000..54ae7ff --- /dev/null +++ b/AUTH_README.md @@ -0,0 +1,106 @@ +# Система авторизации и Leaderboard + +## Описание + +Игра теперь поддерживает систему авторизации/регистрации и глобальный рейтинг (Leaderboard) с использованием Redis в качестве базы данных. + +## Режимы работы + +### 1. Без Redis (для тестов) +По умолчанию приложение работает **без Redis**. Это позволяет тестировать игру без необходимости настройки базы данных. + +**Как запустить без Redis:** +- Просто запустите `node server.js` или `npm start` +- Или установите переменную окружения: `USE_REDIS=false` + +### 2. С Redis (для продакшена) +Для использования авторизации, регистрации и Leaderboard необходимо запустить Redis. + +**Как запустить с Redis:** + +#### Локально: +1. Установите Redis: `sudo apt-get install redis-server` (Debian/Ubuntu) +2. Запустите Redis: `redis-server` +3. Установите переменные окружения: + ```bash + export USE_REDIS=true + export REDIS_URL=redis://localhost:6379 + ``` +4. Запустите сервер: `node server.js` + +#### Docker: +1. Используйте `docker-compose.yml` - Redis уже настроен +2. Запустите: `docker compose up -d` +3. Redis автоматически подключится + +## Переменные окружения + +Создайте файл `.env` на основе `.env.example`: + +```bash +USE_REDIS=true # Использовать Redis (true/false) +REDIS_URL=redis://redis:6379 # URL Redis (для Docker: redis://redis:6379) +SESSION_SECRET=your-secret-key # Секретный ключ для сессий +PORT=3542 # Порт сервера +NODE_ENV=production # Окружение +``` + +## Deploy.sh + +Скрипт `deploy.sh` автоматически: +1. Останавливает Redis контейнер +2. Обновляет код из Git +3. Останавливает все контейнеры +4. Собирает образы +5. Запускает контейнеры (включая Redis) + +**Использование:** +```bash +chmod +x deploy.sh +./deploy.sh +``` + +## API Endpoints + +### Регистрация +``` +POST /api/register +Body: { username: string, password: string, email?: string } +``` + +### Вход +``` +POST /api/login +Body: { username: string, password: string } +``` + +### Выход +``` +POST /api/logout +``` + +### Информация о пользователе +``` +GET /api/user +``` + +### Leaderboard +``` +GET /api/leaderboard +Response: { leaderboard: [{ username, gamesPlayed, gamesWon, winRate, totalDamage, totalHealing }] } +``` + +## Статистика игроков + +Статистика автоматически обновляется при окончании игры: +- `gamesPlayed` - количество сыгранных игр +- `gamesWon` - количество побед +- `totalDamage` - общий нанесенный урон +- `totalHealing` - общее восстановленное здоровье + +## UI + +- **Модальное окно авторизации** появляется при первом запуске +- Можно продолжить без авторизации (кнопка "Продолжить без авторизации") +- **Кнопка Leaderboard** в правом верхнем углу лобби +- **Информация о пользователе** отображается в левом верхнем углу (если авторизован) diff --git a/deploy.sh b/deploy.sh index 4f8a0f7..b6b78d3 100644 --- a/deploy.sh +++ b/deploy.sh @@ -18,7 +18,7 @@ else fi echo "" -echo "🛑 Шаг 2/4: Остановка контейнеров..." +echo "🛑 Шаг 2/5: Остановка контейнеров..." docker compose down if [ $? -eq 0 ]; then echo "✅ Контейнеры остановлены" @@ -28,7 +28,12 @@ else fi echo "" -echo "🔨 Шаг 3/4: Сборка образов (без кеша)..." +echo "💾 Шаг 3/5: Остановка Redis (если запущен)..." +docker compose stop redis 2>/dev/null || true +echo "✅ Redis остановлен (если был запущен)" + +echo "" +echo "🔨 Шаг 4/5: Сборка образов (без кеша)..." docker compose build --no-cache if [ $? -eq 0 ]; then echo "✅ Образы собраны успешно" @@ -38,10 +43,14 @@ else fi echo "" -echo "▶️ Шаг 4/4: Запуск контейнеров..." +echo "▶️ Шаг 5/5: Запуск контейнеров..." docker compose up -d if [ $? -eq 0 ]; then echo "✅ Контейнеры запущены" + echo "" + echo "⏳ Ожидание готовности Redis..." + sleep 3 + docker compose ps redis | grep -q "Up" && echo "✅ Redis запущен" || echo "⚠️ Redis не запущен (работаем без БД)" else echo "❌ Ошибка при запуске контейнеров" exit 1 diff --git a/docker-compose.yml b/docker-compose.yml index 68169aa..11e7682 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,23 @@ version: '3.8' services: + redis: + image: redis:7-alpine + container_name: star-wars-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + restart: unless-stopped + networks: + - star-wars-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + star-wars-game: build: context: . @@ -11,7 +28,13 @@ services: environment: - NODE_ENV=production - PORT=3542 + - USE_REDIS=${USE_REDIS:-true} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} + - SESSION_SECRET=${SESSION_SECRET:-star-wars-hearthstone-secret-key-change-in-production} restart: unless-stopped + depends_on: + redis: + condition: service_healthy networks: - star-wars-network healthcheck: @@ -21,6 +44,9 @@ services: retries: 3 start_period: 5s +volumes: + redis-data: + networks: star-wars-network: driver: bridge diff --git a/package.json b/package.json index 0bc1763..cacfbc1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ }, "dependencies": { "express": "^4.18.2", - "socket.io": "^4.7.2" + "socket.io": "^4.7.2", + "redis": "^4.6.12", + "bcrypt": "^5.1.1", + "express-session": "^1.17.3", + "connect-redis": "^7.1.0" } } diff --git a/public/game.js b/public/game.js index c7c6018..7f4915a 100644 --- a/public/game.js +++ b/public/game.js @@ -189,6 +189,10 @@ socket = io(url, { transports: ['websocket', 'polling'], reconnection: false }); socket.on('connect', () => { clearError(); + // Отправляем имя пользователя на сервер, если авторизованы + if (currentUser && currentUser.username) { + socket.emit('setUsername', currentUser.username); + } }); socket.on('connect_error', (e) => { const errorMsg = e.message || 'Не удалось подключиться к серверу'; @@ -2830,8 +2834,281 @@ }); } + // Авторизация + let currentUser = null; + + async function checkAuth() { + try { + const response = await fetch('/api/user', { credentials: 'include' }); + if (response.ok) { + const data = await response.json(); + currentUser = data.user; + updateUserUI(); + return true; + } else { + // Не авторизован, но не показываем модальное окно автоматически + // Пользователь может продолжить без авторизации + return false; + } + } catch (error) { + console.error('Ошибка проверки авторизации:', error); + // Не показываем модальное окно при ошибке, пользователь может продолжить + return false; + } + } + + function updateUserUI() { + const userInfo = $('user-info'); + const userName = $('user-name'); + if (currentUser && userInfo && userName) { + userInfo.style.display = 'flex'; + userName.textContent = currentUser.username; + if (currentUser.gamesPlayed !== undefined) { + userName.textContent += ` (${currentUser.gamesWon || 0}/${currentUser.gamesPlayed || 0})`; + } + } + } + + function showAuthModal() { + const authOverlay = $('auth-overlay'); + if (authOverlay) { + authOverlay.classList.remove('hidden'); + } + } + + function hideAuthModal() { + const authOverlay = $('auth-overlay'); + if (authOverlay) { + authOverlay.classList.add('hidden'); + } + } + + function initAuth() { + // Переключение между вкладками авторизации + $all('.auth-tab').forEach(tab => { + tab.addEventListener('click', () => { + $all('.auth-tab').forEach(t => t.classList.remove('active')); + $all('.auth-panel').forEach(p => p.classList.add('hidden')); + tab.classList.add('active'); + const panelId = 'auth-' + tab.dataset.authTab + '-panel'; + const panel = $(panelId); + if (panel) { + panel.classList.remove('hidden'); + panel.classList.add('active'); + } + }); + }); + + // Форма входа + const loginForm = $('login-form'); + if (loginForm) { + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = $('login-username')?.value.trim(); + const password = $('login-password')?.value; + const errorEl = $('login-error'); + + if (!username || !password) { + if (errorEl) { + errorEl.textContent = 'Заполните все поля'; + errorEl.classList.remove('hidden'); + } + return; + } + + try { + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ username, password }) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + currentUser = data.user; + updateUserUI(); + hideAuthModal(); + if (errorEl) errorEl.classList.add('hidden'); + } else { + if (errorEl) { + errorEl.textContent = data.error || 'Ошибка входа'; + errorEl.classList.remove('hidden'); + } + } + } catch (error) { + console.error('Ошибка входа:', error); + if (errorEl) { + errorEl.textContent = 'Ошибка соединения с сервером'; + errorEl.classList.remove('hidden'); + } + } + }); + } + + // Форма регистрации + const registerForm = $('register-form'); + if (registerForm) { + registerForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = $('register-username')?.value.trim(); + const password = $('register-password')?.value; + const email = $('register-email')?.value.trim(); + const errorEl = $('register-error'); + + if (!username || !password) { + if (errorEl) { + errorEl.textContent = 'Заполните обязательные поля'; + errorEl.classList.remove('hidden'); + } + return; + } + + try { + const response = await fetch('/api/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ username, password, email }) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + currentUser = data.user; + updateUserUI(); + hideAuthModal(); + if (errorEl) errorEl.classList.add('hidden'); + // Отправляем имя пользователя на сервер через socket + if (socket && socket.connected && currentUser.username) { + socket.emit('setUsername', currentUser.username); + } + } else { + if (errorEl) { + errorEl.textContent = data.error || 'Ошибка регистрации'; + errorEl.classList.remove('hidden'); + } + } + } catch (error) { + console.error('Ошибка регистрации:', error); + if (errorEl) { + errorEl.textContent = 'Ошибка соединения с сервером'; + errorEl.classList.remove('hidden'); + } + } + }); + } + + // Кнопка закрытия авторизации + const authClose = $('auth-close'); + if (authClose) { + authClose.addEventListener('click', () => { + hideAuthModal(); + }); + } + + // Кнопка выхода + const btnLogout = $('btn-logout'); + if (btnLogout) { + btnLogout.addEventListener('click', async () => { + try { + await fetch('/api/logout', { method: 'POST', credentials: 'include' }); + currentUser = null; + const userInfo = $('user-info'); + if (userInfo) userInfo.style.display = 'none'; + showAuthModal(); + } catch (error) { + console.error('Ошибка выхода:', error); + } + }); + } + + // Leaderboard + const btnLeaderboard = $('btn-leaderboard'); + if (btnLeaderboard) { + btnLeaderboard.addEventListener('click', async () => { + const overlay = $('leaderboard-overlay'); + const content = $('leaderboard-content'); + if (overlay) overlay.classList.remove('hidden'); + if (content) content.innerHTML = '
Загрузка...
'; + + try { + const response = await fetch('/api/leaderboard', { credentials: 'include' }); + const data = await response.json(); + + if (response.ok && data.leaderboard) { + if (data.leaderboard.length === 0) { + content.innerHTML = 'Рейтинг пуст
'; + } else { + content.innerHTML = ` +| # | +Игрок | +Игр | +Побед | +% Побед | +Урон | +Хил | +
|---|---|---|---|---|---|---|
| ${idx + 1} | +${escapeHtml(player.username)} | +${player.gamesPlayed} | +${player.gamesWon} | +${player.winRate}% | +${player.totalDamage} | +${player.totalHealing} | +
Ошибка загрузки рейтинга
'; + } + } catch (error) { + console.error('Ошибка загрузки leaderboard:', error); + if (content) { + content.innerHTML = 'Ошибка соединения
'; + } + } + + if (typeof lucide !== 'undefined') { + setTimeout(() => { + try { + lucide.createIcons(); + } catch (e) { + console.warn('Error updating Lucide icons:', e); + } + }, 100); + } + }); + } + + const leaderboardClose = $('leaderboard-close'); + if (leaderboardClose) { + leaderboardClose.addEventListener('click', () => { + const overlay = $('leaderboard-overlay'); + if (overlay) overlay.classList.add('hidden'); + }); + } + } + function init() { initLobby(); + initAuth(); + // Проверяем авторизацию, но не блокируем доступ + checkAuth().catch(() => { + // Если авторизация не удалась, просто показываем модальное окно + // Пользователь может продолжить без авторизации + }); showScreen('lobby'); socket = null; } diff --git a/public/index.html b/public/index.html index 5617a6e..8312c59 100644 --- a/public/index.html +++ b/public/index.html @@ -20,6 +20,55 @@ + + + + + +PvP до 4 игроков · Игра с ИИ · Работает через Radmin VPN
- - - +