249 lines
8.5 KiB
JavaScript
249 lines
8.5 KiB
JavaScript
|
|
/**
|
||
|
|
* Sheep Entity
|
||
|
|
* Grazes on grass and dense flower tufts, flees from predators,
|
||
|
|
* seeks mates when well-fed, and gives birth to growing baby sheep.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { Animal } from './animal.js';
|
||
|
|
|
||
|
|
export class Sheep extends Animal {
|
||
|
|
constructor(x, y, isBaby = false) {
|
||
|
|
super(x, y, 'sheep', isBaby);
|
||
|
|
this.grazingTimer = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
update(dt, worldWidth, worldHeight, config, plants, wolves, allSheep, particleSystem, onBabyBorn, terrain = null) {
|
||
|
|
if (this.isDead) return;
|
||
|
|
|
||
|
|
// Deplete hunger over time using config
|
||
|
|
this.hunger -= (config.sheepHungerDepletionRate || 1.2) * dt;
|
||
|
|
|
||
|
|
// Base update (boundaries, health tick if starving, walk cycle)
|
||
|
|
super.updateBase(dt, worldWidth, worldHeight, config);
|
||
|
|
if (this.isDead) return;
|
||
|
|
|
||
|
|
// Flock Separation: maintain personal space so sheep don't bunch into a single spot
|
||
|
|
for (let i = 0; i < allSheep.length; i++) {
|
||
|
|
const other = allSheep[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 minFlockDist = 26 * this.growthScale;
|
||
|
|
if (dist < minFlockDist && dist > 0.05) {
|
||
|
|
const force = ((minFlockDist - dist) / minFlockDist) * 0.3;
|
||
|
|
this.vx -= (dx / dist) * force;
|
||
|
|
this.vy -= (dy / dist) * force;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 1. Check for nearby threats (Wolves) - TOP PRIORITY: FLEE!
|
||
|
|
let nearestWolf = null;
|
||
|
|
let minWolfDist = config.sheepPerceptionRadius || 160;
|
||
|
|
|
||
|
|
for (let i = 0; i < wolves.length; i++) {
|
||
|
|
const w = wolves[i];
|
||
|
|
if (w.isDead) continue;
|
||
|
|
const dist = Math.hypot(w.x - this.x, w.y - this.y);
|
||
|
|
if (dist < minWolfDist) {
|
||
|
|
minWolfDist = dist;
|
||
|
|
nearestWolf = w;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (nearestWolf) {
|
||
|
|
this.actionState = 'flee';
|
||
|
|
const fleeSpeed = config.sheepFleeSpeed || 2.8;
|
||
|
|
this.steerAwayFrom(nearestWolf.x, nearestWolf.y, fleeSpeed, worldWidth, worldHeight);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Mating Behavior:
|
||
|
|
// If sheep is full, adult, and off cooldown -> search for another full sheep
|
||
|
|
if (this.canMate()) {
|
||
|
|
let nearestMate = null;
|
||
|
|
let minMateDist = 180;
|
||
|
|
|
||
|
|
for (let i = 0; i < allSheep.length; i++) {
|
||
|
|
const s = allSheep[i];
|
||
|
|
if (s === this || s.isDead || !s.canMate()) continue;
|
||
|
|
const dist = Math.hypot(s.x - this.x, s.y - this.y);
|
||
|
|
if (dist < minMateDist) {
|
||
|
|
minMateDist = dist;
|
||
|
|
nearestMate = s;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (nearestMate) {
|
||
|
|
this.actionState = 'mating';
|
||
|
|
const approachSpeed = config.sheepMaxSpeed || 1.8;
|
||
|
|
this.steerTowards(nearestMate.x, nearestMate.y, approachSpeed);
|
||
|
|
|
||
|
|
const collisionThreshold = (16 * this.growthScale) + (16 * nearestMate.growthScale);
|
||
|
|
if (minMateDist <= collisionThreshold) {
|
||
|
|
const cooldown = config.sheepMatingCooldown || 22;
|
||
|
|
this.consumeMatingEnergy(cooldown);
|
||
|
|
nearestMate.consumeMatingEnergy(cooldown);
|
||
|
|
|
||
|
|
if (particleSystem) {
|
||
|
|
particleSystem.emitHeart(this.x, this.y);
|
||
|
|
particleSystem.emitHeart(nearestMate.x, nearestMate.y);
|
||
|
|
}
|
||
|
|
|
||
|
|
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, 'sheep');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Foraging / Grazing:
|
||
|
|
// Sheep are active grazers — will seek and mow down plants if hunger < 96%
|
||
|
|
if (this.hunger < 96 || this.isBaby) {
|
||
|
|
let bestPlant = null;
|
||
|
|
let bestScore = -Infinity;
|
||
|
|
|
||
|
|
for (let i = 0; i < plants.length; i++) {
|
||
|
|
const p = plants[i];
|
||
|
|
if (p.isDead || p.growth < 0.25) continue;
|
||
|
|
const dist = Math.hypot(p.x - this.x, p.y - this.y);
|
||
|
|
if (dist > 280) continue;
|
||
|
|
|
||
|
|
const flowerBonus = p.type === 'flower' ? 50 : 0;
|
||
|
|
const score = 320 - dist + flowerBonus;
|
||
|
|
if (score > bestScore) {
|
||
|
|
bestScore = score;
|
||
|
|
bestPlant = p;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (bestPlant) {
|
||
|
|
this.actionState = 'seek_food';
|
||
|
|
const dist = Math.hypot(bestPlant.x - this.x, bestPlant.y - this.y);
|
||
|
|
const grazeReach = Math.max(18, (config.sheepGrazingReach || 18) * this.growthScale);
|
||
|
|
|
||
|
|
if (dist <= grazeReach) {
|
||
|
|
// Rapid chomp: instantly consume & remove plant node
|
||
|
|
const nutrition = bestPlant.type === 'flower' ? 45 : 30;
|
||
|
|
bestPlant.takeBite(); // removes the node immediately
|
||
|
|
this.recordMeal(nutrition, particleSystem);
|
||
|
|
|
||
|
|
if (terrain) {
|
||
|
|
terrain.absorbDecalsNear(bestPlant.x, bestPlant.y, 25);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (particleSystem) {
|
||
|
|
particleSystem.emitEatingCrumbs(
|
||
|
|
bestPlant.x,
|
||
|
|
bestPlant.y,
|
||
|
|
bestPlant.type === 'flower' ? bestPlant.flowerColor : '#48C738',
|
||
|
|
5
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
this.steerTowards(bestPlant.x, bestPlant.y, config.sheepMaxSpeed || 1.8);
|
||
|
|
|
||
|
|
// Maintain flock spacing during grazing approach
|
||
|
|
for (let i = 0; i < allSheep.length; i++) {
|
||
|
|
const other = allSheep[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 minFlockDist = 26 * this.growthScale;
|
||
|
|
if (dist < minFlockDist && dist > 0.05) {
|
||
|
|
const force = ((minFlockDist - dist) / minFlockDist) * 0.35;
|
||
|
|
this.vx -= (dx / dist) * force;
|
||
|
|
this.vy -= (dy / dist) * force;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 4. Default: Casual Wandering
|
||
|
|
this.actionState = 'wander';
|
||
|
|
this.wander(1.1);
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
|
||
|
|
// Body dimensions
|
||
|
|
const bodyW = Math.round(22 * scale);
|
||
|
|
const bodyH = Math.round(16 * scale);
|
||
|
|
const halfW = Math.round(bodyW / 2);
|
||
|
|
const halfH = Math.round(bodyH / 2);
|
||
|
|
|
||
|
|
// 1. Hooves (4 little black pixel feet bobbing)
|
||
|
|
ctx.fillStyle = '#222224';
|
||
|
|
const legW = Math.max(2, Math.round(3 * scale));
|
||
|
|
const legH = Math.max(3, Math.round(5 * scale));
|
||
|
|
const legBob1 = Math.round(Math.sin(this.walkCycle) * 2);
|
||
|
|
const legBob2 = Math.round(Math.sin(this.walkCycle + Math.PI) * 2);
|
||
|
|
|
||
|
|
// Front/Back legs
|
||
|
|
ctx.fillRect(px - halfW + 3, py + halfH - 2 + legBob1, legW, legH);
|
||
|
|
ctx.fillRect(px - halfW + 8, py + halfH - 2 + legBob2, legW, legH);
|
||
|
|
ctx.fillRect(px + halfW - 9, py + halfH - 2 + legBob2, legW, legH);
|
||
|
|
ctx.fillRect(px + halfW - 4, py + halfH - 2 + legBob1, legW, legH);
|
||
|
|
|
||
|
|
// 2. Fluffy Wool Body (Squarish pixel block with wool puff corners)
|
||
|
|
ctx.fillStyle = '#E8ECEF'; // main wool
|
||
|
|
ctx.fillRect(px - halfW, py - halfH, bodyW, bodyH);
|
||
|
|
|
||
|
|
// Wool shadow/shading underneath
|
||
|
|
ctx.fillStyle = '#CBD2D8';
|
||
|
|
ctx.fillRect(px - halfW, py + halfH - Math.round(4 * scale), bodyW, Math.round(4 * scale));
|
||
|
|
|
||
|
|
// Wool puff details
|
||
|
|
ctx.fillStyle = '#FFFFFF';
|
||
|
|
ctx.fillRect(px - halfW + 2, py - halfH + 2, Math.round(6 * scale), Math.round(4 * scale));
|
||
|
|
ctx.fillRect(px + 2, py - halfH + 2, Math.round(5 * scale), Math.round(3 * scale));
|
||
|
|
|
||
|
|
// 3. Head & Ears
|
||
|
|
const headW = Math.round(8 * scale);
|
||
|
|
const headH = Math.round(9 * scale);
|
||
|
|
const headX = facingLeft ? px - halfW - headW + 2 : px + halfW - 2;
|
||
|
|
const headY = py - halfH + 1;
|
||
|
|
|
||
|
|
ctx.fillStyle = '#2B2B2E'; // black sheep face
|
||
|
|
ctx.fillRect(headX, headY, headW, headH);
|
||
|
|
|
||
|
|
// Ears twitch
|
||
|
|
const earX = facingLeft ? headX + headW - 2 : headX;
|
||
|
|
ctx.fillStyle = '#1A1A1C';
|
||
|
|
ctx.fillRect(earX, headY - Math.round(2 * scale), Math.round(3 * scale), Math.round(3 * scale));
|
||
|
|
|
||
|
|
// Eye (little white pixel + pupil)
|
||
|
|
const eyeX = facingLeft ? headX + 2 : headX + headW - 3;
|
||
|
|
ctx.fillStyle = '#FFFFFF';
|
||
|
|
ctx.fillRect(eyeX, headY + 2, Math.max(1, Math.round(2 * scale)), Math.max(1, Math.round(2 * scale)));
|
||
|
|
|
||
|
|
// 4. Status Indicator (Fleeing panic / Mating heart / Baby icon)
|
||
|
|
if (this.actionState === 'flee') {
|
||
|
|
ctx.fillStyle = '#FF4444';
|
||
|
|
ctx.font = 'bold 10px monospace';
|
||
|
|
ctx.fillText('!', px - 2, py - halfH - 4);
|
||
|
|
} else if (this.actionState === 'mating') {
|
||
|
|
ctx.fillStyle = '#FF5588';
|
||
|
|
ctx.font = '8px monospace';
|
||
|
|
ctx.fillText('♥', px - 3, py - halfH - 4);
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx.restore();
|
||
|
|
}
|
||
|
|
}
|