// ============= Enemy System ============= const blueShades = [ 'rgb(0, 0, 255)', // Bright blue 'rgb(0, 0, 200)', // Medium blue 'rgb(0, 0, 150)', // Darker blue 'rgb(0, 0, 100)', // Even darker blue 'rgb(0, 0, 50)' // Very dark blue ]; const generateEnemies = (villagers, collisionMap) => { const enemies = []; const gridSize = CONFIG.display.grid.size; const occupiedCells = new Set([...collisionMap.keys()]); villagers.forEach(villager => { if (villager.status === 'rescued') return; // Generate 2-5 enemies per villager const numEnemies = 2 + Math.floor(Math.random() * 4); for (let i = 0; i < numEnemies; i++) { // Place within 2-3 cells of villager const radius = 2 + Math.floor(Math.random()); const angle = Math.random() * Math.PI * 2; const cellX = villager.cellX + Math.floor(Math.cos(angle) * radius); const cellY = villager.cellY + Math.floor(Math.sin(angle) * radius); const cellKey = `${cellX},${cellY}`; // Skip if cell is occupied if (occupiedCells.has(cellKey)) { continue; } // Generate random color between orange and dark red const red = 150 + Math.floor(Math.random() * 105); // 150-255 const green = Math.floor(Math.random() * 100); // 0-100 const blue = 0; enemies.push({ x: (cellX * gridSize) + (gridSize / 2), y: (cellY * gridSize) + (gridSize / 2), color: `rgb(${red}, ${green}, ${blue})`, size: CONFIG.enemies.size.min + Math.random() * (CONFIG.enemies.size.max - CONFIG.enemies.size.min), targetVillager: villager, patrolAngle: Math.random() * Math.PI * 2, isChasing: false, hp: 2 + Math.floor(Math.random() * 9), stunned: false, stunEndTime: 0, attacking: false, attackCooldown: false, attackCooldownUntil: 0, knockback: { active: false, startX: 0, startY: 0, targetX: 0, targetY: 0, startTime: 0, duration: 300 } }); occupiedCells.add(cellKey); } }); return enemies; }; const handleEnemyDamage = (enemy, damage, knockbackForce = 0, angle = 0) => { const gridSize = CONFIG.display.grid.size; enemy.hp -= damage; if (damage > 0 && enemy.hp <= 0) { // Create death particles const numParticles = 15 + Math.floor(Math.random() * 10); for (let i = 0; i < numParticles; i++) { const angle = (i / numParticles) * Math.PI * 2; const speed = 2 + Math.random() * 3; state.particles.push({ x: enemy.x, y: enemy.y, dx: Math.cos(angle) * speed, dy: Math.sin(angle) * speed, size: enemy.size * (0.1 + Math.random() * 0.2), color: enemy.color, lifetime: 1000, createdAt: animationTime }); } // Generate diamonds when enemy is defeated const diamondCount = Math.floor(Math.random() * 5); // 0 to 4 diamonds for (let i = 0; i < diamondCount; i++) { state.diamonds.push({ x: enemy.x + (Math.random() - 0.5) * 20, y: enemy.y + (Math.random() - 0.5) * 20, size: 6, // Smaller size (was 10) collected: false }); } } return damage > 0 && enemy.hp <= 0; }; const updateEnemies = (enemies, deltaTime) => { const gridSize = CONFIG.display.grid.size; const aggroRange = gridSize * CONFIG.enemies.chase.range; // Check for weapon collisions enemies.forEach(enemy => { // Check bubble collisions state.player.bubbles.forEach((bubble, bubbleIndex) => { const dx = enemy.x - bubble.x; const dy = enemy.y - bubble.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance < enemy.size + CONFIG.bubble.size) { // Remove the bubble state.player.bubbles.splice(bubbleIndex, 1); // Normalize the direction vector const length = Math.sqrt(dx * dx + dy * dy); const dirX = dx / length; const dirY = dy / length; // Set up knockback animation enemy.knockback = { active: true, startX: enemy.x, startY: enemy.y, targetX: enemy.x + dirX * CONFIG.display.grid.size, targetY: enemy.y + dirY * CONFIG.display.grid.size, startTime: animationTime, duration: 300 }; // Stun the enemy enemy.stunned = true; enemy.stunEndTime = animationTime + 500; // Stun for 500ms } }); // Check sword collision if (state.player.isSwinging && state.player.equipment === 'sword') { const swordTip = { x: state.player.x + Math.cos(state.player.swordAngle) * CONFIG.sword.length, y: state.player.y + Math.sin(state.player.swordAngle) * CONFIG.sword.length }; const dx = enemy.x - swordTip.x; const dy = enemy.y - swordTip.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance < enemy.size + 10) { // 10px sword hit tolerance handleEnemyDamage(enemy, 1); enemy.stunned = true; enemy.stunEndTime = animationTime + 300; // Stun for 300ms } } }); return enemies.filter(enemy => enemy.hp > 0).map(enemy => { const baseSpeed = CONFIG.enemies.patrol.speed.base * deltaTime / 1000; // Convert to pixels per frame // Handle knockback animation if (enemy.knockback.active) { const progress = (animationTime - enemy.knockback.startTime) / enemy.knockback.duration; if (progress >= 1) { enemy.knockback.active = false; enemy.x = enemy.knockback.targetX; enemy.y = enemy.knockback.targetY; // Calculate distance from villager after knockback const dvx = enemy.x - enemy.targetVillager.x; const dvy = enemy.y - enemy.targetVillager.y; const distanceToVillager = Math.sqrt(dvx * dvx + dvy * dvy); // If too far from villager, set returning state if (distanceToVillager > gridSize * 3) { return { ...enemy, x: enemy.x, y: enemy.y, isChasing: false, isReturning: true }; } } else { // Smooth easing function (ease-out) const t = 1 - Math.pow(1 - progress, 3); enemy.x = enemy.knockback.startX + (enemy.knockback.targetX - enemy.knockback.startX) * t; enemy.y = enemy.knockback.startY + (enemy.knockback.targetY - enemy.knockback.startY) * t; } return { ...enemy, x: enemy.x, y: enemy.y, isChasing: false }; } // If enemy is returning to villager if (enemy.isReturning) { const dvx = enemy.targetVillager.x - enemy.x; const dvy = enemy.targetVillager.y - enemy.y; const distanceToVillager = Math.sqrt(dvx * dvx + dvy * dvy); // If back within range, resume normal behavior if (distanceToVillager <= gridSize * 2) { enemy.isReturning = false; } else { // Move towards villager const angle = Math.atan2(dvy, dvx); const returnSpeed = baseSpeed * CONFIG.enemies.return.speedMultiplier; return { ...enemy, x: enemy.x + Math.cos(angle) * returnSpeed, y: enemy.y + Math.sin(angle) * returnSpeed, isChasing: false, isReturning: true }; } } // Check if stun has worn off if (enemy.stunned && animationTime >= enemy.stunEndTime) { enemy.stunned = false; } if (enemy.targetVillager.status === 'rescued') { return { ...enemy, color: CONFIG.enemies.colors.defeated }; } // Calculate distance to player const dx = state.player.x - enemy.x; const dy = state.player.y - enemy.y; const distanceToPlayer = Math.sqrt(dx * dx + dy * dy); if (distanceToPlayer <= aggroRange) { // Check if in attack range (half grid cell) const attackRange = gridSize * 0.5; // If on attack cooldown, keep distance if (enemy.attackCooldown) { if (animationTime >= enemy.attackCooldownUntil) { enemy.attackCooldown = false; } else if (distanceToPlayer < gridSize) { // Retreat if too close during cooldown const retreatAngle = Math.atan2(dy, dx) + Math.PI; // Opposite direction const retreatSpeed = baseSpeed * CONFIG.enemies.chase.speedMultiplier; return { ...enemy, x: enemy.x + Math.cos(retreatAngle) * retreatSpeed, y: enemy.y + Math.sin(retreatAngle) * retreatSpeed, isChasing: true }; } } // If in attack range and not on cooldown, initiate attack if (distanceToPlayer <= attackRange && !enemy.attacking && !enemy.attackCooldown) { enemy.attacking = true; enemy.attackStartPosition = { x: enemy.x, y: enemy.y }; enemy.attackTargetPosition = { x: state.player.x, y: state.player.y }; enemy.attackStartTime = animationTime; // Change enemy color to a random blue shade enemy.color = blueShades[Math.floor(Math.random() * blueShades.length)]; } // Handle attack animation and damage if (enemy.attacking) { const attackDuration = 200; // 200ms attack const progress = (animationTime - enemy.attackStartTime) / attackDuration; if (progress >= 1) { enemy.attacking = false; enemy.attackCooldown = true; enemy.attackCooldownUntil = animationTime + 1000; // 1 second cooldown // Check if attack hit the player const finalDx = state.player.x - enemy.x; const finalDy = state.player.y - enemy.y; const finalDistance = Math.sqrt(finalDx * finalDx + finalDy * finalDy); if (finalDistance < enemy.size + CONFIG.player.size && !state.player.isInvulnerable && !state.player.isDefending && !state.player.isSwinging && state.player.bubbles.length === 0) { state.player.hp -= 1; state.player.isInvulnerable = true; state.player.invulnerableUntil = animationTime + 1000; } return { ...enemy, isChasing: true }; } // Fast lunge animation with ease-in const t = progress * progress; // Quadratic ease-in return { ...enemy, x: enemy.attackStartPosition.x + (enemy.attackTargetPosition.x - enemy.attackStartPosition.x) * t, y: enemy.attackStartPosition.y + (enemy.attackTargetPosition.y - enemy.attackStartPosition.y) * t, isChasing: true, attacking: true }; } // Normal chase behavior if not attacking const angle = Math.atan2(dy, dx); const chaseSpeed = baseSpeed * CONFIG.enemies.chase.speedMultiplier; return { ...enemy, x: enemy.x + Math.cos(angle) * chaseSpeed, y: enemy.y + Math.sin(angle) * chaseSpeed, isChasing: true }; } else { // Get current distance from villager const dvx = enemy.x - enemy.targetVillager.x; const dvy = enemy.y - enemy.targetVillager.y; const distanceToVillager = Math.sqrt(dvx * dvx + dvy * dvy); // If too far from villager, start returning if (distanceToVillager > gridSize * 3) { return { ...enemy, isReturning: true, isChasing: false }; } // Patrol behavior - move in small circles around current position if (!enemy.patrolAngle) { enemy.patrolAngle = Math.random() * Math.PI * 2; } enemy.patrolAngle += baseSpeed * 0.02; // Small angle change for circular movement return { ...enemy, x: enemy.x + Math.cos(enemy.patrolAngle) * baseSpeed, y: enemy.y + Math.sin(enemy.patrolAngle) * baseSpeed, patrolAngle: enemy.patrolAngle, isChasing: false }; } }); }; const renderEnemies = (ctx, enemies) => { // Pre-create the health circle gradient once const healthGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 2); healthGradient.addColorStop(0, 'rgba(255, 0, 0, 0.8)'); healthGradient.addColorStop(0.7, 'rgba(200, 0, 0, 0.6)'); healthGradient.addColorStop(1, 'rgba(150, 0, 0, 0.4)'); enemies.forEach(enemy => { // Draw enemy body ctx.beginPath(); ctx.arc(enemy.x, enemy.y, enemy.size, 0, Math.PI * 2); ctx.fillStyle = enemy.stunned ? 'rgb(150, 150, 150)' : enemy.color; ctx.fill(); // Add a glowing effect const glowSize = enemy.stunned ? 1.1 : (enemy.attacking ? 1.6 : enemy.isChasing ? 1.4 : 1.2); const glowIntensity = enemy.stunned ? 0.1 : (enemy.attacking ? 0.5 : enemy.isChasing ? 0.3 : 0.2); // Create glow gradient only once per enemy const glowGradient = ctx.createRadialGradient( enemy.x, enemy.y, enemy.size * 0.5, enemy.x, enemy.y, enemy.size * glowSize ); glowGradient.addColorStop(0, `rgba(255, 0, 0, ${glowIntensity})`); glowGradient.addColorStop(1, 'rgba(255, 0, 0, 0)'); ctx.beginPath(); ctx.arc(enemy.x, enemy.y, enemy.size * glowSize, 0, Math.PI * 2); ctx.fillStyle = glowGradient; ctx.fill(); // Draw HP circles if (enemy.hp > 0) { const circleRadius = 3; const circleSpacing = 8; const totalCircles = enemy.hp; const startX = enemy.x - ((totalCircles - 1) * circleSpacing) / 2; const circleY = enemy.y - enemy.size - 15; // Batch draw the circle borders ctx.beginPath(); for (let i = 0; i < totalCircles; i++) { const circleX = startX + (i * circleSpacing); ctx.moveTo(circleX + circleRadius, circleY); ctx.arc(circleX, circleY, circleRadius, 0, Math.PI * 2); } ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; ctx.lineWidth = 1; ctx.stroke(); // Batch draw the filled circles ctx.beginPath(); for (let i = 0; i < totalCircles; i++) { const circleX = startX + (i * circleSpacing); ctx.moveTo(circleX + circleRadius - 1, circleY); ctx.arc(circleX, circleY, circleRadius - 1, 0, Math.PI * 2); } ctx.fillStyle = 'rgba(255, 0, 0, 0.6)'; ctx.fill(); } }); }; const generateDiamonds = () => { const diamondCount = Math.floor(Math.random() * 5); // 0 to 4 diamonds const diamonds = []; for (let i = 0; i < diamondCount; i++) { diamonds.push({ x: enemy.x + (Math.random() - 0.5) * 20, // Random position around the enemy y: enemy.y + (Math.random() - 0.5) * 20, size: 10, // Size of the diamond collected: false // Track if collected }); } return diamonds; }; // Export the functions window.enemySystem = { generateEnemies, updateEnemies, renderEnemies };