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