/* ============================================================ Wildlife Kingdom — js/sim.js The Game: time · money · research · missions · rating · guest spawning · staff dispatch · placement rules · save/load ============================================================ */ (function(){ 'use strict'; const WK = window.WK; const D = WK.Data; const SAVE_KEY='wildlifeKingdom.save.v1'; class Game{ constructor(){ const g=D.GRID; this.world=new WK.World(g.COLS,g.ROWS); this.money=D.START_MONEY; this.rp=0; this.researchTier=1; this.day=1; this.minutes=8*60; this.speed=1; this.animals=[]; this.guests=[]; this.staff=[]; this.particles=new WK.Particles(); this.stats={paths:0,fences:0,adopted:0,guestsTotal:0,guestsToday:0,revenueTotal:0,revenueToday:0,expensesTotal:0,sold:0}; this.missionIdx=0; this.rating=1; this.ratingParts={}; this.spawnAcc=0; this.sfx=(n)=>{ if(WK.AudioSys&&WK.AudioSys.enabled) WK.AudioSys.sfx(n); }; this._scoreCache={ver:-1,map:new Map()}; this._poiVer=-1; this.pois=[]; this.exitTile=null; this.crowdStress=0; this.selected=null; this.timeAbs=0; } /* ---------------- helpers ---------------- */ get maxRegion(){ this.world.ensureRegions(); let m=0; for(const r of this.world.regions) if(r.openEdges===0&&!r.hasPath) m=Math.max(m,r.area); return m; } speciesCount(){ return new Set(this.animals.map(a=>a.species)).size; } notify(msg,type){ if(this.ui&&this.ui.toast) this.ui.toast(msg,type); } earnMoney(n,src){ this.money+=n; this.stats.revenueTotal+=n; this.stats.revenueToday+=n; if(this.ui&&this.ui.flashStat) this.ui.flashStat('money'); } spendMoney(n){ if(this.moneyr.id===regionId); let score=8; if(reg){ const sp=D.SPECIES[species]; const area=reg.area; let bio=0, enrich=0, water=0; for(const k of reg.tiles){ const gid=WK.GIDS[this.world.ground[k]]; bio+= (sp.biome[gid]!=null)? sp.biome[gid] : 0.25; const o=this.world.obj[k]; if(o){ enrich+=D.NATURE[WK.NIDS[o-1]].enrich||0; } if(D.GROUND[gid].water) water++; } bio/=area; const areaF=WK.clamp(area/Math.max(6,sp.minArea),0,1); const enr=WK.clamp(enrich/Math.max(0.5,sp.enrichNeed||0.5),0,1); score = WK.clamp(bio*52 + areaF*22 + enr*16 + (sp.swim?(water>0?10:(bio>0.75?6:0)):4), 5, 100); } this._scoreCache.map.set(key,score); return score; } habitatScore(animal){ const s=this.habitatScoreFor(animal.regionId,animal.species); // crowd of same species penalty (max 4 comfortable per species) const same=this.animals.filter(a=>a.regionId===animal.regionId&&a.species===animal.species).length; return WK.clamp(s-(same>4?(same-4)*4:0),5,100); } validHabitatAt(x,y){ this.world.ensureRegions(); const rid=this.world.regionOf(x,y); if(rid<0) return {error:'Can\u2019t place that here.'}; const reg=this.world.regions.find(r=>r.id===rid); if(!reg) return {error:'No region here.'}; if(reg.hasPath) return {error:'Paths run through this area — habitats must be fence-only.'}; if(reg.openEdges>0) return {error:'Not enclosed! Fully surround the area with fences.'}; return {region:reg}; } /* ---------------- placement operations ---------------- */ tryGround(x,y,gid){ const def=D.GROUND[gid]; if(def.tier>this.researchTier) return false; const cur=this.world.gid(x,y); if(cur===gid) return false; if(!this.spendMoney(def.cost)) return false; if(this.world.setGround(x,y,gid)){ this.sfx('dig'); return true; } this.money+=def.cost; return false; } tryPath(x,y,pid){ const def=D.PATH[pid]; if(def.tier>this.researchTier) return false; if(this.world.path[this.world.idx(x,y)]) { // replace path: charge diff const cur=this.world.pid(x,y); if(cur===pid) return false; const diff=Math.max(0,def.cost-D.PATH[cur].cost); if(diff&&!this.spendMoney(diff)) return false; this.world.setPath(x,y,pid); this.sfx('place'); this.stats.paths++; return true; } if(!this.spendMoney(def.cost)) return false; if(this.world.setPath(x,y,pid)){ this.sfx('place'); this.stats.paths++; return true; } this.money+=def.cost; return false; } tryNature(x,y,nid){ const def=D.NATURE[nid]; if(def.tier>this.researchTier) return false; if(this.world.oid(x,y)) { this.tryBulldozeObject(x,y,true); } if(!this.spendMoney(def.cost)) return false; if(this.world.setObj(x,y,nid,(Math.random()*3)|0)){ this.sfx('place'); return true; } this.money+=def.cost; return false; } tryFence(x,y,edge,kindName){ const kind= kindName==='gate'?3: kindName==='hedge'?2:1; const def=D.FENCE[kind]; if(def.tier>this.researchTier) return false; const cur= edge==='S'?this.world.fenceS[this.world.idx(x,y)]:this.world.fenceE[this.world.idx(x,y)]; if(cur===kind) return false; if(!this.spendMoney(def.cost)) return false; this.world.setFence(x,y,edge,kind); this.sfx('place'); this.stats.fences++; return true; } tryBulldoze(x,y){ const b=this.world.buildingAt(x,y); if(b){ return this.demolishBuilding(b); } if(this.world.obj[this.world.idx(x,y)]) return this.tryBulldozeObject(x,y); if(this.world.path[this.world.idx(x,y)]){ this.world.setPath(x,y,null); this.sfx('trash'); return true; } return false; } tryBulldozeObject(x,y,silent){ if(!this.world.obj[this.world.idx(x,y)]) return false; this.money+=Math.round(D.NATURE[this.world.oid(x,y)].cost*0.4); this.world.setObj(x,y,null); if(!silent) this.sfx('trash'); return true; } tryBulldozeEdge(x,y,edge){ const cur= edge==='S'?this.world.fenceS[this.world.idx(x,y)]:this.world.fenceE[this.world.idx(x,y)]; if(!cur) return false; this.money+=Math.round(D.FENCE[cur].cost*0.4); this.world.setFence(x,y,edge,0); this.sfx('trash'); return true; } tryBuilding(type,x,y){ const def=D.BUILDING[type]; if(def.tier>this.researchTier){ this.notify('Locked — unlock more research tiers.','bad'); return false; } if(!this.world.canPlaceBuilding(x,y,def.w,def.h)){ this.notify('Space is blocked — clear the area first.','bad'); this.sfx('error'); return false; } if(!this.spendMoney(def.cost)) return false; const b=this.world.placeBuilding(type,x,y); if(!b){ this.money+=def.cost; return false; } this.sfx('place'); if(def.staff==='keeper'){ this.addStaff('keeper',b); this.notify('A keeper joined your staff!','good'); } if(def.staff==='vet'){ this.addStaff('vet',b); this.notify('A vet joined your staff!','good'); } this.refreshPois(); return true; } demolishBuilding(b){ const def=D.BUILDING[b.type]; this.money+=Math.round(def.cost*0.5); if(def.staff){ const idx=this.staff.findIndex(s=>s.role===def.staff&&!s._homeRemoved); if(idx>=0){ this.staff.splice(idx,1); this.notify(def.staff==='keeper'?'A keeper left your staff.':'A vet left your staff.','bad'); } } this.world.removeBuildingById(b.id); this.sfx('trash'); this.refreshPois(); if(this.ui&&this.ui.hideSelection) this.ui.hideSelection(); return true; } addStaff(role,b){ const d=this.world.doorTile(b); this.staff.push(new WK.Staff(role,d[0],d[1])); } tryAdopt(species,x,y){ const sp=D.SPECIES[species]; if(sp.tier>this.researchTier){ this.notify('Locked — research more tiers first.','bad'); return false; } const chk=this.validHabitatAt(x,y); if(chk.error){ this.notify(chk.error,'bad'); this.sfx('error'); return false; } const reg=chk.region; if(reg.area0||a.sick) continue; if(a.hunger>62 && (!best||a.hunger>best.hunger)) best=a; } if(best){ best._taken=true; return best; } }else{ for(const a of this.animals){ if(a.sick&&!a._taken){ a._taken=true; return a; } } } return null; } abandonJob(role,a){ a._taken=false; } /* ---------------- POIs ---------------- */ refreshPois(){ this._poiVer=-1; } rebuildPois(){ this.world.ensureRegions(); const pois=[]; const regionAppeal=new Map(); for(const a of this.animals){ const cur=regionAppeal.get(a.regionId)||{appeal:0,q:0,n:0}; cur.appeal=Math.max(cur.appeal,D.SPECIES[a.species].appeal); cur.q+=a.happiness/100; cur.n++; regionAppeal.set(a.regionId,cur); } for(const [rid,info] of regionAppeal){ const vps=this.world.viewpoints(rid); const q=info.q/info.n; for(const vp of vps.slice(0,10)){ pois.push({type:'view',x:vp[0],y:vp[1],regionId:rid,appeal:info.appeal,quality:q}); } } for(const b of this.world.buildings){ const def=D.BUILDING[b.type]; const d=this.world.doorTile(b); if(def.entrance) continue; if(def.restroom){} pois.push({ type: def.income>0?'shop':(b.type==='restroom'?'restroom':'visit'), btype:b.type, x:d[0], y:d[1], income:def.income, joy:def.joy, }); if(b.type==='restroom'){ pois.pop(); pois.push({type:'restroom',btype:b.type,x:d[0],y:d[1]}); } } this.pois=pois; // exits const ent=this.world.buildings.filter(b=>D.BUILDING[b.type].entrance); this.exitTile = ent.length ? this.world.doorTile(ent[0]) : null; this.entrances=ent.length; this.entranceCap = ent.reduce((s,b)=>s+(D.BUILDING[b.type].big?90:45),0); this._poiVer=this.world.ver||0; } /* ---------------- rating ---------------- */ recomputeRating(){ const P={}; P.welfare = this.animals.length ? this.animals.reduce((s,a)=>s+a.happiness,0)/(this.animals.length*100) : 0.55; P.variety = WK.clamp(this.speciesCount()/10,0,1); P.joy = this.guests.length ? this.guests.reduce((s,g)=>s+g.joy,0)/(this.guests.length*100) : (this.ratingParts.joy!=null?this.ratingParts.joy:0.5); const guests=this.guests.length; const restrooms=this.world.buildings.filter(b=>b.type==='restroom').length; const food=this.world.buildings.filter(b=>['burger','icecream','restaurant'].includes(b.type)).length; const benches=this.countObj('bench'); P.facilities = WK.clamp( 0.1 + 0.45*Math.min(restrooms/Math.max(1,guests/28),1) + 0.25*Math.min(food/Math.max(1,guests/45),1) + 0.2*Math.min(benches/Math.max(1,guests/14),1), 0,1); P.decor = WK.clamp(this.countNature()/55,0,1); P.exhibits = WK.clamp(this.world.buildings.filter(b=>D.BUILDING[b.type].exhibit).length/3,0,1); this.ratingParts=P; this.rating = WK.clamp((P.welfare*1.5 + P.variety*1.2 + P.joy*1.0 + P.facilities*0.8 + P.decor*0.4 + P.exhibits*0.6)/5.5*5, 0.4, 5); } countObj(id){ let n=0; const arr=this.world.obj; for(let i=0;i0){ this.minutes+=sdt*4; while(this.minutes>=1440){ this.minutes-=1440; this.newDay(); } // world version → caches if((this.world.ver||0)!==this._poiVer) this.rebuildPois(); // research const centers=this.world.buildings.filter(b=>b.type==='research').length; this.rp += sdt*(0.003 + centers*(5/60)); // entities for(let i=this.animals.length-1;i>=0;i--){ const a=this.animals[i]; a.update(sdt,this); // sickness rolls if(!a.sick){ const p=a.happiness<45?0.0011:0.00013; if(Math.random()150){ a.sick=false; a._sickT=0; this.notify(a.name+' recovered on their own.','good'); } } if(a.eatT<=0&&a.state!=='eat'&&!a.sick){ /* _taken cleared on feeding */ } } // escape check (throttled) this._escT=(this._escT||0)+sdt; if(this._escT>2){ this._escT=0; for(const a of this.animals){ const reg=this.world.regions.find(r=>r.id===a.regionId); if(!reg||(reg.openEdges>0)||reg.hasPath){ a.happiness=WK.clamp(a.happiness-sdt*8,5,100); if(!a._escWarned){ a._escWarned=true; this.notify(a.name+'\u2019s habitat is not secure! Check fences & paths.','bad'); } }else a._escWarned=false; } } // guests this.crowdStress = this.guests.length > 25 + this.world.buildings.filter(b=>b.type==='restroom').length*15 ? 0.06 : 0; const cap=Math.min(D.CAPS.guests, 12 + (this.entranceCap||0) + Math.round(this.rating*8)); const rate=(0.05 + this.rating*0.09); this.spawnAcc+=sdt*rate; if(this.spawnAcc>=1){ this.spawnAcc-=1; if(this.guests.length=0;i--){ const g=this.guests[i]; g.update(sdt); if(g.done){ if(g.joy>85) this.ratingParts.joy=Math.min(1,(this.ratingParts.joy==null?0.6:this.ratingParts.joy)+0.01); if(g.joy<28) this.ratingParts.joy=Math.max(0.1,(this.ratingParts.joy==null?0.5:this.ratingParts.joy)-0.02); this.guests.splice(i,1); } } for(const s of this.staff) s.update(sdt,this); this.particles.update(sdt); // rating throttle this._rateT=(this._rateT||0)+dt; if(this._rateT>1){ this._rateT=0; this.recomputeRating(); } // missions this._misT=(this._misT||0)+dt; if(this._misT>0.8){ this._misT=0; this.checkMission(); } } } coinFloat(x,y){ this.particles.coins(x,y); } coinBurst(x,y){ this.particles.coins(x,y); } puffHeart(x,y){ this.particles.hearts(x,y); } newDay(){ this.day++; let wages=0; wages += this.staff.filter(s=>s.role==='keeper').length*70; wages += this.staff.filter(s=>s.role==='vet').length*90; let upkeep=0; for(const b of this.world.buildings) upkeep+=Math.round(D.BUILDING[b.type].cost*0.006); let animalUpkeep=0; for(const a of this.animals) animalUpkeep+=D.SPECIES[a.species].upkeep; const total=wages+upkeep+animalUpkeep; this.money-=total; this.stats.expensesTotal+=total; const profit=this.stats.revenueToday-total; this.notify('Day '+this.day+' — wages '+WK.fmtMoney(wages)+' · upkeep '+WK.fmtMoney(upkeep+animalUpkeep)+(profit>=0?' · profit '+WK.fmtMoney(profit):' · loss '+WK.fmtMoney(-profit)), profit>=0?'good':'bad'); this.stats.revenueToday=0; this.stats.guestsToday=0; this.sfx(profit>=0?'coin':'error'); } /* ---------------- missions & research ---------------- */ checkMission(){ const m=D.MISSIONS[this.missionIdx]; if(!m) return; if(m.check(this)){ let msg='Mission complete: '+m.label+'!'; if(typeof m.reward==='number'){ this.money+=m.reward; msg+=' +'+WK.fmtMoney(m.reward); } else{ if(m.reward.cash){ this.money+=m.reward.cash; msg+=' +'+WK.fmtMoney(m.reward.cash); } if(m.reward.rp){ this.rp+=m.reward.rp; msg+=' +'+m.reward.rp+' RP'; } } this.notify(msg,'gold'); this.sfx('unlock'); this.missionIdx++; } } researchCost(){ const next=D.RESEARCH_TIERS.find(t=>t.tier===this.researchTier+1); return next?next.cost:null; } buyResearch(){ const cost=this.researchCost(); if(cost==null) return false; if(this.rpt.tier===this.researchTier).label+'!','gold'); this.sfx('unlock'); return true; } /* ---------------- persistence ---------------- */ save(){ try{ const data={ v:1, money:this.money, rp:this.rp, researchTier:this.researchTier, day:this.day, minutes:this.minutes, missionIdx:this.missionIdx, stats:this.stats, ratingJoy:this.ratingParts.joy, world:this.world.serialize(), animals:this.animals.map(a=>({s:a.species,n:a.name,x:a.x,y:a.y,h:a.hunger,p:a.happiness,k:a.sick,r:a.regionId})), staff:this.staff.map(s=>({r:s.role,x:s.x,y:s.y})), }; localStorage.setItem(SAVE_KEY,JSON.stringify(data)); return true; }catch(e){ return false; } } static hasSave(){ try{ return !!localStorage.getItem(SAVE_KEY); }catch(e){ return false; } } static load(){ try{ const raw=localStorage.getItem(SAVE_KEY); if(!raw) return null; const d=JSON.parse(raw); const g=new Game(); g.world.deserialize(d.world); g.money=d.money; g.rp=d.rp; g.researchTier=d.researchTier; g.day=d.day; g.minutes=d.minutes; g.missionIdx=d.missionIdx; Object.assign(g.stats,d.stats||{}); g.ratingParts.joy=d.ratingJoy; for(const a of d.animals||[]){ const an=new WK.Animal(a.s,a.x,a.y,a.r||0); an.name=a.n; an.hunger=a.h; an.happiness=a.p; an.sick=!!a.k; g.animals.push(an); } for(const s of d.staff||[]) g.staff.push(new WK.Staff(s.r,s.x,s.y)); g.recomputeRating(); g.rebuildPois(); return g; }catch(e){ return null; } } static clearSave(){ try{ localStorage.removeItem(SAVE_KEY); }catch(e){} } } /* starter zoo: cozy beginning — entrance, plaza path, some greenery */ Game.buildStarter = function(game){ const w=game.world; const cx=(w.cols/2)|0, sy=w.rows-6; const b=w.placeBuilding('entrance_s',cx-1,sy); // plaza for(let x=cx-4;x<=cx+5;x++){ w.setGround(x,sy+1,'grass'); w.setPath(x,sy+1,'gravel'); w.setGround(x,sy+2,'grass'); w.setPath(x,sy+2,'gravel'); } for(let y=sy-8;y<=sy+2;y++){ w.setGround(cx+2,y,'grass'); w.setPath(cx+2,y,'gravel'); } // greenery const deco=[[cx-6,sy-2,'tree'],[cx+7,sy-1,'tree'],[cx-5,sy+2,'bush'],[cx+6,sy+2,'bush'], [cx-3,sy-4,'flower'],[cx+3,sy-4,'flower'],[cx-8,sy,'tree'],[cx+9,sy,'tree'], [cx-7,sy+3,'bench'],[cx+8,sy+3,'bench'],[cx-2,sy-5,'rock']]; for(const [x,y,o] of deco){ if(w.inBounds(x,y)&&w.occ[w.idx(x,y)]<0) w.setObj(x,y,o,(Math.random()*3)|0); } game.refreshPois(); return b; }; /* demo zoo for the menu hero scene */ Game.buildDemo = function(game){ const w=game.world; const C=w.cols, Rws=w.rows; const cx=(C/2)|0; const rnd=WK.mulberry32(20240001); // grand entrance south w.placeBuilding('entrance_g',cx-2,Rws-5); for(let x=cx-6;x<=cx+7;x++){ w.setPath(x,Rws-4,'stonep'); w.setPath(x,Rws-3,'stonep'); } // ring promenade const yN=8, yS=Rws-6, xW=6, xE=C-7; for(let x=xW;x<=xE;x++){ w.setPath(x,yN,'stonep'); w.setPath(x,yS,'stonep'); } for(let y=yN;y<=yS;y++){ w.setPath(xW,y,'stonep'); w.setPath(xE,y,'stonep'); } for(let y=yS;y<=Rws-3;y++) w.setPath(cx,y,'stonep'); // helper: rectangular fenced habitat with terrain + animals + gate on given side path function habitat(x0,y0,x1,y1,grounds,species,waterRect){ for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++) w.setGround(x,y,grounds[(rnd()*grounds.length)|0]); if(waterRect){ for(let y=waterRect[1];y<=waterRect[3];y++)for(let x=waterRect[0];x<=waterRect[2];x++) w.setGround(x,y, waterRect[4]||'water'); } for(let x=x0;x<=x1;x++){ w.setFence(x,y0-1,'S',1); w.setFence(x,y1,'S',1); } for(let y=y0;y<=y1;y++){ w.setFence(x0-1,y,'E',1); w.setFence(x1,y,'E',1); } // gate on south side middle connecting to nearest path below const gx=((x0+x1)/2)|0; w.setFence(gx,y1,'S',3); const rid=w.regionOf(((x0+x1)/2)|0,((y0+y1)/2)|0); w.computeRegions(); for(const sp of species){ const t=w.randomRegionTile(rid,D.SPECIES[sp],rnd)||[x0+1,y0+1]; const a=new WK.Animal(sp,t[0],t[1],rid); game.animals.push(a); } // interior decor for(let i=0;i{ w.canPlaceBuilding(x,y,D.BUILDING[t].w,D.BUILDING[t].h)&&w.placeBuilding(t,x,y); }; put('aquarium',31,29); put('restaurant',8,29); put('gift',13,29); put('icecream',18,30); put('burger',24,30); put('restroom',28,29); put('restroom',7,19); put('aviary',40,20); put('reptile',40,29); put('visitor',20,4); put('research',27,3); put('vet',13,3); put('hut',37,3); // street life for(let i=0;i<40;i++){ const x=xW+((rnd()*(xE-xW))|0), y=rnd()<0.5?yN:yS; const o=['tree','bush','flower','lamp','bench'][(rnd()*5)|0]; if(w.inBounds(x,y)&&!w.path[w.idx(x,y)]) w.setObj(x,y,o,(rnd()*3)|0); } for(let i=0;i<26;i++){ const ang=rnd()*Math.PI*2, rr=6+rnd()*14; const x=cx+Math.cos(ang)*rr|0, y=((yN+yS)/2+Math.sin(ang)*rr*0.7)|0; if(w.inBounds(x,y)&&w.occ[w.idx(x,y)]<0&&!w.path[w.idx(x,y)]) w.setObj(x,y,['tree','tree','bush','flower'][(rnd()*4)|0],(rnd()*3)|0); } w.computeRegions(); game.rebuildPois(); game.recomputeRating(); }; Game.SAVE_KEY=SAVE_KEY; WK.Game = Game; })();