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
};