diff options
Diffstat (limited to 'html/plains/enemies.js')
-rw-r--r-- | html/plains/enemies.js | 438 |
1 files changed, 438 insertions, 0 deletions
diff --git a/html/plains/enemies.js b/html/plains/enemies.js new file mode 100644 index 0000000..3d40c3e --- /dev/null +++ b/html/plains/enemies.js @@ -0,0 +1,438 @@ +const blueShades = [ + 'rgb(0, 0, 255)', + 'rgb(0, 0, 200)', + 'rgb(0, 0, 150)', + 'rgb(0, 0, 100)', + 'rgb(0, 0, 50)' +]; + +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; + + // 2 - 5 enemies per villager + const numEnemies = 2 + Math.floor(Math.random() * 4); + + for (let i = 0; i < numEnemies; i++) { + // make sure to place an enemy within 2 - 3 cells of a 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}`; + + if (occupiedCells.has(cellKey)) { + continue; + } + + // 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() * 4), + 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 createDeathParticles = (enemy) => { + const numParticles = 15 + Math.floor(Math.random() * 10); + for (let i = 0; i < numParticles; i++) { + const particleAngle = (i / numParticles) * Math.PI * 2; + const speed = 2 + Math.random() * 3; + state.particles.push({ + x: enemy.x, + y: enemy.y, + dx: Math.cos(particleAngle) * speed, + dy: Math.sin(particleAngle) * speed, + size: enemy.size * (0.1 + Math.random() * 0.2), + color: enemy.color, + lifetime: 1000, + createdAt: animationTime + }); + } +}; + +const createDeathDiamonds = (enemy) => { + const diamondCount = Math.floor(Math.random() * 5); + 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, + collected: false + }); + } +}; + +const handleEnemyDamage = (enemy, damage, knockbackForce = 0, angle = 0) => { + const gridSize = CONFIG.display.grid.size; + enemy.hp -= damage; + + // stun the enemy when you hit them + enemy.stunned = true; + enemy.stunEndTime = animationTime + 500; + + // knock the enemy back when you hit them + if (knockbackForce > 0) { + const knockbackDistance = gridSize * 0.5; + enemy.knockback = { + active: true, + startX: enemy.x, + startY: enemy.y, + targetX: enemy.x + Math.cos(angle) * knockbackDistance, + targetY: enemy.y + Math.sin(angle) * knockbackDistance, + startTime: animationTime, + duration: 300 + }; + } + + if (damage > 0 && enemy.hp <= 0) { + createDeathParticles(enemy); + createDeathDiamonds(enemy); + } + + return damage > 0 && enemy.hp <= 0; +}; + +// find the nearest lost villager, zoot to 'em +const findNearestLostVillager = (enemyX, enemyY, villagers) => { + let nearestVillager = null; + let shortestDistance = Infinity; + + villagers.forEach(villager => { + if (villager.status === 'lost') { + const dx = villager.x - enemyX; + const dy = villager.y - enemyY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < shortestDistance) { + shortestDistance = distance; + nearestVillager = villager; + } + } + }); + + return nearestVillager; +}; + +const updateEnemies = (enemies, deltaTime) => { + const gridSize = CONFIG.display.grid.size; + const aggroRange = gridSize * CONFIG.enemies.chase.range; + + return enemies.filter(enemy => enemy.hp > 0).map(enemy => { + const baseSpeed = CONFIG.enemies.patrol.speed.base * deltaTime / 1000; + + 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; + + const dvx = enemy.x - enemy.targetVillager.x; + const dvy = enemy.y - enemy.targetVillager.y; + const distanceToVillager = Math.sqrt(dvx * dvx + dvy * dvy); + + if (distanceToVillager > gridSize * 3) { + return { + ...enemy, + x: enemy.x, + y: enemy.y, + isChasing: false, + isReturning: true + }; + } + } else { + // 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, go back to normal behavior + if (distanceToVillager <= gridSize * 2) { + enemy.isReturning = false; + } else { + // Move to the 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 + }; + } + } + + // Still stunned? + if (enemy.stunned && animationTime >= enemy.stunEndTime) { + enemy.stunned = false; + } + + // Was your villager rescued?? + if (enemy.targetVillager.status === 'rescued') { + // find a new villager + const newTarget = findNearestLostVillager(enemy.x, enemy.y, state.villagers); + + if (newTarget) { + // zoot towards that villager + enemy.targetVillager = newTarget; + enemy.isReturning = true; + enemy.isChasing = false; + } else { + // no more villagers, get real sad + return { + ...enemy, + color: CONFIG.enemies.colors.defeated + }; + } + } + + // 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) { + + const attackRange = gridSize * 0.5; + + if (enemy.attackCooldown) { + if (animationTime >= enemy.attackCooldownUntil) { + enemy.attackCooldown = false; + } else if (distanceToPlayer < gridSize) { + const retreatAngle = Math.atan2(dy, dx) + Math.PI; // retreat in the 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 (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; + enemy.color = blueShades[Math.floor(Math.random() * blueShades.length)]; + } + + // attack animation and damage + if (enemy.attacking) { + const attackDuration = 200; + const progress = (animationTime - enemy.attackStartTime) / attackDuration; + + if (progress >= 1) { + enemy.attacking = false; + enemy.attackCooldown = true; + enemy.attackCooldownUntil = animationTime + 500; + + // did the 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 + }; + } + + // lunge towards the player + 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 + }; + } + + 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 { + + const dvx = enemy.x - enemy.targetVillager.x; + const dvy = enemy.y - enemy.targetVillager.y; + const distanceToVillager = Math.sqrt(dvx * dvx + dvy * dvy); + + if (distanceToVillager > gridSize * 3) { + return { + ...enemy, + isReturning: true, + isChasing: false + }; + } + + if (!enemy.patrolAngle) { + enemy.patrolAngle = Math.random() * Math.PI * 2; + } + + enemy.patrolAngle += baseSpeed * 0.02; + + 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) => { + // I tried to generate this per enemy, but it was wildly inefficient + 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 => { + 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(); + + // she be radiant + 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); + 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(); + + 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; + + 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(); + + 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() * 3); + const diamonds = []; + + for (let i = 0; i < diamondCount; i++) { + diamonds.push({ + x: enemy.x + (Math.random() - 0.5) * 20, + y: enemy.y + (Math.random() - 0.5) * 20, + size: 10, + collected: false + }); + } + + return diamonds; +}; + +window.enemySystem = { + generateEnemies, + updateEnemies, + renderEnemies, + findNearestLostVillager, + handleEnemyDamage +}; \ No newline at end of file |