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)
This commit is contained in:
2026-08-23 07:01:21 +00:00
commit c06786518c
22 changed files with 5130 additions and 0 deletions
+614
View File
@@ -0,0 +1,614 @@
/* ============================================================
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.money<n){ this.notify('Not enough coins!','bad'); this.sfx('error'); return false; }
this.money-=n; return true;
}
/* ---------------- habitat scoring ---------------- */
habitatScoreFor(regionId,species){
const ver=this.world.ver||0;
if(this._scoreCache.ver!==ver){ this._scoreCache.ver=ver; this._scoreCache.map.clear(); }
const key=regionId+':'+species;
if(this._scoreCache.map.has(key)) return this._scoreCache.map.get(key);
const reg=this.world.regions.find(r=>r.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.area<sp.minArea){
this.notify(sp.name+' needs a habitat of at least '+sp.minArea+' tiles.','bad');
this.sfx('error'); return false;
}
const score=this.habitatScoreFor(reg.id,species);
if(score<50){
this.notify('Terrain unsuitable for a '+sp.name+'! Match its favorite biomes (needs '+Math.round(score)+'%, want 50%+).','bad');
this.sfx('error'); return false;
}
if(!this.spendMoney(sp.cost)) return false;
// drop the animal onto a passable nearby tile inside region
let px=x,py=y;
const g0=D.GROUND[this.world.gid(x,y)];
if((g0.water&&!sp.swim)||(this.world.oid(x,y)&&D.NATURE[this.world.oid(x,y)].block)){
const alt=this.world.randomRegionTile(reg.id,sp,Math.random);
if(alt){px=alt[0];py=alt[1];}
}
const a=new WK.Animal(species,px+0.5-0.5,py+0.5-0.5,reg.id);
a.x=px; a.y=py;
this.animals.push(a);
this.stats.adopted++;
this.sfx('chirp');
this.notify(a.name+' the '+sp.name+' joins the zoo!','good');
this.particles.hearts(px,py);
return true;
}
sellAnimal(a){
const i=this.animals.indexOf(a);
if(i<0)return;
this.animals.splice(i,1);
const refund=Math.round(D.SPECIES[a.species].cost*0.45);
this.money+=refund;
this.stats.sold++;
this.notify(a.name+' found a new home (+'+WK.fmtMoney(refund)+').','good');
this.sfx('coin');
if(this.ui&&this.ui.hideSelection) this.ui.hideSelection();
}
/* ---------------- jobs dispatcher ---------------- */
requestJob(role){
if(role==='keeper'){
let best=null;
for(const a of this.animals){
if(a._taken||a.eatT>0||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;i<arr.length;i++) if(arr[i]===WK.NIDS.indexOf(id)+1) n++;
return n;
}
countNature(){
let n=0; const arr=this.world.obj;
for(let i=0;i<arr.length;i++) if(arr[i]) n++;
return n;
}
/* ---------------- main tick ---------------- */
tick(dt){
this.timeAbs+=dt;
const spd=this.speed;
const sdt=dt*spd;
if(sdt>0){
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()<p*sdt*4){ a.sick=true; a._taken=false;
this.notify(a.name+' the '+D.SPECIES[a.species].name+' feels sick! Build a Vet Clinic.','bad'); this.sfx('error'); }
}else{
a._sickT=(a._sickT||0)+sdt;
if(a._sickT>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<cap && this.exitTile){
const e=this.exitTile;
const gs=new WK.Guest(this,e[0]+0.5,e[1]+0.5);
this.guests.push(gs);
const fee=8+Math.round(this.rating*4);
this.earnMoney(fee,'ticket');
this.stats.guestsTotal++; this.stats.guestsToday++;
if(this.guests.length%10===0) this.coinFloat(e[0],e[1]);
}
}
for(let i=this.guests.length-1;i>=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.rp<cost){ this.notify('Need '+Math.ceil(cost-this.rp)+' more RP.','bad'); this.sfx('error'); return false; }
this.rp-=cost;
this.researchTier++;
this.notify('Research unlocked: '+D.RESEARCH_TIERS.find(t=>t.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<Math.max(2,((x1-x0)*(y1-y0))/18|0);i++){
const rx=x0+1+((rnd()*(x1-x0-1))|0), ry=y0+1+((rnd()*(y1-y0-1))|0);
const o=grounds.includes('snow')?'rock':(grounds.includes('sand')?'palm':(species.includes('panda')?'bamboo':'tree'));
if(!w.path[w.idx(rx,ry)]) w.setObj(rx,ry,o,0);
}
}
// savanna center-left: lions+zebra+giraffe
habitat(9,11,17,17,['grass','grass','sand'],['lion','zebra','giraffe']);
// elephants right of it
habitat(19,11,27,17,['grass','dirt'],['elephant','elephant']);
// arctic NW corner
habitat(9,20,16,26,['snow','ice'],['penguin','penguin','polarbear'],[11,22,14,25,'water']);
// wetland SW
habitat(19,21,26,27,['sand','grass'],['flamingo','flamingo'],[21,23,24,25,'water']);
// jungle SE quadrant
habitat(29,20,38,28,['dirt','grass'],['tiger','gorilla','monkey']);
for(let i=0;i<8;i++){ const rx=30+((rnd()*8)|0), ry=21+((rnd()*7)|0); w.setObj(rx,ry,'bamboo',0); }
// panda grove NE
habitat(29,10,36,16,['grass','dirt'],['panda','panda']);
for(let i=0;i<6;i++){ const rx=30+((rnd()*6)|0), ry=11+((rnd()*5)|0); w.setObj(rx,ry,'bamboo',0); }
// croc lagoon far east
habitat(39,12,C-2,18,['sand'],['crocodile'],[40,14,C-3,17,'deep']);
w.computeRegions();
// facilities along paths
const put=(t,x,y)=>{ 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;
})();