/* ============================================================ 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=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=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;jb.id===id); if(bi<0) return null; const b=this.buildings[bi]; for(let j=0;j=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=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=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;idist[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{ 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