2026-10-20 16:51:26 +00:00
|
|
|
/**
|
|
|
|
|
* Wolf Entity
|
|
|
|
|
* Stalks and hunts sheep via multi-bite combat, triggers blood splatter decals,
|
|
|
|
|
* feeds on prey, mates when satiated, and nurtures growing wolf pups.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { Animal } from './animal.js';
|
|
|
|
|
|
|
|
|
|
export class Wolf extends Animal {
|
|
|
|
|
constructor(x, y, isBaby = false) {
|
|
|
|
|
super(x, y, 'wolf', isBaby);
|
|
|
|
|
this.attackCooldownTimer = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
canMate(threshold = 86) {
|
|
|
|
|
return !this.isBaby && this.hunger >= threshold && this.mateCooldown <= 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 18:11:58 +00:00
|
|
|
update(dt, worldWidth, worldHeight, config, sheepList, allWolves, terrain, particleSystem, onBabyBorn, onSheepKilled, audio = null) {
|
2026-10-20 16:51:26 +00:00
|
|
|
if (this.isDead) return;
|
|
|
|
|
|
|
|
|
|
// Deplete hunger over time (gentler for growing pups)
|
|
|
|
|
const baseDeplete = (config.wolfHungerDepletionRate || 1.3) * (this.isBaby ? 0.8 : 1.0);
|
|
|
|
|
const isHunting = this.actionState === 'hunt';
|
|
|
|
|
this.hunger -= (isHunting ? baseDeplete * 1.4 : baseDeplete) * dt;
|
|
|
|
|
|
|
|
|
|
if (this.attackCooldownTimer > 0) {
|
|
|
|
|
this.attackCooldownTimer -= dt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Base update (boundaries, health tick if starving, walk cycle)
|
|
|
|
|
super.updateBase(dt, worldWidth, worldHeight, config);
|
|
|
|
|
if (this.isDead) return;
|
|
|
|
|
|
|
|
|
|
// Pack Separation: maintain personal space so wolves don't stack on top of each other
|
|
|
|
|
for (let i = 0; i < allWolves.length; i++) {
|
|
|
|
|
const other = allWolves[i];
|
|
|
|
|
if (other === this || other.isDead) continue;
|
|
|
|
|
const dx = other.x - this.x;
|
|
|
|
|
const dy = other.y - this.y;
|
|
|
|
|
const dist = Math.hypot(dx, dy);
|
|
|
|
|
const minPackDist = 32 * this.growthScale;
|
|
|
|
|
if (dist < minPackDist && dist > 0.05) {
|
|
|
|
|
const force = ((minPackDist - dist) / minPackDist) * 0.35;
|
|
|
|
|
this.vx -= (dx / dist) * force;
|
|
|
|
|
this.vy -= (dy / dist) * force;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 1. Mating Behavior:
|
|
|
|
|
// Requires high satiety (wolfMatingThreshold) and long cooldown
|
|
|
|
|
const mateThreshold = config.wolfMatingThreshold || 86;
|
|
|
|
|
const mateCooldown = config.wolfMatingCooldown || 35;
|
|
|
|
|
|
|
|
|
|
if (this.canMate(mateThreshold)) {
|
|
|
|
|
let nearestMate = null;
|
|
|
|
|
let minMateDist = 220;
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < allWolves.length; i++) {
|
|
|
|
|
const w = allWolves[i];
|
|
|
|
|
if (w === this || w.isDead || !w.canMate(mateThreshold)) continue;
|
|
|
|
|
const dist = Math.hypot(w.x - this.x, w.y - this.y);
|
|
|
|
|
if (dist < minMateDist) {
|
|
|
|
|
minMateDist = dist;
|
|
|
|
|
nearestMate = w;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (nearestMate) {
|
|
|
|
|
this.actionState = 'mating';
|
|
|
|
|
const approachSpeed = config.wolfMaxSpeed || 2.2;
|
|
|
|
|
this.steerTowards(nearestMate.x, nearestMate.y, approachSpeed);
|
|
|
|
|
|
|
|
|
|
const collisionThreshold = (18 * this.growthScale) + (18 * nearestMate.growthScale);
|
|
|
|
|
if (minMateDist <= collisionThreshold) {
|
|
|
|
|
this.consumeMatingEnergy(mateCooldown);
|
|
|
|
|
nearestMate.consumeMatingEnergy(mateCooldown);
|
|
|
|
|
|
|
|
|
|
if (particleSystem) {
|
|
|
|
|
particleSystem.emitHeart(this.x, this.y);
|
|
|
|
|
particleSystem.emitHeart(nearestMate.x, nearestMate.y);
|
|
|
|
|
}
|
2026-10-20 18:11:58 +00:00
|
|
|
if (audio) {
|
|
|
|
|
audio.playHeartChime();
|
|
|
|
|
}
|
2026-10-20 16:51:26 +00:00
|
|
|
|
|
|
|
|
if (onBabyBorn) {
|
|
|
|
|
const babyX = (this.x + nearestMate.x) / 2 + (Math.random() - 0.5) * 12;
|
|
|
|
|
const babyY = (this.y + nearestMate.y) / 2 + (Math.random() - 0.5) * 12;
|
|
|
|
|
onBabyBorn(babyX, babyY, 'wolf');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Hunting & Scavenging Behavior:
|
|
|
|
|
if (this.hunger < 92 || this.isBaby) {
|
|
|
|
|
// 2a. Scavenging: Baby wolves can feast on fresh sheep carcasses left by the pack
|
|
|
|
|
if (this.isBaby && terrain && terrain.carcasses.length > 0 && this.hunger < 85) {
|
|
|
|
|
let nearestCarcass = null;
|
|
|
|
|
let minCarcassDist = 200;
|
|
|
|
|
for (let i = 0; i < terrain.carcasses.length; i++) {
|
|
|
|
|
const c = terrain.carcasses[i];
|
|
|
|
|
if (c.species === 'sheep' && c.decayProgress < 0.75) {
|
|
|
|
|
const d = Math.hypot(c.x - this.x, c.y - this.y);
|
|
|
|
|
if (d < minCarcassDist) {
|
|
|
|
|
minCarcassDist = d;
|
|
|
|
|
nearestCarcass = c;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (nearestCarcass) {
|
|
|
|
|
this.actionState = 'hunt';
|
|
|
|
|
if (minCarcassDist <= 24) {
|
|
|
|
|
// Feast on carcass
|
|
|
|
|
this.recordMeal(35, particleSystem);
|
2026-10-20 18:11:58 +00:00
|
|
|
if (audio) {
|
|
|
|
|
audio.playWolfChomp();
|
|
|
|
|
}
|
2026-10-20 16:51:26 +00:00
|
|
|
nearestCarcass.decayProgress = Math.min(1.0, nearestCarcass.decayProgress + 0.18);
|
|
|
|
|
if (particleSystem) {
|
|
|
|
|
particleSystem.emitEatingCrumbs(nearestCarcass.x, nearestCarcass.y, '#8A1010', 4);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
} else {
|
|
|
|
|
this.steerTowards(nearestCarcass.x, nearestCarcass.y, 2.6, 0.2);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2b. Hunt Live Sheep:
|
|
|
|
|
let targetSheep = null;
|
|
|
|
|
let minSheepDist = config.wolfPerceptionRadius || 260;
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < sheepList.length; i++) {
|
|
|
|
|
const s = sheepList[i];
|
|
|
|
|
if (s.isDead) continue;
|
|
|
|
|
const dist = Math.hypot(s.x - this.x, s.y - this.y);
|
|
|
|
|
// Baby wolves prioritize baby sheep or nearby targets
|
|
|
|
|
const scoreDist = (this.isBaby && s.isBaby) ? dist * 0.6 : dist;
|
|
|
|
|
if (scoreDist < minSheepDist) {
|
|
|
|
|
minSheepDist = scoreDist;
|
|
|
|
|
targetSheep = s;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (targetSheep) {
|
|
|
|
|
this.actionState = 'hunt';
|
|
|
|
|
const rawDist = Math.hypot(targetSheep.x - this.x, targetSheep.y - this.y);
|
|
|
|
|
|
|
|
|
|
// Fast, agile chase speed (4.1+ vs sheep's 2.8) so wolves reliably catch targets without agonizing long chases
|
|
|
|
|
const chaseSpeed = (config.wolfChaseSpeed || 4.1) * (this.isBaby ? 1.05 : 1.0);
|
|
|
|
|
const turnRate = this.isBaby ? 0.25 : 0.22;
|
|
|
|
|
|
|
|
|
|
// Attack reach ensures wolf strikes cleanly before collision bounce
|
|
|
|
|
const attackDist = Math.max(30, 34 * this.growthScale);
|
|
|
|
|
|
|
|
|
|
if (rawDist <= attackDist) {
|
|
|
|
|
// Attack range! Multi-bite combat
|
|
|
|
|
if (this.attackCooldownTimer <= 0) {
|
|
|
|
|
this.attackCooldownTimer = config.wolfAttackCooldown || 0.65;
|
|
|
|
|
|
|
|
|
|
// Wolves expend health on attack lunge (struggle/exertion) - reduced for babies
|
|
|
|
|
const baseLunge = config.wolfLungeCost !== undefined ? config.wolfLungeCost : 7;
|
|
|
|
|
const lungeCost = this.isBaby ? baseLunge * 0.35 : baseLunge;
|
|
|
|
|
this.health -= lungeCost;
|
|
|
|
|
if (this.health <= 0) {
|
|
|
|
|
const biteDamage = (config.wolfAttackDamage || 38) * (this.isBaby ? 0.75 : 1.0);
|
|
|
|
|
if (targetSheep.health <= biteDamage) {
|
|
|
|
|
// Clutch kill! Spent last burst of energy to take down prey and survive
|
|
|
|
|
this.health = 18;
|
|
|
|
|
} else {
|
|
|
|
|
this.health = 0;
|
|
|
|
|
this.die('exertion');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const biteDamage = (config.wolfAttackDamage || 38) * (this.isBaby ? 0.75 : 1.0);
|
|
|
|
|
const biteAngle = Math.atan2(targetSheep.y - this.y, targetSheep.x - this.x);
|
|
|
|
|
const sheepDied = targetSheep.takeDamage(biteDamage, biteAngle, particleSystem);
|
|
|
|
|
|
2026-10-20 18:11:58 +00:00
|
|
|
// Play synthesized arcade wolf chomp / crunch!
|
|
|
|
|
if (audio) {
|
|
|
|
|
audio.playWolfChomp();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 16:51:26 +00:00
|
|
|
// Stamp blood on the ground where attack occurred
|
|
|
|
|
if (terrain) {
|
|
|
|
|
terrain.addBloodSplotch(targetSheep.x, targetSheep.y, 4 + Math.random() * 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sheepDied) {
|
|
|
|
|
// Target killed! Wolf feasts
|
|
|
|
|
this.recordMeal(42, particleSystem);
|
|
|
|
|
if (onSheepKilled) onSheepKilled(targetSheep, this);
|
|
|
|
|
} else {
|
|
|
|
|
// Taste on bite strike (sustains pursuit and rewards successful strikes)
|
|
|
|
|
this.recordMeal(this.isBaby ? 16 : 10, particleSystem);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Sprint towards sheep with high agility
|
|
|
|
|
this.steerTowards(targetSheep.x, targetSheep.y, chaseSpeed, turnRate);
|
|
|
|
|
|
|
|
|
|
// Pack dispersion during chase: wolves flank around target rather than stacking in single file
|
|
|
|
|
for (let i = 0; i < allWolves.length; i++) {
|
|
|
|
|
const other = allWolves[i];
|
|
|
|
|
if (other === this || other.isDead) continue;
|
|
|
|
|
const dx = other.x - this.x;
|
|
|
|
|
const dy = other.y - this.y;
|
|
|
|
|
const dist = Math.hypot(dx, dy);
|
|
|
|
|
const minPackDist = 34 * this.growthScale;
|
|
|
|
|
if (dist < minPackDist && dist > 0.05) {
|
|
|
|
|
const force = ((minPackDist - dist) / minPackDist) * 0.4;
|
|
|
|
|
this.vx -= (dx / dist) * force;
|
|
|
|
|
this.vy -= (dy / dist) * force;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Default: Stalking / Wandering
|
|
|
|
|
this.actionState = 'wander';
|
|
|
|
|
this.wander(1.3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
draw(ctx) {
|
|
|
|
|
if (this.isDead) return;
|
|
|
|
|
|
|
|
|
|
ctx.save();
|
|
|
|
|
const scale = this.growthScale;
|
|
|
|
|
const px = Math.round(this.x);
|
|
|
|
|
const py = Math.round(this.y - this.bobOffset);
|
|
|
|
|
|
|
|
|
|
// Direction flip
|
|
|
|
|
const facingLeft = Math.cos(this.heading) < 0;
|
|
|
|
|
|
|
|
|
|
// Dimensions
|
|
|
|
|
const bodyW = Math.round(26 * scale);
|
|
|
|
|
const bodyH = Math.round(14 * scale);
|
|
|
|
|
const halfW = Math.round(bodyW / 2);
|
|
|
|
|
const halfH = Math.round(bodyH / 2);
|
|
|
|
|
|
|
|
|
|
// 1. Paws / Legs (4 sleek grey legs bobbing)
|
|
|
|
|
ctx.fillStyle = '#26292C';
|
|
|
|
|
const legW = Math.max(2, Math.round(3 * scale));
|
|
|
|
|
const legH = Math.max(4, Math.round(6 * scale));
|
|
|
|
|
const legBob1 = Math.round(Math.sin(this.walkCycle) * 2.5);
|
|
|
|
|
const legBob2 = Math.round(Math.sin(this.walkCycle + Math.PI) * 2.5);
|
|
|
|
|
|
|
|
|
|
ctx.fillRect(px - halfW + 3, py + halfH - 2 + legBob1, legW, legH);
|
|
|
|
|
ctx.fillRect(px - halfW + 9, py + halfH - 2 + legBob2, legW, legH);
|
|
|
|
|
ctx.fillRect(px + halfW - 10, py + halfH - 2 + legBob2, legW, legH);
|
|
|
|
|
ctx.fillRect(px + halfW - 4, py + halfH - 2 + legBob1, legW, legH);
|
|
|
|
|
|
|
|
|
|
// 2. Tail
|
|
|
|
|
const tailX = facingLeft ? px + halfW - 2 : px - halfW - Math.round(6 * scale) + 2;
|
|
|
|
|
const tailBob = Math.round(Math.sin(this.walkCycle * 1.5) * 2);
|
|
|
|
|
ctx.fillStyle = '#3F4448';
|
|
|
|
|
ctx.fillRect(tailX, py - Math.round(2 * scale) + tailBob, Math.round(6 * scale), Math.round(3 * scale));
|
|
|
|
|
|
|
|
|
|
// 3. Torso
|
|
|
|
|
ctx.fillStyle = '#44494E'; // slate grey coat
|
|
|
|
|
ctx.fillRect(px - halfW, py - halfH, bodyW, bodyH);
|
|
|
|
|
|
|
|
|
|
// Belly & Back shading
|
|
|
|
|
ctx.fillStyle = '#2C3033'; // underbelly shadow
|
|
|
|
|
ctx.fillRect(px - halfW, py + halfH - Math.round(3 * scale), bodyW, Math.round(3 * scale));
|
|
|
|
|
ctx.fillStyle = '#5A6066'; // back highlight
|
|
|
|
|
ctx.fillRect(px - halfW + 2, py - halfH, bodyW - 4, Math.round(2 * scale));
|
|
|
|
|
|
|
|
|
|
// 4. Head, Muzzle & Pointed Ears
|
|
|
|
|
const headW = Math.round(10 * scale);
|
|
|
|
|
const headH = Math.round(9 * scale);
|
|
|
|
|
const headX = facingLeft ? px - halfW - headW + 2 : px + halfW - 2;
|
|
|
|
|
const headY = py - halfH - 1;
|
|
|
|
|
|
|
|
|
|
// Head base
|
|
|
|
|
ctx.fillStyle = '#3A3F43';
|
|
|
|
|
ctx.fillRect(headX, headY, headW, headH);
|
|
|
|
|
|
|
|
|
|
// Snout / Muzzle
|
|
|
|
|
const snoutW = Math.round(5 * scale);
|
|
|
|
|
const snoutH = Math.round(4 * scale);
|
|
|
|
|
const snoutX = facingLeft ? headX - snoutW : headX + headW;
|
|
|
|
|
ctx.fillStyle = '#2B2F32';
|
|
|
|
|
ctx.fillRect(snoutX, headY + headH - snoutH - 1, snoutW, snoutH);
|
|
|
|
|
|
|
|
|
|
// Pointed Ears
|
|
|
|
|
const earX = facingLeft ? headX + headW - Math.round(3 * scale) : headX;
|
|
|
|
|
ctx.fillStyle = '#232629';
|
|
|
|
|
ctx.fillRect(earX, headY - Math.round(4 * scale), Math.round(3 * scale), Math.round(4 * scale));
|
|
|
|
|
|
|
|
|
|
// Predator Eye (Glowing Amber/Red)
|
|
|
|
|
const eyeX = facingLeft ? headX + 3 : headX + headW - 4;
|
|
|
|
|
ctx.fillStyle = this.actionState === 'hunt' ? '#FF2200' : '#FFB703';
|
|
|
|
|
ctx.fillRect(eyeX, headY + 2, Math.max(1, Math.round(2 * scale)), Math.max(1, Math.round(2 * scale)));
|
|
|
|
|
|
|
|
|
|
// 5. Status Indicator
|
|
|
|
|
if (this.actionState === 'hunt') {
|
|
|
|
|
ctx.fillStyle = '#FF2222';
|
|
|
|
|
ctx.font = 'bold 9px monospace';
|
|
|
|
|
ctx.fillText('⚡', px - 3, py - halfH - 5);
|
|
|
|
|
} else if (this.actionState === 'mating') {
|
|
|
|
|
ctx.fillStyle = '#FF5588';
|
|
|
|
|
ctx.font = '8px monospace';
|
|
|
|
|
ctx.fillText('♥', px - 3, py - halfH - 5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.restore();
|
|
|
|
|
}
|
|
|
|
|
}
|