import React, { useState, useEffect, useCallback, useMemo } from 'react'; export default function App() { // Game constants - FIXED CENTER POSITION const GRID_SIZE = 41; const CELL_SIZE = 20; // Reduced for better fit on screen const CENTER = 20; // Fixed: 41x41 grid center is at index 20 (0-40) const MAX_TURNS = 50; const ENEMIES_PER_EDGE = 4; const EDGES = ['top', 'bottom', 'left', 'right']; // Unit images const UNIT_IMAGES = { player: { swordsman: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/12113b933-b87e-4d4b-a18a-4f60b59bb68f.png', archer: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/11be387d4-127e-4c62-8c4f-fd5a4c88f6f0.png', cavalry: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/1c13e4fab-616b-4856-b6ad-901c050c84a5.png', healer: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/1ff55a4a7-2f9c-406c-8fb8-5bece454992c.png', mage: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/118f5c4e5-9f86-4714-99bb-9f83fe3905fa.png', worker: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/1af5f1e98-fa5c-4258-9c30-0543a614796a.png' }, enemy: { skeletonSwordsman: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/145342a77-3351-46db-ba8e-5258e6465c11.png', skeletonArcher: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/14425a419-dc2e-4d30-b42b-7da68d8d0ffd.png', zombie: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/1453faeaf-df10-4e28-8922-3e18b82e85da.png', slime: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/1286de95c-b437-41a0-891f-0e231381ed1d.png' }, terrain: { campfire: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/1bcee79c2-fd49-4bd3-a164-fc1cb086d316.png', barricade: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/181c8c691-5a90-4542-a13a-ab03bd83c9d3.png', grass: 'https://image.qwenlm.ai/public_source/0910f5fb-4456-42ce-9169-ab5e04fda3bb/1a6cfa346-bf9f-407b-a8a1-aaca50b321c8.png' } }; // Unit types configuration with damage values - REBALANCED ENEMIES const UNIT_TYPES = { player: { swordsman: { name: 'Мечник', hp: 40, moveRange: 3, attack: 5, attackRange: 1, ability: '50% шанс заблокировать урон щитом', color: 'bg-blue-500', image: UNIT_IMAGES.player.swordsman, abilityType: 'defense' }, archer: { name: 'Стрелок', hp: 15, moveRange: 5, attack: 7, attackRange: 5, ability: '30% шанс нанести двойной урон', color: 'bg-green-500', image: UNIT_IMAGES.player.archer, abilityType: 'attack' }, cavalry: { name: 'Кавалерист', hp: 30, moveRange: 10, attack: 6, attackRange: 1, ability: '40% шанс оглушить врага', color: 'bg-yellow-500', image: UNIT_IMAGES.player.cavalry, abilityType: 'stun' }, healer: { name: 'Хиллер', hp: 5, moveRange: 3, attack: 1, attackRange: 1, ability: 'В конце хода исцеляет самого раненого союзника на 10 ХП (тратит атаку)', color: 'bg-purple-500', image: UNIT_IMAGES.player.healer, abilityType: 'heal' }, mage: { name: 'Маг', hp: 10, moveRange: 3, attack: 10, attackRange: 5, ability: '20% шанс мгновенно убить врага', color: 'bg-indigo-500', image: UNIT_IMAGES.player.mage, abilityType: 'instakill' }, worker: { name: 'Рабочий', hp: 5, moveRange: 4, attack: 1, attackRange: 1, ability: 'Может строить баррикады (5 ХП) на своей позиции', color: 'bg-gray-500', image: UNIT_IMAGES.player.worker, abilityType: 'build' } }, enemy: { skeletonSwordsman: { name: 'Скелет-мечник', hp: 5, // REBALANCED: Reduced from 15 to 5 moveRange: 5, attack: 4, attackRange: 1, ability: '30% шанс уклониться от атаки', color: 'bg-red-500', image: UNIT_IMAGES.enemy.skeletonSwordsman, abilityType: 'dodge' }, skeletonArcher: { name: 'Скелет-лучник', hp: 5, // REBALANCED: Reduced from 15 to 5 moveRange: 5, attack: 6, attackRange: 5, ability: '30% шанс уклониться от атаки', color: 'bg-orange-500', image: UNIT_IMAGES.enemy.skeletonArcher, abilityType: 'dodge' }, zombie: { name: 'Зомби', hp: 20, // REBALANCED: Reduced from 30 to 20 moveRange: 3, attack: 5, attackRange: 1, ability: '50% шанс отравить при атаке (2 урона 4 хода)', color: 'bg-lime-700', image: UNIT_IMAGES.enemy.zombie, abilityType: 'poison' }, slime: { name: 'Поганище', hp: 35, // REBALANCED: Reduced from 50 to 35 moveRange: 4, attack: 10, attackRange: 1, ability: 'Атакует всех вокруг себя', color: 'bg-pink-600', image: UNIT_IMAGES.enemy.slime, abilityType: 'aoe' } } }; // Game state const [grid, setGrid] = useState([]); const [units, setUnits] = useState({}); const [turn, setTurn] = useState(0); const [gamePhase, setGamePhase] = useState('setup'); // setup, player, enemy, gameOver, victory const [selectedUnit, setSelectedUnit] = useState(null); const [possibleMoves, setPossibleMoves] = useState([]); const [possibleAttacks, setPossibleAttacks] = useState([]); const [attackRangeCells, setAttackRangeCells] = useState([]); // Show attack range area const [log, setLog] = useState([]); const [enemySpawnCounts, setEnemySpawnCounts] = useState({}); // Initialize grid and units useEffect(() => { const initialGrid = Array(GRID_SIZE).fill().map(() => Array(GRID_SIZE).fill(null)); // Place campfire at center (FIXED POSITION) - mark as permanent initialGrid[CENTER][CENTER] = { type: 'campfire', image: UNIT_IMAGES.terrain.campfire, permanent: true }; // Place initial player units around campfire const initialUnits = {}; const startingPositions = [ { x: CENTER, y: CENTER - 2, type: 'swordsman' }, { x: CENTER - 2, y: CENTER, type: 'archer' }, { x: CENTER + 2, y: CENTER, type: 'cavalry' }, { x: CENTER, y: CENTER + 2, type: 'healer' }, { x: CENTER - 1, y: CENTER - 1, type: 'mage' }, { x: CENTER + 1, y: CENTER + 1, type: 'worker' } ]; startingPositions.forEach((pos, index) => { const id = `player-${pos.type}-${index}`; initialUnits[id] = { id, type: pos.type, faction: 'player', ...UNIT_TYPES.player[pos.type], hp: UNIT_TYPES.player[pos.type].hp, maxHp: UNIT_TYPES.player[pos.type].hp, position: { x: pos.x, y: pos.y }, experience: 0, level: 1, poisoned: 0, stunned: false, hasMoved: false, hasAttacked: false }; initialGrid[pos.y][pos.x] = { type: 'unit', id }; }); setGrid(initialGrid); setUnits(initialUnits); setGamePhase('player'); addToLog('Игра началась! Защитите лагерь от врагов.'); }, []); // Reset unit action flags at start of player turn useEffect(() => { if (gamePhase === 'player') { setUnits(prev => { const newUnits = {...prev}; Object.values(newUnits).forEach(unit => { if (unit.faction === 'player') { unit.hasMoved = false; unit.hasAttacked = false; } }); return newUnits; }); setSelectedUnit(null); setPossibleMoves([]); setPossibleAttacks([]); setAttackRangeCells([]); } }, [gamePhase]); // Add message to game log (without selection messages) const addToLog = useCallback((message) => { // Skip messages about unit selection if (message.includes('Выбран')) return; setLog(prev => { const newLog = [message, ...prev]; return newLog.slice(0, 20); // Keep only last 20 messages }); }, []); // Calculate possible moves for a unit (Added diagonal movement) const calculatePossibleMoves = useCallback((unit) => { if (!unit || unit.hasMoved) return []; const { x, y } = unit.position; const moves = []; const visited = new Set(); const queue = [{ x, y, steps: 0 }]; while (queue.length > 0) { const current = queue.shift(); const key = `${current.x},${current.y}`; if (visited.has(key)) continue; visited.add(key); if (current.steps > 0) { moves.push({ x: current.x, y: current.y }); } if (current.steps < unit.moveRange) { // Added diagonal movement (8 directions) const neighbors = [ { x: current.x, y: current.y - 1 }, // up { x: current.x, y: current.y + 1 }, // down { x: current.x - 1, y: current.y }, // left { x: current.x + 1, y: current.y }, // right { x: current.x - 1, y: current.y - 1 }, // up-left { x: current.x + 1, y: current.y - 1 }, // up-right { x: current.x - 1, y: current.y + 1 }, // down-left { x: current.x + 1, y: current.y + 1 } // down-right ]; neighbors.forEach(neighbor => { if ( neighbor.x >= 0 && neighbor.x < GRID_SIZE && neighbor.y >= 0 && neighbor.y < GRID_SIZE && !visited.has(`${neighbor.x},${neighbor.y}`) ) { const cell = grid[neighbor.y][neighbor.x]; // Can move to empty cells // Cannot move through other units // Allies can move through barricades and campfire, enemies cannot let canMove = false; if (!cell) { canMove = true; } else if (cell.type === 'campfire') { canMove = true; } else if (cell.type === 'barricade' && unit.faction === 'player') { canMove = true; // Allies can move through barricades } if (canMove) { queue.push({ ...neighbor, steps: current.steps + 1 }); } } }); } } return moves; }, [grid]); // FIXED: Calculate possible attacks with diagonal support using Chebyshev distance const calculatePossibleAttacks = useCallback((unit) => { if (!unit || unit.hasAttacked) return []; const { x, y } = unit.position; const attacks = []; for (let dy = -unit.attackRange; dy <= unit.attackRange; dy++) { for (let dx = -unit.attackRange; dx <= unit.attackRange; dx++) { const targetX = x + dx; const targetY = y + dy; if ( targetX >= 0 && targetX < GRID_SIZE && targetY >= 0 && targetY < GRID_SIZE && Math.max(Math.abs(dx), Math.abs(dy)) <= unit.attackRange && // FIXED: Chebyshev distance for diagonal attacks !(dx === 0 && dy === 0) ) { const cell = grid[targetY][targetX]; if (cell && cell.type === 'unit') { const targetUnit = units[cell.id]; if (targetUnit && targetUnit.faction !== unit.faction) { attacks.push({ x: targetX, y: targetY, id: cell.id }); } } // Allow attacking barricades (only enemies can attack barricades) if (cell && (cell.type === 'barricade' || cell.type === 'unit_and_barricade') && unit.faction === 'enemy') { attacks.push({ x: targetX, y: targetY, id: cell.id, isBarricade: true }); } } } } return attacks; }, [grid, units]); // FIXED: Calculate attack range area with diagonal support using Chebyshev distance const calculateAttackRangeCells = useCallback((unit) => { if (!unit) return []; const { x, y } = unit.position; const rangeCells = []; for (let dy = -unit.attackRange; dy <= unit.attackRange; dy++) { for (let dx = -unit.attackRange; dx <= unit.attackRange; dx++) { const targetX = x + dx; const targetY = y + dy; if ( targetX >= 0 && targetX < GRID_SIZE && targetY >= 0 && targetY < GRID_SIZE && Math.max(Math.abs(dx), Math.abs(dy)) <= unit.attackRange && // FIXED: Chebyshev distance !(dx === 0 && dy === 0) ) { rangeCells.push({ x: targetX, y: targetY }); } } } return rangeCells; }, []); // Handle unit selection const handleUnitSelect = useCallback((unitId) => { if (gamePhase !== 'player') return; const unit = units[unitId]; if (unit && unit.faction === 'player') { setSelectedUnit(unitId); setPossibleMoves(calculatePossibleMoves(unit)); setPossibleAttacks(calculatePossibleAttacks(unit)); setAttackRangeCells(calculateAttackRangeCells(unit)); // Don't log selection messages } }, [gamePhase, units, calculatePossibleMoves, calculatePossibleAttacks, calculateAttackRangeCells]); // Handle cell click for movement or attack const handleCellClick = useCallback((x, y) => { if (gamePhase !== 'player' || !selectedUnit) return; const unit = units[selectedUnit]; if (!unit) return; // Check if moving to this cell const isMoveTarget = possibleMoves.some(pos => pos.x === x && pos.y === y); if (isMoveTarget && !unit.hasMoved) { // Move unit setGrid(prev => { const newGrid = prev.map(row => [...row]); const oldPos = unit.position; const oldCell = newGrid[oldPos.y][oldPos.x]; // Clear old position: if it was unit_and_barricade, leave the barricade; else set to null // But preserve campfire if present if (oldCell && oldCell.type === 'campfire' && oldCell.permanent) { // Keep campfire and add unit to it newGrid[oldPos.y][oldPos.x] = { type: 'unit_and_campfire', unitId: unit.id, campfire: oldCell }; } else if (oldCell && oldCell.type === 'unit_and_barricade') { // Leave the barricade behind newGrid[oldPos.y][oldPos.x] = { type: 'barricade', id: oldCell.barricade.id, hp: oldCell.barricade.hp, maxHp: oldCell.barricade.maxHp, image: UNIT_IMAGES.terrain.barricade }; } else { newGrid[oldPos.y][oldPos.x] = null; } // Set new position const newCell = newGrid[y][x]; if (newCell && newCell.type === 'barricade' && unit.faction === 'player') { // Allies can occupy barricade cells - combine unit and barricade newGrid[y][x] = { type: 'unit_and_barricade', unitId: unit.id, barricade: { id: newCell.id, hp: newCell.hp, maxHp: newCell.maxHp, image: UNIT_IMAGES.terrain.barricade } }; } else if (newCell && newCell.type === 'campfire' && newCell.permanent) { // Unit moves onto campfire newGrid[y][x] = { type: 'unit_and_campfire', unitId: unit.id, campfire: newCell }; } else { newGrid[y][x] = { type: 'unit', id: unit.id }; } return newGrid; }); setUnits(prev => ({ ...prev, [unit.id]: { ...unit, position: { x, y }, hasMoved: true } })); // Recalculate possible attacks from new position const newUnit = { ...unit, position: { x, y } }; setPossibleAttacks(calculatePossibleAttacks(newUnit)); setAttackRangeCells(calculateAttackRangeCells(newUnit)); addToLog(`${unit.name} переместился в (${x}, ${y})`); return; } // Check if attacking this cell const attackTarget = possibleAttacks.find(pos => pos.x === x && pos.y === y); if (attackTarget && !unit.hasAttacked) { if (attackTarget.isBarricade) { // Attack barricade handleBarricadeAttack(unit, attackTarget.id, x, y); } else { // Attack unit handleAttack(unit, units[attackTarget.id]); } // Mark unit as having attacked setUnits(prev => { const newUnits = {...prev}; if (newUnits[unit.id]) { newUnits[unit.id] = { ...newUnits[unit.id], hasAttacked: true }; } return newUnits; }); setSelectedUnit(null); setPossibleMoves([]); setPossibleAttacks([]); setAttackRangeCells([]); return; } }, [gamePhase, selectedUnit, units, possibleMoves, possibleAttacks, grid, calculatePossibleAttacks, calculateAttackRangeCells, addToLog]); // FIXED: Handle attack on barricade for both barricade types const handleBarricadeAttack = useCallback((attacker, cellId, x, y) => { setGrid(prev => { const newGrid = prev.map(row => [...row]); const cell = newGrid[y][x]; if (!cell) return newGrid; let barricade, isUnitAndBarricade = false; if (cell.type === 'barricade') { barricade = cell; } else if (cell.type === 'unit_and_barricade') { barricade = cell.barricade; isUnitAndBarricade = true; } else { return newGrid; } barricade.hp -= attacker.attack; if (barricade.hp <= 0) { // Remove barricade if (isUnitAndBarricade) { // Leave the unit behind newGrid[y][x] = { type: 'unit', id: cell.unitId }; } else { newGrid[y][x] = null; } addToLog(`${attacker.name} уничтожил баррикаду!`); } else { // Update barricade HP if (isUnitAndBarricade) { newGrid[y][x] = { ...cell, barricade: { ...barricade } }; } else { newGrid[y][x] = { ...cell, hp: barricade.hp }; } addToLog(`${attacker.name} нанес ${attacker.attack} урона баррикаде! Осталось ${barricade.hp}/${barricade.maxHp} HP`); } return newGrid; }); }, [addToLog]); // FIXED: Handle attack between units with proper ability activation AND COUNTERATTACK const handleAttack = useCallback((attacker, defender, isCounterAttack = false) => { if (!attacker || !defender) return; // Check if stunned if (attacker.stunned) { addToLog(`${attacker.name} оглушен и пропускает атаку!`); return; } let damage = attacker.attack; let instantKill = false; let poisonApplied = false; let shouldStun = false; let shouldDodge = false; let abilityMessage = ''; // Apply attacker abilities BEFORE damage calculation (only on main attack, not counterattack) if (!isCounterAttack) { if (attacker.type === 'archer' && Math.random() < 0.3) { damage *= 2; abilityMessage = ' (двойной урон!)'; } else if (attacker.type === 'mage' && Math.random() < 0.2) { instantKill = true; abilityMessage = ' (мгновенное убийство!)'; } else if (attacker.type === 'cavalry' && Math.random() < 0.4) { shouldStun = true; abilityMessage = ' (оглушение!)'; } else if (attacker.type === 'zombie' && Math.random() < 0.5) { poisonApplied = true; abilityMessage = ' (отравление!)'; } } // Apply defender abilities BEFORE damage calculation (only on main attack, not counterattack) if (!isCounterAttack) { if (defender.type === 'swordsman' && Math.random() < 0.5) { addToLog(`🛡️ Мечник заблокировал атаку щитом!`); damage = 0; } else if ((defender.type === 'skeletonSwordsman' || defender.type === 'skeletonArcher') && Math.random() < 0.3) { addToLog(`🎯 ${defender.name} уклонился от атаки!`); damage = 0; shouldDodge = true; } } // Apply damage if (instantKill) { damage = defender.hp; // Kill instantly } const newDefenderHp = Math.max(0, defender.hp - damage); const isDead = newDefenderHp <= 0; // Prepare attack message let attackMessage = ''; if (isCounterAttack) { attackMessage = `${attacker.name} контратакует ${defender.name}!`; } else { attackMessage = `${attacker.name} атакует ${defender.name}!`; } if (!instantKill && damage > 0) { attackMessage += ` Наносит ${damage} урона${abilityMessage}, осталось ${newDefenderHp}/${defender.maxHp} HP`; } else if (instantKill) { attackMessage += ` Наносит ${damage} урона${abilityMessage}`; } else if (damage === 0) { attackMessage += ` Не наносит урона${abilityMessage}`; } addToLog(attackMessage); // Update defender setUnits(prev => { const newUnits = {...prev}; if (isDead) { // Remove unit from grid setGrid(grid => { const newGrid = grid.map(row => [...row]); const pos = defender.position; const cell = newGrid[pos.y][pos.x]; // Restore underlying terrain if unit was on something if (cell && cell.type === 'unit_and_barricade') { newGrid[pos.y][pos.x] = { type: 'barricade', id: cell.barricade.id, hp: cell.barricade.hp, maxHp: cell.barricade.maxHp, image: UNIT_IMAGES.terrain.barricade }; } else if (cell && cell.type === 'unit_and_campfire') { newGrid[pos.y][pos.x] = cell.campfire; // Restore campfire } else { newGrid[pos.y][pos.x] = null; } return newGrid; }); delete newUnits[defender.id]; addToLog(`${defender.name} убит!`); // Add kill experience to attacker (including counterattacks) if (newUnits[attacker.id]) { const oldExp = newUnits[attacker.id].experience; newUnits[attacker.id] = { ...newUnits[attacker.id], experience: oldExp + 10 }; addToLog(`⭐ ${attacker.name} получил 10 опыта за убийство!`); newUnits[attacker.id] = checkLevelUpInternal(newUnits[attacker.id]); } } else { newUnits[defender.id] = { ...defender, hp: newDefenderHp, poisoned: poisonApplied ? 4 : defender.poisoned, // 4 turns of poison stunned: shouldStun ? true : defender.stunned // Apply stun }; // Add experience to defender for taking damage (unless dodged) // FIXED: Give experience for EVERY attack, not just main attacks if (!shouldDodge) { const oldExp = newUnits[defender.id].experience; newUnits[defender.id] = { ...newUnits[defender.id], experience: oldExp + 1 }; addToLog(`🛡️ ${defender.name} получил 1 опыт за полученный урон!`); newUnits[defender.id] = checkLevelUpInternal(newUnits[defender.id]); } } // Add attack experience to attacker (including counterattacks) if (newUnits[attacker.id]) { const oldExp = newUnits[attacker.id].experience; newUnits[attacker.id] = { ...newUnits[attacker.id], experience: oldExp + 1 }; addToLog(`⚔️ ${attacker.name} получил 1 опыт за атаку!`); // FIXED: Check level up immediately newUnits[attacker.id] = checkLevelUpInternal(newUnits[attacker.id]); } return newUnits; }); // COUNTERATTACK LOGIC: If defender is still alive and attacker is in defender's attack range, counterattack if (!isCounterAttack && !isDead) { const attackDx = Math.abs(attacker.position.x - defender.position.x); const attackDy = Math.abs(attacker.position.y - defender.position.y); const attackDistance = Math.max(attackDx, attackDy); // Check if attacker is within defender's attack range if (attackDistance <= defender.attackRange) { // Defender counterattacks attacker (mark as counterattack to avoid infinite loop) handleAttack(defender, attacker, true); } } }, [addToLog]); // Internal level up function with experience carry-over const checkLevelUpInternal = useCallback((unit) => { let updatedUnit = { ...unit }; let currentExp = updatedUnit.experience; let currentLevel = updatedUnit.level; // Loop while we have enough experience for next level while (currentExp >= currentLevel * 10) { // Calculate excess experience const requiredExp = currentLevel * 10; currentExp -= requiredExp; currentLevel += 1; // Increase a random stat const statIncrease = Math.floor(Math.random() * 3); if (statIncrease === 0) { updatedUnit.hp += 1; updatedUnit.maxHp += 1; } else if (statIncrease === 1) { updatedUnit.attack += 1; } else { updatedUnit.moveRange += 1; } addToLog(`⭐ ${updatedUnit.name} достиг ${currentLevel} уровня! Получил +1 к ${['ХП', 'атаке', 'дальности хода'][statIncrease]}`); } return { ...updatedUnit, experience: currentExp, level: currentLevel }; }, [addToLog]); // FIXED: Build barricade immediately on worker's current position const buildBarricade = useCallback((unitId) => { if (gamePhase !== 'player') return; const unit = units[unitId]; if (!unit || unit.type !== 'worker' || unit.hasAttacked) { addToLog('Невозможно построить баррикаду: нет действия атаки'); return; } const { x, y } = unit.position; const cell = grid[y][x]; // Cannot build on campfire or existing barricade if (cell && (cell.type === 'campfire' || cell.type === 'barricade' || cell.type === 'unit_and_barricade')) { addToLog('Невозможно построить баррикаду: клетка занята'); return; } // Create barricade on current position - unit remains on the cell setGrid(prev => { const newGrid = prev.map(row => [...row]); // Create combined cell with both unit and barricade newGrid[y][x] = { type: 'unit_and_barricade', unitId: unit.id, barricade: { id: `barricade-${Date.now()}`, hp: 5, maxHp: 5, image: UNIT_IMAGES.terrain.barricade } }; return newGrid; }); // Add experience for building and mark as attacked (since building uses attack action) setUnits(prev => { const newUnits = {...prev}; const oldExp = unit.experience; const updatedUnit = { ...unit, experience: oldExp + 1, hasAttacked: true }; addToLog(`🛠️ ${unit.name} получил 1 опыт за постройку баррикады!`); // FIXED: Check level up immediately after gaining experience newUnits[unitId] = checkLevelUpInternal(updatedUnit); return newUnits; }); addToLog(`🛠️ ${unit.name} построил баррикаду на своей позиции!`); }, [gamePhase, units, grid, addToLog, checkLevelUpInternal]); // FIXED: Wave-based enemy spawning system const spawnEnemies = useCallback(() => { const spawnCounts = {}; const currentWave = turn + 1; // Determine available enemy types based on wave let availableEnemyTypes = []; let limits = {}; if (currentWave <= 10) { // Waves 1-10: Only skeletons, max 3 skeleton archers availableEnemyTypes = ['skeletonSwordsman', 'skeletonArcher']; limits = { skeletonArcher: 3 }; } else if (currentWave <= 20) { // Waves 11-20: Skeletons + zombies, max 2 zombies, no limit on archers availableEnemyTypes = ['skeletonSwordsman', 'skeletonArcher', 'zombie']; limits = { zombie: 2 }; } else { // Waves 21+: All enemies, max 1 slime, no other limits availableEnemyTypes = ['skeletonSwordsman', 'skeletonArcher', 'zombie', 'slime']; limits = { slime: 1 }; } setGrid(prev => { const newGrid = prev.map(row => [...row]); const newUnits = {...units}; EDGES.forEach(edge => { let spawnedThisEdge = {}; for (let i = 0; i < ENEMIES_PER_EDGE; i++) { let x, y; // Determine spawn position based on edge switch(edge) { case 'top': x = Math.floor(Math.random() * GRID_SIZE); y = 0; break; case 'bottom': x = Math.floor(Math.random() * GRID_SIZE); y = GRID_SIZE - 1; break; case 'left': x = 0; y = Math.floor(Math.random() * GRID_SIZE); break; case 'right': x = GRID_SIZE - 1; y = Math.floor(Math.random() * GRID_SIZE); break; } // Find empty cell near the edge if the exact position is occupied let attempts = 0; while (attempts < 10 && (newGrid[y][x] || (x === CENTER && y === CENTER))) { switch(edge) { case 'top': x = Math.floor(Math.random() * GRID_SIZE); y = Math.min(2, GRID_SIZE - 1); break; case 'bottom': x = Math.floor(Math.random() * GRID_SIZE); y = Math.max(GRID_SIZE - 3, 0); break; case 'left': x = Math.min(2, GRID_SIZE - 1); y = Math.floor(Math.random() * GRID_SIZE); break; case 'right': x = Math.max(GRID_SIZE - 3, 0); y = Math.floor(Math.random() * GRID_SIZE); break; } attempts++; } if (newGrid[y][x] || (x === CENTER && y === CENTER)) continue; // Choose enemy type respecting limits let enemyType; let typeAttempts = 0; while (typeAttempts < 20) { enemyType = availableEnemyTypes[Math.floor(Math.random() * availableEnemyTypes.length)]; const currentCount = (spawnedThisEdge[enemyType] || 0) + (spawnCounts[UNIT_TYPES.enemy[enemyType].name] || 0); if (!limits[enemyType] || currentCount < limits[enemyType]) { break; } typeAttempts++; } if (typeAttempts >= 20) { // If can't find valid type, skip this spawn continue; } const enemyConfig = UNIT_TYPES.enemy[enemyType]; const id = `enemy-${enemyType}-${Date.now()}-${i}`; newGrid[y][x] = { type: 'unit', id }; newUnits[id] = { id, type: enemyType, faction: 'enemy', ...enemyConfig, hp: enemyConfig.hp, maxHp: enemyConfig.hp, position: { x, y }, experience: 0, level: 1, poisoned: 0, stunned: false, hasMoved: false, hasAttacked: false }; // Count spawns for logging spawnCounts[enemyConfig.name] = (spawnCounts[enemyConfig.name] || 0) + 1; spawnedThisEdge[enemyType] = (spawnedThisEdge[enemyType] || 0) + 1; } }); setUnits(newUnits); // Set spawn counts for logging after state update setEnemySpawnCounts(spawnCounts); return newGrid; }); }, [units, turn, addToLog]); // FIXED: Enemy AI with proper movement and attack for all units, reset action flags - IMPROVED PATHFINDING const enemyTurn = useCallback(() => { // Reset enemy action flags at start of enemy turn setUnits(prev => { const newUnits = {...prev}; Object.values(newUnits).forEach(unit => { if (unit.faction === 'enemy') { unit.hasMoved = false; unit.hasAttacked = false; if (unit.stunned) { unit.stunned = false; // Remove stun after one full turn } } }); return newUnits; }); // Process enemy actions after state update setTimeout(() => { setUnits(prev => { const newUnits = {...prev}; const playerUnits = Object.values(newUnits).filter(u => u.faction === 'player'); if (playerUnits.length === 0) return newUnits; // Process each enemy unit Object.values(newUnits).forEach(unit => { if (unit.faction !== 'enemy' || unit.stunned) { return; } // Find nearest player unit using Chebyshev distance let nearestPlayer = null; let minDistance = Infinity; playerUnits.forEach(player => { const dx = unit.position.x - player.position.x; const dy = unit.position.y - player.position.y; // Use Chebyshev distance for consistency with attack range const distance = Math.max(Math.abs(dx), Math.abs(dy)); if (distance < minDistance) { minDistance = distance; nearestPlayer = player; } }); if (!nearestPlayer) return; // Move towards nearest player if not in attack range and hasn't moved // FIXED: Use full movement range to get as close as possible, improved pathfinding if (minDistance > unit.attackRange && !unit.hasMoved) { let currentX = unit.position.x; let currentY = unit.position.y; let stepsUsed = 0; while (stepsUsed < unit.moveRange) { // Find best direction to move - prioritize directions that reduce distance let bestX = currentX; let bestY = currentY; let bestDistance = Math.max(Math.abs(currentX - nearestPlayer.position.x), Math.abs(currentY - nearestPlayer.position.y)); // Check all 8 directions, sorted by priority (cardinal directions first) const directions = [ { dx: 0, dy: -1, priority: 1 }, { dx: 0, dy: 1, priority: 1 }, { dx: -1, dy: 0, priority: 1 }, { dx: 1, dy: 0, priority: 1 }, { dx: -1, dy: -1, priority: 2 }, { dx: 1, dy: -1, priority: 2 }, { dx: -1, dy: 1, priority: 2 }, { dx: 1, dy: 1, priority: 2 } ]; // Sort directions by priority and potential distance reduction directions.sort((a, b) => { const newXa = currentX + a.dx; const newYa = currentY + a.dy; const newDistanceA = Math.max( Math.abs(newXa - nearestPlayer.position.x), Math.abs(newYa - nearestPlayer.position.y) ); const newXb = currentX + b.dx; const newYb = currentY + b.dy; const newDistanceB = Math.max( Math.abs(newXb - nearestPlayer.position.x), Math.abs(newYb - nearestPlayer.position.y) ); if (newDistanceA !== newDistanceB) { return newDistanceA - newDistanceB; } return a.priority - b.priority; }); let foundBetterPosition = false; for (const dir of directions) { const newX = currentX + dir.dx; const newY = currentY + dir.dy; if ( newX >= 0 && newX < GRID_SIZE && newY >= 0 && newY < GRID_SIZE ) { const cell = grid[newY][newX]; // Can only move to empty cells or campfire (enemies cannot move through barricades or units) let canMove = false; if (!cell) { canMove = true; } else if (cell.type === 'campfire') { canMove = true; } if (canMove) { const newDistance = Math.max( Math.abs(newX - nearestPlayer.position.x), Math.abs(newY - nearestPlayer.position.y) ); // Always move if it reduces distance, or if we're stuck and need to make progress if (newDistance < bestDistance || (stepsUsed === 0 && newDistance <= bestDistance)) { bestDistance = newDistance; bestX = newX; bestY = newY; foundBetterPosition = true; break; // Take the first good move } } } } // If found a better position, move there if (foundBetterPosition && (bestX !== currentX || bestY !== currentY)) { currentX = bestX; currentY = bestY; stepsUsed++; // Update grid immediately for pathfinding of other units setGrid(grid => { const newGrid = grid.map(row => [...row]); // Clear old position newGrid[unit.position.y][unit.position.x] = null; // Set new position newGrid[currentY][currentX] = { type: 'unit', id: unit.id }; return newGrid; }); } else { // No better position found or can't move, stop moving break; } } // Update unit position if (currentX !== unit.position.x || currentY !== unit.position.y) { newUnits[unit.id] = { ...unit, position: { x: currentX, y: currentY }, hasMoved: true }; // Update distance after move minDistance = Math.max( Math.abs(currentX - nearestPlayer.position.x), Math.abs(currentY - nearestPlayer.position.y) ); } } // Attack if hasn't attacked yet if (!unit.hasAttacked) { // Check if nearest player is in attack range const attackDx = Math.abs(unit.position.x - nearestPlayer.position.x); const attackDy = Math.abs(unit.position.y - nearestPlayer.position.y); const attackDistance = Math.max(attackDx, attackDy); if (attackDistance <= unit.attackRange) { // Check if there's a barricade on the player's cell const playerCell = grid[nearestPlayer.position.y][nearestPlayer.position.x]; if (playerCell && (playerCell.type === 'barricade' || playerCell.type === 'unit_and_barricade')) { // Attack barricade first if present on player's cell handleBarricadeAttack(unit, playerCell.id, nearestPlayer.position.x, nearestPlayer.position.y); } else { handleAttack(unit, nearestPlayer); } newUnits[unit.id] = { ...newUnits[unit.id], hasAttacked: true }; } else { // Look for barricades to attack within attack range let nearestBarricade = null; let minBarricadeDistance = Infinity; // Search in a square around the enemy for (let dy = -unit.attackRange; dy <= unit.attackRange; dy++) { for (let dx = -unit.attackRange; dx <= unit.attackRange; dx++) { const bx = unit.position.x + dx; const by = unit.position.y + dy; if (bx >= 0 && bx < GRID_SIZE && by >= 0 && by < GRID_SIZE) { const cell = grid[by][bx]; if (cell && (cell.type === 'barricade' || cell.type === 'unit_and_barricade')) { const barricadeDistance = Math.max(Math.abs(dx), Math.abs(dy)); if (barricadeDistance <= unit.attackRange && barricadeDistance < minBarricadeDistance) { minBarricadeDistance = barricadeDistance; nearestBarricade = { x: bx, y: by, cell }; } } } } } if (nearestBarricade) { handleBarricadeAttack(unit, nearestBarricade.cell.id, nearestBarricade.x, nearestBarricade.y); newUnits[unit.id] = { ...newUnits[unit.id], hasAttacked: true }; } } } }); return newUnits; }); // Apply poison effects to player units setUnits(prev => { const newUnits = {...prev}; Object.values(newUnits).forEach(unit => { if (unit.faction === 'player' && unit.poisoned > 0) { unit.hp = Math.max(0, unit.hp - 2); unit.poisoned -= 1; if (unit.hp <= 0) { // Remove dead unit setGrid(grid => { const newGrid = grid.map(row => [...row]); const pos = unit.position; const cell = newGrid[pos.y][pos.x]; // Restore terrain if (cell && cell.type === 'unit_and_barricade') { newGrid[pos.y][pos.x] = { type: 'barricade', id: cell.barricade.id, hp: cell.barricade.hp, maxHp: cell.barricade.maxHp, image: UNIT_IMAGES.terrain.barricade }; } else if (cell && cell.type === 'unit_and_campfire') { newGrid[pos.y][pos.x] = cell.campfire; } else { newGrid[pos.y][pos.x] = null; } return newGrid; }); delete newUnits[unit.id]; addToLog(`${unit.name} умер от отравления!`); } else { addToLog(`☠️ ${unit.name} получает 2 урона от отравления! Осталось ${unit.poisoned} ходов`); } } }); return newUnits; }); // Healer ability: heal most injured ally at end of enemy turn - FIXED: Only if healer hasn't attacked and add experience setUnits(prev => { const newUnits = {...prev}; const playerUnits = Object.values(newUnits).filter(u => u.faction === 'player'); if (playerUnits.length === 0) return newUnits; playerUnits.forEach(unit => { if (unit.type === 'healer' && !unit.hasAttacked) { // Find most injured ally (excluding self) let mostInjured = null; let minHp = Infinity; playerUnits.forEach(ally => { if (ally.id !== unit.id && ally.hp < ally.maxHp && ally.hp < minHp) { minHp = ally.hp; mostInjured = ally; } }); if (mostInjured) { const healAmount = Math.min(10, mostInjured.maxHp - mostInjured.hp); mostInjured.hp += healAmount; addToLog(`❤️ ${unit.name} исцелил ${mostInjured.name} на ${healAmount} ХП!`); // Add experience to healer for healing and mark as attacked const oldExp = unit.experience; const updatedHealer = { ...unit, experience: oldExp + 1, hasAttacked: true // Healing uses attack action }; addToLog(`❤️ ${unit.name} получил 1 опыт за лечение!`); newUnits[unit.id] = checkLevelUpInternal(updatedHealer); } } }); return newUnits; }); }, 0); }, [grid, handleAttack, handleBarricadeAttack, addToLog, checkLevelUpInternal]); // End player turn const endTurn = useCallback(() => { if (gamePhase !== 'player') return; // Reset stunned status for player units setUnits(prev => { const newUnits = {...prev}; Object.values(newUnits).forEach(unit => { if (unit.faction === 'player') { unit.stunned = false; } }); return newUnits; }); setGamePhase('enemy'); addToLog(`Ход ${turn + 1} завершен. Ход врагов...`); // Process enemy turn after a short delay setTimeout(() => { spawnEnemies(); // Log aggregated enemy spawns after spawning setTimeout(() => { if (Object.keys(enemySpawnCounts).length > 0) { let spawnMessage = 'На горизонте появились враги: '; const messages = []; for (const [name, count] of Object.entries(enemySpawnCounts)) { // Proper pluralization for Russian let unitName = name; if (count > 1 && count < 5) { // Simplified pluralization unitName = name; } messages.push(`${count} ${unitName}`); } addToLog(spawnMessage + messages.join(', ')); setEnemySpawnCounts({}); } enemyTurn(); // Check win/lose conditions after enemy turn setTimeout(() => { setTurn(prev => prev + 1); // Check victory condition if (turn + 1 >= MAX_TURNS) { const playerUnits = Object.values(units).filter(u => u.faction === 'player'); if (playerUnits.length > 0) { setGamePhase('victory'); addToLog('ПОБЕДА! Вы выжили 50 ходов!'); return; } } // Check game over const playerUnits = Object.values(units).filter(u => u.faction === 'player'); if (playerUnits.length === 0) { setGamePhase('gameOver'); addToLog('ПОРАЖЕНИЕ! Все ваши бойцы погибли!'); return; } // Continue to next turn setGamePhase('player'); addToLog(`Начало хода ${turn + 2}`); }, 800); }, 300); }, 300); }, [gamePhase, turn, units, spawnEnemies, enemySpawnCounts, enemyTurn, addToLog]); // Grid cells memoization for performance - FIXED: Better visual hierarchy with images and enemy HP bars const gridCells = useMemo(() => { const cells = []; for (let y = 0; y < GRID_SIZE; y++) { for (let x = 0; x < GRID_SIZE; x++) { const cell = grid[y]?.[x] || null; const isPossibleMove = possibleMoves.some(pos => pos.x === x && pos.y === y); const isPossibleAttack = possibleAttacks.some(pos => pos.x === x && pos.y === y); const isInAttackRange = attackRangeCells.some(pos => pos.x === x && pos.y === y); const isSelectedUnit = selectedUnit && units[selectedUnit]?.position?.x === x && units[selectedUnit]?.position?.y === y; const isBothMoveAndAttack = isPossibleMove && isInAttackRange; // Determine cell styling with proper priority let bgColor = ''; let overlayColor = ''; let backgroundImage = UNIT_IMAGES.terrain.grass; if (cell?.type === 'campfire' || cell?.type === 'unit_and_campfire') { backgroundImage = UNIT_IMAGES.terrain.campfire; } else if (cell?.type === 'barricade' || (cell?.type === 'unit_and_barricade' && cell?.barricade)) { backgroundImage = UNIT_IMAGES.terrain.barricade; } // Movement and attack range overlays if (isBothMoveAndAttack) { overlayColor = 'bg-purple-500/40'; } else if (isPossibleAttack) { overlayColor = 'bg-red-600/40'; } else if (isPossibleMove) { overlayColor = 'bg-blue-500/40'; } else if (isInAttackRange && !isPossibleAttack && !isPossibleMove) { overlayColor = 'bg-yellow-500/30'; } // Get unit image if present let unitImage = null; let unit = null; if (cell?.type === 'unit') { unit = units[cell.id]; if (unit) unitImage = unit.image; } else if (cell?.type === 'unit_and_barricade') { unit = units[cell.unitId]; if (unit) unitImage = unit.image; } else if (cell?.type === 'unit_and_campfire') { unit = units[cell.unitId]; if (unit) unitImage = unit.image; } cells.push(
handleCellClick(x, y)} style={{ width: CELL_SIZE, height: CELL_SIZE }} title={ cell?.type === 'barricade' ? `Баррикада: ${cell.hp}/${cell.maxHp} HP` : cell?.type === 'unit_and_barricade' ? `Баррикада: ${cell.barricade.hp}/${cell.barricade.maxHp} HP` : '' } > {/* Background terrain */} {/* Unit image */} {unitImage && ( )} {/* Enemy HP bar (only if damaged) */} {unit && unit.faction === 'enemy' && unit.hp < unit.maxHp && (
)} {/* Overlay for movement/attack ranges */} {overlayColor && (
)}
); } } return cells; }, [grid, possibleMoves, possibleAttacks, attackRangeCells, selectedUnit, units, handleCellClick]); // Player units panel memoization with images const playerUnitsPanel = useMemo(() => { const playerUnits = Object.values(units).filter(u => u.faction === 'player'); return playerUnits.map(unit => { const percentage = (unit.hp / unit.maxHp) * 100; const requiredExp = unit.level * 10; const expPercentage = (unit.experience / requiredExp) * 100; return (
handleUnitSelect(unit.id)} >
{unit.name}
{unit.name} (ур. {unit.level})
HP: {unit.hp}/{unit.maxHp} Атака: {unit.attack}
Опыт: {unit.experience}/{requiredExp} Движение: {unit.hasMoved ? '✓' : '✕'} Атака: {unit.hasAttacked ? '✓' : '✕'}
); }); }, [units, selectedUnit, handleUnitSelect]); // Game status text const statusText = useMemo(() => { switch(gamePhase) { case 'setup': return 'Разместите своих бойцов'; case 'player': return `Ход ${turn + 1}/${MAX_TURNS} - Ваш ход`; case 'enemy': return `Ход ${turn + 1}/${MAX_TURNS} - Ход врагов...`; case 'gameOver': return 'ПОРАЖЕНИЕ! Все бойцы погибли!'; case 'victory': return 'ПОБЕДА! Вы выжили 50 ходов!'; default: return ''; } }, [gamePhase, turn]); return (

Оборона лагеря

{statusText}
{/* Game grid */}
{gridCells}

Костер: ({CENTER}, {CENTER})

Поле: {GRID_SIZE}×{GRID_SIZE}

Волна: {turn + 1}/{MAX_TURNS}

{/* Sidebar */}
{/* Player units panel */}

Ваши бойцы {Object.values(units).filter(u => u.faction === 'player').length}

{playerUnitsPanel.length > 0 ? ( playerUnitsPanel ) : (
Нет живых бойцов
)}
{/* Build barricade button for selected worker */} {selectedUnit && units[selectedUnit]?.type === 'worker' && !units[selectedUnit]?.hasAttacked && ( )}
{/* Game log */}

Журнал событий

{log.map((entry, index) => { // Determine color based on content let bgColor = 'bg-gray-900 border-l-4 border-gray-600'; if (entry.includes('ПОБЕДА')) { bgColor = 'bg-green-900/60 border-l-4 border-green-500'; } else if (entry.includes('ПОРАЖЕНИЕ')) { bgColor = 'bg-red-900/60 border-l-4 border-red-500'; } else if (entry.includes('уровень')) { bgColor = 'bg-purple-900/40 border-l-4 border-purple-500'; } else if (entry.includes('отрав')) { bgColor = 'bg-lime-900/40 border-l-4 border-lime-500'; } else if (entry.includes('исцелил') || entry.includes('❤️')) { bgColor = 'bg-blue-900/40 border-l-4 border-blue-500'; } else if (entry.includes('построил') || entry.includes('🛠️')) { bgColor = 'bg-amber-900/40 border-l-4 border-amber-500'; } else if (entry.includes('оглушил') || entry.includes(' stun') || entry.includes('оглушение')) { bgColor = 'bg-yellow-900/40 border-l-4 border-yellow-500'; } else if (entry.includes('убит')) { bgColor = 'bg-red-900/40 border-l-4 border-red-600'; } else if (entry.includes('контратак')) { bgColor = 'bg-orange-900/40 border-l-4 border-orange-500'; } else if (entry.includes('блокировал') || entry.includes('🛡️')) { bgColor = 'bg-cyan-900/40 border-l-4 border-cyan-500'; } else if (entry.includes('уклонился') || entry.includes('🎯')) { bgColor = 'bg-teal-900/40 border-l-4 border-teal-500'; } else if (entry.includes('двойной урон') || entry.includes('мгновенное убийство')) { bgColor = 'bg-pink-900/40 border-l-4 border-pink-500'; } else if (entry.includes('получил') && entry.includes('опыт')) { // Experience gain messages if (entry.includes('10 опыта')) { bgColor = 'bg-yellow-900/50 border-l-4 border-yellow-400'; } else if (entry.includes('1 опыт')) { bgColor = 'bg-blue-900/30 border-l-4 border-blue-400'; } } return (
{entry}
); })}
{/* Game rules */}

Правила игры

  • Выживите 50 ходов с хотя бы одним живым бойцом
  • Каждый ход появляются 16 врагов (по 4 с каждого края)
  • Сначала ваш ход, затем ход врагов
  • Рабочий строит баррикаду на своей позиции (тратит атаку)
  • Хиллер лечит самого раненого союзника в конце хода (тратит атаку)
  • За убийство +10 опыта, за действия +1 опыта
  • Каждый уровень дает +1 к случайной характеристике
  • Каждый юнит может сделать 1 перемещение и 1 атаку за ход
  • Баррикады блокируют врагов, но не союзников
  • Атака и движение доступны по диагонали
  • Контратака: если атакующий в зоне атаки цели, получает ответный удар
{/* Game over / Victory overlay */} {(gamePhase === 'gameOver' || gamePhase === 'victory') && (

{gamePhase === 'victory' ? 'ПОБЕДА!' : 'ПОРАЖЕНИЕ!'}

{gamePhase === 'victory' ? 'Вы отстояли лагерь все 50 ходов!' : 'Все ваши бойцы пали в бою...'}

)}
); }