diff options
Diffstat (limited to 'html/XCOM/game.js')
-rw-r--r-- | html/XCOM/game.js | 3598 |
1 files changed, 3598 insertions, 0 deletions
diff --git a/html/XCOM/game.js b/html/XCOM/game.js new file mode 100644 index 0000000..6c4e256 --- /dev/null +++ b/html/XCOM/game.js @@ -0,0 +1,3598 @@ +// 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; +} + + |