- 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)
356 lines
12 KiB
JavaScript
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;
|
|
})();
|