Files
deepseek c06786518c Wildlife Kingdom — original browser zoo-management sim
- Procedural canvas art: 15 species × 4 poses, 15 buildings, modular terrain, UI icons
- Habitat enclosure detection, biome suitability, staff/guest AI pathfinding
- Economy: tickets, shops, wages, star rating, research tiers, 15 missions
- Day/night cycle, particles, WebAudio soundtrack & synth SFX
- Mouse + touch controls, responsive UI, localStorage autosave
- Verified: 31/31 logic tests (test/smoke.js) · 12/12 render audits (test/qa.html)
2026-08-23 07:01:21 +00:00

484 lines
16 KiB
JavaScript

/* ============================================================
Wildlife Kingdom — js/entities.js
Animals · Guests · Keepers · Vets · Particles
All coordinates are float tile-space; drawing projects them.
============================================================ */
(function(){
'use strict';
const WK = window.WK;
const D = WK.Data;
const HW = WK.HW, HH = WK.HH;
WK.ANIMAL_SIZE = {
deer:.95, zebra:.95, monkey:.78, flamingo:.78, penguin:.68, crocodile:.9,
giraffe:1.12, lion:1, hippo:1.08, tiger:.98, rhino:1.05, elephant:1.22,
gorilla:.92, polarbear:1.08, panda:.88,
};
let NEXT_ID = 1;
const R = Math.random;
/* ============================================================
ANIMAL
============================================================ */
class Animal{
constructor(speciesId, x, y, regionId){
const sp = D.SPECIES[speciesId];
this.id = NEXT_ID++;
this.kind='animal';
this.species = speciesId;
this.sp = sp;
this.name = D.ANIMAL_NAMES[(R()*D.ANIMAL_NAMES.length)|0];
this.x=x; this.y=y;
this.face = R()<.5?-1:1;
this.state='idle';
this.stateT = 1+R()*4;
this.phase = R()*10;
this.tOff = R()*100;
this.hunger = 20+R()*20;
this.happiness = 62;
this.happyT = 0; // remaining happy-burst time
this.eatT = 0;
this.sick = false;
this.regionId = regionId;
this.wp=null;
}
get size(){ return WK.ANIMAL_SIZE[this.species]||1; }
startEating(){
this.eatT = 3;
this.hunger = 4;
this.happyT = 3.5;
this._taken = false;
}
makeHappy(){ this.happyT = Math.max(this.happyT, 2.6); }
update(sdt, game){
const sp=this.sp;
// needs drift
this.hunger = WK.clamp(this.hunger + sdt*0.19, 0, 100);
let target = game.habitatScore(this);
if(this.hunger>55) target -= (this.hunger-55)*0.85;
if(this.sick) target -= 38;
target = WK.clamp(target,5,98);
this.happiness += (target-this.happiness)*Math.min(1,sdt*0.07);
if(this.happyT>0) this.happyT-=sdt;
if(this.eatT>0){ this.eatT-=sdt; this.state='eat'; return; }
if(this.happyT>0 && this.state!=='walk'){ this.state='happy'; this.stateT-=sdt; if(this.stateT<=0)this.state='idle'; return; }
this.stateT -= sdt;
if(this.state==='idle'){
if(this.stateT<=0){
// choose a stroll
const dest = game.world.randomRegionTile(this.regionId, sp, R);
if(dest){
const path = game.world.findPathAnimal(sp, this.x, this.y, dest[0], dest[1], this.regionId);
if(path&&path.length){ this.wp=path; this.state='walk'; }
}
this.stateT = 3.5+R()*7;
}
}else if(this.state==='walk'){
if(!this.wp||!this.wp.length){ this.state='idle'; this.stateT=2+R()*4; return; }
const [tx,ty]=this.wp[0];
const dx=tx-this.x, dy=ty-this.y;
const d=Math.hypot(dx,dy);
const step=sp.speed*sdt;
if(d<=step){ this.x=tx; this.y=ty; this.wp.shift(); if(!this.wp.length){this.state='idle'; this.stateT=2.5+R()*6;} }
else{ this.x+=dx/d*step; this.y+=dy/d*step; }
const screenDx=(tx-this.x)-(ty-this.y);
if(Math.abs(screenDx)>0.05) this.face = screenDx>0?1:-1;
this.phase += step*5.2;
}
}
draw(ctx, cam, time, groundId){
const p = cam.project(this.x,this.y);
if(!cam.isVisible(p.x,p.y,80)) return;
const s=this.size;
const g = WK.Data.GROUND[groundId||'grass'];
const swimming = g.water && this.sp.swim;
ctx.save();
ctx.translate(p.x,p.y);
// shadow (not for swimmers)
if(!swimming){
ctx.fillStyle='rgba(50,40,15,.26)';
ctx.beginPath(); ctx.ellipse(0,1,15*s,5.5*s,0,0,WK.TAU); ctx.fill();
}
ctx.scale(this.face*s, s);
if(swimming){
ctx.save();
ctx.beginPath(); ctx.rect(-48,-72,96,64); ctx.clip(); // hide feet under waterline
WK.Sprites.drawAnimal(ctx, this.species, {
state:this.state==='walk'?'walk':this.state==='eat'?'eat':this.state==='happy'?'happy':'idle',
ph:this.phase, t:time+this.tOff,
});
ctx.restore();
// ripples at waterline
ctx.strokeStyle='rgba(255,255,255,.55)'; ctx.lineWidth=1.6;
const rr=(14+Math.sin(time*3+this.tOff)*2)*s;
ctx.beginPath(); ctx.ellipse(0,-6*s,rr,rr*0.36,0,0,WK.TAU); ctx.stroke();
ctx.beginPath(); ctx.ellipse(0,-6*s,rr*.6,rr*.2,0,0,WK.TAU); ctx.stroke();
}else{
WK.Sprites.drawAnimal(ctx, this.species, {
state:this.state==='walk'?'walk':this.state==='eat'?'eat':this.state==='happy'?'happy':'idle',
ph:this.phase, t:time+this.tOff,
});
}
ctx.restore();
// status markers
if(this.sick || this.hunger>82){
ctx.font='bold 13px sans-serif'; ctx.textAlign='center';
ctx.fillText(this.sick?'🤒':'🍖', p.x, p.y-46*s + Math.sin(time*4)*1.5);
}
}
}
WK.Animal = Animal;
/* ============================================================
GUEST
============================================================ */
const SKINS=['#f5d5b8','#eec39a','#d9a06b','#a9744f','#8a5a3a'];
const SHIRTS=['#e8624e','#ffc93c','#63b34c','#69c3ef','#b78ae8','#ff8aa8','#ff9d47','#5fb8a8'];
const PANTS=['#4a6a8a','#6b4a8a','#7a6a55','#43362a','#5a7a4a'];
const HAIRS=['#5a3c22','#2e2a26','#a9743f','#d9b97e','#8a3a2a','#e8e2d4'];
class Guest{
constructor(game, ex, ey){
this.id=NEXT_ID++;
this.kind='guest';
this.game=game;
this.x=ex; this.y=ey;
this.skin=SKINS[(R()*SKINS.length)|0];
this.shirt=SHIRTS[(R()*SHIRTS.length)|0];
this.pants=PANTS[(R()*PANTS.length)|0];
this.hair=HAIRS[(R()*HAIRS.length)|0];
this.kid=R()<0.3;
this.balloon=R()<0.22?(SHIRTS[(R()*SHIRTS.length)|0]):null;
this.joy=58+R()*10;
this.speed=(this.kid?1.25:1.6)*(0.9+R()*0.25);
this.lane=(R()-0.5)*0.24;
this.state='walk';
this.phase=R()*10;
this.itin=[];
const stops=2+(R()*4|0);
for(let i=0;i<stops;i++) this.itin.push('poi');
this.leaving=false;
this.wantsRestroom=false;
this.restroomWarned=false;
this.dwellT=0;
this.poi=null;
this.wp=null;
this.waitT=0.5+R();
}
setPathTo(tx,ty){
const w=this.game.world;
this.wp=w.findPathVisitor(Math.round(this.x),Math.round(this.y),tx,ty);
if(this.wp&&this.wp.length===0) this.wp=[[tx,ty]];
return !!this.wp;
}
pickNext(){
const pois=this.game.pois;
if(!pois||!pois.length){ this.leave(); return; }
let pool=[];
if(this.wantsRestroom){
const rr=pois.filter(p=>p.type==='restroom');
if(rr.length) pool=rr.map(p=>({p,w:50}));
}
if(!pool.length){
for(const p of pois){
let wgt=0;
if(p.type==='view') wgt=6+p.appeal*2.2;
else if(p.type==='shop') wgt=3+p.income*0.5+p.joy;
else if(p.type==='visit') wgt=1.5+p.joy*0.5;
else if(p.type==='bench') wgt=2;
else if(p.type==='restroom') wgt=this.wantsRestroom?40:1.2;
pool.push({p,w:wgt||0.5});
}
}
const pick=WK.weightedPick(pool,R).p;
this.poi=pick;
if(this.setPathTo(pick.x,pick.y)){
this.state='walk';
}else{
// unreachable: skip stop
if(this.itin.length)this.itin.pop();
if(!this.itin.length) this.leave();
else this.pickNext();
}
}
leave(){
this.leaving=true; this.poi=null;
const e=this.game.exitTile;
if(e) this.setPathTo(e[0],e[1]);
}
arriveAtPoi(){
const p=this.poi;
this.dwellT=2.2+R()*3.5;
this.state='dwell';
this.act='stand';
if(R()<0.3) this.act='photo';
if(!p) return;
const g=this.game;
if(p.type==='view'){
this.joy=WK.clamp(this.joy+3.5+p.appeal*0.85*(p.quality||1),0,100);
this.act=R()<0.45?'photo':'point';
if(R()<0.3){ g.puffHeart(this.x,this.y); }
}else if(p.type==='shop'){
const def=D.BUILDING[p.btype];
this.joy=WK.clamp(this.joy+def.joy*0.65,0,100);
const spend=Math.round(def.income*(0.8+R()*0.4));
g.earnMoney(spend, 'shop:'+p.btype);
g.coinBurst(p.x,p.y);
if(['burger','icecream','restaurant'].includes(p.btype)) this.wantsRestroom=true;
}else if(p.type==='visit'){
const def=D.BUILDING[p.btype];
this.joy=WK.clamp(this.joy+def.joy*0.4,0,100);
}else if(p.type==='restroom'){
this.wantsRestroom=false;
this.joy=WK.clamp(this.joy+3,0,100);
}else if(p.type==='bench'){
this.joy=WK.clamp(this.joy+2.5,0,100);
this.act='sit';
this.dwellT=3.5+R()*3;
}
}
update(sdt){
if(this.waitT>0){ this.waitT-=sdt; return; }
// slow boredom / crowd stress
this.joy=WK.clamp(this.joy - sdt*0.16 - (this.game.crowdStress||0)*sdt, 0, 100);
if(this.state==='walk'){
if(!this.wp){
if(this.leaving){ this.done=true; return; }
this.pickNext(); if(!this.wp) return;
}
if(!this.wp.length){
if(this.leaving){ this.done=true; return; }
this.arriveAtPoi();
return;
}
const [tx,ty]=this.wp[0];
const dx=tx-this.x, dy=ty-this.y;
const d=Math.hypot(dx,dy);
const step=this.speed*sdt;
if(d<=step){ this.x=tx; this.y=ty; this.wp.shift(); }
else{
// gentle lane offset for organic crowds
const px=-dy/d*this.lane, py=dx/d*this.lane;
this.x+=(dx/d)*step+px*sdt*0.6;
this.y+=(dy/d)*step+py*sdt*0.6;
const sdx=dx-dy;
if(Math.abs(sdx)>0.05) this.faceDir=sdx>0?1:-1;
}
this.phase+=step*5.5;
this.act='walk';
}else if(this.state==='dwell'){
this.dwellT-=sdt;
this.phase+=sdt*1.4;
if(this.dwellT<=0){
this.poi=null;
if(this.itin.length){ this.itin.pop(); this.pickNext(); }
else this.leave();
this.state='walk';
}
}
if(this.leaving && (!this.wp || !this.wp.length) && this.state!=='dwell'){
this.done=true;
}
}
draw(ctx,cam,time){
const p=cam.project(this.x,this.y);
if(!cam.isVisible(p.x,p.y,40)) return;
ctx.save();
ctx.translate(p.x,p.y);
let act=this.act||'stand';
if(this.state==='walk') act='walk';
if(act==='sit') ctx.translate(0,-6);
WK.Sprites.drawPerson(ctx,{
phase:this.phase, state:act,
shirt:this.shirt, pants:this.pants, skin:this.skin, hair:this.hair,
kid:this.kid, balloon:this.balloon, face:this.faceDir||1,
smile:this.joy>45,
});
if(this.joy<25 && R()<0.02){
ctx.fillStyle='rgba(90,110,220,.8)';
ctx.font='bold 10px sans-serif'; ctx.textAlign='center';
ctx.fillText('💧',8,-34);
}
ctx.restore();
}
}
WK.Guest = Guest;
/* ============================================================
STAFF (keeper / vet)
============================================================ */
class Staff{
constructor(role,x,y){
this.id=NEXT_ID++;
this.kind='staff'; this.role=role; // 'keeper'|'vet'
this.x=x; this.y=y;
this.speed=role==='keeper'?2.1:2.3;
this.state='idle';
this.job=null;
this.wp=null;
this.workT=0;
this.phase=R()*10;
this.shirt=role==='keeper'?'#5fb868':'#f5f5f5';
this.pants='#43362a';
this.skin=SKINS[(R()*SKINS.length)|0];
this.hair=HAIRS[(R()*HAIRS.length)|0];
this.face=1;
this.idleT=1+R()*2;
}
update(sdt,game){
if(this.workT>0){
this.workT-=sdt;
if(this.workT<=0){
if(this.role==='keeper'&&this.job&&this.job.alive){
this.job.startEating();
game.puffHeart(this.job.x,this.job.y);
this.job.makeHappy();
}
if(this.role==='vet'&&this.job&&this.job.alive){
this.job.sick=false;
this.job.makeHappy();
game.puffHeart(this.job.x,this.job.y);
game.notify(this.job.name+' the '+D.SPECIES[this.job.species].name+' was healed!','good');
}
this.job=null;
this.state='idle';
this.idleT=0.5;
}
return;
}
if(!this.job){
this.idleT-=sdt;
// ask dispatcher
const j=game.requestJob(this.role,this);
if(j){
this.job=j;
const w=game.world;
this.wp=w.findPathKeeper(Math.round(this.x),Math.round(this.y),Math.round(j.x),Math.round(j.y));
if(!this.wp){ game.abandonJob(this.role,j); this.job=null; this.idleT=2; }
else { this.state='walk'; }
return;
}
if(this.idleT<=0){
// casual wander
const w=game.world;
const b=w.buildings.find(bb=>bb.type===(this.role==='keeper'?'hut':'vet'))||w.buildings[0];
let tx,ty;
if(b){ const d=w.doorTile(b); tx=d[0]+((R()*7)|0)-3; ty=d[1]+((R()*7)|0)-3; }
else { tx=(R()*w.cols)|0; ty=(R()*w.rows)|0; }
tx=WK.clamp(tx,1,w.cols-2); ty=WK.clamp(ty,1,w.rows-2);
if(w.path[w.idx(tx,ty)]||D.GROUND[w.gid(tx,ty)].walk){
this.wp=w.findPathKeeper(Math.round(this.x),Math.round(this.y),tx,ty);
if(this.wp&&this.wp.length) this.state='walk';
}
this.idleT=4+R()*5;
}
if(this.state==='idle') return;
}
if(this.state==='walk'){
if(!this.wp||!this.wp.length){
if(this.job){ this.state='work'; this.workT=this.role==='keeper'?2.4:3; this.face=this.job.x>=this.x?1:-1; }
else { this.state='idle'; }
return;
}
const [tx,ty]=this.wp[0];
const dx=tx-this.x, dy=ty-this.y;
const d=Math.hypot(dx,dy);
const step=this.speed*sdt;
if(d<=step){ this.x=tx; this.y=ty; this.wp.shift(); }
else{ this.x+=dx/d*step; this.y+=dy/d*step; }
const sdx=(tx-this.x)-(ty-this.y);
if(Math.abs(sdx)>0.05) this.face=sdx>0?1:-1;
this.phase+=step*5.5;
}
}
draw(ctx,cam,time){
const p=cam.project(this.x,this.y);
if(!cam.isVisible(p.x,p.y,40)) return;
ctx.save();
ctx.translate(p.x,p.y);
const act=this.workT>0?'feed':(this.state==='walk'?'walk':'stand');
WK.Sprites.drawPerson(ctx,{
phase:this.phase,state:act,shirt:this.shirt,pants:this.pants,
skin:this.skin,hair:this.hair,kid:false,balloon:null,face:this.face,smile:true,
});
if(this.workT>0){
// work bubble
ctx.font='bold 12px sans-serif'; ctx.textAlign='center';
ctx.fillText(this.role==='keeper'?'🍎':'🩺',0,-36+Math.sin(time*6));
}
// role cap
ctx.fillStyle=this.role==='keeper'?'#3e8a33':'#e86a6a';
ctx.beginPath(); ctx.ellipse(0,-29,4.6,2,0,0,WK.TAU); ctx.fill();
ctx.restore();
}
}
WK.Staff = Staff;
/* ============================================================
PARTICLES
============================================================ */
class Particles{
constructor(){ this.list=[]; }
add(o){ o.age=0; this.list.push(o); if(this.list.length>240) this.list.splice(0,this.list.length-240); }
coins(x,y){ for(let i=0;i<3;i++) this.add({t:'coin',x:x+(R()-.5)*.4,y,vz:0,z:14+R()*8,vx:(R()-.5)*0.8,vy:(R()-.5)*0.4,dur:0.9}); }
hearts(x,y){ for(let i=0;i<2;i++) this.add({t:'heart',x:x+(R()-.5)*.5,y:y-0.2,z:20,vx:(R()-.5)*0.3,vy:-0.5-R()*0.4,dur:1.2}); }
puff(x,y,c){ this.add({t:'puff',x,y,z:4,vx:0,vy:0,dur:0.5,c:c||'rgba(180,160,130,'}); }
splash(x,y){ this.add({t:'splash',x,y,z:2,vx:0,vy:0,dur:0.5}); }
note(x,y){ this.add({t:'note',x,y,z:18,vx:0.2,vy:-0.6,dur:1}); }
update(sdt){
const L=this.list;
for(let i=L.length-1;i>=0;i--){
const p=L[i];
p.age+=sdt;
if(p.age>=p.dur){ L.splice(i,1); continue; }
p.x+=(p.vx||0)*sdt; p.y+=(p.vy||0)*sdt;
}
}
draw(ctx,cam,time){
for(const p of this.list){
const k=p.age/p.dur;
const pr=cam.project(p.x,p.y);
if(!cam.isVisible(pr.x,pr.y,30)) continue;
const z=p.z*(1-k)+ (p.z||0)*0;
ctx.save();
ctx.translate(pr.x,pr.y-(p.z||0)*(0.4+k*1.4)*16);
ctx.globalAlpha=1-k*k;
if(p.t==='coin'){
ctx.fillStyle='#ffc93c';
ctx.beginPath(); ctx.ellipse(0,0,4.4*Math.abs(Math.cos(k*9)),4.4,0,0,WK.TAU); ctx.fill();
ctx.strokeStyle='#d99a1e'; ctx.lineWidth=1.2; ctx.stroke();
}else if(p.t==='heart'){
ctx.fillStyle='#ff6b81';
const s=3.2+k*2;
ctx.beginPath();
ctx.moveTo(0,s*0.9); ctx.bezierCurveTo(-s*1.6,-s*0.3,-s*0.9,-s*1.4,0,-s*0.4);
ctx.bezierCurveTo(s*0.9,-s*1.4,s*1.6,-s*0.3,0,s*0.9);
ctx.fill();
}else if(p.t==='puff'){
ctx.fillStyle=p.c+(0.5*(1-k))+')';
ctx.beginPath(); ctx.arc(0,0,4+k*14,0,WK.TAU); ctx.fill();
}else if(p.t==='splash'){
ctx.strokeStyle='rgba(200,235,250,'+(0.8*(1-k))+')';
ctx.lineWidth=1.6;
ctx.beginPath(); ctx.arc(0,0,3+k*13,0,WK.TAU); ctx.stroke();
}else if(p.t==='note'){
ctx.fillStyle='rgba(90,60,120,'+(1-k)+')';
ctx.font='bold 12px sans-serif'; ctx.textAlign='center';
ctx.fillText(Math.sin(p.x*7)>0?'♪':'♫',0,0);
}
ctx.restore();
}
ctx.globalAlpha=1;
}
}
WK.Particles = Particles;
})();