diff options
-rw-r--r-- | html/XCOM/game.js | 1662 |
1 files changed, 1419 insertions, 243 deletions
diff --git a/html/XCOM/game.js b/html/XCOM/game.js index 20be052..6c4e256 100644 --- a/html/XCOM/game.js +++ b/html/XCOM/game.js @@ -36,7 +36,17 @@ * deathParticles: DeathParticle[], * isDead: boolean, * pendingDamage: number, - * justCompletedShot: boolean + * 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, @@ -49,7 +59,7 @@ * color: string * }} DeathParticle * @typedef {{ type: 'AwaitingSelection' } | { type: 'UnitSelected', unitId: number } | { type: 'AttackMode', unitId: number }} UIState - * @typedef {{ grid: Tile[][], units: Unit[], turn: UnitOwner, uiState: UIState }} Model + * @typedef {{ grid: Tile[][], units: Unit[], selectedUnit: Unit | null, uiState: UIState, currentTurnIndex: number }} Model */ // ------------------------------------------------ @@ -59,6 +69,10 @@ 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', @@ -146,6 +160,8 @@ function calculateGridSize() { * @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); @@ -156,17 +172,20 @@ function isPathBlocked(startX, startY, endX, endY, grid) { 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; } @@ -400,11 +419,20 @@ function getUnitPosition(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 }; } - if (!unit.path || unit.path.length === 0) { + // 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 }; } @@ -424,7 +452,7 @@ function getUnitPosition(unit) { if (pathPoint && typeof pathPoint.x === 'number' && typeof pathPoint.y === 'number') { return pathPoint; } else { - console.error('getUnitPosition: invalid path point:', pathPoint); + console.warn('getUnitPosition: invalid path point at step', currentStep, 'for unit', unit.id, 'path:', unit.path); return { x: unit.x, y: unit.y }; } } @@ -1277,9 +1305,9 @@ function createPlayerUnit(id, x, y) { owner: 'player', x, y, - maxMovement: generateStat(2, 10), + maxMovement: generateStat(2, 6), // Reduced from 2-10 to 2-6 movementRange: 0, // Will be set to maxMovement - shootRange: generateStat(4, 15), + 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 @@ -1299,7 +1327,17 @@ function createPlayerUnit(id, x, y) { deathParticles: [], isDead: false, pendingDamage: 0, - justCompletedShot: false + 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 @@ -1346,14 +1384,16 @@ function generateEnemySquad(grid) { * @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, 10), + maxMovement: generateStat(2, 6), // Reduced from 2-10 to 2-6 movementRange: 0, // Will be set to maxMovement - shootRange: generateStat(4, 15), + 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 @@ -1373,7 +1413,17 @@ function createEnemyUnit(id, x, y) { deathParticles: [], isDead: false, pendingDamage: 0, - justCompletedShot: false + 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 @@ -1491,7 +1541,7 @@ function processCompletedShots(units, grid) { }); return { - units: unitsUpdated ? units : updatedUnits, + units: unitsUpdated ? cleanupDeadUnits(units) : updatedUnits, grid: newGrid, updated: unitsUpdated || gridUpdated }; @@ -1514,13 +1564,19 @@ function init() { // Generate squads const playerUnits = generatePlayerSquad(grid); const enemyUnits = generateEnemySquad(grid); - const units = [...playerUnits, ...enemyUnits]; + let units = [...playerUnits, ...enemyUnits]; + + // Generate turn order for all units + const unitsWithTurnOrder = generateTurnOrder(units); // Update cover status for all units - units.forEach(unit => { + 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; @@ -1532,13 +1588,17 @@ function init() { } } 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, - units, - turn: 'player', + grid: grid, + units: unitsWithVisibility, + selectedUnit: null, uiState: { type: 'AwaitingSelection' }, + currentTurnIndex: 0 }; } @@ -1553,7 +1613,11 @@ function init() { * @returns {Model} The new state. */ function update(msg, model) { - if (model.turn !== 'player') { + // 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; } @@ -1561,10 +1625,29 @@ function update(msg, model) { 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); @@ -1587,11 +1670,26 @@ function update(msg, model) { unit.inCover = checkCover(unit, model.grid); }); - return { ...model, units: newUnits, uiState: { type: 'AwaitingSelection' } }; + // 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; @@ -1707,14 +1805,40 @@ function update(msg, model) { 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 } @@ -1727,6 +1851,13 @@ function update(msg, 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 } @@ -1737,12 +1868,8 @@ function update(msg, model) { return model; } case 'END_TURN_CLICKED': { - const nextUnits = model.units.map(u => ({ - ...u, - hasMoved: false, - hasAttacked: false - })); - return { ...model, turn: 'enemy', units: nextUnits, uiState: { type: 'AwaitingSelection' } }; + // Advance to next turn in turn order + return advanceTurn(model); } default: return model; @@ -1823,7 +1950,12 @@ function drawHighlights(model, ctx) { if (model.uiState.type === 'AwaitingSelection') return; const selectedUnit = model.units.find(u => u.id === model.uiState.unitId); - if (!selectedUnit) return; + 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 @@ -1858,7 +1990,9 @@ function drawHighlights(model, ctx) { // Highlight potential targets ctx.fillStyle = 'rgba(255, 0, 0, 0.6)'; model.units.forEach(unit => { - if (unit.id !== selectedUnit.id) { + 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(); @@ -1890,243 +2024,338 @@ function drawHighlights(model, ctx) { */ function drawUnits(model, ctx) { model.units.forEach(unit => { - try { - const position = getUnitPosition(unit); - if (!position || typeof position.x !== 'number' || typeof position.y !== 'number') { - console.error('Invalid position for unit:', unit.id, position); - return; // Skip this 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 }; } - - const { x, y } = position; - const centerX = x * TILE_SIZE + TILE_SIZE / 2; - const centerY = y * TILE_SIZE + TILE_SIZE / 2; + } + + 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 (unit.inCover) { - ctx.fillStyle = COLORS.coverHighlight; + // 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.arc(centerX, centerY, UNIT_RADIUS + 5, 0, Math.PI * 2); + 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(); - } - - // Draw shooting animation effect - if (unit.isAnimating && unit.animationType === 'shooting') { - const pulseSize = UNIT_RADIUS + 10 + Math.sin(unit.animationProgress * Math.PI * 4) * 5; - ctx.fillStyle = 'rgba(255, 255, 0, 0.3)'; + + // Add glow effect + ctx.shadowColor = '#FFFF00'; + ctx.shadowBlur = 8; ctx.beginPath(); - ctx.arc(centerX, centerY, pulseSize, 0, Math.PI * 2); + ctx.arc(projectilePos.x, projectilePos.y, 2, 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; - // Draw projectile - const projectilePos = getProjectilePosition(unit); - 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)'); + // 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; - ctx.strokeStyle = gradient; - ctx.lineWidth = 3; - ctx.lineCap = 'round'; - ctx.beginPath(); - ctx.moveTo(trailStartX, trailStartY); - ctx.lineTo(projectilePos.x, projectilePos.y); - ctx.stroke(); + // Impact explosion + const explosionSize = (sanitizedUnit.animationProgress - 0.8) * 20; + const alpha = 1 - (sanitizedUnit.animationProgress - 0.8) * 5; - // Draw projectile bolt - ctx.fillStyle = '#FFFF00'; + ctx.fillStyle = `rgba(255, 100, 0, ${alpha})`; ctx.beginPath(); - ctx.arc(projectilePos.x, projectilePos.y, 4, 0, Math.PI * 2); + ctx.arc(targetCenterX, targetCenterY, explosionSize, 0, Math.PI * 2); ctx.fill(); - // Add glow effect - ctx.shadowColor = '#FFFF00'; - ctx.shadowBlur = 8; + // Inner explosion + ctx.fillStyle = `rgba(255, 255, 0, ${alpha * 0.8})`; ctx.beginPath(); - ctx.arc(projectilePos.x, projectilePos.y, 2, 0, Math.PI * 2); + ctx.arc(targetCenterX, targetCenterY, explosionSize * 0.6, 0, Math.PI * 2); ctx.fill(); - ctx.shadowBlur = 0; - - // Draw impact effect when projectile reaches target - if (unit.animationProgress > 0.8) { - const targetCenterX = unit.projectileTargetX * TILE_SIZE + TILE_SIZE / 2; - const targetCenterY = unit.projectileTargetY * TILE_SIZE + TILE_SIZE / 2; - - // Impact explosion - const explosionSize = (unit.animationProgress - 0.8) * 20; - const alpha = 1 - (unit.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 death particles - if (unit.isAnimating && unit.animationType === 'dying' && unit.deathParticles) { - unit.deathParticles.forEach(particle => { - const alpha = particle.life / particle.maxLife; - const size = particle.size * 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]); - ctx.fillStyle = particle.color; - ctx.globalAlpha = alpha; + const reticleSize = UNIT_RADIUS + 8; ctx.beginPath(); - ctx.arc(particle.x, particle.y, size, 0, Math.PI * 2); - ctx.fill(); + ctx.arc(centerX, centerY, reticleSize, 0, Math.PI * 2); + ctx.stroke(); - // Add glow effect - ctx.shadowColor = particle.color; - ctx.shadowBlur = size * 2; + // Draw crosshairs 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 !== unit.id && - unit.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 - unit.x) + Math.abs(selectedUnit.y - unit.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([]); - } + 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 = unit.owner === 'player' ? COLORS.player : COLORS.enemy; - ctx.strokeStyle = (model.uiState.type === 'UnitSelected' && model.uiState.unitId === unit.id) - ? COLORS.selectedBorder - : COLORS.unitBorder; - ctx.lineWidth = 2; + // 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; + } - // Add pending damage indicator - if (unit.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(); } - - 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); + // 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(); + } - // HP bar - const hpPercentage = unit.currentHp / unit.maxHp; - ctx.fillStyle = hpPercentage > 0.5 ? '#4CAF50' : hpPercentage > 0.25 ? '#FF9800' : '#F44336'; - ctx.fillRect(hpBarX, hpBarY, hpBarWidth * hpPercentage, hpBarHeight); + // 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(); - // HP text - ctx.fillStyle = '#fff'; - ctx.font = '10px Arial'; + // Draw turn indicator text + ctx.fillStyle = sanitizedUnit.owner === 'player' ? '#00FF00' : '#FF0000'; + ctx.font = '14px Arial'; ctx.textAlign = 'center'; - ctx.fillText(`${unit.currentHp}/${unit.maxHp}`, centerX, hpBarY - 2); + ctx.fillText(sanitizedUnit.owner === 'player' ? 'YOUR TURN' : 'ENEMY TURN', centerX, centerY + UNIT_RADIUS + 25); - // Draw action indicators - if (unit.owner === 'player') { - // Movement indicator - if (!unit.hasMoved) { - ctx.fillStyle = '#4CAF50'; - ctx.beginPath(); - ctx.arc(centerX - 8, centerY + UNIT_RADIUS + 5, 3, 0, Math.PI * 2); - ctx.fill(); - } + // 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(); - // Attack indicator - if (!unit.hasAttacked) { - ctx.fillStyle = unit.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(); - } + // Draw action feedback text + ctx.fillStyle = `rgba(255, 255, 0, ${feedbackAlpha})`; + ctx.font = '12px Arial'; + ctx.textAlign = 'center'; - // Phase indicator text - if (model.uiState.type === 'UnitSelected' && model.uiState.unitId === unit.id) { - ctx.fillStyle = '#fff'; - ctx.font = '12px Arial'; - ctx.textAlign = 'center'; - if (!unit.hasMoved) { - ctx.fillText('MOVE', centerX, centerY - UNIT_RADIUS - 15); - } else if (!unit.hasAttacked) { - ctx.fillText('ATTACK', centerX, centerY - UNIT_RADIUS - 15); + 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); } } - - // Draw movement path preview - if (unit.isAnimating && unit.animationType === 'moving' && unit.path.length > 0) { - ctx.strokeStyle = 'rgba(0, 255, 0, 0.5)'; + + // 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.setLineDash([5, 5]); - ctx.beginPath(); - ctx.moveTo(unit.x * TILE_SIZE + TILE_SIZE / 2, unit.y * TILE_SIZE + TILE_SIZE / 2); - - for (const pathPoint of unit.path) { - ctx.lineTo(pathPoint.x * TILE_SIZE + TILE_SIZE / 2, pathPoint.y * TILE_SIZE + TILE_SIZE / 2); - } - + ctx.arc(centerX, centerY, UNIT_RADIUS + 12, 0, Math.PI * 2); ctx.stroke(); - ctx.setLineDash([]); + + // 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); } - } catch (error) { - console.error('Error drawing unit:', unit.id, error); - return; // Skip this unit } -}); -} + + // 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. @@ -2141,9 +2370,69 @@ function view(model, canvas, ctx) { 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 // ------------------------------------------------ @@ -2172,15 +2461,22 @@ function App() { * Updates button states based on current game state */ function updateButtonStates() { - const playerUnits = model.units.filter(u => u.owner === 'player'); - const hasUnfinishedUnits = playerUnits.some(u => !u.hasMoved || !u.hasAttacked); - const hasAnimatingUnits = model.units.some(u => u.isAnimating); + const currentUnit = model.units.find(u => u.turnOrder === model.currentTurnIndex); + const isPlayerTurn = currentUnit && currentUnit.owner === 'player'; - endTurnBtn.disabled = hasUnfinishedUnits || hasAnimatingUnits; + // 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 + } - if (model.uiState.type === 'UnitSelected') { + // 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) { + if (selectedUnit && selectedUnit.id === currentUnit.id) { skipMovementBtn.disabled = selectedUnit.hasMoved || selectedUnit.isAnimating; skipAttackBtn.disabled = selectedUnit.hasAttacked || selectedUnit.isAnimating; } else { @@ -2211,13 +2507,79 @@ function App() { // 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(); - // Continue loop if there are animations - if (model.units.some(u => u.isAnimating)) { - animationId = requestAnimationFrame(gameLoop); + // 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(); } } @@ -2226,16 +2588,26 @@ function App() { * @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(); } @@ -2253,14 +2625,7 @@ function App() { return; } - if (model.turn === 'enemy') { - setTimeout(() => { - console.log("Enemy turn ends."); - const nextUnits = model.units.map(u => ({...u})); - model = { ...model, turn: 'player', units: nextUnits }; - dispatch({ type: 'NO_OP' }); // Dispatch a dummy event to trigger re-render - }, 500); - } + } // Setup Event Listeners @@ -2322,8 +2687,22 @@ function checkLineOfSight(startX, startY, endX, endY, grid, units) { 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; @@ -2344,7 +2723,8 @@ function checkLineOfSight(startX, startY, endX, endY, grid, units) { blocked: true, blocker: null, obstacleX: checkX, - obstacleY: checkY + obstacleY: checkY, + reason: 'obstacle' }; } @@ -2358,12 +2738,13 @@ function checkLineOfSight(startX, startY, endX, endY, grid, units) { blocked: true, blocker: blockingUnit, obstacleX: null, - obstacleY: null + obstacleY: null, + reason: 'unit' }; } } - return { blocked: false, blocker: null, obstacleX: null, obstacleY: null }; + return { blocked: false, blocker: null, obstacleX: null, obstacleY: null, reason: 'clear' }; } /** @@ -2420,3 +2801,798 @@ function createObstacle(x, y) { 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; +} + + |