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;
}