218 lines
6.4 KiB
JavaScript
218 lines
6.4 KiB
JavaScript
/**
|
|
* Base Animal Entity Class
|
|
* Encapsulates movement, steering behaviors, soft boundaries, hunger, health,
|
|
* reproduction prerequisites, baby growth cycle, and walking bob animations.
|
|
*/
|
|
|
|
export class Animal {
|
|
constructor(x, y, species, isBaby = false) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.species = species; // 'sheep' or 'wolf'
|
|
|
|
// Movement
|
|
this.vx = (Math.random() - 0.5) * 1.5;
|
|
this.vy = (Math.random() - 0.5) * 1.5;
|
|
this.heading = Math.atan2(this.vy, this.vx);
|
|
this.wanderAngle = Math.random() * Math.PI * 2;
|
|
|
|
// Biological stats
|
|
this.health = 100;
|
|
this.hunger = isBaby ? 80 : 75; // 0 to 100
|
|
this.age = 0;
|
|
this.isDead = false;
|
|
this.deathCause = null;
|
|
|
|
// Baby & Growth
|
|
this.isBaby = isBaby;
|
|
this.foodEatenAsBaby = 0;
|
|
this.growthScale = isBaby ? 0.55 : 1.0;
|
|
|
|
// Mating
|
|
this.mateCooldown = 12 + Math.random() * 6; // seconds before ready
|
|
this.isSeekingMate = false;
|
|
|
|
// Animation
|
|
this.walkCycle = Math.random() * Math.PI * 2;
|
|
this.bobOffset = 0;
|
|
this.actionState = 'wander'; // 'wander', 'seek_food', 'flee', 'hunt', 'mating'
|
|
this.actionTimer = 0;
|
|
}
|
|
|
|
updateBase(dt, worldWidth, worldHeight, config) {
|
|
if (this.isDead) return;
|
|
|
|
this.age += dt;
|
|
this.walkCycle += Math.hypot(this.vx, this.vy) * dt * 4.5;
|
|
this.bobOffset = Math.abs(Math.sin(this.walkCycle)) * 2.5;
|
|
|
|
// Mating cooldown
|
|
if (this.mateCooldown > 0) {
|
|
this.mateCooldown -= dt;
|
|
}
|
|
|
|
// Health decay if starving
|
|
if (this.hunger <= 0) {
|
|
this.hunger = 0;
|
|
this.health -= 8.0 * dt; // dies in 12.5s without food
|
|
if (this.health <= 0) {
|
|
this.health = 0;
|
|
this.die('starvation');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Soft boundary steering
|
|
this.applyBoundarySteering(worldWidth, worldHeight, config.boundaryMargin || 45);
|
|
|
|
// Integrate position
|
|
this.x += this.vx * dt * 45;
|
|
this.y += this.vy * dt * 45;
|
|
|
|
// Update facing heading if moving
|
|
const currentSpeed = Math.hypot(this.vx, this.vy);
|
|
if (currentSpeed > 0.05) {
|
|
this.heading = Math.atan2(this.vy, this.vx);
|
|
}
|
|
}
|
|
|
|
applyBoundarySteering(width, height, margin = 55) {
|
|
const turnForce = 0.45;
|
|
const bodyPadding = 22; // Keeps entire head and body comfortably inside visible canvas
|
|
|
|
// Proactive steering away from boundaries
|
|
if (this.x < margin) {
|
|
this.vx += turnForce * ((margin - this.x) / margin);
|
|
} else if (this.x > width - margin) {
|
|
this.vx -= turnForce * ((this.x - (width - margin)) / margin);
|
|
}
|
|
|
|
if (this.y < margin) {
|
|
this.vy += turnForce * ((margin - this.y) / margin);
|
|
} else if (this.y > height - margin) {
|
|
this.vy -= turnForce * ((this.y - (height - margin)) / margin);
|
|
}
|
|
|
|
// Physical boundary bounce: reflect velocity inward so animals never get stuck facing walls
|
|
if (this.x <= bodyPadding) {
|
|
this.x = bodyPadding;
|
|
if (this.vx < 0) {
|
|
this.vx = Math.abs(this.vx) * 0.7 + 0.3;
|
|
this.wanderAngle = 0; // face inward (right)
|
|
}
|
|
} else if (this.x >= width - bodyPadding) {
|
|
this.x = width - bodyPadding;
|
|
if (this.vx > 0) {
|
|
this.vx = -Math.abs(this.vx) * 0.7 - 0.3;
|
|
this.wanderAngle = Math.PI; // face inward (left)
|
|
}
|
|
}
|
|
|
|
if (this.y <= bodyPadding) {
|
|
this.y = bodyPadding;
|
|
if (this.vy < 0) {
|
|
this.vy = Math.abs(this.vy) * 0.7 + 0.3;
|
|
this.wanderAngle = Math.PI * 0.5; // face inward (down)
|
|
}
|
|
} else if (this.y >= height - bodyPadding) {
|
|
this.y = height - bodyPadding;
|
|
if (this.vy > 0) {
|
|
this.vy = -Math.abs(this.vy) * 0.7 - 0.3;
|
|
this.wanderAngle = Math.PI * 1.5; // face inward (up)
|
|
}
|
|
}
|
|
}
|
|
|
|
wander(speed = 1.2) {
|
|
this.wanderAngle += (Math.random() - 0.5) * 0.45;
|
|
const targetVx = Math.cos(this.wanderAngle) * speed;
|
|
const targetVy = Math.sin(this.wanderAngle) * speed;
|
|
this.vx += (targetVx - this.vx) * 0.1;
|
|
this.vy += (targetVy - this.vy) * 0.1;
|
|
}
|
|
|
|
steerTowards(targetX, targetY, speed = 2.0, turnRate = 0.15) {
|
|
const dx = targetX - this.x;
|
|
const dy = targetY - this.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
if (dist > 1) {
|
|
const targetVx = (dx / dist) * speed;
|
|
const targetVy = (dy / dist) * speed;
|
|
this.vx += (targetVx - this.vx) * turnRate;
|
|
this.vy += (targetVy - this.vy) * turnRate;
|
|
}
|
|
}
|
|
|
|
steerAwayFrom(hazardX, hazardY, speed = 2.6, worldWidth = 1280, worldHeight = 760) {
|
|
let dx = this.x - hazardX;
|
|
let dy = this.y - hazardY;
|
|
|
|
// Wall deflection: curve along perimeter into open field rather than pinning against wall
|
|
const wallBuffer = 55;
|
|
if (this.x < wallBuffer) dx += 1.8;
|
|
else if (this.x > worldWidth - wallBuffer) dx -= 1.8;
|
|
|
|
if (this.y < wallBuffer) dy += 1.8;
|
|
else if (this.y > worldHeight - wallBuffer) dy -= 1.8;
|
|
|
|
const dist = Math.hypot(dx, dy);
|
|
if (dist > 0.1) {
|
|
const targetVx = (dx / dist) * speed;
|
|
const targetVy = (dy / dist) * speed;
|
|
this.vx += (targetVx - this.vx) * 0.25;
|
|
this.vy += (targetVy - this.vy) * 0.25;
|
|
}
|
|
}
|
|
|
|
recordMeal(nutritionAmount = 40, particleSystem = null) {
|
|
this.hunger = Math.min(100, this.hunger + nutritionAmount);
|
|
|
|
if (this.isBaby) {
|
|
this.foodEatenAsBaby++;
|
|
// Baby grows progressively with each meal:
|
|
// 0 meals: 0.55 scale, 1 meal: 0.70 scale, 2 meals: 0.85 scale, 3 meals: 1.0 adult!
|
|
this.growthScale = 0.55 + (this.foodEatenAsBaby / 3) * 0.45;
|
|
|
|
if (this.foodEatenAsBaby >= 3) {
|
|
this.isBaby = false;
|
|
this.growthScale = 1.0;
|
|
if (particleSystem) {
|
|
particleSystem.emitGrowthSparkles(this.x, this.y, 14);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
canMate() {
|
|
return !this.isBaby && this.hunger >= 82 && this.mateCooldown <= 0;
|
|
}
|
|
|
|
consumeMatingEnergy(cooldown = 20) {
|
|
this.hunger = Math.max(25, this.hunger - 45);
|
|
this.mateCooldown = cooldown + Math.random() * 6;
|
|
}
|
|
|
|
takeDamage(amount, fromAngle = 0, particleSystem = null) {
|
|
this.health -= amount;
|
|
|
|
// Bite strike knockback impulse pushes prey away from predator
|
|
const knockback = 2.6;
|
|
this.vx += Math.cos(fromAngle) * knockback;
|
|
this.vy += Math.sin(fromAngle) * knockback;
|
|
|
|
if (particleSystem) {
|
|
particleSystem.emitBloodSpray(this.x, this.y, fromAngle, 12);
|
|
}
|
|
if (this.health <= 0) {
|
|
this.health = 0;
|
|
this.die('wolf');
|
|
return true; // died
|
|
}
|
|
return false;
|
|
}
|
|
|
|
die(cause = 'starvation') {
|
|
this.isDead = true;
|
|
this.deathCause = cause;
|
|
}
|
|
}
|