// XCOM-like Game - Game Logic and Rendering // Built using The Elm Architecture pattern // ------------------------------------------------ // TYPE DEFINITIONS // ------------------------------------------------ /** * @typedef {'floor' | 'wall' | 'obstacle'} TileType * @typedef {{ type: TileType, x: number, y: number, providesCover: boolean, health: number, damageFlash: number, color: string }} Tile * @typedef {'player' | 'enemy'} UnitOwner * @typedef {{ * id: number, * owner: UnitOwner, * x: number, * y: number, * maxMovement: number, * movementRange: number, * shootRange: number, * damage: number, * maxHp: number, * currentHp: number, * inCover: boolean, * hasMoved: boolean, * hasAttacked: boolean, * isAnimating: boolean, * animationType: 'none' | 'moving' | 'shooting' | 'dying', * animationProgress: number, * targetX: number, * targetY: number, * path: {x: number, y: number}[], * projectileX: number, * projectileY: number, * projectileTargetX: number, * projectileTargetY: number, * deathParticles: DeathParticle[], * isDead: boolean, * pendingDamage: number, * justCompletedShot: boolean, * isVisible: boolean, * lastSeen: number, * lastKnownX: number, * lastKnownY: number, * aiBehavior: 'aggressive' | 'patrol' | 'stationary', * turnOrder: number, * patrolCenterX: number, * patrolCenterY: number, * patrolRadius: number, * actionFeedbackTimer: number * }} Unit * @typedef {{ * x: number, * y: number, * velocityX: number, * velocityY: number, * size: number, * life: number, * maxLife: number, * color: string * }} DeathParticle * @typedef {{ type: 'AwaitingSelection' } | { type: 'UnitSelected', unitId: number } | { type: 'AttackMode', unitId: number }} UIState * @typedef {{ grid: Tile[][], units: Unit[], selectedUnit: Unit | null, uiState: UIState, currentTurnIndex: number }} Model */ // ------------------------------------------------ // RENDER CONSTANTS // ------------------------------------------------ const TILE_SIZE = 40; const UNIT_RADIUS = 15; const OBSTACLE_MAX_HEALTH = 20; const MAX_VISIBILITY_RANGE = 6; // Maximum distance units can see const ENEMY_TURN_SPEED = 0.3; // Enemy turns are 3x faster than player turns const ENEMY_ACTION_FEEDBACK_DURATION = 800; // How long to show enemy action feedback const AI_BEHAVIORS = ['aggressive', 'patrol', 'stationary']; const COLORS = { gridLine: '#444', floor: '#2a2a2a', wall: '#555', obstacle: '#2C3E50', // Dark blue-grey for obstacles player: '#00f', enemy: '#f00', unitBorder: '#fff', selectedBorder: '#0f0', moveHighlight: 'rgba(0, 150, 255, 0.4)', coverHighlight: 'rgba(255, 215, 0, 0.3)' }; // ------------------------------------------------ // PROCEDURAL GENERATION CONSTANTS // ------------------------------------------------ const GENERATION_TYPES = { SPARSE_WAREHOUSE: 'sparse_warehouse', HALLWAY_ROOMS: 'hallway_rooms', DRUNKARDS_WALK: 'drunkards_walk', CELLULAR_AUTOMATA: 'cellular_automata' }; const WAREHOUSE_CONFIG = { obstacleChance: 0.15, minObstacles: 3, maxObstacles: 8 }; const HALLWAY_CONFIG = { minRoomSize: 3, maxRoomSize: 6, minRooms: 2, maxRooms: 5, corridorWidth: 2 }; const DRUNKARD_CONFIG = { steps: 0.4, // Percentage of grid to fill maxSteps: 1000, minPathLength: 0.3 // Minimum percentage of floor tiles }; const CELLULAR_CONFIG = { iterations: 4, birthThreshold: 4, survivalThreshold: 3, minFloorPercentage: 0.4 }; // ------------------------------------------------ // UTILITY FUNCTIONS // ------------------------------------------------ /** * Calculates the optimal grid size to fit the viewport * @returns {{ width: number, height: number }} Grid dimensions */ function calculateGridSize() { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; // Account for button space and borders const availableWidth = viewportWidth - 40; const availableHeight = viewportHeight - 40; const width = Math.floor(availableWidth / TILE_SIZE); const height = Math.floor(availableHeight / TILE_SIZE); // Ensure minimum grid size return { width: Math.max(width, 8), height: Math.max(height, 6) }; } /** * Checks if a path between two points is blocked by obstacles * @param {number} startX * @param {number} startY * @param {number} endX * @param {number} endY * @param {Tile[][]} grid * @returns {boolean} True if path is blocked */ function isPathBlocked(startX, startY, endX, endY, grid) { console.log('isPathBlocked check from', startX, startY, 'to', endX, endY); const dx = Math.sign(endX - startX); const dy = Math.sign(endY - startY); let currentX = startX; let currentY = startY; while (currentX !== endX || currentY !== endY) { if (currentX !== endX) { currentX += dx; if (grid[currentY][currentX].type === 'obstacle') { console.log('Path blocked by obstacle at', currentX, currentY); return true; } } if (currentY !== endY) { currentY += dy; if (grid[currentY][currentX].type === 'obstacle') { console.log('Path blocked by obstacle at', currentX, currentY); return true; } } } console.log('Path is clear'); return false; } /** * Checks if a unit is in cover based on adjacent obstacles * @param {Unit} unit * @param {Tile[][]} grid * @returns {boolean} True if unit is in cover */ function checkCover(unit, grid) { const { x, y } = unit; const directions = [ { dx: -1, dy: 0 }, { dx: 1, dy: 0 }, { dx: 0, dy: -1 }, { dx: 0, dy: 1 } ]; return directions.some(({ dx, dy }) => { const checkX = x + dx; const checkY = y + dy; return checkX >= 0 && checkX < grid[0].length && checkY >= 0 && checkY < grid.length && grid[checkY][checkX].type === 'obstacle'; }); } // ------------------------------------------------ // PATHFINDING AND ANIMATION // ------------------------------------------------ /** * A* pathfinding algorithm to find optimal path between two points * @param {number} startX * @param {number} startY * @param {number} endX * @param {number} endY * @param {Tile[][]} grid * @returns {{x: number, y: number}[]} Path array */ function findPath(startX, startY, endX, endY, grid) { const width = grid[0].length; const height = grid.length; // Validate input coordinates if (startX < 0 || startX >= width || startY < 0 || startY >= height || endX < 0 || endX >= width || endY < 0 || endY >= height) { console.error('findPath: Invalid coordinates:', { startX, startY, endX, endY, width, height }); return []; } // If start and end are the same, no path needed if (startX === endX && startY === endY) { return []; } // Simple pathfinding for now - can be enhanced with A* later const path = []; let currentX = startX; let currentY = startY; // Move horizontally first, then vertically while (currentX !== endX) { const nextX = currentX + Math.sign(endX - currentX); if (grid[currentY][nextX].type !== 'obstacle') { currentX = nextX; path.push({ x: currentX, y: currentY }); } else { // Try to go around obstacle if (currentY + 1 < height && grid[currentY + 1][currentX].type !== 'obstacle') { currentY++; path.push({ x: currentX, y: currentY }); } else if (currentY - 1 >= 0 && grid[currentY - 1][currentX].type !== 'obstacle') { currentY--; path.push({ x: currentX, y: currentY }); } else { // Can't find path console.warn('findPath: Cannot find path around obstacle'); return []; } } } while (currentY !== endY) { const nextY = currentY + Math.sign(endY - currentY); if (grid[nextY][currentX].type !== 'obstacle') { currentY = nextY; path.push({ x: currentX, y: currentY }); } else { // Try to go around obstacle if (currentX + 1 < width && grid[currentY][currentX + 1].type !== 'obstacle') { currentX++; path.push({ x: currentX, y: currentY }); } else if (currentX - 1 >= 0 && grid[currentX - 1][currentY].type !== 'obstacle') { currentX--; path.push({ x: currentX, y: currentY }); } else { // Can't find path console.warn('findPath: Cannot find path around obstacle'); return []; } } } // Validate path before returning const validPath = path.filter(point => point && typeof point.x === 'number' && typeof point.y === 'number' && point.x >= 0 && point.x < width && point.y >= 0 && point.y < height ); if (validPath.length !== path.length) { console.error('findPath: Invalid path points found:', path); } return validPath; } /** * Starts movement animation for a unit * @param {Unit} unit * @param {number} targetX * @param {number} targetY * @param {Tile[][]} grid * @returns {Unit} Updated unit with animation state */ function startMovementAnimation(unit, targetX, targetY, grid) { console.log('startMovementAnimation called with:', { unit: unit.id, from: { x: unit.x, y: unit.y }, to: { x: targetX, y: targetY } }); const path = findPath(unit.x, unit.y, targetX, targetY, grid); console.log('Path found:', path); if (path.length === 0) { console.warn('No path found for unit', unit.id); return unit; // No path found } const animatedUnit = { ...unit, isAnimating: true, animationType: 'moving', animationProgress: 0, targetX, targetY, path }; console.log('Animated unit created:', animatedUnit); return animatedUnit; } /** * Updates animation progress for all units * @param {Unit[]} units * @param {number} deltaTime * @returns {Unit[]} Updated units */ function updateAnimations(units, deltaTime) { const ANIMATION_SPEED = 0.003; // Adjust for animation speed return units.map(unit => { if (!unit.isAnimating) return unit; const newProgress = unit.animationProgress + deltaTime * ANIMATION_SPEED; if (newProgress >= 1) { // Animation complete if (unit.animationType === 'moving') { return { ...unit, x: unit.targetX, y: unit.targetY, hasMoved: true, isAnimating: false, animationType: 'none', animationProgress: 0, targetX: -1, targetY: -1, path: [] }; } else if (unit.animationType === 'shooting') { return { ...unit, hasAttacked: true, isAnimating: false, animationType: 'none', animationProgress: 0, projectileX: -1, projectileY: -1, // Don't reset projectileTargetX/Y yet - keep them for damage processing justCompletedShot: true // Flag to indicate shot just completed }; } else if (unit.animationType === 'dying') { // Death animation is complete, remove the unit return null; } } // Update death particles if dying if (unit.animationType === 'dying') { const updatedParticles = updateDeathParticles(unit.deathParticles); if (updatedParticles.length === 0) { // All particles are gone, complete the death animation return null; } return { ...unit, deathParticles: updatedParticles, animationProgress: newProgress }; } return { ...unit, animationProgress: newProgress }; }).filter(unit => unit !== null); // Filter out units that completed their animation } /** * Gets the current position of a unit during animation * @param {Unit} unit * @returns {{x: number, y: number}} Current position */ function getUnitPosition(unit) { // Safety checks if (!unit) { console.error('getUnitPosition: unit is undefined'); return { x: 0, y: 0 }; } if (typeof unit.x !== 'number' || typeof unit.y !== 'number') { console.error('getUnitPosition: unit coordinates are invalid:', unit); return { x: 0, y: 0 }; } // If unit is not animating or not moving, return current position if (!unit.isAnimating || unit.animationType !== 'moving') { return { x: unit.x, y: unit.y }; } // Safety check for path data if (!unit.path || !Array.isArray(unit.path) || unit.path.length === 0) { console.warn('getUnitPosition: unit has invalid path data, returning current position:', unit.id); return { x: unit.x, y: unit.y }; } // Safety check for animation progress if (typeof unit.animationProgress !== 'number' || unit.animationProgress < 0 || unit.animationProgress > 1) { console.warn('getUnitPosition: unit has invalid animation progress, returning current position:', unit.id); return { x: unit.x, y: unit.y }; } // Calculate position along path const totalDistance = unit.path.length; const currentStep = Math.floor(unit.animationProgress * totalDistance); if (currentStep >= unit.path.length) { if (typeof unit.targetX === 'number' && typeof unit.targetY === 'number') { return { x: unit.targetX, y: unit.targetY }; } else { return { x: unit.x, y: unit.y }; } } const pathPoint = unit.path[currentStep]; if (pathPoint && typeof pathPoint.x === 'number' && typeof pathPoint.y === 'number') { return pathPoint; } else { console.warn('getUnitPosition: invalid path point at step', currentStep, 'for unit', unit.id, 'path:', unit.path); return { x: unit.x, y: unit.y }; } } /** * Gets the current projectile position during shooting animation * @param {Unit} unit * @returns {{x: number, y: number} | null} Current projectile position or null if not shooting */ function getProjectilePosition(unit) { if (!unit.isAnimating || unit.animationType !== 'shooting') { return null; } if (typeof unit.projectileX !== 'number' || typeof unit.projectileY !== 'number' || typeof unit.projectileTargetX !== 'number' || typeof unit.projectileTargetY !== 'number') { return null; } // Calculate projectile position along the line from start to target const startX = unit.projectileX * TILE_SIZE + TILE_SIZE / 2; const startY = unit.projectileY * TILE_SIZE + TILE_SIZE / 2; const targetX = unit.projectileTargetX * TILE_SIZE + TILE_SIZE / 2; const targetY = unit.projectileTargetY * TILE_SIZE + TILE_SIZE / 2; const currentX = startX + (targetX - startX) * unit.animationProgress; const currentY = startY + (targetY - startY) * unit.animationProgress; return { x: currentX, y: currentY }; } /** * Starts shooting animation for a unit * @param {Unit} unit * @param {number} targetX * @param {number} targetY * @returns {Unit} Updated unit with shooting animation */ function startShootingAnimation(unit, targetX, targetY) { console.log('Starting shooting animation for unit:', unit.id, 'at target:', targetX, targetY); return { ...unit, isAnimating: true, animationType: 'shooting', animationProgress: 0, projectileX: unit.x, projectileY: unit.y, projectileTargetX: targetX, projectileTargetY: targetY }; } /** * Creates death particles for a unit * @param {Unit} unit * @returns {Unit} Updated unit with death particles */ function createDeathParticles(unit) { const DEATH_PARTICLE_COUNT = 30; const DEATH_PARTICLE_SPEED = 8; const DEATH_PARTICLE_SIZE = 3; const DEATH_PARTICLE_LIFETIME = 60; const centerX = unit.x * TILE_SIZE + TILE_SIZE / 2; const centerY = unit.y * TILE_SIZE + TILE_SIZE / 2; const deathParticles = []; for (let i = 0; i < DEATH_PARTICLE_COUNT; i++) { const angle = (Math.PI * 2 * i) / DEATH_PARTICLE_COUNT; const speed = DEATH_PARTICLE_SPEED * (0.5 + Math.random()); // Choose color based on unit type let color; if (unit.owner === 'player') { color = ['#4CAF50', '#45a049', '#2E7D32'][Math.floor(Math.random() * 3)]; // Green variants } else { color = ['#F44336', '#D32F2F', '#B71C1C'][Math.floor(Math.random() * 3)]; // Red variants } deathParticles.push({ x: centerX + (Math.random() - 0.5) * 10, y: centerY + (Math.random() - 0.5) * 10, velocityX: Math.cos(angle) * speed, velocityY: Math.sin(angle) * speed, size: DEATH_PARTICLE_SIZE + Math.random() * 2, life: DEATH_PARTICLE_LIFETIME, maxLife: DEATH_PARTICLE_LIFETIME, color: color }); } return { ...unit, isAnimating: true, animationType: 'dying', animationProgress: 0, deathParticles, isDead: true }; } /** * Updates death particle animations * @param {DeathParticle[]} particles * @returns {DeathParticle[]} Updated particles */ function updateDeathParticles(particles) { return particles.map(particle => { // Apply gravity particle.velocityY += 0.3; // Update position particle.x += particle.velocityX; particle.y += particle.velocityY; // Reduce life particle.life--; // Add some randomness to movement particle.velocityX *= 0.98; particle.velocityY *= 0.98; return particle; }).filter(particle => particle.life > 0); } // ------------------------------------------------ // PROCEDURAL GENERATION ALGORITHMS // ------------------------------------------------ /** * Generates a sparse warehouse-like environment * @param {number} width * @param {number} height * @returns {Tile[][]} Generated grid */ function generateSparseWarehouse(width, height) { const grid = Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => ({ type: 'floor', x, y, providesCover: false, health: 0, damageFlash: 0, color: generateObstacleColor() })) ); // Add random obstacles const numObstacles = Math.floor( Math.random() * (WAREHOUSE_CONFIG.maxObstacles - WAREHOUSE_CONFIG.minObstacles + 1) + WAREHOUSE_CONFIG.minObstacles ); let obstaclesPlaced = 0; const maxAttempts = width * height * 2; let attempts = 0; while (obstaclesPlaced < numObstacles && attempts < maxAttempts) { const x = Math.floor(Math.random() * width); const y = Math.floor(Math.random() * height); // Don't place on edges or if already occupied if (x > 0 && x < width - 1 && y > 0 && y < height - 1 && grid[y][x].type === 'floor') { // Check if obstacle provides meaningful cover (not isolated) const hasAdjacentFloor = [ { dx: -1, dy: 0 }, { dx: 1, dy: 0 }, { dx: 0, dy: -1 }, { dx: 0, dy: 1 } ].some(({ dx, dy }) => { const checkX = x + dx; const checkY = y + dy; return checkX >= 0 && checkX < width && checkY >= 0 && checkY < height && grid[checkY][checkX].type === 'floor'; }); if (hasAdjacentFloor) { grid[y][x] = createObstacle(x, y); obstaclesPlaced++; } } attempts++; } return grid; } /** * Generates a structured hallway and room layout * @param {number} width * @param {number} height * @returns {Tile[][]} Generated grid */ function generateHallwayRooms(width, height) { const grid = Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => ({ type: 'floor', x, y, providesCover: false, health: 0, damageFlash: 0, color: generateObstacleColor() })) ); // Generate rooms const numRooms = Math.floor( Math.random() * (HALLWAY_CONFIG.maxRooms - HALLWAY_CONFIG.minRooms + 1) + HALLWAY_CONFIG.minRooms ); const rooms = []; for (let i = 0; i < numRooms; i++) { const roomWidth = Math.floor( Math.random() * (HALLWAY_CONFIG.maxRoomSize - HALLWAY_CONFIG.minRoomSize + 1) + HALLWAY_CONFIG.minRoomSize ); const roomHeight = Math.floor( Math.random() * (HALLWAY_CONFIG.maxRoomSize - HALLWAY_CONFIG.minRoomSize + 1) + HALLWAY_CONFIG.minRoomSize ); // Try to place room let roomPlaced = false; let attempts = 0; const maxAttempts = 50; while (!roomPlaced && attempts < maxAttempts) { const startX = Math.floor(Math.random() * (width - roomWidth - 2)) + 1; const startY = Math.floor(Math.random() * (height - roomHeight - 2)) + 1; // Check if room can fit let canFit = true; for (let y = startY - 1; y <= startY + roomHeight; y++) { for (let x = startX - 1; x <= startX + roomWidth; x++) { if (y < 0 || y >= height || x < 0 || x >= width) { canFit = false; break; } if (grid[y][x].type === 'obstacle') { canFit = false; break; } } if (!canFit) break; } if (canFit) { // Place room walls for (let y = startY; y < startY + roomHeight; y++) { for (let x = startX; x < startX + roomWidth; x++) { grid[y][x] = createObstacle(x, y); } } // Add some interior obstacles for cover const interiorObstacles = Math.floor(Math.random() * 3) + 1; for (let j = 0; j < interiorObstacles; j++) { const obstacleX = startX + 1 + Math.floor(Math.random() * (roomWidth - 2)); const obstacleY = startY + 1 + Math.floor(Math.random() * (roomHeight - 2)); if (grid[obstacleY][obstacleX].type === 'obstacle') { grid[obstacleY][obstacleX] = { type: 'floor', x: obstacleX, y: obstacleY, providesCover: false, health: 0, damageFlash: 0, color: generateObstacleColor() }; } } rooms.push({ startX, startY, width: roomWidth, height: roomHeight }); roomPlaced = true; } attempts++; } } // Connect rooms with corridors if (rooms.length > 1) { for (let i = 0; i < rooms.length - 1; i++) { const room1 = rooms[i]; const room2 = rooms[i + 1]; // Create corridor between room centers const center1X = Math.floor(room1.startX + room1.width / 2); const center1Y = Math.floor(room1.startY + room1.height / 2); const center2X = Math.floor(room2.startX + room2.width / 2); const center2Y = Math.floor(room2.startY + room2.height / 2); // Horizontal corridor const corridorStartX = Math.min(center1X, center2X); const corridorEndX = Math.max(center1X, center2X); for (let x = corridorStartX; x <= corridorEndX; x++) { if (x >= 0 && x < width) { for (let y = center1Y - 1; y <= center1Y + 1; y++) { if (y >= 0 && y < height && grid[y][x].type === 'floor') { grid[y][x] = { type: 'obstacle', x, y, providesCover: true, health: OBSTACLE_MAX_HEALTH, // 6 shots to destroy damageFlash: 0, color: generateObstacleColor() }; } } } } // Vertical corridor const corridorStartY = Math.min(center1Y, center2Y); const corridorEndY = Math.max(center1Y, center2Y); for (let y = corridorStartY; y <= corridorEndY; y++) { if (y >= 0 && y < height) { for (let x = center2X - 1; x <= center2X + 1; x++) { if (x >= 0 && x < width && grid[y][x].type === 'floor') { grid[y][x] = { type: 'obstacle', x, y, providesCover: true, health: OBSTACLE_MAX_HEALTH, // 6 shots to destroy damageFlash: 0, color: generateObstacleColor() }; } } } } } } return grid; } /** * Generates a level using the Drunkard's Walk algorithm * @param {number} width * @param {number} height * @returns {Tile[][]} Generated grid */ function generateDrunkardsWalk(width, height) { const grid = Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => ({ type: 'obstacle', x, y, providesCover: true, health: 0, damageFlash: 0, color: generateObstacleColor() })) ); // Start from center let currentX = Math.floor(width / 2); let currentY = Math.floor(height / 2); const targetSteps = Math.floor(width * height * DRUNKARD_CONFIG.steps); let steps = 0; let maxAttempts = DRUNKARD_CONFIG.maxSteps; let attempts = 0; // Ensure starting position is floor grid[currentY][currentX] = { type: 'floor', x: currentX, y: currentY, providesCover: false, health: 0, damageFlash: 0, color: generateObstacleColor() }; while (steps < targetSteps && attempts < maxAttempts) { // Random direction: 0=up, 1=right, 2=down, 3=left const direction = Math.floor(Math.random() * 4); let newX = currentX; let newY = currentY; switch (direction) { case 0: newY = Math.max(0, currentY - 1); break; // Up case 1: newX = Math.min(width - 1, currentX + 1); break; // Right case 2: newY = Math.min(height - 1, currentY + 1); break; // Down case 3: newX = Math.max(0, currentX - 1); break; // Left } // Carve path if (grid[newY][newX].type === 'obstacle') { grid[newY][newX] = { type: 'floor', x: newX, y: newY, providesCover: false, health: 0, damageFlash: 0, color: generateObstacleColor() }; steps++; } currentX = newX; currentY = newY; attempts++; } // Add some random floor tiles to ensure connectivity const additionalFloorTiles = Math.floor(width * height * 0.1); for (let i = 0; i < additionalFloorTiles; i++) { const x = Math.floor(Math.random() * width); const y = Math.floor(Math.random() * height); if (grid[y][x].type === 'obstacle') { // Check if it's adjacent to existing floor const hasAdjacentFloor = [ { dx: -1, dy: 0 }, { dx: 1, dy: 0 }, { dx: 0, dy: -1 }, { dx: 0, dy: 1 } ].some(({ dx, dy }) => { const checkX = x + dx; const checkY = y + dy; return checkX >= 0 && checkX < width && checkY >= 0 && checkY < height && grid[checkY][checkX].type === 'floor'; }); if (hasAdjacentFloor) { grid[y][x] = { type: 'floor', x, y, providesCover: false, health: 0, damageFlash: 0, color: generateObstacleColor() }; } } } return grid; } /** * Generates a level using Cellular Automata * @param {number} width * @param {number} height * @returns {Tile[][]} Generated grid */ function generateCellularAutomata(width, height) { // Initialize with random noise let grid = Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => Math.random() < 0.45 ? createObstacle(x, y) : { type: 'floor', x, y, providesCover: false, health: 0, damageFlash: 0, color: generateObstacleColor() } ) ); // Ensure edges are obstacles for (let y = 0; y < height; y++) { grid[y][0] = createObstacle(0, y); grid[y][width - 1] = createObstacle(width - 1, y); } for (let x = 0; x < width; x++) { grid[0][x] = createObstacle(x, 0); grid[height - 1][x] = createObstacle(x, height - 1); } // Run cellular automata iterations for (let iteration = 0; iteration < CELLULAR_CONFIG.iterations; iteration++) { const newGrid = Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => ({ ...grid[y][x] })) ); for (let y = 1; y < height - 1; y++) { for (let x = 1; x < width - 1; x++) { // Count adjacent obstacles let adjacentObstacles = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; if (grid[y + dy][x + dx].type === 'obstacle') { adjacentObstacles++; } } } // Apply rules if (grid[y][x].type === 'obstacle') { // Survival rule if (adjacentObstacles < CELLULAR_CONFIG.survivalThreshold) { newGrid[y][x].type = 'floor'; newGrid[y][x].providesCover = false; } } else { // Birth rule if (adjacentObstacles >= CELLULAR_CONFIG.birthThreshold) { newGrid[y][x] = createObstacle(x, y); } } } } grid = newGrid; } // Ensure minimum floor percentage const floorCount = grid.flat().filter(tile => tile.type === 'floor').length; const minFloorTiles = Math.floor(width * height * CELLULAR_CONFIG.minFloorPercentage); if (floorCount < minFloorTiles) { // Convert some obstacles to floor to meet minimum const obstaclesToConvert = minFloorTiles - floorCount; const obstacles = grid.flat().filter(tile => tile.type === 'obstacle' && tile.x > 0 && tile.x < width - 1 && tile.y > 0 && tile.y < height - 1); for (let i = 0; i < Math.min(obstaclesToConvert, obstacles.length); i++) { const obstacle = obstacles[i]; grid[obstacle.y][obstacle.x].type = 'floor'; grid[obstacle.y][obstacle.x].providesCover = false; } } return grid; } /** * Checks if a grid is fully navigable using flood fill * @param {Tile[][]} grid * @returns {boolean} True if grid is navigable */ function isGridNavigable(grid) { const width = grid[0].length; const height = grid.length; // Find a starting floor tile let startX = -1, startY = -1; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { if (grid[y][x].type === 'floor') { startX = x; startY = y; break; } } if (startX !== -1) break; } if (startX === -1) return false; // No floor tiles // Flood fill to count accessible floor tiles const visited = Array.from({ length: height }, () => new Array(width).fill(false)); const queue = [{ x: startX, y: startY }]; let accessibleCount = 0; while (queue.length > 0) { const { x, y } = queue.shift(); if (visited[y][x] || grid[y][x].type !== 'floor') continue; visited[y][x] = true; accessibleCount++; // Add adjacent tiles to queue const directions = [ { dx: -1, dy: 0 }, { dx: 1, dy: 0 }, { dx: 0, dy: -1 }, { dx: 0, dy: 1 } ]; for (const { dx, dy } of directions) { const newX = x + dx; const newY = y + dy; if (newX >= 0 && newX < width && newY >= 0 && newY < height && !visited[newY][newX] && grid[newY][newX].type === 'floor') { queue.push({ x: newX, y: newY }); } } } // Count total floor tiles const totalFloorTiles = grid.flat().filter(tile => tile.type === 'floor').length; // Grid is navigable if at least 90% of floor tiles are accessible return accessibleCount >= totalFloorTiles * 0.9; } /** * Ensures a grid is navigable by adding connecting paths if needed * @param {Tile[][]} grid * @returns {Tile[][]} Navigable grid */ function ensureNavigability(grid) { if (isGridNavigable(grid)) { return grid; } const width = grid[0].length; const height = grid.length; // Find isolated floor regions and connect them const visited = Array.from({ length: height }, () => new Array(width).fill(false)); const regions = []; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { if (grid[y][x].type === 'floor' && !visited[y][x]) { // Found new region, flood fill it const region = []; const queue = [{ x, y }]; while (queue.length > 0) { const { x: cx, y: cy } = queue.shift(); if (visited[cy][cx] || grid[cy][cx].type !== 'floor') continue; visited[cy][cx] = true; region.push({ x: cx, y: cy }); const directions = [ { dx: -1, dy: 0 }, { dx: 1, dy: 0 }, { dx: 0, dy: -1 }, { dx: 0, dy: 1 } ]; for (const { dx, dy } of directions) { const newX = cx + dx; const newY = cy + dy; if (newX >= 0 && newX < width && newY >= 0 && newY < height && !visited[newY][newX] && grid[newY][newX].type === 'floor') { queue.push({ x: newX, y: newY }); } } } if (region.length > 0) { regions.push(region); } } } } // Connect regions by creating paths between them for (let i = 0; i < regions.length - 1; i++) { const region1 = regions[i]; const region2 = regions[i + 1]; // Find closest points between regions let minDistance = Infinity; let point1 = null, point2 = null; for (const tile1 of region1) { for (const tile2 of region2) { const distance = Math.abs(tile1.x - tile2.x) + Math.abs(tile1.y - tile2.y); if (distance < minDistance) { minDistance = distance; point1 = tile1; point2 = tile2; } } } if (point1 && point2) { // Create path between points let currentX = point1.x; let currentY = point1.y; while (currentX !== point2.x || currentY !== point2.y) { if (currentX !== point2.x) { currentX += Math.sign(point2.x - currentX); if (grid[currentY][currentX].type === 'obstacle') { grid[currentY][currentX] = { type: 'floor', x: currentX, y: currentY, providesCover: false, health: 0, damageFlash: 0, color: generateObstacleColor() }; } } if (currentY !== point2.y) { currentY += Math.sign(point2.y - currentY); if (grid[currentY][currentX].type === 'obstacle') { grid[currentY][currentX] = { type: 'floor', x: currentX, y: currentY, providesCover: false, health: 0, damageFlash: 0, color: generateObstacleColor() }; } } } } } return grid; } /** * Generates the game world using a randomly selected algorithm * @param {number} width * @param {number} height * @returns {Tile[][]} Generated grid */ function generateWorld(width, height) { const generationType = Math.random() < 0.25 ? GENERATION_TYPES.SPARSE_WAREHOUSE : Math.random() < 0.33 ? GENERATION_TYPES.HALLWAY_ROOMS : Math.random() < 0.5 ? GENERATION_TYPES.DRUNKARDS_WALK : GENERATION_TYPES.CELLULAR_AUTOMATA; console.log(`Generating ${generationType} layout...`); let grid; switch (generationType) { case GENERATION_TYPES.SPARSE_WAREHOUSE: grid = generateSparseWarehouse(width, height); break; case GENERATION_TYPES.HALLWAY_ROOMS: grid = generateHallwayRooms(width, height); break; case GENERATION_TYPES.DRUNKARDS_WALK: grid = generateDrunkardsWalk(width, height); break; case GENERATION_TYPES.CELLULAR_AUTOMATA: grid = generateCellularAutomata(width, height); break; default: grid = generateSparseWarehouse(width, height); } // Ensure navigability grid = ensureNavigability(grid); return grid; } // ------------------------------------------------ // SQUAD GENERATION // ------------------------------------------------ /** * Generates a random stat within a given range * @param {number} min * @param {number} max * @returns {number} Random stat value */ function generateStat(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } /** * Generates a player squad of 3 units * @param {Tile[][]} grid * @returns {Unit[]} Array of player units */ function generatePlayerSquad(grid) { const units = []; const width = grid[0].length; const height = grid.length; // Find a good starting position for the squad let squadCenterX, squadCenterY; let attempts = 0; const maxAttempts = 100; do { squadCenterX = Math.floor(Math.random() * (width - 4)) + 2; squadCenterY = Math.floor(Math.random() * (height - 4)) + 2; attempts++; } while ( attempts < maxAttempts && (grid[squadCenterY][squadCenterX].type === 'obstacle' || grid[squadCenterY][squadCenterX + 1].type === 'obstacle' || grid[squadCenterY + 1][squadCenterX].type === 'obstacle') ); // Place units in a triangle formation around the center const positions = [ { x: squadCenterX, y: squadCenterY }, { x: squadCenterX + 1, y: squadCenterY }, { x: squadCenterX, y: squadCenterY + 1 } ]; // Ensure all positions are valid const validPositions = positions.filter(pos => pos.x >= 0 && pos.x < width && pos.y >= 0 && pos.y < height && grid[pos.y][pos.x].type !== 'obstacle' ); // If we can't place all units in formation, place them randomly but close if (validPositions.length < 3) { for (let i = 0; i < 3; i++) { let x, y; do { x = squadCenterX + Math.floor(Math.random() * 3) - 1; y = squadCenterY + Math.floor(Math.random() * 3) - 1; x = Math.max(0, Math.min(width - 1, x)); y = Math.max(0, Math.min(height - 1, y)); } while (grid[y][x].type === 'obstacle' || units.some(unit => unit.x === x && unit.y === y)); const unit = createPlayerUnit(i + 1, x, y); units.push(unit); } } else { // Place units in formation for (let i = 0; i < 3; i++) { const pos = validPositions[i]; const unit = createPlayerUnit(i + 1, pos.x, pos.y); units.push(unit); } } return units; } /** * Creates a player unit with the given stats * @param {number} id * @param {number} x * @param {number} y * @returns {Unit} Player unit */ function createPlayerUnit(id, x, y) { const unit = { id, owner: 'player', x, y, maxMovement: generateStat(2, 6), // Reduced from 2-10 to 2-6 movementRange: 0, // Will be set to maxMovement shootRange: generateStat(3, 8), // Reduced from 4-15 to 3-8 damage: generateStat(5, 20), maxHp: generateStat(30, 60), currentHp: 0, // Will be set to maxHp inCover: false, hasMoved: false, hasAttacked: false, isAnimating: false, animationType: 'none', animationProgress: 0, targetX: -1, targetY: -1, path: [], projectileX: -1, projectileY: -1, projectileTargetX: -1, projectileTargetY: -1, deathParticles: [], isDead: false, pendingDamage: 0, justCompletedShot: false, isVisible: true, lastSeen: 0, lastKnownX: x, lastKnownY: y, aiBehavior: 'aggressive', turnOrder: 0, patrolCenterX: 0, patrolCenterY: 0, patrolRadius: 0, actionFeedbackTimer: 0 }; // Set current values to max values unit.movementRange = unit.maxMovement; unit.currentHp = unit.maxHp; // Validate unit creation console.log('Player unit created:', unit); return unit; } /** * Generates an enemy squad of 2-7 units * @param {Tile[][]} grid * @returns {Unit[]} Array of enemy units */ function generateEnemySquad(grid) { const units = []; const width = grid[0].length; const height = grid.length; const squadSize = generateStat(2, 7); for (let i = 0; i < squadSize; i++) { let x, y; do { x = Math.floor(Math.random() * width); y = Math.floor(Math.random() * height); } while (grid[y][x].type === 'obstacle' || units.some(unit => unit.x === x && unit.y === y)); const unit = createEnemyUnit(100 + i + 1, x, y); units.push(unit); } return units; } /** * Creates an enemy unit with the given stats * @param {number} id * @param {number} x * @param {number} y * @returns {Unit} Enemy unit */ function createEnemyUnit(id, x, y) { const aiBehavior = AI_BEHAVIORS[Math.floor(Math.random() * AI_BEHAVIORS.length)]; const unit = { id, owner: 'enemy', x, y, maxMovement: generateStat(2, 6), // Reduced from 2-10 to 2-6 movementRange: 0, // Will be set to maxMovement shootRange: generateStat(3, 8), // Reduced from 4-15 to 3-8 damage: generateStat(5, 10), maxHp: generateStat(10, 20), currentHp: 0, // Will be set to maxHp inCover: false, hasMoved: false, hasAttacked: false, isAnimating: false, animationType: 'none', animationProgress: 0, targetX: -1, targetY: -1, path: [], projectileX: -1, projectileY: -1, projectileTargetX: -1, projectileTargetY: -1, deathParticles: [], isDead: false, pendingDamage: 0, justCompletedShot: false, isVisible: false, // Enemy units start hidden lastSeen: 0, lastKnownX: x, lastKnownY: y, aiBehavior, turnOrder: 0, // Will be set by generateTurnOrder patrolCenterX: x, // Starting position becomes patrol center patrolCenterY: y, patrolRadius: generateStat(3, 8), // Random patrol radius actionFeedbackTimer: 0 }; // Set current values to max values unit.movementRange = unit.maxMovement; unit.currentHp = unit.maxHp; // Validate unit creation console.log('Enemy unit created:', unit); return unit; } /** * Checks for completed shooting animations and applies damage to targets * @param {Unit[]} units * @param {Tile[][]} grid * @returns {{units: Unit[], grid: Tile[][], updated: boolean}} Updated units and grid with damage applied */ function processCompletedShots(units, grid) { let unitsUpdated = false; let gridUpdated = false; let newGrid = grid; const updatedUnits = units.map(unit => { // Check if this unit just completed a shooting animation if (unit.justCompletedShot) { console.log('Processing completed shot for unit:', unit.id); console.log('Projectile target coordinates:', unit.projectileTargetX, unit.projectileTargetY); console.log('All units and their coordinates:'); units.forEach(u => console.log(`Unit ${u.id} (${u.owner}): x=${u.x}, y=${u.y}`)); // Check if we hit an obstacle if (grid[unit.projectileTargetY] && grid[unit.projectileTargetY][unit.projectileTargetX] && grid[unit.projectileTargetY][unit.projectileTargetX].type === 'obstacle') { console.log('Shot hit obstacle, applying damage'); if (!gridUpdated) { newGrid = grid.map(row => [...row]); gridUpdated = true; } // Get the current obstacle const currentObstacle = newGrid[unit.projectileTargetY][unit.projectileTargetX]; // Apply damage currentObstacle.health -= 1; currentObstacle.damageFlash = 1.0; // Set flash effect if (currentObstacle.health <= 0) { // Obstacle destroyed newGrid[unit.projectileTargetY][unit.projectileTargetX] = { type: 'floor', x: unit.projectileTargetX, y: unit.projectileTargetY, providesCover: false, health: 0, damageFlash: 0, color: generateObstacleColor() }; console.log('Obstacle destroyed by shot!'); } unitsUpdated = true; } else { // Find the target unit const targetUnit = units.find(u => u.x === unit.projectileTargetX && u.y === unit.projectileTargetY ); console.log('Target unit found:', targetUnit); console.log('Target pending damage:', targetUnit?.pendingDamage); if (targetUnit && targetUnit.pendingDamage > 0) { // Apply damage to target const newHp = Math.max(0, targetUnit.currentHp - targetUnit.pendingDamage); console.log('Applying damage:', targetUnit.pendingDamage, 'New HP:', newHp); if (newHp <= 0 && !targetUnit.isDead) { // Target died - start death animation console.log('Target died, starting death animation'); const deadUnit = createDeathParticles({ ...targetUnit, currentHp: newHp, pendingDamage: 0 }); // Update the target unit in the array const targetIndex = units.findIndex(u => u.id === targetUnit.id); if (targetIndex !== -1) { units[targetIndex] = deadUnit; } unitsUpdated = true; } else { // Target survived - just apply damage console.log('Target survived with new HP:', newHp); const updatedTarget = { ...targetUnit, currentHp: newHp, pendingDamage: 0 }; const targetIndex = units.findIndex(u => u.id === targetUnit.id); units[targetIndex] = updatedTarget; unitsUpdated = true; } } else { console.log('No target found or no pending damage'); } } // Clear the flag and return updated shooting unit return { ...unit, justCompletedShot: false, projectileTargetX: -1, projectileTargetY: -1 }; } return unit; }); return { units: unitsUpdated ? cleanupDeadUnits(units) : updatedUnits, grid: newGrid, updated: unitsUpdated || gridUpdated }; } // ------------------------------------------------ // 1. STATE INITIALIZATION (MODEL) // ------------------------------------------------ /** * Creates the initial state of the game. * @returns {Model} The initial model. */ function init() { const { width, height } = calculateGridSize(); // Generate world using procedural generation const grid = generateWorld(width, height); // Generate squads const playerUnits = generatePlayerSquad(grid); const enemyUnits = generateEnemySquad(grid); let units = [...playerUnits, ...enemyUnits]; // Generate turn order for all units const unitsWithTurnOrder = generateTurnOrder(units); // Update cover status for all units unitsWithTurnOrder.forEach(unit => { unit.inCover = checkCover(unit, grid); }); // Set initial visibility - enemies start hidden unless they're in line of sight of player units const unitsWithVisibility = updateUnitVisibility(unitsWithTurnOrder, grid); // Debug: Show all obstacle health values console.log('=== GAME INITIALIZATION ==='); let obstacleCount = 0; for (let y = 0; y < grid.length; y++) { for (let x = 0; x < grid[y].length; x++) { if (grid[y][x].type === 'obstacle') { obstacleCount++; } } } console.log(`Total obstacles created: ${obstacleCount}`); console.log('Initial currentTurnIndex:', 0); console.log('Initial turn order:', unitsWithVisibility.map(u => ({ id: u.id, owner: u.owner, turnOrder: u.turnOrder }))); console.log('First unit in turn order:', unitsWithVisibility.find(u => u.turnOrder === 0)); console.log('=== END INITIALIZATION ==='); return { grid: grid, units: unitsWithVisibility, selectedUnit: null, uiState: { type: 'AwaitingSelection' }, currentTurnIndex: 0 }; } // ------------------------------------------------ // 2. LOGIC (UPDATE) // ------------------------------------------------ /** * Handles all state changes in the application. * @param {object} msg - The action message. * @param {Model} model - The current state. * @returns {Model} The new state. */ function update(msg, model) { // Check if it's a player unit's turn const currentUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); const isPlayerTurn = currentUnit && currentUnit.owner === 'player'; if (!isPlayerTurn) { return model; } switch (msg.type) { case 'TILE_CLICKED': { const { x, y } = msg.payload; const unitAtTile = model.units.find(u => u.x === x && u.y === y); console.log('=== TILE CLICK ==='); console.log('Clicked at:', x, y); console.log('Unit at tile:', unitAtTile ? `${unitAtTile.owner} ${unitAtTile.id}` : 'none'); // Check if it's a player unit's turn const currentUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); console.log('Current unit in turn order:', currentUnit ? `${currentUnit.owner} ${currentUnit.id}` : 'none'); console.log('Is player turn:', currentUnit && currentUnit.owner === 'player'); if (!currentUnit || currentUnit.owner !== 'player') { console.log('Not a player unit\'s turn, returning'); return model; // Not a player unit's turn } if (model.uiState.type === 'UnitSelected') { const selectedUnit = model.units.find(u => u.id === model.uiState.unitId); // Only allow actions if the selected unit is the current unit in turn order if (selectedUnit.id !== currentUnit.id) { return model; // Can't act with a different unit } if (!selectedUnit.hasMoved) { // Movement phase const distance = Math.abs(selectedUnit.x - x) + Math.abs(selectedUnit.y - y); const isMoveValid = distance > 0 && distance <= selectedUnit.movementRange && !unitAtTile && model.grid[y][x].type !== 'obstacle' && !isPathBlocked(selectedUnit.x, selectedUnit.y, x, y, model.grid) && !selectedUnit.hasMoved; if (isMoveValid) { const newUnits = model.units.map(unit => unit.id === selectedUnit.id ? startMovementAnimation(unit, x, y, model.grid) : unit ); // Update cover status for moved unit newUnits.forEach(unit => { unit.inCover = checkCover(unit, model.grid); }); // Update unit visibility after movement const unitsWithVisibility = updateUnitVisibility(newUnits, model.grid); return { ...model, units: unitsWithVisibility, uiState: { type: 'AwaitingSelection' } }; } } else if (!selectedUnit.hasAttacked) { // Attack phase - can attack any unit (including friendly fire) if (unitAtTile) { // Can't attack enemy units that are not visible if (unitAtTile.owner === 'enemy' && !unitAtTile.isVisible) { return model; // Invalid attack - enemy not visible } // Can't attack units that are at their last known position (ghosts) if (unitAtTile.owner === 'enemy' && unitAtTile.lastSeen && (unitAtTile.x !== unitAtTile.lastKnownX || unitAtTile.y !== unitAtTile.lastKnownY)) { return model; // Invalid attack - attacking ghost position } const distance = Math.abs(selectedUnit.x - unitAtTile.x) + Math.abs(selectedUnit.y - unitAtTile.y); const isAttackValid = distance <= selectedUnit.shootRange && !selectedUnit.hasAttacked; if (isAttackValid) { // Check line of sight const los = checkLineOfSight( selectedUnit.x, selectedUnit.y, unitAtTile.x, unitAtTile.y, model.grid, model.units ); if (los.blocked) { // Line of sight is blocked - handle stray shot let damage = selectedUnit.damage; if (los.obstacleX !== null && los.obstacleY !== null) { // Hit an obstacle console.log('Stray shot hit obstacle at:', los.obstacleX, los.obstacleY); const newGrid = model.grid.map(row => [...row]); // Get the current obstacle const currentObstacle = newGrid[los.obstacleY][los.obstacleX]; // Apply damage currentObstacle.health -= 1; currentObstacle.damageFlash = 1.0; // Set flash effect if (currentObstacle.health <= 0) { // Obstacle destroyed newGrid[los.obstacleY][los.obstacleX] = { type: 'floor', x: los.obstacleX, y: los.obstacleY, providesCover: false, health: 0, damageFlash: 0, color: generateObstacleColor() }; console.log('Obstacle destroyed by stray shot!'); } // Start shooting animation towards the obstacle const newUnits = model.units.map(unit => unit.id === selectedUnit.id ? startShootingAnimation(unit, los.obstacleX, los.obstacleY) : unit ); return { ...model, grid: newGrid, units: newUnits, uiState: { type: 'AwaitingSelection' } }; } else if (los.blocker) { // Hit a blocking unit console.log('Stray shot hit blocking unit:', los.blocker.id); const newUnits = model.units.map(unit => unit.id === los.blocker.id ? { ...unit, pendingDamage: damage } : unit.id === selectedUnit.id ? startShootingAnimation(unit, los.blocker.x, los.blocker.y) : unit ); return { ...model, units: newUnits, uiState: { type: 'AwaitingSelection' } }; } } else { // Clear line of sight - proceed with normal attack // Calculate damage (cover reduces damage by 50%) let damage = selectedUnit.damage; if (unitAtTile.inCover) { damage = Math.floor(damage * 0.5); } // Start shooting animation and mark target for damage const newUnits = model.units.map(unit => unit.id === unitAtTile.id ? { ...unit, pendingDamage: damage } // Mark target for damage when projectile hits : unit.id === selectedUnit.id ? startShootingAnimation(unit, unitAtTile.x, unitAtTile.y) : unit ); return { ...model, units: newUnits, uiState: { type: 'AwaitingSelection' } }; } } } } } // Check if we can target an obstacle directly if (model.uiState.type === 'UnitSelected' && model.grid[y][x].type === 'obstacle') { const selectedUnit = model.units.find(u => u.id === model.uiState.unitId); if (selectedUnit && !selectedUnit.hasAttacked) { const distance = Math.abs(selectedUnit.x - x) + Math.abs(selectedUnit.y - y); const isAttackValid = distance <= selectedUnit.shootRange && !selectedUnit.hasAttacked; if (isAttackValid) { console.log('Direct obstacle attack at:', x, y); // Start shooting animation towards the obstacle const newUnits = model.units.map(unit => unit.id === selectedUnit.id ? startShootingAnimation(unit, x, y) : unit ); return { ...model, units: newUnits, uiState: { type: 'AwaitingSelection' } }; } } } if (unitAtTile && unitAtTile.owner === 'player' && (!unitAtTile.hasMoved || !unitAtTile.hasAttacked)) { console.log('Attempting to select player unit:', unitAtTile.id); console.log('Unit state:', { hasMoved: unitAtTile.hasMoved, hasAttacked: unitAtTile.hasAttacked }); // Only allow selecting the current unit in turn order if (unitAtTile.id !== currentUnit.id) { console.log('Cannot select unit - not current unit in turn order'); return model; // Can't select a different unit } console.log('Successfully selecting unit:', unitAtTile.id); return { ...model, uiState: { type: 'UnitSelected', unitId: unitAtTile.id } }; } // Can't select enemy units that are not visible if (unitAtTile && unitAtTile.owner === 'enemy' && !unitAtTile.isVisible) { return model; // Invalid selection - enemy not visible } return { ...model, uiState: { type: 'AwaitingSelection' } }; } case 'SKIP_MOVEMENT': { if (model.uiState.type === 'UnitSelected') { const selectedUnit = model.units.find(u => u.id === model.uiState.unitId); const currentUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); // Only allow skipping if it's the current unit's turn if (!currentUnit || selectedUnit.id !== currentUnit.id) { return model; } const newUnits = model.units.map(unit => unit.id === selectedUnit.id ? { ...unit, hasMoved: true } : unit ); return { ...model, units: newUnits, uiState: { type: 'AwaitingSelection' } }; } return model; } case 'SKIP_ATTACK': { if (model.uiState.type === 'UnitSelected') { const selectedUnit = model.units.find(u => u.id === model.uiState.unitId); const currentUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); // Only allow skipping if it's the current unit's turn if (!currentUnit || selectedUnit.id !== currentUnit.id) { return model; } const newUnits = model.units.map(unit => unit.id === selectedUnit.id ? { ...unit, hasAttacked: true } : unit ); return { ...model, units: newUnits, uiState: { type: 'AwaitingSelection' } }; } return model; } case 'END_TURN_CLICKED': { // Advance to next turn in turn order return advanceTurn(model); } default: return model; } } // ------------------------------------------------ // 3. RENDERER (VIEW) // ------------------------------------------------ /** * Draws the grid tiles and obstacles. * @param {Model} model * @param {CanvasRenderingContext2D} ctx */ function drawGrid(model, ctx) { const gridWidth = model.grid[0].length; const gridHeight = model.grid.length; // Draw tiles for (let y = 0; y < gridHeight; y++) { for (let x = 0; x < gridWidth; x++) { const tile = model.grid[y][x]; const tileX = x * TILE_SIZE; const tileY = y * TILE_SIZE; // Fill tile ctx.fillStyle = tile.type === 'obstacle' ? tile.color : COLORS.floor; ctx.fillRect(tileX, tileY, TILE_SIZE, TILE_SIZE); // Draw obstacle health indicator if (tile.type === 'obstacle' && tile.health < OBSTACLE_MAX_HEALTH) { // Create a more gradual color fade based on health using grey/black/blue palette const healthRatio = tile.health / OBSTACLE_MAX_HEALTH; let damageColor; if (healthRatio > 0.83) { // 5 HP - slight blue tint damageColor = `rgba(52, 73, 94, ${0.2 + (1 - healthRatio) * 0.3})`; } else if (healthRatio > 0.66) { // 4 HP - more blue-grey damageColor = `rgba(44, 62, 80, ${0.3 + (1 - healthRatio) * 0.4})`; } else if (healthRatio > 0.5) { // 3 HP - darker blue-grey damageColor = `rgba(36, 51, 66, ${0.4 + (1 - healthRatio) * 0.4})`; } else if (healthRatio > 0.33) { // 2 HP - very dark blue-grey damageColor = `rgba(28, 40, 52, ${0.5 + (1 - healthRatio) * 0.4})`; } else { // 1 HP - almost black with blue tint damageColor = `rgba(20, 29, 38, ${0.6 + (1 - healthRatio) * 0.4})`; } ctx.fillStyle = damageColor; ctx.fillRect(tileX + 2, tileY + 2, TILE_SIZE - 4, TILE_SIZE - 4); } // Draw damage flash effect if obstacle was recently hit if (tile.type === 'obstacle' && tile.damageFlash && tile.damageFlash > 0) { ctx.fillStyle = `rgba(52, 152, 219, ${tile.damageFlash * 0.6})`; // Blue-white flash ctx.fillRect(tileX, tileY, TILE_SIZE, TILE_SIZE); } // Draw grid lines ctx.strokeStyle = COLORS.gridLine; ctx.lineWidth = 1; ctx.strokeRect(tileX, tileY, TILE_SIZE, TILE_SIZE); } } } /** * Draws highlights for valid moves and attacks. * @param {Model} model * @param {CanvasRenderingContext2D} ctx */ function drawHighlights(model, ctx) { if (model.uiState.type === 'AwaitingSelection') return; const selectedUnit = model.units.find(u => u.id === model.uiState.unitId); const currentUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); if (!selectedUnit || !currentUnit) return; // Only show highlights if the selected unit is the current unit in turn order if (selectedUnit.id !== currentUnit.id) return; if (model.uiState.type === 'UnitSelected' && !selectedUnit.hasMoved) { // Show movement range ctx.fillStyle = COLORS.moveHighlight; for (const row of model.grid) { for (const tile of row) { if (tile.type === 'obstacle') continue; const distance = Math.abs(selectedUnit.x - tile.x) + Math.abs(selectedUnit.y - tile.y); if (distance > 0 && distance <= selectedUnit.movementRange) { // Check if path is not blocked if (!isPathBlocked(selectedUnit.x, selectedUnit.y, tile.x, tile.y, model.grid)) { ctx.fillRect(tile.x * TILE_SIZE, tile.y * TILE_SIZE, TILE_SIZE, TILE_SIZE); } } } } } if (model.uiState.type === 'UnitSelected' && selectedUnit.hasMoved && !selectedUnit.hasAttacked) { // Show attack range ctx.fillStyle = 'rgba(255, 0, 0, 0.3)'; for (const row of model.grid) { for (const tile of row) { const distance = Math.abs(selectedUnit.x - tile.x) + Math.abs(selectedUnit.y - tile.y); if (distance > 0 && distance <= selectedUnit.shootRange) { ctx.fillRect(tile.x * TILE_SIZE, tile.y * TILE_SIZE, TILE_SIZE, TILE_SIZE); } } } // Highlight potential targets ctx.fillStyle = 'rgba(255, 0, 0, 0.6)'; model.units.forEach(unit => { if (unit.id !== selectedUnit.id && (unit.owner === 'player' || (unit.owner === 'enemy' && unit.isVisible && unit.x === unit.lastKnownX && unit.y === unit.lastKnownY))) { const distance = Math.abs(selectedUnit.x - unit.x) + Math.abs(selectedUnit.y - unit.y); if (distance <= selectedUnit.shootRange) { ctx.beginPath(); ctx.arc(unit.x * TILE_SIZE + TILE_SIZE / 2, unit.y * TILE_SIZE + TILE_SIZE / 2, UNIT_RADIUS + 5, 0, Math.PI * 2); ctx.fill(); } } }); // Highlight targetable obstacles ctx.fillStyle = 'rgba(52, 152, 219, 0.4)'; // Blue highlight for targetable obstacles for (const row of model.grid) { for (const tile of row) { if (tile.type === 'obstacle') { const distance = Math.abs(selectedUnit.x - tile.x) + Math.abs(selectedUnit.y - tile.y); if (distance > 0 && distance <= selectedUnit.shootRange) { ctx.fillRect(tile.x * TILE_SIZE + 2, tile.y * TILE_SIZE + 2, TILE_SIZE - 4, TILE_SIZE - 4); } } } } } } /** * Draws the units on the grid. * @param {Model} model * @param {CanvasRenderingContext2D} ctx */ function drawUnits(model, ctx) { model.units.forEach(unit => { if (unit.isDead) return; // Sanitize unit state to prevent crashes const sanitizedUnit = sanitizeUnitState(unit); let drawPosition; if (sanitizedUnit.owner === 'enemy' && !sanitizedUnit.isVisible && sanitizedUnit.lastSeen) { // Draw at last known position for invisible enemy units drawPosition = { x: sanitizedUnit.lastKnownX, y: sanitizedUnit.lastKnownY }; } else { // Draw at current position for visible units try { drawPosition = getUnitPosition(sanitizedUnit); } catch (error) { console.warn('Error getting position for unit', sanitizedUnit.id, ':', error); drawPosition = { x: sanitizedUnit.x, y: sanitizedUnit.y }; } } if (!drawPosition || typeof drawPosition.x !== 'number' || typeof drawPosition.y !== 'number') { console.warn('Invalid position for unit:', sanitizedUnit.id, drawPosition, 'skipping render'); return; // Skip this unit } const { x, y } = drawPosition; const centerX = x * TILE_SIZE + TILE_SIZE / 2; const centerY = y * TILE_SIZE + TILE_SIZE / 2; // For invisible enemy units, draw as a ghost if (sanitizedUnit.owner === 'enemy' && !sanitizedUnit.isVisible) { ctx.globalAlpha = 0.3; // Make them semi-transparent ctx.fillStyle = '#666'; // Grey color for ghosts } else { ctx.globalAlpha = 1.0; // Full opacity for visible units ctx.fillStyle = sanitizedUnit.owner === 'player' ? COLORS.player : COLORS.enemy; } // Draw cover indicator if (sanitizedUnit.inCover) { ctx.fillStyle = COLORS.coverHighlight; ctx.beginPath(); ctx.arc(centerX, centerY, UNIT_RADIUS + 5, 0, Math.PI * 2); ctx.fill(); } // Draw shooting animation effect if (sanitizedUnit.isAnimating && sanitizedUnit.animationType === 'shooting') { const pulseSize = UNIT_RADIUS + 10 + Math.sin(sanitizedUnit.animationProgress * Math.PI * 4) * 5; ctx.fillStyle = 'rgba(255, 255, 0, 0.3)'; ctx.beginPath(); ctx.arc(centerX, centerY, pulseSize, 0, Math.PI * 2); ctx.fill(); // Draw projectile const projectilePos = getProjectilePosition(sanitizedUnit); if (projectilePos) { // Draw projectile trail const trailLength = 15; const angle = Math.atan2(projectilePos.y - centerY, projectilePos.x - centerX); const trailStartX = projectilePos.x - Math.cos(angle) * trailLength; const trailStartY = projectilePos.y - Math.sin(angle) * trailLength; // Gradient for trail effect const gradient = ctx.createLinearGradient(trailStartX, trailStartY, projectilePos.x, projectilePos.y); gradient.addColorStop(0, 'rgba(255, 255, 0, 0)'); gradient.addColorStop(0.5, 'rgba(255, 255, 0, 0.8)'); gradient.addColorStop(1, 'rgba(255, 255, 0, 1)'); ctx.strokeStyle = gradient; ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(trailStartX, trailStartY); ctx.lineTo(projectilePos.x, projectilePos.y); ctx.stroke(); // Draw projectile bolt ctx.fillStyle = '#FFFF00'; ctx.beginPath(); ctx.arc(projectilePos.x, projectilePos.y, 4, 0, Math.PI * 2); ctx.fill(); // Add glow effect ctx.shadowColor = '#FFFF00'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(projectilePos.x, projectilePos.y, 2, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; // Draw impact effect when projectile reaches target if (sanitizedUnit.animationProgress > 0.8) { const targetCenterX = sanitizedUnit.projectileTargetX * TILE_SIZE + TILE_SIZE / 2; const targetCenterY = sanitizedUnit.projectileTargetY * TILE_SIZE + TILE_SIZE / 2; // Impact explosion const explosionSize = (sanitizedUnit.animationProgress - 0.8) * 20; const alpha = 1 - (sanitizedUnit.animationProgress - 0.8) * 5; ctx.fillStyle = `rgba(255, 100, 0, ${alpha})`; ctx.beginPath(); ctx.arc(targetCenterX, targetCenterY, explosionSize, 0, Math.PI * 2); ctx.fill(); // Inner explosion ctx.fillStyle = `rgba(255, 255, 0, ${alpha * 0.8})`; ctx.beginPath(); ctx.arc(targetCenterX, targetCenterY, explosionSize * 0.6, 0, Math.PI * 2); ctx.fill(); } } } // Draw death particles if (sanitizedUnit.isAnimating && sanitizedUnit.animationType === 'dying' && sanitizedUnit.deathParticles) { sanitizedUnit.deathParticles.forEach(particle => { const alpha = particle.life / particle.maxLife; const size = particle.size * alpha; ctx.fillStyle = particle.color; ctx.globalAlpha = alpha; ctx.beginPath(); ctx.arc(particle.x, particle.y, size, 0, Math.PI * 2); ctx.fill(); // Add glow effect ctx.shadowColor = particle.color; ctx.shadowBlur = size * 2; ctx.beginPath(); ctx.arc(particle.x, particle.y, size * 0.5, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; }); ctx.globalAlpha = 1; // Reset global alpha } // Draw targeting reticle for units in attack range if (model.uiState.type === 'UnitSelected' && model.uiState.unitId !== sanitizedUnit.id && sanitizedUnit.owner !== 'player') { const selectedUnit = model.units.find(u => u.id === model.uiState.unitId); if (selectedUnit && selectedUnit.hasMoved && !selectedUnit.hasAttacked) { const distance = Math.abs(selectedUnit.x - sanitizedUnit.x) + Math.abs(selectedUnit.y - sanitizedUnit.y); if (distance <= selectedUnit.shootRange) { // Draw targeting reticle ctx.strokeStyle = '#FF0000'; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); const reticleSize = UNIT_RADIUS + 8; ctx.beginPath(); ctx.arc(centerX, centerY, reticleSize, 0, Math.PI * 2); ctx.stroke(); // Draw crosshairs ctx.beginPath(); ctx.moveTo(centerX - reticleSize - 5, centerY); ctx.lineTo(centerX - reticleSize + 5, centerY); ctx.moveTo(centerX + reticleSize - 5, centerY); ctx.lineTo(centerX + reticleSize + 5, centerY); ctx.moveTo(centerX, centerY - reticleSize - 5); ctx.lineTo(centerX, centerY - reticleSize + 5); ctx.moveTo(centerX, centerY + reticleSize - 5); ctx.lineTo(centerX, centerY + reticleSize + 5); ctx.stroke(); ctx.setLineDash([]); } } } // Draw unit ctx.fillStyle = sanitizedUnit.owner === 'player' ? COLORS.player : COLORS.enemy; ctx.strokeStyle = (model.uiState.type === 'UnitSelected' && model.uiState.unitId === sanitizedUnit.id) ? COLORS.selectedBorder : COLORS.unitBorder; ctx.lineWidth = 2; // Add pending damage indicator if (sanitizedUnit.pendingDamage > 0) { ctx.strokeStyle = '#FF0000'; ctx.lineWidth = 4; } ctx.beginPath(); ctx.arc(centerX, centerY, UNIT_RADIUS, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // Draw HP bar const hpBarWidth = UNIT_RADIUS * 2; const hpBarHeight = 4; const hpBarX = centerX - hpBarWidth / 2; const hpBarY = centerY - UNIT_RADIUS - 10; // Background ctx.fillStyle = '#333'; ctx.fillRect(hpBarX, hpBarY, hpBarWidth, hpBarHeight); // HP bar const hpPercentage = sanitizedUnit.currentHp / sanitizedUnit.maxHp; ctx.fillStyle = hpPercentage > 0.5 ? '#4CAF50' : hpPercentage > 0.25 ? '#FF9800' : '#F44336'; ctx.fillRect(hpBarX, hpBarY, hpBarWidth * hpPercentage, hpBarHeight); // HP text ctx.fillStyle = '#fff'; ctx.font = '10px Arial'; ctx.textAlign = 'center'; ctx.fillText(`${sanitizedUnit.currentHp}/${sanitizedUnit.maxHp}`, centerX, hpBarY - 2); // Draw action indicators if (sanitizedUnit.owner === 'player') { // Movement indicator if (!sanitizedUnit.hasMoved) { // ctx.fillStyle = '#4CAF50'; // ctx.beginPath(); // ctx.arc(centerX - 8, centerY + UNIT_RADIUS + 5, 3, 0, Math.PI * 2); // ctx.fill(); } // Attack indicator if (!sanitizedUnit.hasAttacked) { ctx.fillStyle = sanitizedUnit.hasMoved ? '#FF0000' : '#FF9800'; // Red if ready to attack, orange if waiting ctx.beginPath(); ctx.arc(centerX + 8, centerY + UNIT_RADIUS + 5, 3, 0, Math.PI * 2); ctx.fill(); } // Phase indicator text if (model.uiState.type === 'UnitSelected' && model.uiState.unitId === sanitizedUnit.id) { ctx.fillStyle = '#fff'; ctx.font = '12px Arial'; ctx.textAlign = 'center'; if (!sanitizedUnit.hasMoved) { // ctx.fillText('MOVE', centerX, centerY - UNIT_RADIUS - 15); } else if (!sanitizedUnit.hasAttacked) { // ctx.fillText('ATTACK', centerX, centerY - UNIT_RADIUS - 15); } } } // Draw turn indicator for current unit const currentUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); if (currentUnit && currentUnit.id === sanitizedUnit.id) { // Draw turn indicator ring ctx.strokeStyle = sanitizedUnit.owner === 'player' ? '#00FF00' : '#FF0000'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(centerX, centerY, UNIT_RADIUS + 8, 0, Math.PI * 2); ctx.stroke(); // Draw turn indicator text ctx.fillStyle = sanitizedUnit.owner === 'player' ? '#00FF00' : '#FF0000'; ctx.font = '14px Arial'; ctx.textAlign = 'center'; // ctx.fillText(sanitizedUnit.owner === 'player' ? 'YOUR TURN' : 'ENEMY TURN', centerX, centerY + UNIT_RADIUS + 25); // For player units, add a subtle glow to show they're selectable if (sanitizedUnit.owner === 'player' && !sanitizedUnit.hasMoved && !sanitizedUnit.hasAttacked) { ctx.shadowColor = '#00FF00'; ctx.shadowBlur = 15; ctx.beginPath(); ctx.arc(centerX, centerY, UNIT_RADIUS + 2, 0, Math.PI * 2); ctx.stroke(); ctx.shadowBlur = 0; } // For enemy units, show action feedback if (sanitizedUnit.owner === 'enemy' && sanitizedUnit.actionFeedbackTimer > 0) { // Draw action feedback ring const feedbackAlpha = sanitizedUnit.actionFeedbackTimer / ENEMY_ACTION_FEEDBACK_DURATION; ctx.strokeStyle = `rgba(255, 255, 0, ${feedbackAlpha})`; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(centerX, centerY, UNIT_RADIUS + 15, 0, Math.PI * 2); ctx.stroke(); // Draw action feedback text ctx.fillStyle = `rgba(255, 255, 0, ${feedbackAlpha})`; ctx.font = '12px Arial'; ctx.textAlign = 'center'; if (sanitizedUnit.isAnimating) { if (sanitizedUnit.animationType === 'moving') { ctx.fillText('MOVING...', centerX, centerY + UNIT_RADIUS + 40); } else if (sanitizedUnit.animationType === 'shooting') { ctx.fillText('ATTACKING...', centerX, centerY + UNIT_RADIUS + 40); } } else { ctx.fillText('THINKING...', centerX, centerY + UNIT_RADIUS + 40); } } // Show thinking indicator for current enemy unit even without action feedback if (sanitizedUnit.owner === 'enemy' && currentUnit && currentUnit.id === sanitizedUnit.id && !sanitizedUnit.actionFeedbackTimer && !sanitizedUnit.isAnimating) { // Draw subtle thinking indicator ctx.strokeStyle = 'rgba(255, 255, 0, 0.3)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(centerX, centerY, UNIT_RADIUS + 12, 0, Math.PI * 2); ctx.stroke(); // Draw thinking text ctx.fillStyle = 'rgba(255, 255, 0, 0.5)'; ctx.font = '10px Arial'; ctx.textAlign = 'center'; ctx.fillText('THINKING...', centerX, centerY + UNIT_RADIUS + 35); } } // Draw movement path preview if (sanitizedUnit.isAnimating && sanitizedUnit.animationType === 'moving' && sanitizedUnit.path.length > 0) { ctx.strokeStyle = 'rgba(0, 255, 0, 0.5)'; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); ctx.beginPath(); ctx.moveTo(sanitizedUnit.x * TILE_SIZE + TILE_SIZE / 2, sanitizedUnit.y * TILE_SIZE + TILE_SIZE / 2); for (const pathPoint of sanitizedUnit.path) { ctx.lineTo(pathPoint.x * TILE_SIZE + TILE_SIZE / 2, pathPoint.y * TILE_SIZE + TILE_SIZE / 2); } ctx.stroke(); ctx.setLineDash([]); } }); // Reset global alpha to ensure proper rendering ctx.globalAlpha = 1.0; } /** * Renders the entire game state to the canvas. * @param {Model} model - The current state to render. * @param {HTMLCanvasElement} canvas * @param {CanvasRenderingContext2D} ctx */ function view(model, canvas, ctx) { // Pre-condition: canvas and context are valid. ctx.clearRect(0, 0, canvas.width, canvas.height); drawGrid(model, ctx); drawHighlights(model, ctx); drawUnits(model, ctx); drawFogOfWar(model, ctx); // Add fog of war effect drawStatusMessage(model, ctx); // Add status message drawVisibilityRanges(model, ctx); // Add visibility ranges // Post-condition: canvas displays the current model state. } /** * Draws status messages at the top of the screen * @param {Model} model * @param {HTMLCanvasElement} ctx */ function drawStatusMessage(model, ctx) { const currentUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); if (!currentUnit) return; // Draw status message at top of screen ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(0, 0, ctx.canvas.width, 40); ctx.fillStyle = '#FFFFFF'; ctx.font = '16px Arial'; ctx.textAlign = 'center'; if (currentUnit.owner === 'enemy') { if (currentUnit.isAnimating) { if (currentUnit.animationType === 'moving') { ctx.fillText(`Enemy ${currentUnit.id} is moving...`, ctx.canvas.width / 2, 25); } else if (currentUnit.animationType === 'shooting') { ctx.fillText(`Enemy ${currentUnit.id} is attacking...`, ctx.canvas.width / 2, 25); } } else { ctx.fillText(`Enemy ${currentUnit.id}'s turn`, ctx.canvas.width / 2, 25); } } else { ctx.fillText(`Player ${currentUnit.id}'s turn - Move and Attack`, ctx.canvas.width / 2, 25); } } /** * Draws visibility range indicators around player units * @param {Model} model * @param {CanvasRenderingContext2D} ctx */ function drawVisibilityRanges(model, ctx) { model.units.forEach(unit => { if (unit.owner === 'player' && !unit.isDead) { const centerX = unit.x * TILE_SIZE + TILE_SIZE / 2; const centerY = unit.y * TILE_SIZE + TILE_SIZE / 2; // Draw visibility range circle ctx.strokeStyle = 'rgba(0, 255, 255, 0.3)'; // Cyan with transparency ctx.lineWidth = 1; ctx.setLineDash([3, 3]); // Dashed line ctx.beginPath(); ctx.arc(centerX, centerY, MAX_VISIBILITY_RANGE * TILE_SIZE, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); // Reset line dash } }); } // ------------------------------------------------ // 4. MAIN APPLICATION LOOP // ------------------------------------------------ /** * The main driver for the application. */ function App() { const canvas = document.getElementById('gameCanvas'); const endTurnBtn = document.getElementById('endTurnBtn'); const skipMovementBtn = document.getElementById('skipMovementBtn'); const skipAttackBtn = document.getElementById('skipAttackBtn'); if (!canvas || !endTurnBtn || !skipMovementBtn || !skipAttackBtn) return; const ctx = canvas.getContext('2d'); let model = init(); let lastTime = 0; let animationId; // Set canvas dimensions based on model canvas.width = model.grid[0].length * TILE_SIZE; canvas.height = model.grid.length * TILE_SIZE; /** * Updates button states based on current game state */ function updateButtonStates() { const currentUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); const isPlayerTurn = currentUnit && currentUnit.owner === 'player'; // Only enable end turn button if it's a player turn and they've completed their actions if (isPlayerTurn) { const hasUnfinishedActions = !currentUnit.hasMoved || !currentUnit.hasAttacked; const hasAnimatingUnits = model.units.some(u => u.isAnimating); endTurnBtn.disabled = hasUnfinishedActions || hasAnimatingUnits; } else { endTurnBtn.disabled = true; // Disable during enemy turns } // Only enable skip buttons if it's the current unit's turn and they're selected if (model.uiState.type === 'UnitSelected' && isPlayerTurn) { const selectedUnit = model.units.find(u => u.id === model.uiState.unitId); if (selectedUnit && selectedUnit.id === currentUnit.id) { skipMovementBtn.disabled = selectedUnit.hasMoved || selectedUnit.isAnimating; skipAttackBtn.disabled = selectedUnit.hasAttacked || selectedUnit.isAnimating; } else { skipMovementBtn.disabled = true; skipAttackBtn.disabled = true; } } else { skipMovementBtn.disabled = true; skipAttackBtn.disabled = true; } } /** * Game loop for animations */ function gameLoop(currentTime) { const deltaTime = currentTime - lastTime; lastTime = currentTime; // Update animations model.units = updateAnimations(model.units, deltaTime); const shotResult = processCompletedShots(model.units, model.grid); // Process completed shots model.units = shotResult.units; if (shotResult.updated) { model.grid = shotResult.grid; } // Update obstacle flash effects model.grid = updateObstacleFlash(model.grid); // Update unit visibility based on line of sight - ONLY when player units move or when needed // model.units = updateUnitVisibility(model.units, model.grid); // Check if current unit has completed their turn and auto-advance const currentTurnUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); console.log('=== TURN CHECK ==='); console.log('currentTurnIndex:', model.currentTurnIndex); console.log('currentTurnUnit:', currentTurnUnit ? `${currentTurnUnit.owner} ${currentTurnUnit.id}` : 'none'); console.log('currentTurnUnit state:', currentTurnUnit ? { hasMoved: currentTurnUnit.hasMoved, hasAttacked: currentTurnUnit.hasAttacked, isAnimating: currentTurnUnit.isAnimating } : 'none'); if (currentTurnUnit && currentTurnUnit.owner === 'player' && currentTurnUnit.hasMoved && currentTurnUnit.hasAttacked) { // Player unit completed their turn, auto-advance console.log('Player unit completed turn, advancing...'); model = advanceTurn(model); // Check if next unit is enemy and process their turn const nextUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); if (nextUnit && nextUnit.owner === 'enemy') { console.log('Next unit is enemy, processing their turn...'); const result = executeEnemyTurn(model, nextUnit); model = advanceTurn(result); // Continue processing enemy turns until we reach a player unit while (true) { const nextEnemyUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); if (!nextEnemyUnit || nextEnemyUnit.owner === 'player') { break; // Player turn or no more units } console.log('Processing next enemy turn for unit', nextEnemyUnit.id); const enemyResult = executeEnemyTurn(model, nextEnemyUnit); model = advanceTurn(enemyResult); } } } console.log('After turn advancement check - currentTurnIndex:', model.currentTurnIndex, 'currentUnit:', currentTurnUnit ? `${currentTurnUnit.owner} ${currentTurnUnit.id}` : 'none'); console.log('=== END TURN CHECK ==='); // Update action feedback timers model.units = updateActionFeedbackTimers(model.units); // Render view(model, canvas, ctx); updateButtonStates(); // Check if we should continue the game loop const hasAnimations = model.units.some(u => u.isAnimating); const loopCurrentUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); console.log('Game loop continuation check:', { hasAnimations, currentTurnIndex: model.currentTurnIndex, currentUnit: loopCurrentUnit ? `${loopCurrentUnit.owner} ${loopCurrentUnit.id}` : 'none', unitState: loopCurrentUnit ? { hasMoved: loopCurrentUnit.hasMoved, hasAttacked: loopCurrentUnit.hasAttacked, isAnimating: loopCurrentUnit.isAnimating } : 'none' }); if (hasAnimations) { console.log('Continuing game loop for animations...'); requestAnimationFrame(gameLoop); } else { // If no animations, render once and stop console.log('Stopping game loop, rendering once...'); view(model, canvas, ctx); updateButtonStates(); } } /** * The dispatch function, linking events to the update logic. * @param {object} msg */ function dispatch(msg) { console.log('=== DISPATCH ==='); console.log('Message type:', msg.type); model = update(msg, model); console.log('After update - currentTurnIndex:', model.currentTurnIndex); console.log('Current unit:', model.units.find(u => u.turnOrder === model.currentTurnIndex)); console.log('Has animations:', model.units.some(u => u.isAnimating)); // Start animation loop if needed if (model.units.some(u => u.isAnimating)) { console.log('Starting game loop for animations'); if (animationId) { cancelAnimationFrame(animationId); } lastTime = performance.now(); animationId = requestAnimationFrame(gameLoop); } else { console.log('No animations, just rendering once'); // Just render once if no animations view(model, canvas, ctx); updateButtonStates(); } // Check for game over const playerUnits = model.units.filter(u => u.owner === 'player' && !u.isDead); const enemyUnits = model.units.filter(u => u.owner === 'enemy' && !u.isDead); if (playerUnits.length === 0) { alert('Game Over! Enemy wins!'); return; } if (enemyUnits.length === 0) { alert('Victory! Player wins!'); return; } } // Setup Event Listeners canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const tileX = Math.floor(x / TILE_SIZE); const tileY = Math.floor(y / TILE_SIZE); dispatch({ type: 'TILE_CLICKED', payload: { x: tileX, y: tileY } }); }); endTurnBtn.addEventListener('click', () => { dispatch({ type: 'END_TURN_CLICKED' }); }); skipMovementBtn.addEventListener('click', () => { dispatch({ type: 'SKIP_MOVEMENT' }); }); skipAttackBtn.addEventListener('click', () => { dispatch({ type: 'SKIP_ATTACK' }); }); // Handle window resize window.addEventListener('resize', () => { // Recalculate grid size and reinitialize if needed const newSize = calculateGridSize(); if (newSize.width !== model.grid[0].length || newSize.height !== model.grid.length) { model = init(); canvas.width = model.grid[0].length * TILE_SIZE; canvas.height = model.grid.length * TILE_SIZE; view(model, canvas, ctx); updateButtonStates(); } }); // Initial render dispatch({ type: 'INITIAL_RENDER' }); // Dispatch a dummy event to start } // Start the game when DOM is loaded document.addEventListener('DOMContentLoaded', App); /** * Checks if there's a clear line of sight between two points * @param {number} startX * @param {number} startY * @param {number} endX * @param {number} endY * @param {Tile[][]} grid * @param {Unit[]} units * @returns {{blocked: boolean, blocker: Unit | null, obstacleX: number | null, obstacleY: number | null}} Line of sight result */ function checkLineOfSight(startX, startY, endX, endY, grid, units) { const dx = endX - startX; const dy = endY - startY; const distance = Math.sqrt(dx * dx + dy * dy); // Add maximum visibility range - units can't see beyond this distance const MAX_VISIBILITY_RANGE = 6; // Reduced from unlimited to 6 tiles if (distance === 0) return { blocked: false, blocker: null, obstacleX: null, obstacleY: null }; // If target is beyond visibility range, it's blocked if (distance > MAX_VISIBILITY_RANGE) { return { blocked: true, blocker: null, obstacleX: null, obstacleY: null, reason: 'beyond_visibility_range' }; } // Use Bresenham's line algorithm to check each tile along the path const steps = Math.max(Math.abs(dx), Math.abs(dy)); const xStep = dx / steps; const yStep = dy / steps; for (let i = 1; i <= steps; i++) { const checkX = Math.round(startX + xStep * i); const checkY = Math.round(startY + yStep * i); // Check if we've reached the target if (checkX === endX && checkY === endY) { break; } // Check for obstacles if (grid[checkY] && grid[checkY][checkX] && grid[checkY][checkX].type === 'obstacle') { return { blocked: true, blocker: null, obstacleX: checkX, obstacleY: checkY, reason: 'obstacle' }; } // Check for units blocking the path const blockingUnit = units.find(unit => unit.x === checkX && unit.y === checkY && !unit.isDead ); if (blockingUnit) { return { blocked: true, blocker: blockingUnit, obstacleX: null, obstacleY: null, reason: 'unit' }; } } return { blocked: false, blocker: null, obstacleX: null, obstacleY: null, reason: 'clear' }; } /** * Updates obstacle damage flash effects * @param {Tile[][]} grid * @returns {Tile[][]} Updated grid with reduced flash values */ function updateObstacleFlash(grid) { const FLASH_DECAY_RATE = 0.05; // How fast the flash fades return grid.map(row => row.map(tile => { if (tile.type === 'obstacle' && tile.damageFlash > 0) { return { ...tile, damageFlash: Math.max(0, tile.damageFlash - FLASH_DECAY_RATE) }; } return tile; }) ); } /** * Generates a varied obstacle color within the grey/black/blue palette * @returns {string} Hex color for the obstacle */ function generateObstacleColor() { const colors = [ '#2C3E50', // Dark blue-grey (original) '#34495E', // Slightly lighter blue-grey '#2E4053', // Medium blue-grey '#283747', // Darker blue-grey '#1B2631', // Very dark blue-grey '#1F2937' // Dark grey with blue tint ]; return colors[Math.floor(Math.random() * colors.length)]; } /** * Creates a standardized obstacle with consistent properties * @param {number} x * @param {number} y * @returns {Tile} Standardized obstacle tile */ function createObstacle(x, y) { return { type: 'obstacle', x, y, providesCover: true, health: OBSTACLE_MAX_HEALTH, damageFlash: 0, color: generateObstacleColor() }; } /** * Updates unit visibility based on line of sight from player units * @param {Unit[]} units * @param {Tile[][]} grid * @returns {Unit[]} Updated units with visibility updated */ function updateUnitVisibility(units, grid) { const playerUnits = units.filter(unit => unit.owner === 'player' && !unit.isDead); const enemyUnits = units.filter(unit => unit.owner === 'enemy' && !unit.isDead); const currentTime = Date.now(); // Update units with visibility and memory tracking const updatedUnits = units.map(unit => { if (unit.owner === 'enemy') { // Check if this enemy is currently visible to any player unit let isCurrentlyVisible = false; let lastSeenTime = unit.lastSeen || 0; // Check line of sight from each player unit for (const playerUnit of playerUnits) { const los = checkLineOfSight( playerUnit.x, playerUnit.y, unit.x, unit.y, grid, units ); if (!los.blocked) { isCurrentlyVisible = true; lastSeenTime = currentTime; break; } } return { ...unit, isVisible: isCurrentlyVisible, lastSeen: lastSeenTime, lastKnownX: isCurrentlyVisible ? unit.x : (unit.lastKnownX || unit.x), lastKnownY: isCurrentlyVisible ? unit.y : (unit.lastKnownY || unit.y) }; } return unit; }); return updatedUnits; } /** * Draws fog of war effects for areas where enemy units might be hidden * @param {Model} model * @param {CanvasRenderingContext2D} ctx */ function drawFogOfWar(model, ctx) { // Create a subtle fog effect for areas outside player line of sight ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'; // Draw fog over the entire grid ctx.fillRect(0, 0, model.grid[0].length * TILE_SIZE, model.grid.length * TILE_SIZE); // Clear fog around player units (line of sight areas) const playerUnits = model.units.filter(unit => unit.owner === 'player' && !unit.isDead); playerUnits.forEach(playerUnit => { const centerX = playerUnit.x * TILE_SIZE + TILE_SIZE / 2; const centerY = playerUnit.y * TILE_SIZE + TILE_SIZE / 2; const sightRadius = Math.max(playerUnit.shootRange * TILE_SIZE, 100); // Minimum sight radius // Create radial gradient to clear fog around player units const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, sightRadius); gradient.addColorStop(0, 'rgba(0, 0, 0, 0)'); gradient.addColorStop(0.7, 'rgba(0, 0, 0, 0)'); gradient.addColorStop(1, 'rgba(0, 0, 0, 0.1)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, model.grid[0].length * TILE_SIZE, model.grid.length * TILE_SIZE); }); } /** * Generates a random turn order for all units * @param {Unit[]} units * @returns {Unit[]} Units with turn order assigned */ function generateTurnOrder(units) { // Separate player and enemy units const playerUnits = units.filter(u => u.owner === 'player'); const enemyUnits = units.filter(u => u.owner === 'enemy'); // Shuffle each group separately const shuffledPlayers = [...playerUnits].sort(() => Math.random() - 0.5); const shuffledEnemies = [...enemyUnits].sort(() => Math.random() - 0.5); // Combine: players first, then enemies const orderedUnits = [...shuffledPlayers, ...shuffledEnemies]; // Assign turn order return orderedUnits.map((unit, index) => ({ ...unit, turnOrder: index })); } /** * Finds the best cover position for a unit that allows them to attack while being protected * @param {Unit} unit * @param {Tile[][]} grid * @param {Unit[]} allUnits * @returns {{x: number, y: number} | null} Best cover position or null if none found */ function findBestCoverPosition(unit, grid, allUnits) { const width = grid[0].length; const height = grid.length; const maxSearchDistance = 8; // Don't search too far let bestPosition = null; let bestScore = -1; // Search in expanding radius around unit for (let radius = 1; radius <= maxSearchDistance; radius++) { for (let dx = -radius; dx <= radius; dx++) { for (let dy = -radius; dy <= radius; dy++) { if (Math.abs(dx) + Math.abs(dy) !== radius) continue; // Only check perimeter const checkX = unit.x + dx; const checkY = unit.y + dy; // Check bounds if (checkX < 0 || checkX >= width || checkY < 0 || checkY >= height) continue; // Check if position is walkable if (grid[checkY][checkX].type === 'obstacle') continue; // Check if position is occupied if (allUnits.some(u => u.x === checkX && u.y === checkY && !u.isDead)) continue; // Check if position provides cover const hasCover = checkCover({ x: checkX, y: checkY }, grid); // Check if position allows attacking any visible enemies const canAttackFromHere = allUnits.some(target => target.owner === 'player' && !target.isDead && Math.abs(checkX - target.x) + Math.abs(checkY - target.y) <= unit.shootRange ); // Calculate score: prioritize cover + attack capability let score = 0; if (hasCover) score += 10; if (canAttackFromHere) score += 5; score -= Math.abs(dx) + Math.abs(dy); // Prefer closer positions if (score > bestScore) { bestScore = score; bestPosition = { x: checkX, y: checkY }; } } } } return bestPosition; } /** * Makes AI decisions for an enemy unit * @param {Unit} unit * @param {Tile[][]} grid * @param {Unit[]} allUnits * @returns {{action: 'move' | 'attack' | 'skip', targetX?: number, targetY?: number, targetUnit?: Unit} | null} AI decision */ function makeAIDecision(unit, grid, allUnits) { console.log('makeAIDecision called for unit', unit.id, 'with behavior:', unit.aiBehavior); console.log('Unit state:', { hasMoved: unit.hasMoved, hasAttacked: unit.hasAttacked, isDead: unit.isDead }); if (unit.isDead || unit.hasMoved && unit.hasAttacked) { console.log('Unit', unit.id, 'cannot act - dead or completed actions'); return null; // Unit can't act } // Find visible player units const visiblePlayers = allUnits.filter(target => target.owner === 'player' && !target.isDead && target.isVisible ); console.log('Visible players for unit', unit.id, ':', visiblePlayers.length); // If no visible players, behavior depends on AI type if (visiblePlayers.length === 0) { console.log('No visible players for unit', unit.id, 'using behavior:', unit.aiBehavior); switch (unit.aiBehavior) { case 'aggressive': return { type: 'skip' }; // Wait for targets case 'patrol': return generatePatrolAction(unit, grid, allUnits); case 'stationary': return { type: 'skip' }; // Stay put } } // If we have visible players, behavior depends on AI type console.log('Visible players found for unit', unit.id, 'using behavior:', unit.aiBehavior); switch (unit.aiBehavior) { case 'aggressive': return generateAggressiveAction(unit, visiblePlayers, grid, allUnits); case 'patrol': return generatePatrolAction(unit, grid, allUnits, visiblePlayers); case 'stationary': return generateStationaryAction(unit, visiblePlayers, grid, allUnits); } return { type: 'skip' }; } /** * Generates aggressive AI action - move toward and attack visible players * @param {Unit} unit * @param {Unit[]} visiblePlayers * @param {Tile[][]} grid * @param {Unit[]} allUnits * @returns {{action: string, targetX?: number, targetY?: number, targetUnit?: Unit}} */ function generateAggressiveAction(unit, visiblePlayers, grid, allUnits) { console.log('generateAggressiveAction for unit', unit.id, 'with', visiblePlayers.length, 'visible players'); // Find closest visible player const closestPlayer = visiblePlayers.reduce((closest, player) => { const distance = Math.abs(unit.x - player.x) + Math.abs(unit.y - player.y); const closestDistance = Math.abs(unit.x - closest.x) + Math.abs(unit.y - closest.y); return distance < closestDistance ? player : closest; }); const distance = Math.abs(unit.x - closestPlayer.x) + Math.abs(unit.y - closestPlayer.y); console.log('Distance to closest player for unit', unit.id, ':', distance, 'shoot range:', unit.shootRange); // If we can attack and haven't attacked yet, do it! if (!unit.hasAttacked && distance <= unit.shootRange) { console.log('Unit', unit.id, 'can attack player at', closestPlayer.x, closestPlayer.y); return { type: 'attack', targetX: closestPlayer.x, targetY: closestPlayer.y }; } // If we can't attack but can move and haven't moved yet, move closer if (!unit.hasMoved && distance > unit.shootRange) { console.log('Unit', unit.id, 'needs to move closer to attack'); const moveTarget = findMoveTowardTarget(unit, closestPlayer, grid, allUnits); if (moveTarget) { console.log('Move target found for unit', unit.id, ':', moveTarget); return { type: 'move', x: moveTarget.x, y: moveTarget.y }; } else { console.log('No move target found for unit', unit.id); } } // If we've done what we can, skip the turn console.log('Unit', unit.id, 'skipping turn - no more actions possible'); return { type: 'skip' }; } /** * Generates patrol AI action - defend territory, engage if players enter * @param {Unit} unit * @param {Tile[][]} grid * @param {Unit[]} allUnits * @param {Unit[]} visiblePlayers * @returns {{action: string, targetX?: number, targetY?: number, targetUnit?: Unit}} */ function generatePatrolAction(unit, grid, allUnits, visiblePlayers = []) { // If players are visible, engage them if (visiblePlayers.length > 0) { return generateAggressiveAction(unit, visiblePlayers, grid, allUnits); } // Otherwise, patrol within territory if (!unit.hasMoved) { const patrolTarget = findPatrolPosition(unit, grid, allUnits); if (patrolTarget) { return { type: 'move', x: patrolTarget.x, y: patrolTarget.y }; } } return { type: 'skip' }; } /** * Generates stationary AI action - attack from cover, flee when attacked * @param {Unit} unit * @param {Unit[]} visiblePlayers * @param {Tile[][]} grid * @param {Unit[]} allUnits * @returns {{action: string, targetX?: number, targetY?: number, targetUnit?: Unit}} */ function generateStationaryAction(unit, visiblePlayers, grid, allUnits) { // If we can attack, do it if (!unit.hasAttacked) { const attackablePlayer = visiblePlayers.find(player => { const distance = Math.abs(unit.x - player.x) + Math.abs(unit.y - player.y); return distance <= unit.shootRange; }); if (attackablePlayer) { return { type: 'attack', targetX: attackablePlayer.x, targetY: attackablePlayer.y }; } } // If we're not in cover and can move, try to find cover if (!unit.hasMoved && !unit.inCover) { const coverPosition = findBestCoverPosition(unit, grid, allUnits); if (coverPosition) { return { type: 'move', x: coverPosition.x, y: coverPosition.y }; } } return { type: 'skip' }; } /** * Finds a movement target toward a specific target unit * @param {Unit} unit * @param {Unit} target * @param {Tile[][]} grid * @param {Unit[]} allUnits * @returns {{x: number, y: number} | null} Movement target or null if none found */ function findMoveTowardTarget(unit, target, grid, allUnits) { console.log('findMoveTowardTarget for unit', unit.id, 'toward target at', target.x, target.y); console.log('Unit position:', unit.x, unit.y, 'movement range:', unit.movementRange); const width = grid[0].length; const height = grid.length; const maxSearchDistance = Math.min(unit.movementRange, 8); console.log('Searching within distance:', maxSearchDistance); let bestPosition = null; let bestScore = -1; // Search for positions that get us closer to target for (let dx = -maxSearchDistance; dx <= maxSearchDistance; dx++) { for (let dy = -maxSearchDistance; dy <= maxSearchDistance; dy++) { if (Math.abs(dx) + Math.abs(dy) > unit.movementRange) continue; const checkX = unit.x + dx; const checkY = unit.y + dy; // Check bounds if (checkX < 0 || checkX >= width || checkY < 0 || checkY >= height) continue; // Check if position is walkable if (grid[checkY][checkX].type === 'obstacle') continue; // Check if position is occupied if (allUnits.some(u => u.x === checkX && u.y === checkY && !u.isDead)) continue; // Check if path is not blocked if (isPathBlocked(unit.x, unit.y, checkX, checkY, grid)) continue; // Calculate score: prefer positions closer to target const currentDistance = Math.abs(unit.x - target.x) + Math.abs(unit.y - target.y); const newDistance = Math.abs(checkX - target.x) + Math.abs(checkY - target.y); const distanceImprovement = currentDistance - newDistance; let score = distanceImprovement * 10; // Prioritize getting closer if (grid[checkY][checkX].providesCover) score += 5; // Bonus for cover if (score > bestScore) { bestScore = score; bestPosition = { x: checkX, y: checkY }; } } } console.log('Best position found for unit', unit.id, ':', bestPosition, 'with score:', bestScore); return bestPosition; } /** * Finds a patrol position within the unit's territory * @param {Unit} unit * @param {Tile[][]} grid * @param {Unit[]} allUnits * @returns {{x: number, y: number} | null} Patrol position or null if none found */ function findPatrolPosition(unit, grid, allUnits) { const width = grid[0].length; const height = grid.length; const maxSearchDistance = Math.min(unit.movementRange, unit.patrolRadius); let bestPosition = null; let bestScore = -1; // Search for positions within patrol radius for (let dx = -maxSearchDistance; dx <= maxSearchDistance; dx++) { for (let dy = -maxSearchDistance; dy <= maxSearchDistance; dy++) { if (Math.abs(dx) + Math.abs(dy) > unit.movementRange) continue; const checkX = unit.x + dx; const checkY = unit.y + dy; // Check bounds if (checkX < 0 || checkX >= width || checkY < 0 || checkY >= height) continue; // Check if position is within patrol radius const distanceFromCenter = Math.abs(checkX - unit.patrolCenterX) + Math.abs(checkY - unit.patrolCenterY); if (distanceFromCenter > unit.patrolRadius) continue; // Check if position is walkable if (grid[checkY][checkX].type === 'obstacle') continue; // Check if position is occupied if (allUnits.some(u => u.x === checkX && u.y === checkY && !u.isDead)) continue; // Check if path is not blocked if (isPathBlocked(unit.x, unit.y, checkX, checkY, grid)) continue; // Calculate score: prefer positions with good cover and visibility let score = 0; if (grid[checkY][checkX].providesCover) score += 8; // Bonus for positions that allow seeing outside patrol area const canSeeOutside = checkX === 0 || checkX === width - 1 || checkY === 0 || checkY === height - 1; if (canSeeOutside) score += 3; // Small random factor to avoid predictable patterns score += Math.random() * 2; if (score > bestScore) { bestScore = score; bestPosition = { x: checkX, y: checkY }; } } } return bestPosition; } /** * Advances to the next turn in the turn order * @param {Model} model * @returns {Model} Updated model with next turn */ function advanceTurn(model) { let nextTurnIndex = model.currentTurnIndex + 1; // Find next living unit while (nextTurnIndex < model.units.length) { const nextUnit = model.units.find(u => u.turnOrder === nextTurnIndex); if (nextUnit && !nextUnit.isDead) { break; } nextTurnIndex++; } // If we've gone through all units, start over if (nextTurnIndex >= model.units.length) { nextTurnIndex = 0; // Reset all units' actions const updatedUnits = model.units.map(unit => ({ ...unit, hasMoved: false, hasAttacked: false })); return { ...model, units: updatedUnits, currentTurnIndex: nextTurnIndex, uiState: { type: 'AwaitingSelection' } // Reset UI state }; } // Reset UI state when advancing to next turn return { ...model, currentTurnIndex: nextTurnIndex, uiState: { type: 'AwaitingSelection' } }; } /** * Executes the current unit's turn (player or AI) * @param {Model} model * @returns {Model} Updated model after turn execution */ function executeCurrentTurn(model) { const currentUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); if (!currentUnit || currentUnit.isDead) { console.log('No current unit or unit is dead, advancing turn'); return advanceTurn(model); } // If it's a player unit, wait for player input if (currentUnit.owner === 'player') { console.log('Player turn, waiting for input'); return model; } // If it's an enemy unit, execute AI turn immediately console.log('Enemy turn for unit', currentUnit.id, '- executing AI immediately'); // Execute AI turn and advance immediately const result = executeEnemyTurn(model, currentUnit); console.log('AI turn completed for unit', currentUnit.id, 'advancing turn'); // Always advance turn after enemy completes their actions return advanceTurn(result); } /** * Removes dead units from the turn order and adjusts turn indices * @param {Unit[]} units * @returns {Unit[]} Updated units with dead units removed and turn order adjusted */ function cleanupDeadUnits(units) { const livingUnits = units.filter(unit => !unit.isDead); // Reassign turn order for remaining units return livingUnits.map((unit, index) => ({ ...unit, turnOrder: index })); } /** * Updates action feedback timers for enemy units * @param {Unit[]} units * @returns {Unit[]} Updated units */ function updateActionFeedbackTimers(units) { return units.map(unit => { if (unit.actionFeedbackTimer > 0) { return { ...unit, actionFeedbackTimer: Math.max(0, unit.actionFeedbackTimer - 16) }; // 16ms per frame at 60fps } return unit; }); } /** * Pure function: Executes enemy turn with guaranteed termination * @param {Model} model * @param {Unit} enemyUnit * @returns {Model} Updated model with turn completed */ function executeEnemyTurn(model, enemyUnit) { // Pre-condition: enemyUnit is an enemy unit that needs to act if (enemyUnit.owner !== 'enemy') { throw new Error('executeEnemyTurn called with non-enemy unit'); } console.log('Executing enemy turn for unit', enemyUnit.id); // Find visible player units const visiblePlayers = model.units.filter(target => target.owner === 'player' && !target.isDead && target.isVisible ); if (visiblePlayers.length === 0) { console.log('No visible players for unit', enemyUnit.id, 'skipping turn'); // Mark both actions as complete and return const skippedUnit = { ...enemyUnit, hasMoved: true, hasAttacked: true, isAnimating: false, animationType: 'none', animationProgress: 0, path: [], targetX: -1, targetY: -1 }; return { ...model, units: model.units.map(u => u.id === enemyUnit.id ? skippedUnit : u) }; } // Find closest visible player const closestPlayer = visiblePlayers.reduce((closest, player) => { const distance = Math.abs(enemyUnit.x - player.x) + Math.abs(enemyUnit.y - player.y); const closestDistance = Math.abs(enemyUnit.x - closest.x) + Math.abs(enemyUnit.y - closest.y); return distance < closestDistance ? player : closest; }); const distance = Math.abs(enemyUnit.x - closestPlayer.x) + Math.abs(enemyUnit.y - closestPlayer.y); console.log('Unit', enemyUnit.id, 'distance to player:', distance, 'shoot range:', enemyUnit.shootRange); let updatedModel = model; let updatedUnit = enemyUnit; // First action: Move if we can't attack yet if (!updatedUnit.hasMoved && distance > enemyUnit.shootRange) { console.log('Unit', enemyUnit.id, 'moving closer to attack'); const moveTarget = findMoveTowardTarget(updatedUnit, closestPlayer, updatedModel.grid, updatedModel.units); if (moveTarget) { const moveResult = executeEnemyMove(updatedModel, updatedUnit, { type: 'move', x: moveTarget.x, y: moveTarget.y }); updatedModel = moveResult; updatedUnit = moveResult.units.find(u => u.id === enemyUnit.id); console.log('Unit', enemyUnit.id, 'moved to', moveTarget.x, moveTarget.y); } else { // Can't move, mark as moved updatedUnit = { ...updatedUnit, hasMoved: true }; updatedModel = { ...updatedModel, units: updatedModel.units.map(u => u.id === enemyUnit.id ? updatedUnit : u) }; } } else if (!updatedUnit.hasMoved) { // No movement needed, mark as moved updatedUnit = { ...updatedUnit, hasMoved: true }; updatedModel = { ...updatedModel, units: updatedModel.units.map(u => u.id === enemyUnit.id ? updatedUnit : u) }; } // Second action: Attack if we can if (!updatedUnit.hasAttacked && distance <= enemyUnit.shootRange) { console.log('Unit', enemyUnit.id, 'attacking player at', closestPlayer.x, closestPlayer.y); const attackResult = executeEnemyAttack(updatedModel, updatedUnit, { type: 'attack', targetX: closestPlayer.x, targetY: closestPlayer.y }); updatedModel = attackResult; updatedUnit = attackResult.units.find(u => u.id === enemyUnit.id); console.log('Unit', enemyUnit.id, 'attacked'); } else if (!updatedUnit.hasAttacked) { // Can't attack, mark as attacked updatedUnit = { ...updatedUnit, hasAttacked: true }; updatedModel = { ...updatedModel, units: updatedModel.units.map(u => u.id === enemyUnit.id ? updatedUnit : u) }; } // Ensure both actions are marked as complete if (!updatedUnit.hasMoved) { updatedUnit = { ...updatedUnit, hasMoved: true, isAnimating: false, animationType: 'none', animationProgress: 0, path: [], targetX: -1, targetY: -1 }; } if (!updatedUnit.hasAttacked) { updatedUnit = { ...updatedUnit, hasAttacked: true, isAnimating: false, animationType: 'none', animationProgress: 0, projectileX: -1, projectileY: -1, projectileTargetX: -1, projectileTargetY: -1 }; } // Update the model with the final unit state const finalModel = { ...updatedModel, units: updatedModel.units.map(u => u.id === enemyUnit.id ? updatedUnit : u) }; console.log('Enemy turn completed for unit', enemyUnit.id, 'final state:', { hasMoved: updatedUnit.hasMoved, hasAttacked: updatedUnit.hasAttacked }); return finalModel; } /** * Pure function: Executes enemy movement * @param {Model} model * @param {Unit} enemyUnit * @param {Object} decision * @returns {Model} Updated model */ function executeEnemyMove(model, enemyUnit, decision) { console.log('Executing enemy move for unit', enemyUnit.id, 'to', decision.x, decision.y); const path = findPath(enemyUnit.x, enemyUnit.y, decision.x, decision.y, model.grid); console.log('Path found:', path); if (path.length > 1) { // Start movement animation const animatedUnit = startMovementAnimation(enemyUnit, decision.x, decision.y, model.grid); animatedUnit.hasMoved = true; console.log('Enemy unit', enemyUnit.id, 'started movement animation'); return { ...model, units: model.units.map(u => u.id === enemyUnit.id ? animatedUnit : u) }; } else { // No movement needed, mark as moved console.log('Enemy unit', enemyUnit.id, 'no movement needed, marked as moved'); return { ...model, units: model.units.map(u => u.id === enemyUnit.id ? { ...u, hasMoved: true } : u ) }; } } /** * Pure function: Executes enemy attack * @param {Model} model * @param {Unit} enemyUnit * @param {Object} decision * @returns {Model} Updated model */ function executeEnemyAttack(model, enemyUnit, decision) { console.log('Executing enemy attack for unit', enemyUnit.id, 'at target', decision.targetX, decision.targetY); const animatedUnit = startShootingAnimation(enemyUnit, decision.targetX, decision.targetY); animatedUnit.hasAttacked = true; console.log('Enemy unit', enemyUnit.id, 'started attack animation'); return { ...model, units: model.units.map(u => u.id === enemyUnit.id ? animatedUnit : u) }; } /** * Sanitizes unit animation state to prevent crashes * @param {Unit} unit * @returns {Unit} Sanitized unit */ function sanitizeUnitState(unit) { // If unit is not animating, ensure all animation properties are reset if (!unit.isAnimating) { return { ...unit, animationType: 'none', animationProgress: 0, path: [], targetX: -1, targetY: -1, projectileX: -1, projectileY: -1, projectileTargetX: -1, projectileTargetY: -1 }; } // If unit is animating, validate animation properties const sanitizedUnit = { ...unit }; // Validate path data if (sanitizedUnit.animationType === 'moving') { if (!sanitizedUnit.path || !Array.isArray(sanitizedUnit.path)) { sanitizedUnit.path = []; } if (typeof sanitizedUnit.targetX !== 'number' || typeof sanitizedUnit.targetY !== 'number') { sanitizedUnit.targetX = sanitizedUnit.x; sanitizedUnit.targetY = sanitizedUnit.y; } } // Validate projectile data if (sanitizedUnit.animationType === 'shooting') { if (typeof sanitizedUnit.projectileX !== 'number' || typeof sanitizedUnit.projectileY !== 'number') { sanitizedUnit.projectileX = sanitizedUnit.x; sanitizedUnit.projectileY = sanitizedUnit.y; } if (typeof sanitizedUnit.projectileTargetX !== 'number' || typeof sanitizedUnit.projectileTargetY !== 'number') { sanitizedUnit.projectileTargetX = sanitizedUnit.x; sanitizedUnit.projectileTargetY = sanitizedUnit.y; } } // Validate animation progress if (typeof sanitizedUnit.animationProgress !== 'number' || sanitizedUnit.animationProgress < 0 || sanitizedUnit.animationProgress > 1) { sanitizedUnit.animationProgress = 0; } return sanitizedUnit; }