Files
wildlife-kingdom/js/world.js
T
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

356 lines
12 KiB
JavaScript

/* ============================================================
Wildlife Kingdom — js/world.js
Tile grid · terrain/paths/fences/objects · buildings ·
habitat region detection · pathfinding (visitors/staff/animals)
============================================================ */
(function(){
'use strict';
const WK = window.WK;
const D = WK.Data;
WK.GIDS = Object.keys(D.GROUND);
WK.PIDS = Object.keys(D.PATH);
WK.NIDS = Object.keys(D.NATURE);
class World{
constructor(cols,rows){
this.cols=cols; this.rows=rows;
const n=cols*rows;
this.ground=new Uint8Array(n);
this.path=new Uint8Array(n);
this.obj=new Uint8Array(n);
this.objVar=new Uint8Array(n);
this.fenceS=new Uint8Array(n); // edge between (x,y)-(x,y+1)
this.fenceE=new Uint8Array(n); // edge between (x,y)-(x+1,y)
this.occ=new Int16Array(n); // building index or -1
this.region=new Int16Array(n); // habitat region id or -1
this.occ.fill(-1);
this.region.fill(-1);
this.buildings=[];
this.regions=[]; // [{id,tiles,area,openEdges,hasPath}]
this.regionsDirty=true;
this.terrainDirty=true;
this.viewCache=null;
this.nextBid=1;
}
idx(x,y){ return y*this.cols+x; }
inBounds(x,y){ return x>=0&&y>=0&&x<this.cols&&y<this.rows; }
gid(x,y){ return WK.GIDS[this.ground[this.idx(x,y)]]; }
pid(x,y){ const p=this.path[this.idx(x,y)]; return p?WK.PIDS[p-1]:null; }
oid(x,y){ const o=this.obj[this.idx(x,y)]; return o?WK.NIDS[o-1]:null; }
/* ---------- raw setters (cost/validation in Game) ---------- */
setGround(x,y,gid){
if(!this.inBounds(x,y)) return false;
this.ground[this.idx(x,y)]=WK.GIDS.indexOf(gid);
this.path[this.idx(x,y)]=0; // path destroyed by terrain change
this.terrainDirty=true; this.touch();
return true;
}
setPath(x,y,pid){
if(!this.inBounds(x,y)) return false;
const i=this.idx(x,y);
if(D.GROUND[this.gid(x,y)].water||this.occ[i]>=0) return false;
this.path[i]=pid?(WK.PIDS.indexOf(pid)+1):0;
if(!pid&&this.obj[i]){} // keep objects
this.terrainDirty=true; this.touch();
return true;
}
setObj(x,y,oid,varr){
if(!this.inBounds(x,y)) return false;
const i=this.idx(x,y);
if(this.occ[i]>=0) return false;
this.obj[i]=oid?(WK.NIDS.indexOf(oid)+1):0;
this.objVar[i]=varr||0;
this.terrainDirty=true; this.touch();
return true;
}
setFence(x,y,edge,kind){
if(!this.inBounds(x,y)) return false;
const i=this.idx(x,y);
if(edge==='S') this.fenceS[i]=kind; else this.fenceE[i]=kind;
this.terrainDirty=true; this.touch();
return true;
}
touch(){ this.ver=(this.ver||0)+1; this.regionsDirty=true; this.viewCache=null; }
fenceBetween(ax,ay,bx,by){
if(bx===ax&&by===ay+1) return this.fenceS[this.idx(ax,ay)];
if(bx===ax&&by===ay-1) return this.fenceS[this.idx(bx,by)];
if(by===ay&&bx===ax+1) return this.fenceE[this.idx(ax,ay)];
if(by===ay&&bx===ax-1) return this.fenceE[this.idx(bx,by)];
return 0;
}
/* ---------- buildings ---------- */
canPlaceBuilding(bx,by,w,h){
for(let j=0;j<h;j++)for(let i=0;i<w;i++){
const x=bx+i,y=by+j;
if(!this.inBounds(x,y)) return false;
const k=this.idx(x,y);
if(this.occ[k]>=0) return false;
if(D.GROUND[this.gid(x,y)].water) return false;
if(this.obj[k]){ const o=D.NATURE[WK.NIDS[this.obj[k]-1]]; if(o.block) return false; }
if(this.path[k]) return false;
}
return true;
}
placeBuilding(type,x,y){
const def=D.BUILDING[type];
if(!this.canPlaceBuilding(x,y,def.w,def.h)) return null;
const b={id:this.nextBid++, type, x, y, w:def.w, h:def.h};
this.buildings.push(b);
for(let j=0;j<def.h;j++)for(let i=0;i<def.w;i++) this.occ[this.idx(x+i,y+j)]=b.id;
this.terrainDirty=true; this.touch();
return b;
}
removeBuildingById(id){
const bi=this.buildings.findIndex(b=>b.id===id);
if(bi<0) return null;
const b=this.buildings[bi];
for(let j=0;j<b.h;j++)for(let i=0;i<b.w;i++){
const k=this.idx(b.x+i,b.y+j);
if(this.occ[k]===b.id) this.occ[k]=-1;
}
this.buildings.splice(bi,1);
this.terrainDirty=true; this.touch();
return b;
}
buildingAt(x,y){
const id=this.occ[this.idx(x,y)];
return id>=0 ? this.buildings.find(b=>b.id===id) : null;
}
/* best guest-facing door tile: prefer front-south perimeter on a path */
doorTile(b){
const cands=[];
for(let i=0;i<b.w;i++){
cands.push([b.x+i,b.y+b.h]); // south side
}
for(let j=b.h-1;j>=0;j--) cands.push([b.x+b.w,b.y+j]); // east side
for(const [x,y] of cands){
if(this.inBounds(x,y)&&this.path[this.idx(x,y)]) return [x,y];
}
for(const [x,y] of cands) if(this.inBounds(x,y)&&!D.GROUND[this.gid(x,y)].water&&!this.occ[this.idx(x,y)]) return [x,y];
return [b.x,b.y+b.h];
}
/* ---------- habitat regions ---------- */
computeRegions(){
const {cols,rows}=this;
this.region.fill(-1);
this.regions=[];
let id=0;
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
const start=this.idx(x,y);
if(this.region[start]!==-1) continue;
if(this.occ[start]>=0) continue;
id++;
const tiles=[];
let openEdges=0, hasPath=false;
const stack=[start];
this.region[start]=id;
while(stack.length){
const k=stack.pop();
tiles.push(k);
const cx=k%cols, cy=(k/cols)|0;
if(this.path[k]) hasPath=true;
const nb=[[cx+1,cy],[cx-1,cy],[cx,cy+1],[cx,cy-1]];
for(let d=0;d<4;d++){
const nx=nb[d][0], ny=nb[d][1];
if(!this.inBounds(nx,ny)){ continue; } // map border = wall
const ni=this.idx(nx,ny);
if(this.occ[ni]>=0){ continue; } // building = wall
if(this.fenceBetween(cx,cy,nx,ny)){ continue; } // fence = wall
if(this.region[ni]===-1){ this.region[ni]=id; stack.push(ni); }
}
// open-edge audit (no fence to the outside world)
for(let d=0;d<4;d++){
const nx=nb[d][0], ny=nb[d][1];
if(!this.inBounds(nx,ny)) continue;
const ni=this.idx(nx,ny);
if(this.region[ni]===id) continue;
if(this.occ[ni]>=0) continue;
if(this.fenceBetween(cx,cy,nx,ny)) continue;
openEdges++;
}
}
this.regions.push({id, tiles, area:tiles.length, openEdges, hasPath});
}
this.regionsDirty=false;
this.viewCache=null;
return this.regions;
}
ensureRegions(){ if(this.regionsDirty) this.computeRegions(); }
regionOf(x,y){ return this.region[this.idx(x,y)]; }
validHabitats(){
this.ensureRegions();
return this.regions.filter(r=>r.openEdges===0&&!r.hasPath);
}
/* viewpoints: path tiles adjacent to a habitat region */
viewpoints(regionId){
this.ensureRegions();
if(!this.viewCache) this.viewCache=new Map();
let v=this.viewCache.get(regionId);
if(v) return v;
v=[];
const seen=new Set();
const reg=this.regions.find(r=>r.id===regionId);
if(reg){
for(const k of reg.tiles){
const cx=k%this.cols, cy=(k/this.cols)|0;
for(const [nx,ny] of [[cx+1,cy],[cx-1,cy],[cx,cy+1],[cx,cy-1]]){
if(!this.inBounds(nx,ny)) continue;
const ni=this.idx(nx,ny);
if(this.region[ni]!==regionId && this.path[ni] && !seen.has(ni)){
seen.add(ni); v.push([nx,ny]);
}
}
}
}
this.viewCache.set(regionId,v);
return v;
}
/* ---------- pathfinding ---------- */
static NB4=[[1,0],[-1,0],[0,1],[0,-1]];
findPathVisitor(sx,sy,tx,ty){
return this._bfs(sx,sy,tx,ty,(x,y)=>{
if(!this.inBounds(x,y)||!this.path[this.idx(x,y)]) return false;
return true;
},(ax,ay,bx,by)=> this.fenceBetween(ax,ay,bx,by)!==0 ); // visitors blocked by ANY fence incl gates
}
findPathKeeper(sx,sy,tx,ty){
// Dijkstra-lite: fences climbable at high cost, gates cheap
const cols=this.cols, n=cols*this.rows;
const dist=new Float32Array(n).fill(Infinity);
const prev=new Int32Array(n).fill(-1);
const si=this.idx(sx,sy), ti=this.idx(tx,ty);
dist[si]=0;
const open=[[0,si]];
const passable=(x,y)=>{
if(!this.inBounds(x,y)) return false;
const k=this.idx(x,y);
if(this.occ[k]>=0) return false;
const g=D.GROUND[this.gid(x,y)];
if(g.water===2) return false;
if(this.obj[k]){ const o=D.NATURE[WK.NIDS[this.obj[k]-1]]; if(o.block) return false; }
return true;
};
if(!passable(tx,ty)||!passable(sx,sy)) return null;
while(open.length){
let bi=0; for(let i=1;i<open.length;i++) if(open[i][0]<open[bi][0]) bi=i;
const [d,k]=open.splice(bi,1)[0];
if(k===ti) break;
if(d>dist[k]) continue;
const cx=k%cols, cy=(k/cols)|0;
for(const [dx,dy] of World.NB4){
const nx=cx+dx, ny=cy+dy;
if(!passable(nx,ny)) continue;
const ni=this.idx(nx,ny);
let c=1;
const f=this.fenceBetween(cx,cy,nx,ny);
if(f===1||f===2) c+=9;
if(f===3) c+=0.5;
if(D.GROUND[this.gid(nx,ny)].water===1) c+=2;
const nd=d+c;
if(nd<dist[ni]-1e-6){ dist[ni]=nd; prev[ni]=k; open.push([nd,ni]); }
}
}
if(prev[ti]===-1&&ti!==si) return null;
const out=[];
let cur=ti;
while(cur!==-1&&cur!==si){ out.push([cur%cols,(cur/cols)|0]); cur=prev[cur]; }
out.reverse();
return out;
}
_bfs(sx,sy,tx,ty,passable,blockedEdge){
if(!passable(sx,sy)||!passable(tx,ty)) return null;
const cols=this.cols, n=cols*this.rows;
const prev=new Int32Array(n).fill(-2);
const si=this.idx(sx,sy), ti=this.idx(tx,ty);
prev[si]=-1;
let q=[si];
while(q.length){
const nq=[];
for(const k of q){
if(k===ti){
const out=[]; let cur=ti;
while(cur!==-1){ out.push([cur%cols,(cur/cols)|0]); cur=prev[cur]; }
out.reverse(); out.shift();
return out.length?out:[[tx,ty]];
}
const cx=k%cols, cy=(k/cols)|0;
for(const [dx,dy] of World.NB4){
const nx=cx+dx, ny=cy+dy;
if(!passable(nx,ny)) continue;
const ni=this.idx(nx,ny);
if(prev[ni]!==-2) continue;
if(blockedEdge&&blockedEdge(cx,cy,nx,ny)) continue;
prev[ni]=k; nq.push(ni);
}
}
q=nq;
}
return null;
}
/* path for an animal confined to its region */
findPathAnimal(species,x0,y0,x1,y1,regionId){
const sp=species;
const passable=(x,y)=>{
if(!this.inBounds(x,y)) return false;
if(this.regionOf(x,y)!==regionId) return false;
const g=D.GROUND[this.gid(x,y)];
if(g.water){
if(!sp.swim) return false;
}
const o=this.oid(x,y);
if(o&&D.NATURE[o].block) return false;
return true;
};
return this._bfs(Math.round(x0),Math.round(y0),Math.round(x1),Math.round(y1),
(x,y)=>passable(x,y)||(x===Math.round(x0)&&y===Math.round(y0)),
(ax,ay,bx,by)=> this.fenceBetween(ax,ay,bx,by)!==0 );
}
/* random passable tile inside region for a species */
randomRegionTile(regionId,sp,rnd){
const reg=this.regions.find(r=>r.id===regionId);
if(!reg) return null;
for(let tries=0;tries<24;tries++){
const k=reg.tiles[(rnd()*reg.tiles.length)|0];
const x=k%this.cols, y=(k/this.cols)|0;
const g=D.GROUND[this.gid(x,y)];
if(g.water&&!sp.swim) continue;
const o=this.oid(x,y);
if(o&&D.NATURE[o].block) continue;
return [x,y];
}
return null;
}
/* ---------- persistence ---------- */
serialize(){
return {
cols:this.cols, rows:this.rows,
ground:Array.from(this.ground), path:Array.from(this.path),
obj:Array.from(this.obj), objVar:Array.from(this.objVar),
fenceS:Array.from(this.fenceS), fenceE:Array.from(this.fenceE),
buildings:this.buildings.map(b=>({...b})), nextBid:this.nextBid,
};
}
deserialize(s){
this.ground.set(s.ground); this.path.set(s.path);
this.obj.set(s.obj); this.objVar.set(s.objVar);
this.fenceS.set(s.fenceS); this.fenceE.set(s.fenceE);
this.buildings=s.buildings.map(b=>({...b}));
this.nextBid=s.nextBid||this.buildings.length+1;
this.occ.fill(-1);
for(const b of this.buildings)
for(let j=0;j<b.h;j++)for(let i=0;i<b.w;i++)
this.occ[this.idx(b.x+i,b.y+j)]=b.id;
this.terrainDirty=true; this.touch();
}
}
WK.World = World;
})();