/* ============================================================ 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;ip.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; })();