/** * Star Wars Hearthstone - Game Server * PvP over LAN (Radmin VPN). 2-4 players. */ const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const path = require('path'); const app = express(); const server = http.createServer(app); const io = new Server(server); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/cards', (req, res) => { res.json(cardDb); }); const PORT = process.env.PORT || 3542; const MAX_PLAYERS = 4; const MIN_PLAYERS = 2; const cardDb = require('./cards'); const rooms = new Map(); // code -> { lobby, gameState, gameStarted, faction, turnTimerInterval, turnTimeLeft, turnTimerWarnFired } const TURN_SECONDS = 90; const TURN_WARN_AT = 15; function generateRoomCode() { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let code = ''; for (let i = 0; i < 5; i++) { code += chars[Math.floor(Math.random() * chars.length)]; } return code; } function cleanupEmptyRooms() { for (const [code, room] of rooms.entries()) { if (!room.gameStarted && (!room.lobby || room.lobby.length === 0)) { clearTurnTimer(room); rooms.delete(code); } } } // Периодическая очистка пустых комнат setInterval(cleanupEmptyRooms, 30000); // каждые 30 секунд function shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function createDeck(faction) { const minionIds = Object.keys(cardDb).filter((id) => { const c = cardDb[id]; if (c.type !== 'minion') return false; if (faction && c.faction !== faction && c.faction !== 'neutral' && c.faction !== 'animal') return false; return true; }); const spellIds = Object.keys(cardDb).filter((id) => { const c = cardDb[id]; if (c.type !== 'spell') return false; if (faction && c.faction !== faction && c.faction !== 'neutral') return false; return true; }); const deck = []; const legendaryUsed = new Set(); for (let i = 0; i < 15; i++) { const pool = minionIds.filter((id) => { const c = cardDb[id]; if (c.legendary && legendaryUsed.has(id)) return false; return true; }); if (!pool.length) break; const id = pool[Math.floor(Math.random() * pool.length)]; deck.push(id); if (cardDb[id].legendary) legendaryUsed.add(id); } for (let i = 0; i < 5 && spellIds.length; i++) { deck.push(spellIds[Math.floor(Math.random() * spellIds.length)]); } return shuffle(deck); } function drawCards(deck, n) { const drawn = []; for (let i = 0; i < n && deck.length; i++) { drawn.push(deck.pop()); } return drawn; } function initGame(room) { const lobby = room.lobby; const playerCount = lobby.length; const factionChoice = room.faction || null; const players = lobby.map((p, i) => ({ id: p.id, name: p.name || `Player ${i + 1}`, deck: createDeck(factionChoice), hand: [], board: [], mana: 0, maxMana: 0, health: 30, hero: (p.hero || ['luke', 'vader', 'yoda', 'leia', 'rey', 'kylo', 'mace'][i % 7]), manualDrawUsed: false, fatigueCounter: 0, heroAbilityUsed: false, })); players.forEach((p, i) => { p.hand = drawCards(p.deck, i < 2 ? 3 : 4); }); players[0].maxMana = 1; players[0].mana = 1; room.gameState = { phase: 'playing', turn: 1, players, currentPlayerIndex: 0, log: [], }; room.gameStarted = true; startTurnTimer(room); broadcastGameState(room); } function clearTurnTimer(room) { if (room.turnTimerInterval) { clearInterval(room.turnTimerInterval); room.turnTimerInterval = null; } room.turnTimeLeft = TURN_SECONDS; room.turnTimerWarnFired = false; } function startTurnTimer(room) { clearTurnTimer(room); if (!room.gameState || room.gameState.phase !== 'playing') return; room.turnTimeLeft = TURN_SECONDS; room.turnTimerWarnFired = false; io.to(room.code).emit('turnTime', { left: room.turnTimeLeft, currentPlayerIndex: room.gameState.currentPlayerIndex, warn: false, ended: false, }); room.turnTimerInterval = setInterval(() => { if (!room.gameState || room.gameState.phase !== 'playing') { clearTurnTimer(room); return; } room.turnTimeLeft--; io.to(room.code).emit('turnTime', { left: room.turnTimeLeft, currentPlayerIndex: room.gameState.currentPlayerIndex, warn: room.turnTimeLeft === TURN_WARN_AT, ended: room.turnTimeLeft <= 0, }); if (room.turnTimeLeft === TURN_WARN_AT) room.turnTimerWarnFired = true; if (room.turnTimeLeft <= 0) { clearTurnTimer(room); endTurn(room); } }, 1000); } function broadcastGameState(room) { if (!room.gameState) return; room.gameState.players.forEach((p, i) => { const socket = io.sockets.sockets.get(p.id); if (socket) { const mergedCardDb = { ...cardDb, ...(room.gameState.forgedCards || {}) }; const yourView = { ...room.gameState, yourIndex: i, yourHand: [...p.hand], yourDeckCount: p.deck.length, yourManualDrawUsed: !!p.manualDrawUsed, yourHeroAbilityUsed: !!p.heroAbilityUsed, cardDb: mergedCardDb, }; socket.emit('gameState', yourView); } }); } function findPlayerIndex(room, socketId) { return room.gameState.players.findIndex((p) => p.id === socketId); } function getRoomByCode(code) { return rooms.get(code?.toUpperCase()); } function getRoomBySocket(socketId) { for (const room of rooms.values()) { if (room.lobby?.some((p) => p.id === socketId)) return room; if (room.gameState?.players?.some((p) => p.id === socketId)) return room; } return null; } function endTurn(room) { const gameState = room.gameState; const prev = gameState.currentPlayerIndex; gameState.currentPlayerIndex = (gameState.currentPlayerIndex + 1) % gameState.players.length; const next = gameState.currentPlayerIndex; const np = gameState.players[next]; np.maxMana = Math.min(10, np.maxMana + 1); np.mana = np.maxMana; // Эффекты структур в конце хода предыдущего игрока const prevPlayer = gameState.players[prev]; if (prevPlayer) { prevPlayer.board.forEach((m) => { const card = cardDb[m.cardId]; if (!card) return; if (card.name === 'Храм джедаев') { prevPlayer.board.forEach((minion) => { if (minion.id !== m.id && minion.health < (minion.maxHealth || minion.health)) { minion.health = Math.min(minion.maxHealth || minion.health, minion.health + 1); } }); } else if (card.name === 'Станция Звезды Смерти') { const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0); if (enemies.length) { const target = enemies[Math.floor(Math.random() * enemies.length)]; target.health = Math.max(0, target.health - 3); gameState.log.push({ type: 'structure', effect: 'death_star_damage', fromPlayer: prev, toPlayer: gameState.players.indexOf(target), damage: 3 }); } } else if (card.name === 'Кантина Мос-Эйсли') { if (prevPlayer.deck.length > 0) { const drawn = drawCards(prevPlayer.deck, 1); if (drawn.length) prevPlayer.hand.push(...drawn); gameState.log.push({ type: 'structure', effect: 'cantina_draw', playerIndex: prev }); } } else if (card.name === 'Сарлак') { const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0); if (enemies.length) { const enemy = enemies[Math.floor(Math.random() * enemies.length)]; if (enemy.board && enemy.board.length > 0) { const weak = enemy.board.filter(min => min.attack <= 3); if (weak.length > 0) { const target = weak[Math.floor(Math.random() * weak.length)]; const boardIdx = enemy.board.indexOf(target); const targetCard = cardDb[target.cardId]; enemy.board.splice(boardIdx, 1); gameState.log.push({ type: 'structure', effect: 'sarlacc_consume', fromPlayer: prev, toPlayer: gameState.players.indexOf(enemy), toIdx: boardIdx }); if (targetCard && targetCard.deathrattleId) { runDeathrattle(room, targetCard, gameState.players.indexOf(enemy)); } } } } } // Обработка планет else if (card.name === 'Korriban') { const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0); if (enemies.length) { const target = enemies[Math.floor(Math.random() * enemies.length)]; target.health = Math.max(0, target.health - 2); gameState.log.push({ type: 'planet', effect: 'korriban_damage', fromPlayer: prev, toPlayer: gameState.players.indexOf(target), damage: 2 }); } } else if (card.name === 'Tatooine') { if (prevPlayer.deck.length > 0) { const drawn = drawCards(prevPlayer.deck, 1); if (drawn.length) prevPlayer.hand.push(...drawn); gameState.log.push({ type: 'planet', effect: 'tatooine_draw', playerIndex: prev }); } } else if (card.name === 'Coruscant') { prevPlayer.board.forEach((minion) => { if (minion.id !== m.id && minion.health < (minion.maxHealth || minion.health)) { minion.health = Math.min(minion.maxHealth || minion.health, minion.health + 1); } }); gameState.log.push({ type: 'planet', effect: 'coruscant_heal', playerIndex: prev }); } else if (card.name === 'Zakuul') { const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0); enemies.forEach((enemy) => { enemy.health = Math.max(0, enemy.health - 3); }); gameState.log.push({ type: 'planet', effect: 'zakuul_damage', fromPlayer: prev }); } else if (card.name === 'Kamino') { if (prevPlayer.hand.length < 10) { prevPlayer.hand.push('clone_trooper'); gameState.log.push({ type: 'planet', effect: 'kamino_summon', playerIndex: prev }); } } else if (card.name === 'Mustafar') { const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0); enemies.forEach((enemy) => { enemy.health = Math.max(0, enemy.health - 1); if (enemy.board) { enemy.board.forEach((min) => { min.health = Math.max(0, min.health - 1); }); enemy.board = enemy.board.filter((m) => m.health > 0); } }); gameState.log.push({ type: 'planet', effect: 'mustafar_damage', fromPlayer: prev }); } else if (card.name === 'Naboo') { prevPlayer.health = Math.min(30, prevPlayer.health + 2); gameState.log.push({ type: 'planet', effect: 'naboo_heal', playerIndex: prev }); } else if (card.name === 'Endor') { if (prevPlayer.hand.length < 10) { prevPlayer.hand.push('ewok'); gameState.log.push({ type: 'planet', effect: 'endor_summon', playerIndex: prev }); } } else if (card.name === 'Hoth') { const enemies = gameState.players.filter((pl, i) => i !== prev && pl.health > 0); enemies.forEach((enemy) => { if (enemy.board) { enemy.board.forEach((min) => { min.frozen = true; }); } }); gameState.log.push({ type: 'planet', effect: 'hoth_freeze', fromPlayer: prev }); } else if (card.name === 'Dagobah') { if (prevPlayer.deck.length > 0) { const drawn = drawCards(prevPlayer.deck, 1); if (drawn.length) prevPlayer.hand.push(...drawn); gameState.log.push({ type: 'planet', effect: 'dagobah_draw', playerIndex: prev }); } } }); } np.board.forEach((m) => { if (m.frozen) { m.frozen = false; return; } const card = cardDb[m.cardId]; if (card && card.attack > 0) { m.canAttack = true; if (card.canAttackTwice) { m.attacksUsed = 0; // Сбрасываем счётчик атак для двойной атаки } } }); np.manualDrawUsed = false; np.heroAbilityUsed = false; if (np.deck.length === 0) { np.fatigueCounter = (np.fatigueCounter || 0) + 1; np.health = Math.max(0, np.health - np.fatigueCounter); gameState.log.push({ type: 'fatigue', playerIndex: next, damage: np.fatigueCounter }); } else { const drawn = drawCards(np.deck, 1); if (drawn.length) np.hand.push(...drawn); } gameState.turn++; gameState.log.push({ type: 'turn', from: prev, to: next }); checkGameOver(room); startTurnTimer(room); broadcastGameState(room); } function manualDraw(room, socketId) { const gameState = room.gameState; const pi = findPlayerIndex(room, socketId); if (pi < 0 || gameState.currentPlayerIndex !== pi) return; const p = gameState.players[pi]; if (p.manualDrawUsed || p.hand.length >= 10) return; if (p.deck.length === 0) { p.manualDrawUsed = true; p.fatigueCounter = (p.fatigueCounter || 0) + 1; const dmg = p.fatigueCounter; p.health = Math.max(0, p.health - dmg); gameState.log.push({ type: 'fatigue', playerIndex: pi, damage: dmg }); checkGameOver(room); broadcastGameState(room); return; } p.manualDrawUsed = true; const drawn = drawCards(p.deck, 1); if (drawn.length) p.hand.push(...drawn); gameState.log.push({ type: 'draw', playerIndex: pi }); broadcastGameState(room); } function runBattlecry(room, card, playerIndex) { const gameState = room.gameState; const p = gameState.players[playerIndex]; const enemies = gameState.players.filter((pl, i) => i !== playerIndex && pl.health > 0); const id = card.battlecryId; if (id === 'deal_1_hero' && enemies.length) { const t = enemies[Math.floor(Math.random() * enemies.length)]; const idx = gameState.players.indexOf(t); t.health = Math.max(0, t.health - 1); gameState.log.push({ type: 'battlecry', effect: 'deal_1_hero', fromPlayer: playerIndex, toPlayer: idx, damage: 1 }); } else if (id === 'deal_2_hero' && enemies.length) { const t = enemies[Math.floor(Math.random() * enemies.length)]; const idx = gameState.players.indexOf(t); t.health = Math.max(0, t.health - 2); gameState.log.push({ type: 'battlecry', effect: 'deal_2_hero', fromPlayer: playerIndex, toPlayer: idx, damage: 2 }); } else if (id === 'draw_1') { const drawn = drawCards(p.deck, 1); if (drawn.length) p.hand.push(...drawn); gameState.log.push({ type: 'battlecry', effect: 'draw_1', playerIndex }); } else if (id === 'draw_2') { const drawn = drawCards(p.deck, 2); if (drawn.length) p.hand.push(...drawn); gameState.log.push({ type: 'battlecry', effect: 'draw_2', playerIndex }); } else if (id === 'deal_2_minion' && enemies.length) { const enemy = enemies[Math.floor(Math.random() * enemies.length)]; const enemyIdx = gameState.players.indexOf(enemy); if (enemy.board && enemy.board.length > 0) { const target = enemy.board[Math.floor(Math.random() * enemy.board.length)]; target.health = Math.max(0, target.health - 2); const boardIdx = enemy.board.indexOf(target); gameState.log.push({ type: 'battlecry', effect: 'deal_2_minion', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx, damage: 2 }); if (target.health <= 0) { enemy.board.splice(boardIdx, 1); runDeathrattle(room, cardDb[target.cardId], enemyIdx); } } } else if (id === 'destroy_weak_minion' && enemies.length) { const enemy = enemies[Math.floor(Math.random() * enemies.length)]; const enemyIdx = gameState.players.indexOf(enemy); if (enemy.board && enemy.board.length > 0) { const weak = enemy.board.filter(m => m.attack <= 3); if (weak.length > 0) { const target = weak[Math.floor(Math.random() * weak.length)]; const boardIdx = enemy.board.indexOf(target); const targetCard = cardDb[target.cardId]; enemy.board.splice(boardIdx, 1); gameState.log.push({ type: 'battlecry', effect: 'destroy_weak_minion', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx }); if (targetCard && targetCard.deathrattleId) { runDeathrattle(room, targetCard, enemyIdx); } } } } else if (id === 'heal_hero_2') { p.health = Math.min(30, p.health + 2); gameState.log.push({ type: 'battlecry', effect: 'heal_hero_2', playerIndex }); } else if (id === 'heal_hero_3') { p.health = Math.min(30, p.health + 3); gameState.log.push({ type: 'battlecry', effect: 'heal_hero_3', playerIndex }); } else if (id === 'buff_all_friendly_attack') { p.board.forEach((m) => { m.attack += 1; }); gameState.log.push({ type: 'battlecry', effect: 'buff_all_friendly_attack', playerIndex }); } else if (id === 'deal_3_all_enemies') { enemies.forEach((e) => { e.health = Math.max(0, e.health - 3); }); gameState.log.push({ type: 'battlecry', effect: 'deal_3_all_enemies', fromPlayer: playerIndex }); } else if (id === 'deal_3_any') { if (enemies.length) { const t = enemies[Math.floor(Math.random() * enemies.length)]; const idx = gameState.players.indexOf(t); if (t.board && t.board.length > 0) { const target = t.board[Math.floor(Math.random() * t.board.length)]; target.health = Math.max(0, target.health - 3); const boardIdx = t.board.indexOf(target); gameState.log.push({ type: 'battlecry', effect: 'deal_3_any', fromPlayer: playerIndex, toPlayer: idx, toIdx: boardIdx, damage: 3 }); if (target.health <= 0) { t.board.splice(boardIdx, 1); runDeathrattle(room, cardDb[target.cardId], idx); } } else { t.health = Math.max(0, t.health - 3); gameState.log.push({ type: 'battlecry', effect: 'deal_3_any', fromPlayer: playerIndex, toPlayer: idx, damage: 3 }); } } } else if (id === 'draw_1_gain_mana') { const drawn = drawCards(p.deck, 1); if (drawn.length) p.hand.push(...drawn); p.maxMana = Math.min(10, p.maxMana + 1); p.mana = Math.min(10, p.mana + 1); gameState.log.push({ type: 'battlecry', effect: 'draw_1_gain_mana', playerIndex }); } else if (id === 'destroy_medium_minion' && enemies.length) { const enemy = enemies[Math.floor(Math.random() * enemies.length)]; const enemyIdx = gameState.players.indexOf(enemy); if (enemy.board && enemy.board.length > 0) { const weak = enemy.board.filter(m => m.attack <= 4); if (weak.length > 0) { const target = weak[Math.floor(Math.random() * weak.length)]; const boardIdx = enemy.board.indexOf(target); const targetCard = cardDb[target.cardId]; enemy.board.splice(boardIdx, 1); gameState.log.push({ type: 'battlecry', effect: 'destroy_medium_minion', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx }); if (targetCard && targetCard.deathrattleId) { runDeathrattle(room, targetCard, enemyIdx); } } } } else if (id === 'buff_friendly_2_1') { if (p.board && p.board.length > 0) { const target = p.board[Math.floor(Math.random() * p.board.length)]; target.attack += 2; target.health += 1; target.maxHealth = (target.maxHealth || target.health) + 1; const boardIdx = p.board.indexOf(target); gameState.log.push({ type: 'battlecry', effect: 'buff_friendly_2_1', playerIndex, toIdx: boardIdx }); } } else if (id === 'buff_2_2') { if (p.board && p.board.length > 0) { const target = p.board[Math.floor(Math.random() * p.board.length)]; target.attack += 2; target.health += 2; target.maxHealth = (target.maxHealth || target.health) + 2; const boardIdx = p.board.indexOf(target); gameState.log.push({ type: 'battlecry', effect: 'buff_2_2', playerIndex, toIdx: boardIdx }); } } else if (id === 'heal_hero_5') { p.health = Math.min(30, p.health + 5); gameState.log.push({ type: 'battlecry', effect: 'heal_hero_5', playerIndex }); } else if (id === 'return_hand_enemy' && enemies.length) { const enemy = enemies[Math.floor(Math.random() * enemies.length)]; const enemyIdx = gameState.players.indexOf(enemy); if (enemy.board && enemy.board.length > 0) { const target = enemy.board[Math.floor(Math.random() * enemy.board.length)]; const boardIdx = enemy.board.indexOf(target); enemy.board.splice(boardIdx, 1); enemy.hand.push(target.cardId); gameState.log.push({ type: 'battlecry', effect: 'return_hand_enemy', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx }); } } else if (id === 'destroy_strongest_enemy' && enemies.length) { const enemy = enemies[Math.floor(Math.random() * enemies.length)]; const enemyIdx = gameState.players.indexOf(enemy); if (enemy.board && enemy.board.length > 0) { const strongest = enemy.board.reduce((max, m) => (m.attack > (max?.attack || 0) ? m : max), null); if (strongest) { const boardIdx = enemy.board.indexOf(strongest); const targetCard = cardDb[strongest.cardId]; enemy.board.splice(boardIdx, 1); gameState.log.push({ type: 'battlecry', effect: 'destroy_strongest_enemy', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx }); if (targetCard && targetCard.deathrattleId) { runDeathrattle(room, targetCard, enemyIdx); } } } } else if (id === 'freeze_all_enemies') { enemies.forEach((enemy) => { if (enemy.board) { enemy.board.forEach((m) => { m.frozen = true; }); } }); gameState.log.push({ type: 'battlecry', effect: 'freeze_all_enemies', fromPlayer: playerIndex }); } else if (id === 'random_effect') { const effects = [ () => { const drawn = drawCards(p.deck, 1); if (drawn.length) p.hand.push(...drawn); gameState.log.push({ type: 'battlecry', effect: 'random_draw', playerIndex }); }, () => { if (enemies.length) { const t = enemies[Math.floor(Math.random() * enemies.length)]; t.health = Math.max(0, t.health - 1); gameState.log.push({ type: 'battlecry', effect: 'random_damage', fromPlayer: playerIndex, toPlayer: gameState.players.indexOf(t), damage: 1 }); } }, () => { p.health = Math.min(30, p.health + 1); gameState.log.push({ type: 'battlecry', effect: 'random_heal', playerIndex }); } ]; const randomEffect = effects[Math.floor(Math.random() * effects.length)]; randomEffect(); } else if (id === 'deal_2_or_4_hero' && enemies.length) { const enemy = enemies[Math.floor(Math.random() * enemies.length)]; const enemyIdx = gameState.players.indexOf(enemy); if (enemy.board && enemy.board.length > 0) { const target = enemy.board[Math.floor(Math.random() * enemy.board.length)]; target.health = Math.max(0, target.health - 2); const boardIdx = enemy.board.indexOf(target); gameState.log.push({ type: 'battlecry', effect: 'deal_2_or_4_hero', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx, damage: 2 }); if (target.health <= 0) { enemy.board.splice(boardIdx, 1); runDeathrattle(room, cardDb[target.cardId], enemyIdx); } } else { enemy.health = Math.max(0, enemy.health - 4); gameState.log.push({ type: 'battlecry', effect: 'deal_2_or_4_hero', fromPlayer: playerIndex, toPlayer: enemyIdx, damage: 4 }); } } else if (id === 'deal_2_all_enemy_minions') { enemies.forEach((enemy) => { if (enemy.board) { enemy.board.forEach((m) => { m.health = Math.max(0, m.health - 2); if (m.health <= 0) { const card = cardDb[m.cardId]; if (card && card.deathrattleId) runDeathrattle(room, card, gameState.players.indexOf(enemy)); } }); enemy.board = enemy.board.filter((m) => m.health > 0); } }); gameState.log.push({ type: 'battlecry', effect: 'deal_2_all_enemy_minions', fromPlayer: playerIndex }); } else if (id === 'heal_all_friendly_2') { p.board.forEach((m) => { m.health = Math.min(m.maxHealth || m.health, m.health + 2); }); gameState.log.push({ type: 'battlecry', effect: 'heal_all_friendly_2', playerIndex }); } else if (id === 'heal_all_5') { p.health = Math.min(30, p.health + 5); p.board.forEach((m) => { m.health = Math.min(m.maxHealth || m.health, m.health + 5); }); gameState.log.push({ type: 'battlecry', effect: 'heal_all_5', playerIndex }); } else if (id === 'destroy_strong_minion' && enemies.length) { const enemy = enemies[Math.floor(Math.random() * enemies.length)]; const enemyIdx = gameState.players.indexOf(enemy); if (enemy.board && enemy.board.length > 0) { const strong = enemy.board.filter(m => m.attack <= 5); if (strong.length > 0) { const target = strong[Math.floor(Math.random() * strong.length)]; const boardIdx = enemy.board.indexOf(target); const targetCard = cardDb[target.cardId]; enemy.board.splice(boardIdx, 1); gameState.log.push({ type: 'battlecry', effect: 'destroy_strong_minion', fromPlayer: playerIndex, toPlayer: enemyIdx, toIdx: boardIdx }); if (targetCard && targetCard.deathrattleId) { runDeathrattle(room, targetCard, enemyIdx); } } } } else if (id === 'buff_all_friendly_1_1') { p.board.forEach((m) => { m.attack += 1; m.health += 1; m.maxHealth = (m.maxHealth || m.health) + 1; }); gameState.log.push({ type: 'battlecry', effect: 'buff_all_friendly_1_1', playerIndex }); } } function runDeathrattle(room, card, ownerIndex) { if (!card) return; // Проверка на превращение при смерти if (card.transformOnDeath) { handleTransformOnDeath(room, card, ownerIndex); } if (!card.deathrattleId) return; const gameState = room.gameState; const owner = gameState.players[ownerIndex]; const enemies = gameState.players.filter((pl, i) => i !== ownerIndex && pl.health > 0); const id = card.deathrattleId; if (id === 'draw_1') { const drawn = drawCards(owner.deck, 1); if (drawn.length) owner.hand.push(...drawn); gameState.log.push({ type: 'deathrattle', effect: 'draw_1', playerIndex: ownerIndex }); } else if (id === 'deal_2_all_enemies') { enemies.forEach((e) => { e.health = Math.max(0, e.health - 2); }); gameState.log.push({ type: 'deathrattle', effect: 'deal_2_all_enemies', fromPlayer: ownerIndex }); } else if (id === 'deal_1_random_enemy' && enemies.length) { const t = enemies[Math.floor(Math.random() * enemies.length)]; t.health = Math.max(0, t.health - 1); gameState.log.push({ type: 'deathrattle', effect: 'deal_1_random_enemy', fromPlayer: ownerIndex }); } else if (id === 'heal_hero_2') { owner.health = Math.min(30, owner.health + 2); gameState.log.push({ type: 'deathrattle', effect: 'heal_hero_2', playerIndex: ownerIndex }); } else if (id === 'draw_3') { const drawn = drawCards(owner.deck, 3); if (drawn.length) owner.hand.push(...drawn); gameState.log.push({ type: 'deathrattle', effect: 'draw_3', playerIndex: ownerIndex }); } else if (id === 'deal_4_random' && enemies.length) { const t = enemies[Math.floor(Math.random() * enemies.length)]; t.health = Math.max(0, t.health - 4); gameState.log.push({ type: 'deathrattle', effect: 'deal_4_random', fromPlayer: ownerIndex, toPlayer: gameState.players.indexOf(t), damage: 4 }); } else if (id === 'deal_3_random' && enemies.length) { const t = enemies[Math.floor(Math.random() * enemies.length)]; t.health = Math.max(0, t.health - 3); gameState.log.push({ type: 'deathrattle', effect: 'deal_3_random', fromPlayer: ownerIndex, toPlayer: gameState.players.indexOf(t), damage: 3 }); } } function handleTransformOnDeath(room, card, ownerIndex) { if (card.transformOnDeath) { const owner = room.gameState.players[ownerIndex]; const minionOnBoard = owner.board.find(m => { const mCard = cardDb[m.cardId]; return mCard && (mCard.id === card.id || mCard.name === card.name); }); if (minionOnBoard) { minionOnBoard.cardId = card.transformOnDeath; minionOnBoard.health = Math.max(1, minionOnBoard.health); const newCard = cardDb[card.transformOnDeath]; if (newCard) { minionOnBoard.maxHealth = newCard.health; minionOnBoard.attack = newCard.attack; room.gameState.log.push({ type: 'transform', fromCard: card.name, toCard: newCard.name, playerIndex: ownerIndex }); } } } } function handleTransformOnDeath(room, card, ownerIndex) { if (card.transformOnDeath) { const owner = room.gameState.players[ownerIndex]; const minionOnBoard = owner.board.find(m => m.cardId === card.id || m.cardId === Object.keys(cardDb).find(k => cardDb[k] === card)); if (minionOnBoard) { minionOnBoard.cardId = card.transformOnDeath; minionOnBoard.health = Math.max(1, minionOnBoard.health); const newCard = cardDb[card.transformOnDeath]; if (newCard) { minionOnBoard.maxHealth = newCard.health; minionOnBoard.attack = newCard.attack; room.gameState.log.push({ type: 'transform', fromCard: card.id || card.name, toCard: card.transformOnDeath, playerIndex: ownerIndex }); } } } } function checkGameOver(room) { const gameState = room.gameState; const alive = gameState.players.filter((pp) => pp.health > 0); const dead = gameState.players.filter((pp) => pp.health <= 0); // Помечаем мёртвых игроков dead.forEach((p) => { if (p.health <= 0 && !p.isDead) { p.isDead = true; gameState.log.push({ type: 'playerDefeated', playerIndex: gameState.players.indexOf(p), playerName: p.name }); } }); // Если остался один или меньше живых - игра окончена if (alive.length <= 1) { gameState.phase = 'ended'; gameState.winner = alive.length === 1 ? alive[0] : null; } // Если текущий игрок мёртв, переключаем ход const currentPlayer = gameState.players[gameState.currentPlayerIndex]; if (currentPlayer && currentPlayer.health <= 0 && alive.length > 1) { // Пропускаем ход мёртвого игрока gameState.log.push({ type: 'skipTurn', playerIndex: gameState.currentPlayerIndex, reason: 'defeated' }); endTurn(room); } } function playCard(room, socketId, handIndex, boardPos) { const gameState = room.gameState; const pi = findPlayerIndex(room, socketId); if (pi < 0 || gameState.currentPlayerIndex !== pi) return; const p = gameState.players[pi]; if (p.health <= 0 || p.isDead) return; // Мёртвый игрок не может играть const cid = p.hand[handIndex]; if (!cid) return; const card = cardDb[cid]; if (!card || card.type !== 'minion' || p.board.length >= 7) return; const cost = card.cost || 0; if (p.mana < cost) return; p.mana -= cost; p.hand.splice(handIndex, 1); const minion = { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, cardId: cid, attack: card.attack, health: card.health, maxHealth: card.health, canAttack: card.attack > 0, }; p.board.splice(typeof boardPos === 'number' ? boardPos : p.board.length, 0, minion); gameState.log.push({ type: 'play', playerIndex: pi, cardId: cid, minionId: minion.id }); if (card.battlecryId) runBattlecry(room, card, pi); checkGameOver(room); broadcastGameState(room); } function playSpell(room, socketId, handIndex, targetPlayerIndex, targetBoardIndex) { const gameState = room.gameState; const pi = findPlayerIndex(room, socketId); if (pi < 0 || gameState.currentPlayerIndex !== pi) return; const p = gameState.players[pi]; if (p.health <= 0 || p.isDead) return; // Мёртвый игрок не может играть const cid = p.hand[handIndex]; if (!cid) return; const card = cardDb[cid]; if (!card || card.type !== 'spell') return; const cost = card.cost || 0; if (p.mana < cost) return; const eff = card.spellEffect; const needTarget = card.spellTarget && card.spellTarget !== 'none'; if (needTarget && (targetPlayerIndex == null || targetBoardIndex == null)) return; const enemies = gameState.players.filter((pl, i) => i !== pi && pl.health > 0); if (eff === 'deal_2') { if (targetBoardIndex === -1) { const t = gameState.players[targetPlayerIndex]; if (!t || t.health <= 0) return; t.health = Math.max(0, t.health - 2); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, effect: 'deal_2_hero', damage: 2 }); } else { const t = gameState.players[targetPlayerIndex]; const m = t?.board?.[targetBoardIndex]; if (!m) return; m.health -= 2; gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'deal_2_minion', damage: 2 }); if (m.health <= 0) { const card = cardDb[m.cardId]; if (card && card.deathrattleId) runDeathrattle(room, card, targetPlayerIndex); } } } else if (eff === 'draw_2') { const drawn = drawCards(p.deck, 2); if (drawn.length) p.hand.push(...drawn); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, effect: 'draw_2' }); } else if (eff === 'buff_2_2') { const m = p.board[targetBoardIndex]; if (!m) return; m.attack += 2; m.health += 2; m.maxHealth = (m.maxHealth || m.health) + 2; gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toIdx: targetBoardIndex, effect: 'buff_2_2' }); } else if (eff === 'deal_1_all_enemies') { gameState.players.forEach((pl, i) => { if (i === pi) return; pl.health = Math.max(0, pl.health - 1); pl.board.forEach((m) => { m.health -= 1; if (m.health <= 0) { const card = cardDb[m.cardId]; if (card && card.deathrattleId) runDeathrattle(room, card, i); } }); pl.board = pl.board.filter((m) => m.health > 0); }); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, effect: 'deal_1_all_enemies' }); } else if (eff === 'freeze') { const t = gameState.players[targetPlayerIndex]; const m = t?.board?.[targetBoardIndex]; if (!m) return; m.frozen = true; gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'freeze' }); } else if (eff === 'return_hand') { const t = gameState.players[targetPlayerIndex]; const m = t?.board?.[targetBoardIndex]; if (!m) return; t.board.splice(targetBoardIndex, 1); t.hand.push(m.cardId); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'return_hand' }); } else if (eff === 'deal_4') { if (targetBoardIndex === -1) { const t = gameState.players[targetPlayerIndex]; if (!t || t.health <= 0) return; t.health = Math.max(0, t.health - 4); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, effect: 'deal_4_hero', damage: 4 }); } else { const t = gameState.players[targetPlayerIndex]; const m = t?.board?.[targetBoardIndex]; if (!m) return; m.health = Math.max(0, m.health - 4); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'deal_4_minion', damage: 4 }); if (m.health <= 0) { t.board.splice(targetBoardIndex, 1); const card = cardDb[m.cardId]; if (card && card.deathrattleId) runDeathrattle(room, card, targetPlayerIndex); } } } else if (eff === 'heal_4') { if (targetBoardIndex === -1) { const t = gameState.players[targetPlayerIndex]; if (!t) return; t.health = Math.min(30, t.health + 4); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, effect: 'heal_4_hero' }); } else { const t = gameState.players[targetPlayerIndex]; const m = t?.board?.[targetBoardIndex]; if (!m) return; m.health = Math.min(m.maxHealth || m.health, m.health + 4); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'heal_4_minion' }); } } else if (eff === 'deal_3_minion') { const t = gameState.players[targetPlayerIndex]; const m = t?.board?.[targetBoardIndex]; if (!m) return; m.health = Math.max(0, m.health - 3); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'deal_3_minion', damage: 3 }); if (m.health <= 0) { t.board.splice(targetBoardIndex, 1); const card = cardDb[m.cardId]; if (card && card.deathrattleId) runDeathrattle(room, card, targetPlayerIndex); } } else if (eff === 'freeze_damage') { const t = gameState.players[targetPlayerIndex]; const m = t?.board?.[targetBoardIndex]; if (!m) return; m.frozen = true; m.health = Math.max(0, m.health - 2); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'freeze_damage', damage: 2 }); if (m.health <= 0) { t.board.splice(targetBoardIndex, 1); const card = cardDb[m.cardId]; if (card && card.deathrattleId) runDeathrattle(room, card, targetPlayerIndex); } } else if (eff === 'buff_all_friendly') { p.board.forEach((m) => { m.attack += 1; m.health += 1; m.maxHealth = (m.maxHealth || m.health) + 1; }); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, effect: 'buff_all_friendly' }); } else if (eff === 'destroy_weak_2') { enemies.forEach((enemy) => { if (enemy.board && enemy.board.length > 0) { const weak = enemy.board.filter(m => m.attack <= 2); weak.forEach((target) => { const boardIdx = enemy.board.indexOf(target); const targetCard = cardDb[target.cardId]; enemy.board.splice(boardIdx, 1); if (targetCard && targetCard.deathrattleId) { runDeathrattle(room, targetCard, gameState.players.indexOf(enemy)); } }); } }); gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, effect: 'destroy_weak_2' }); } else if (eff === 'buff_3_2') { const m = p.board[targetBoardIndex]; if (!m) return; m.attack += 3; m.health += 2; m.maxHealth = (m.maxHealth || m.health) + 2; gameState.log.push({ type: 'spell', spell: cid, fromPlayer: pi, toIdx: targetBoardIndex, effect: 'buff_3_2' }); } else return; p.mana -= cost; p.hand.splice(handIndex, 1); checkGameOver(room); broadcastGameState(room); } function heroAbility(room, socketId, targetPlayerIndex, targetBoardIndex) { const gameState = room.gameState; const pi = findPlayerIndex(room, socketId); if (pi < 0 || gameState.currentPlayerIndex !== pi) return; const p = gameState.players[pi]; if (p.health <= 0 || p.isDead) return; // Мёртвый игрок не может играть if (p.heroAbilityUsed || p.mana < 2) return; if (targetPlayerIndex == null || targetBoardIndex == null) return; const targetPlayer = gameState.players[targetPlayerIndex]; if (!targetPlayer) return; if (targetBoardIndex === -1) { targetPlayer.health = Math.max(0, targetPlayer.health - 1); gameState.log.push({ type: 'heroAbility', fromPlayer: pi, toPlayer: targetPlayerIndex, effect: 'deal_1_hero' }); } else { const m = targetPlayer.board[targetBoardIndex]; if (!m) return; m.health -= 1; gameState.log.push({ type: 'heroAbility', fromPlayer: pi, toPlayer: targetPlayerIndex, toIdx: targetBoardIndex, effect: 'deal_1_minion' }); } p.mana -= 2; p.heroAbilityUsed = true; if (targetBoardIndex !== -1) { const m = targetPlayer.board[targetBoardIndex]; if (m && m.health <= 0) { const card = cardDb[m.cardId]; if (card && card.deathrattleId) runDeathrattle(room, card, targetPlayerIndex); } } gameState.players.forEach((pl) => { pl.board = pl.board.filter((min) => min.health > 0); }); checkGameOver(room); broadcastGameState(room); } function attack(room, socketId, attackerBoardIndex, targetPlayerIndex, targetBoardIndex) { const gameState = room.gameState; const pi = findPlayerIndex(room, socketId); if (pi < 0 || gameState.currentPlayerIndex !== pi) return; const p = gameState.players[pi]; if (p.health <= 0 || p.isDead) return; // Мёртвый игрок не может атаковать const attacker = p.board[attackerBoardIndex]; if (!attacker || !attacker.canAttack) return; const targetPlayer = gameState.players[targetPlayerIndex]; if (!targetPlayer) return; if (targetBoardIndex === -1) { targetPlayer.health = Math.max(0, targetPlayer.health - attacker.attack); gameState.log.push({ type: 'attackHero', fromPlayer: pi, toPlayer: targetPlayerIndex, attackerMinionId: attacker.id, damage: attacker.attack, }); checkGameOver(room); // Проверяем после атаки по герою } else { const target = targetPlayer.board[targetBoardIndex]; if (!target) return; target.health -= attacker.attack; attacker.health -= target.attack; const attackerDied = attacker.health <= 0; const targetDied = target.health <= 0; gameState.log.push({ type: 'attack', fromPlayer: pi, toPlayer: targetPlayerIndex, fromIdx: attackerBoardIndex, toIdx: targetBoardIndex, attackerMinionId: attacker.id, targetMinionId: target.id, attackerDied, targetDied, damage: attacker.attack, reverseDamage: target.attack, }); const targetCard = cardDb[target.cardId]; const attackerCard = cardDb[attacker.cardId]; if (targetDied && targetCard && targetCard.deathrattleId) { runDeathrattle(room, targetCard, targetPlayerIndex); } if (attackerDied && attackerCard && attackerCard.deathrattleId) { runDeathrattle(room, attackerCard, pi); } } // Механика двойной атаки const attackerCard = cardDb[attacker.cardId]; if (attackerCard && attackerCard.canAttackTwice && !attacker.attacksUsed) { attacker.attacksUsed = 1; // Использована одна атака } else { attacker.canAttack = false; attacker.attacksUsed = 0; } [...gameState.players].forEach((pl) => { pl.board = pl.board.filter((m) => m.health > 0); }); checkGameOver(room); broadcastGameState(room); } io.on('connection', (socket) => { socket.on('createRoom', (name) => { const code = generateRoomCode(); const room = { code, lobby: [{ id: socket.id, name: name || 'Host' }], gameState: null, gameStarted: false, faction: null, turnTimerInterval: null, turnTimeLeft: TURN_SECONDS, turnTimerWarnFired: false, }; rooms.set(code, room); socket.join(code); socket.emit('roomCreated', { code, you: room.lobby[0], lobby: room.lobby, serverIP: serverIP, serverPort: serverPort, }); cleanupEmptyRooms(); }); socket.on('joinRoom', (data) => { const { code, name } = data || {}; if (!code) { socket.emit('error', 'Код комнаты не указан'); return; } const room = getRoomByCode(code); if (!room) { socket.emit('error', 'Комната не найдена'); return; } if (room.gameStarted) { socket.emit('error', 'Игра уже началась'); return; } if (room.lobby.length >= MAX_PLAYERS) { socket.emit('error', 'Комната заполнена'); return; } room.lobby.push({ id: socket.id, name: name || `Player ${room.lobby.length + 1}` }); socket.join(code); io.to(code).emit('lobbyUpdate', room.lobby); socket.emit('roomJoined', { code, you: room.lobby[room.lobby.length - 1], lobby: room.lobby }); cleanupEmptyRooms(); }); socket.on('leaveLobby', () => { const room = getRoomBySocket(socket.id); if (!room) return; room.lobby = room.lobby.filter((p) => p.id !== socket.id); socket.leave(room.code); if (room.lobby.length === 0 && !room.gameStarted) { clearTurnTimer(room); rooms.delete(room.code); } else { io.to(room.code).emit('lobbyUpdate', room.lobby); } cleanupEmptyRooms(); }); socket.on('startGame', (data) => { const room = getRoomBySocket(socket.id); if (!room) { socket.emit('error', 'Комната не найдена'); return; } if (room.lobby[0]?.id !== socket.id) { socket.emit('error', 'Только хост может начать игру'); return; } if (room.lobby.length < MIN_PLAYERS) { socket.emit('error', `Нужно минимум ${MIN_PLAYERS} игроков`); return; } const validFactions = ['rebellion', 'empire', 'pirates', 'mandalorians']; const faction = data?.faction && validFactions.includes(data.faction) ? data.faction : null; room.faction = faction; initGame(room); }); socket.on('playCard', (data) => { const room = getRoomBySocket(socket.id); if (!room || !room.gameState || room.gameState.phase !== 'playing') return; playCard(room, socket.id, data.handIndex, data.boardPos); }); socket.on('playSpell', (data) => { const room = getRoomBySocket(socket.id); if (!room || !room.gameState || room.gameState.phase !== 'playing') return; playSpell(room, socket.id, data.handIndex, data.targetPlayerIndex, data.targetBoardIndex); }); socket.on('heroAbility', (data) => { const room = getRoomBySocket(socket.id); if (!room || !room.gameState || room.gameState.phase !== 'playing') return; heroAbility(room, socket.id, data.targetPlayerIndex, data.targetBoardIndex); }); socket.on('attack', (data) => { const room = getRoomBySocket(socket.id); if (!room || !room.gameState || room.gameState.phase !== 'playing') return; attack( room, socket.id, data.attackerBoardIndex, data.targetPlayerIndex, data.targetBoardIndex ?? -1 ); }); socket.on('endTurn', () => { const room = getRoomBySocket(socket.id); if (!room || !room.gameState || room.gameState.phase !== 'playing') return; if (findPlayerIndex(room, socket.id) !== room.gameState.currentPlayerIndex) return; endTurn(room); }); socket.on('drawCard', () => { const room = getRoomBySocket(socket.id); if (!room || !room.gameState || room.gameState.phase !== 'playing') return; manualDraw(room, socket.id); }); socket.on('forgeCard', (data) => { const room = getRoomBySocket(socket.id); if (!room || !room.gameState || room.gameState.phase !== 'playing') return; forgeCard(room, socket.id, data.cardIds); }); socket.on('resetToLobby', () => { const room = getRoomBySocket(socket.id); if (!room || !room.gameState || !room.gameState.players?.length) return; const hostId = room.gameState.players[0].id; if (socket.id !== hostId) return; clearTurnTimer(room); room.lobby = room.gameState.players.map((p) => ({ id: p.id, name: p.name })); room.gameState = null; room.gameStarted = false; io.to(room.code).emit('backToLobby'); }); socket.on('disconnect', () => { const room = getRoomBySocket(socket.id); if (!room) return; if (!room.gameStarted) { room.lobby = room.lobby.filter((p) => p.id !== socket.id); socket.leave(room.code); if (room.lobby.length === 0) { clearTurnTimer(room); rooms.delete(room.code); } else { io.to(room.code).emit('lobbyUpdate', room.lobby); } cleanupEmptyRooms(); return; } if (room.gameState) { const idx = findPlayerIndex(room, socket.id); if (idx >= 0) { room.gameState.players[idx].health = 0; const alive = room.gameState.players.filter((p) => p.health > 0); if (alive.length <= 1) { room.gameState.phase = 'ended'; room.gameState.winner = alive[0] || null; } broadcastGameState(room); } } }); }); let serverIP = 'localhost'; let serverPort = PORT; server.listen(PORT, '0.0.0.0', () => { const os = require('os'); const nets = os.networkInterfaces(); let lan = 'localhost'; for (const name of Object.keys(nets)) { for (const n of nets[name]) { if (n.family === 'IPv4' && !n.internal) { lan = n.address; break; } } } serverIP = lan; serverPort = PORT; console.log(` ╔══════════════════════════════════════════════════════════╗ ║ STAR WARS HEARTHSTONE - Server running ║ ║ Local: http://localhost:${PORT} ║ ║ LAN: http://${lan}:${PORT} (use for Radmin VPN) ║ ╚══════════════════════════════════════════════════════════╝ `); });