diff --git a/public/game.js b/public/game.js index 4c84237..6acf712 100644 --- a/public/game.js +++ b/public/game.js @@ -1776,7 +1776,8 @@ connect(url); socket.once('connect', () => { console.log('Подключено, создаём комнату'); - socket.emit('createRoom', name); + const aiMode = $('ai-mode')?.checked || false; + socket.emit('createRoom', { name, aiMode }); }); socket.once('connect_error', (e) => { console.error('Ошибка подключения:', e); @@ -1785,7 +1786,8 @@ } else { console.log('Уже подключен, создаём комнату'); // Уже подключен, сразу отправляем - socket.emit('createRoom', name); + const aiMode = $('ai-mode')?.checked || false; + socket.emit('createRoom', { name, aiMode }); } }); @@ -1804,13 +1806,15 @@ if (!socket || !socket.connected) { connect(url); socket.once('connect', () => { - socket.emit('createRoom', name); + const aiMode = $('ai-mode')?.checked || false; + socket.emit('createRoom', { name, aiMode }); }); socket.once('connect_error', (e) => { showError('Не удалось подключиться к серверу. Проверьте, что сервер запущен.'); }); } else { - socket.emit('createRoom', name); + const aiMode = $('ai-mode')?.checked || false; + socket.emit('createRoom', { name, aiMode }); } }; } else { @@ -2257,8 +2261,17 @@ } function addCardToForge(cardId, source) { + if (!cardId) return; if (forgeSelected.includes(cardId)) return; // Уже добавлена - if (forgeSelected.length >= 3) return; // Максимум 3 карты + if (forgeSelected.length >= 3) { + // Показываем сообщение, что максимум 3 карты + const selectedEl = $('forge-selected'); + if (selectedEl) { + selectedEl.style.animation = 'shake 0.5s'; + setTimeout(() => selectedEl.style.animation = '', 500); + } + return; // Максимум 3 карты + } const meta = cardDb[cardId]; if (!meta || meta.type !== 'minion') return; // Только миньоны @@ -2275,6 +2288,9 @@ renderForgeDeck(gameState); } } + + // Настраиваем drag-and-drop заново после добавления + setTimeout(() => setupForgeDragAndDrop(gameState), 100); } function setupForgeDragAndDrop(state) { @@ -2334,6 +2350,131 @@ selectedEl.addEventListener('dragleave', handleDragLeave); selectedEl.addEventListener('drop', handleDrop); + // Поддержка touch для мобильных устройств + if (isTouchDevice()) { + let touchStartCard = null; + let touchStartSource = null; + let touchStartElement = null; + + // Обработка touch для карт в руке - упрощённая версия: просто тап добавляет карту + $all('#your-hand .card-wrap').forEach(cardWrap => { + const cardId = cardWrap.dataset.cardId; + const meta = cardDb[cardId]; + if (cardId && meta && meta.type === 'minion') { + // Удаляем старые обработчики + cardWrap.ontouchstart = null; + cardWrap.ontouchend = null; + + cardWrap.ontouchstart = function(e) { + if (sidebar && !sidebar.classList.contains('hidden')) { + touchStartCard = cardId; + touchStartSource = 'hand'; + touchStartElement = cardWrap; + cardWrap._touchStartTime = Date.now(); + cardWrap.classList.add('touch-active'); + e.preventDefault(); + } + }; + + cardWrap.ontouchend = function(e) { + if (touchStartCard === cardId && sidebar && !sidebar.classList.contains('hidden')) { + const touch = e.changedTouches[0]; + const timeDiff = Date.now() - (touchStartElement?._touchStartTime || Date.now()); + + // Если это быстрый тап (менее 300мс), просто добавляем карту + if (timeDiff < 300) { + addCardToForge(cardId, 'hand'); + } else { + // Иначе проверяем, куда перетащили + const dropEl = document.elementFromPoint(touch.clientX, touch.clientY); + if (dropEl && (dropEl === selectedEl || selectedEl.contains(dropEl) || + dropEl.closest('#forge-selected'))) { + addCardToForge(cardId, 'hand'); + } + } + + cardWrap.classList.remove('touch-active'); + touchStartCard = null; + touchStartSource = null; + touchStartElement = null; + e.preventDefault(); + } + }; + + cardWrap.ontouchcancel = function() { + if (touchStartCard === cardId) { + cardWrap.classList.remove('touch-active'); + touchStartCard = null; + touchStartSource = null; + touchStartElement = null; + } + }; + } + }); + + // Обработка touch для карт в колоде - упрощённая версия + $all('.forge-deck-card').forEach(cardWrap => { + const cardId = cardWrap.dataset.cardId; + if (cardId) { + // Удаляем старые обработчики + cardWrap.ontouchstart = null; + cardWrap.ontouchend = null; + + cardWrap.ontouchstart = function(e) { + touchStartCard = cardId; + touchStartSource = 'deck'; + touchStartElement = cardWrap; + cardWrap._touchStartTime = Date.now(); + cardWrap.classList.add('touch-active'); + e.preventDefault(); + }; + + cardWrap.ontouchend = function(e) { + if (touchStartCard === cardId) { + const touch = e.changedTouches[0]; + const timeDiff = Date.now() - (touchStartElement?._touchStartTime || Date.now()); + + // Если это быстрый тап (менее 300мс), просто добавляем карту + if (timeDiff < 300) { + addCardToForge(cardId, 'deck'); + } else { + // Иначе проверяем, куда перетащили + const dropEl = document.elementFromPoint(touch.clientX, touch.clientY); + if (dropEl && (dropEl === selectedEl || selectedEl.contains(dropEl) || + dropEl.closest('#forge-selected'))) { + addCardToForge(cardId, 'deck'); + } + } + + cardWrap.classList.remove('touch-active'); + touchStartCard = null; + touchStartSource = null; + touchStartElement = null; + e.preventDefault(); + } + }; + + cardWrap.ontouchcancel = function() { + if (touchStartCard === cardId) { + cardWrap.classList.remove('touch-active'); + touchStartCard = null; + touchStartSource = null; + touchStartElement = null; + } + }; + } + }); + + // Также добавляем обработчик на саму область выбранных карт для визуальной обратной связи + selectedEl.ontouchstart = function(e) { + selectedEl.classList.add('drag-over'); + }; + + selectedEl.ontouchend = function(e) { + selectedEl.classList.remove('drag-over'); + }; + } + // Делаем карты в руке перетаскиваемыми в кузницу $all('#your-hand .card-wrap').forEach(cardWrap => { const cardId = cardWrap.dataset.cardId; @@ -2414,13 +2555,16 @@ `; }).filter(Boolean).join(''); - // Клик по карте в колоде тоже добавляет её - $all('.forge-deck-card').forEach(card => { - card.onclick = function() { - const cardId = card.dataset.cardId; - addCardToForge(cardId, 'deck'); - }; - }); + // Клик по карте в колоде тоже добавляет её (для десктопа) + // На мобильных это обрабатывается через touch события + if (!isTouchDevice()) { + $all('.forge-deck-card').forEach(card => { + card.onclick = function() { + const cardId = card.dataset.cardId; + addCardToForge(cardId, 'deck'); + }; + }); + } renderForgeSelected(); updateForgeCraftButton(); diff --git a/public/index.html b/public/index.html index 65bd179..3c6caad 100644 --- a/public/index.html +++ b/public/index.html @@ -27,7 +27,7 @@ STAR WARS HEARTHSTONE -

PvP до 4 игроков · Работает через Radmin VPN

+

PvP до 4 игроков · Игра с ИИ · Работает через Radmin VPN

@@ -40,6 +40,10 @@
+
diff --git a/public/styles.css b/public/styles.css index ba36833..0cdddac 100644 --- a/public/styles.css +++ b/public/styles.css @@ -34,7 +34,8 @@ html, body { height: 100%; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; font-family: var(--font-body); background: var(--space); color: #e2e8f0; @@ -42,12 +43,16 @@ html, body { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; + -webkit-overflow-scrolling: touch; } #app { position: relative; min-height: 100vh; width: 100%; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; } /* Static starfield - no animation */ @@ -1587,6 +1592,12 @@ html, body { opacity: 1; pointer-events: auto; } +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-10px); } + 75% { transform: translateX(10px); } +} + @media (max-width: 768px) { .forge-sidebar { width: 100vw; @@ -2075,50 +2086,72 @@ html, body { html, body { touch-action: manipulation; -webkit-tap-highlight-color: transparent; + overflow-x: hidden; } .screen { - padding: 1rem; + padding: 0.5rem; + min-height: 100vh; + overflow-x: hidden; } .lobby-card { padding: 1.5rem; max-width: 100%; + margin: 0 auto; } .game-header { flex-direction: column; gap: 0.5rem; padding: 0.75rem; + position: sticky; + top: 0; + z-index: 100; + background: var(--space); + border-bottom: 1px solid rgba(0,180,255,0.2); } .header-left, .header-center, .header-right { width: 100%; justify-content: space-between; flex-wrap: wrap; + gap: 0.5rem; + } + + .header-center { + order: -1; + flex-wrap: wrap; + gap: 0.5rem; } .btn { min-height: 44px; padding: 0.85rem 1.5rem; font-size: 1rem; + touch-action: manipulation; } .btn-end-turn { min-height: 48px; padding: 0.7rem 1.6rem; font-size: 1.05rem; + flex: 1; + min-width: 120px; } .btn-instructions, .btn-settings { - width: 40px; - height: 40px; + width: 44px; + height: 44px; font-size: 1.2rem; + min-width: 44px; + min-height: 44px; } .card-wrap { width: 140px; height: 196px; + touch-action: manipulation; } .board .card-wrap { @@ -2131,22 +2164,51 @@ html, body { height: 140px; } + .opponents { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + + .opponent-block { + min-width: auto; + width: 100%; + padding: 0.5rem; + } + + .hand { + overflow-x: auto; + overflow-y: visible; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x proximity; + padding: 0.5rem; + gap: 0.3rem; + } + .hand .card-wrap { - margin-left: -40px; + margin-left: -30px; + scroll-snap-align: start; + flex-shrink: 0; } .hand .card-wrap:first-child { margin-left: 0; } + .hand .card-wrap:last-child { + margin-right: 0.5rem; + } + .hand-container { - min-height: 180px; + min-height: 200px; padding: 0.5rem; + position: relative; } .deck-fan { width: 90px; height: 126px; + flex-shrink: 0; } .deck-stack { @@ -2162,21 +2224,31 @@ html, body { .board { min-height: 150px; padding: 0.5rem; + overflow-x: auto; + -webkit-overflow-scrolling: touch; } .your-board { min-height: 110px; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x proximity; } - .opponent-block { - min-width: 140px; - padding: 0.5rem; + .your-board .card-wrap { + scroll-snap-align: start; + flex-shrink: 0; } .attack-mode { bottom: 200px; padding: 0.7rem 1.2rem; font-size: 0.9rem; + left: 50%; + transform: translateX(-50%); + width: calc(100% - 2rem); + max-width: 400px; } .attack-mode p { @@ -2187,6 +2259,8 @@ html, body { max-width: 95vw; padding: 1.5rem; margin: 1rem; + max-height: 90vh; + overflow-y: auto; } .cards-gallery-grid { @@ -2199,14 +2273,38 @@ html, body { height: 140px !important; } + .forge-sidebar { + width: 100vw; + max-width: 100vw; + right: 0; + left: 0; + border-radius: 0; + max-height: 100vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + .forge-deck-list { grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 0.5rem; + max-height: 300px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; } .forge-deck-card { width: 80px !important; height: 112px !important; + touch-action: manipulation; + } + + .forge-selected { + min-height: 120px; + touch-action: manipulation; + } + + .forge-selected-card { + touch-action: manipulation; } .mana-display, .health-display { @@ -2225,6 +2323,8 @@ html, body { .game-log { font-size: 0.75rem; max-height: 60px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; } .card-name { @@ -2233,6 +2333,7 @@ html, body { .card-text { font-size: 0.68rem; + line-height: 1.3; } .card-cost, .card-atk-hp { @@ -2240,9 +2341,11 @@ html, body { } .card-btn-info { - width: 24px; - height: 24px; - font-size: 0.8rem; + width: 28px; + height: 28px; + font-size: 0.9rem; + min-width: 28px; + min-height: 28px; } /* Touch feedback */ @@ -2256,11 +2359,21 @@ html, body { } /* Prevent text selection on touch */ - .card-wrap, .btn, .tab { + .card-wrap, .btn, .tab, .deck-fan { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; } + + /* Улучшенная поддержка touch для интерактивных элементов */ + .btn:active { + transform: scale(0.95); + transition: transform 0.1s; + } + + .card-wrap:active:not(.disabled) { + transform: scale(0.98); + } } /* Small mobile devices */ diff --git a/server.js b/server.js index a289270..f577a88 100644 --- a/server.js +++ b/server.js @@ -113,8 +113,28 @@ function initGame(room) { manualDrawUsed: false, fatigueCounter: 0, heroAbilityUsed: false, + isAI: false, })); + // Если режим ИИ, добавляем ИИ игрока + if (room.aiMode) { + players.push({ + id: 'AI_' + Date.now(), + name: 'ИИ Противник', + deck: createDeck(factionChoice), + hand: [], + board: [], + mana: 0, + maxMana: 0, + health: 30, + hero: 'vader', + manualDrawUsed: false, + fatigueCounter: 0, + heroAbilityUsed: false, + isAI: true, + }); + } + players.forEach((p, i) => { p.hand = drawCards(p.deck, i < 2 ? 3 : 4); }); @@ -131,6 +151,193 @@ function initGame(room) { room.gameStarted = true; startTurnTimer(room); broadcastGameState(room); + + // Если первый ход ИИ, делаем его ход + if (room.aiMode && players[0].isAI) { + setTimeout(() => { + if (room.gameState && room.gameState.phase === 'playing') { + makeAITurn(room); + } + }, 1000); + } +} + +// Функция для хода ИИ +function makeAITurn(room) { + const gameState = room.gameState; + if (!gameState || gameState.phase !== 'playing') return; + + const aiPlayerIndex = gameState.currentPlayerIndex; + const aiPlayer = gameState.players[aiPlayerIndex]; + + if (!aiPlayer || !aiPlayer.isAI || aiPlayer.health <= 0) return; + if (gameState.currentPlayerIndex !== aiPlayerIndex) return; + + // Применяем синергии перед ходом + applySynergies(room); + + // Небольшая задержка для визуализации + setTimeout(() => { + let actionsDone = 0; + const maxActions = 5; // Максимум действий за ход + + // 1. Играем карты (приоритет дешёвым и эффективным) + const playableCards = aiPlayer.hand + .map((cardId, index) => { + const card = cardDb[cardId]; + if (!card) return null; + const cost = card.cost || 0; + if (cost > aiPlayer.mana) return null; + if (card.type === 'minion' && aiPlayer.board.length >= 7) return null; + + // Оценка карты (чем выше, тем лучше) + let value = 0; + if (card.type === 'minion') { + value = (card.attack || 0) + (card.health || 0) - cost * 2; + if (card.battlecryId) value += 2; + if (card.legendary) value += 1; + } else if (card.type === 'spell') { + value = 3 - cost; + if (card.spellEffect === 'deal_3_minion' || card.spellEffect === 'deal_3_any') value += 2; + if (card.spellEffect === 'buff_3_2') value += 1; + } + + return { index, card, cost, value }; + }) + .filter(Boolean) + .sort((a, b) => b.value - a.value); + + // Играем лучшие карты + let cardIndex = 0; + const playNextCard = () => { + if (cardIndex >= playableCards.length || actionsDone >= 3) { + // Переходим к атакам + setTimeout(() => { + performAIAttacks(room, aiPlayerIndex); + }, 500); + return; + } + + const playable = playableCards[cardIndex]; + const currentPlayer = gameState.players[aiPlayerIndex]; + + if (!currentPlayer || playable.cost > currentPlayer.mana) { + cardIndex++; + playNextCard(); + return; + } + + if (playable.card.type === 'minion') { + // Играем миньона (пропускаем карты с выбором цели для упрощения) + if (playable.card.battlecryId === 'return_hand_enemy') { + cardIndex++; + playNextCard(); + return; + } + const boardPos = currentPlayer.board.length; + playCard(room, aiPlayer.id, playable.index, boardPos); + actionsDone++; + cardIndex++; + setTimeout(playNextCard, 600); + } else if (playable.card.type === 'spell') { + // Используем заклинание + const enemies = gameState.players.filter((p, i) => i !== aiPlayerIndex && p.health > 0); + if (enemies.length > 0) { + const targetEnemy = enemies[0]; + let targetBoardIndex = -1; + + if (playable.card.spellTarget === 'enemy_minion' || playable.card.spellTarget === 'any_minion') { + if (targetEnemy.board && targetEnemy.board.length > 0) { + targetBoardIndex = 0; + } else { + targetBoardIndex = -1; + } + } else if (playable.card.spellTarget === 'friendly_minion') { + if (currentPlayer.board && currentPlayer.board.length > 0) { + targetBoardIndex = 0; + } else { + cardIndex++; + playNextCard(); + return; + } + } else if (playable.card.spellTarget === 'enemy_player') { + // Для заклинаний типа "Грабеж" пропускаем (требует выбор карт) + if (playable.card.spellEffect === 'steal_cards') { + cardIndex++; + playNextCard(); + return; + } + targetBoardIndex = -1; + } + + playSpell(room, aiPlayer.id, playable.index, gameState.players.indexOf(targetEnemy), targetBoardIndex); + actionsDone++; + cardIndex++; + setTimeout(playNextCard, 600); + } else { + cardIndex++; + playNextCard(); + } + } else { + cardIndex++; + playNextCard(); + } + }; + + playNextCard(); + }, 800); +} + +// Функция для атак ИИ +function performAIAttacks(room, aiPlayerIndex) { + const gameState = room.gameState; + if (!gameState || gameState.phase !== 'playing') { + endTurn(room); + return; + } + + const aiPlayer = gameState.players[aiPlayerIndex]; + if (!aiPlayer || aiPlayer.health <= 0) { + endTurn(room); + return; + } + + const attackableMinions = aiPlayer.board.filter(m => m.canAttack && !m.frozen); + const enemies = gameState.players.filter((p, i) => i !== aiPlayerIndex && p.health > 0); + + if (attackableMinions.length === 0 || enemies.length === 0) { + setTimeout(() => endTurn(room), 500); + return; + } + + let attackIndex = 0; + const performNextAttack = () => { + if (attackIndex >= attackableMinions.length) { + setTimeout(() => endTurn(room), 500); + return; + } + + const minion = attackableMinions[attackIndex]; + const boardIndex = aiPlayer.board.indexOf(minion); + const targetEnemy = enemies[0]; + const targetPlayerIndex = gameState.players.indexOf(targetEnemy); + let targetBoardIndex = -1; + + // Выбираем цель: сначала слабые миньоны, потом герой + if (targetEnemy.board && targetEnemy.board.length > 0) { + const weakMinion = targetEnemy.board.find(m => m.health <= minion.attack) || + targetEnemy.board[0]; + targetBoardIndex = targetEnemy.board.indexOf(weakMinion); + } else { + targetBoardIndex = -1; // Атакуем героя + } + + attack(room, aiPlayer.id, boardIndex, targetPlayerIndex, targetBoardIndex); + attackIndex++; + setTimeout(performNextAttack, 800); + }; + + performNextAttack(); } function clearTurnTimer(room) { @@ -364,6 +571,15 @@ function endTurn(room) { checkGameOver(room); startTurnTimer(room); broadcastGameState(room); + + // Если следующий игрок - ИИ, делаем его ход + if (np.isAI && gameState.phase === 'playing') { + setTimeout(() => { + if (room.gameState && room.gameState.phase === 'playing') { + makeAITurn(room); + } + }, 1500); + } } function manualDraw(room, socketId) { @@ -1519,7 +1735,9 @@ function attack(room, socketId, attackerBoardIndex, targetPlayerIndex, targetBoa } io.on('connection', (socket) => { - socket.on('createRoom', (name) => { + socket.on('createRoom', (data) => { + const name = typeof data === 'string' ? data : (data?.name || 'Host'); + const aiMode = typeof data === 'object' && data?.aiMode === true; const code = generateRoomCode(); const room = { code, @@ -1527,6 +1745,7 @@ io.on('connection', (socket) => { gameState: null, gameStarted: false, faction: null, + aiMode: aiMode, turnTimerInterval: null, turnTimeLeft: TURN_SECONDS, turnTimerWarnFired: false, @@ -1539,6 +1758,7 @@ io.on('connection', (socket) => { lobby: room.lobby, serverIP: serverIP, serverPort: serverPort, + aiMode: aiMode, }); cleanupEmptyRooms(); }); @@ -1593,7 +1813,8 @@ io.on('connection', (socket) => { socket.emit('error', 'Только хост может начать игру'); return; } - if (room.lobby.length < MIN_PLAYERS) { + // В режиме ИИ можно начать игру с одним игроком + if (!room.aiMode && room.lobby.length < MIN_PLAYERS) { socket.emit('error', `Нужно минимум ${MIN_PLAYERS} игроков`); return; }