1235 lines
49 KiB
JavaScript
1235 lines
49 KiB
JavaScript
/**
|
||
* 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) ║
|
||
╚══════════════════════════════════════════════════════════╝
|
||
`);
|
||
});
|