Files
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

355 lines
12 KiB
JavaScript

/* ============================================================
Wildlife Kingdom — js/render.js
Isometric camera · layered renderer · cached terrain ·
water sparkle · day/night light · ghost previews · hero scene
============================================================ */
(function(){
'use strict';
const WK = window.WK;
const D = WK.Data;
const HW = WK.HW, HH = WK.HH, TAU = WK.TAU;
/* ---------------- Camera ---------------- */
class Camera{
constructor(){ this.x=0; this.y=0; this.zoom=1; this.w=800; this.h=600; }
resize(w,h){ this.w=w; this.h=h; }
centerOnTile(gx,gy){
const wx=(gx-gy)*HW, wy=(gx+gy)*HH;
this.x=wx; this.y=wy;
}
/* tile coords → screen px */
project(gx,gy){
const wx=(gx-gy)*HW, wy=(gx+gy)*HH;
return {
x:(wx-this.x)*this.zoom + this.w/2,
y:(wy-this.y)*this.zoom + this.h/2,
};
}
worldToScreen(wx,wy){
return { x:(wx-this.x)*this.zoom+this.w/2, y:(wy-this.y)*this.zoom+this.h/2 };
}
screenToWorld(sx,sy){
return {
x:(sx-this.w/2)/this.zoom + this.x,
y:(sy-this.h/2)/this.zoom + this.y,
};
}
screenToTile(sx,sy){
const w=this.screenToWorld(sx,sy);
return {
x:Math.floor((w.x/HW + w.y/HH)/2),
y:Math.floor((w.y/HH - w.x/HW)/2),
};
}
panBy(dx,dy){ this.x+=dx/this.zoom; this.y+=dy/this.zoom; }
zoomAt(sx,sy,factor){
const before=this.screenToWorld(sx,sy);
this.zoom=WK.clamp(this.zoom*factor,0.45,2.4);
const after=this.screenToWorld(sx,sy);
this.x+=before.x-after.x; this.y+=before.y-after.y;
}
clampToWorld(world){
const maxX=((world.cols))*HW, maxY=((world.cols+world.rows))*HH;
this.x=WK.clamp(this.x,-maxX,maxX);
this.y=WK.clamp(this.y,-200,maxY+200);
}
isVisible(sx,sy,margin){
const m=(margin||40)+60;
return sx>-m && sx<this.w+m && sy>-m && sy<this.h+m;
}
}
WK.Camera = Camera;
/* ---------------- Renderer ---------------- */
class Renderer{
constructor(canvas){
this.canvas=canvas;
this.ctx=canvas.getContext('2d');
this.dpr=Math.min(2,window.devicePixelRatio||1);
this.resize();
this._terrain=document.createElement('canvas');
this._terrainW=0; this._terrainH=0; this._terrainOX=0; this._terrainOY=0;
this._lampCacheVer=-1; this._lamps=[];
}
resize(){
this.dpr=Math.min(2,window.devicePixelRatio||1);
const r=this.canvas.getBoundingClientRect();
this.cssW=Math.max(320,r.width); this.cssH=Math.max(240,r.height);
this.canvas.width=Math.round(this.cssW*this.dpr);
this.canvas.height=Math.round(this.cssH*this.dpr);
}
/* ---- static terrain layer ---- */
rebuildTerrain(world){
const W=(world.cols+world.rows)*HW+4, H=(world.cols+world.rows)*HH+80;
if(this._terrainW!==W||this._terrainH!==H){
this._terrain.width=W; this._terrain.height=H;
this._terrainW=W; this._terrainH=H;
this._terrainOX=-(world.rows-1)*HW-2;
this._terrainOY=HH-30;
}
const c=this._terrain.getContext('2d');
c.clearRect(0,0,W,H);
for(let y=0;y<world.rows;y++)for(let x=0;x<world.cols;x++){
const wx=(x-y)*HW-this._terrainOX, wy=(x+y)*HH-this._terrainOY;
WK.Sprites.paintGround(c,wx,wy,world.gid(x,y),WK.hash2(x,y,17));
}
for(let y=0;y<world.rows;y++)for(let x=0;x<world.cols;x++){
const pid=world.pid(x,y);
if(!pid) continue;
const wx=(x-y)*HW-this._terrainOX, wy=(x+y)*HH-this._terrainOY;
WK.Sprites.paintPath(c,wx,wy,pid,WK.hash2(x,y,31));
}
world.terrainDirty=false;
this._lampCacheVer=-1;
}
repaintTile(world,x,y){
const c=this._terrain.getContext('2d');
const wx=(x-y)*HW-this._terrainOX, wy=(x+y)*HH-this._terrainOY;
c.save();
WK.Sprites.diamondPath(c,wx,wy,0); c.clip();
c.clearRect(wx-HW-2,wy-HH-2,HW*2+4,HH*2+4);
WK.Sprites.paintGround(c,wx,wy,world.gid(x,y),WK.hash2(x,y,17));
const pid=world.pid(x,y);
if(pid) WK.Sprites.paintPath(c,wx,wy,pid,WK.hash2(x,y,31));
c.restore();
world.terrainDirty=false;
this._lampCacheVer=-1;
}
lampsFor(world){
const ver=world.ver||0;
if(this._lampCacheVer===ver) return this._lamps;
this._lamps=[];
for(let y=0;y<world.rows;y++)for(let x=0;x<world.cols;x++){
if(world.oid(x,y)==='lamp') this._lamps.push([x,y]);
}
this._lampCacheVer=ver;
return this._lamps;
}
/* ---- main frame ---- */
frame(game,cam,opt){
opt=opt||{};
const ctx=this.ctx;
ctx.setTransform(this.dpr,0,0,this.dpr,0,0);
const world=game.world;
// sky / backdrop follows daylight
const dl=this.daylight(game.minutes);
const top=WK.mix('#2c3e66','#7ec8f5',dl), bot=WK.mix('#4a5f8a','#cdeef9',dl);
const grd=ctx.createLinearGradient(0,0,0,this.cssH);
grd.addColorStop(0,top); grd.addColorStop(1,bot);
ctx.fillStyle=grd; ctx.fillRect(0,0,this.cssW,this.cssH);
if(world.terrainDirty) this.rebuildTerrain(world);
// terrain layer
const tl=cam.worldToScreen(this._terrainOX,this._terrainOY);
ctx.imageSmoothingEnabled=false;
ctx.drawImage(this._terrain,
tl.x,tl.y,
this._terrainW*cam.zoom, this._terrainH*cam.zoom);
ctx.imageSmoothingEnabled=true;
// visible tile range
const corners=[cam.screenToTile(0,0),cam.screenToTile(this.cssW,0),cam.screenToTile(0,this.cssH),cam.screenToTile(this.cssW,this.cssH)];
let minX=1e9,maxX=-1e9,minY=1e9,maxY=-1e9;
for(const c2 of corners){
minX=Math.min(minX,c2.x); maxX=Math.max(maxX,c2.x);
minY=Math.min(minY,c2.y); maxY=Math.max(maxY,c2.y);
}
minX=Math.max(0,minX-2); maxX=Math.min(world.cols-1,maxX+2);
minY=Math.max(0,minY-2); maxY=Math.min(world.rows-1,maxY+2);
// water shimmer (under everything dynamic)
ctx.save(); ctx.strokeStyle='rgba(255,255,255,.4)'; ctx.lineWidth=1.4;
const t=game.timeAbs;
for(let y=minY;y<=maxY;y++)for(let x=minX;x<=maxX;x++){
if(!D.GROUND[world.gid(x,y)].water) continue;
const h=WK.hash2(x,y,7);
const p=cam.project(x,y);
const ph=(t*(0.6+h)+h*9)%1;
const a=Math.sin(ph*Math.PI);
ctx.globalAlpha=a*0.5;
const ox=(h-0.5)*20;
ctx.beginPath();
ctx.moveTo(p.x-10+ox,p.y+(h-0.5)*8);
ctx.quadraticCurveTo(p.x+ox,p.y+(h-0.5)*8-3,p.x+10+ox,p.y+(h-0.5)*8);
ctx.stroke();
}
ctx.restore();
// build-mode grid
if(opt.grid){
ctx.save(); ctx.strokeStyle='rgba(50,40,20,.14)'; ctx.lineWidth=1;
for(let y=minY;y<=maxY;y++)for(let x=minX;x<=maxX;x++){
const p=cam.project(x,y);
WK.Sprites.diamondPath(ctx,p.x,p.y,0.5); ctx.stroke();
}
ctx.restore();
}
// region tint overlay
if(opt.tintRegionId){
const reg=world.regions.find(r=>r.id===opt.tintRegionId);
if(reg){
ctx.fillStyle='rgba(120,220,140,.16)';
for(const k of reg.tiles){
const x=k%world.cols, y=(k/world.cols)|0;
if(x<minX||x>maxX||y<minY||y>maxY) continue;
const p=cam.project(x,y);
WK.Sprites.diamondPath(ctx,p.x,p.y,0.5); ctx.fill();
}
}
}
/* ---------- collect drawables (painter's algorithm) ---------- */
const draws=[];
const z=cam.zoom;
const push=(d,fn)=>draws.push({d,fn});
for(let y=minY;y<=maxY;y++)for(let x=minX;x<=maxX;x++){
const k=world.idx(x,y);
const p=cam.project(x,y);
// fences — exact diamond-corner geometry:
// W corner = C+(-HW,0) · S corner = C+(0,+HH) · E corner = C+(+HW,0)
// so consecutive collinear edges share endpoints perfectly.
const fs=world.fenceS[k], fe=world.fenceE[k];
if(fs){
push(x+y+0.32,()=>{ WK.Sprites.drawFenceEdge(ctx,p.x-HW*z,p.y,p.x,p.y+HH*z,fs,t); });
}
if(fe){
push(x+y+0.34,()=>{ WK.Sprites.drawFenceEdge(ctx,p.x,p.y+HH*z,p.x+HW*z,p.y,fe,t); });
}
// nature objects
const oid=world.oid(x,y);
if(oid){
const spr=WK.Sprites.getObject(oid,world.objVar[k]);
const scale=(WK.Sprites.objectRealSize[oid]||1);
push(x+y+0.5+(x-y)*0.001,()=>{
ctx.save();
ctx.translate(p.x,p.y);
const sway=Math.sin(t*1.1+x*1.7+y)*0.02;
if(scale!==1) ctx.scale(scale,scale);
ctx.rotate(sway);
ctx.drawImage(spr.cv,-spr.ax,-spr.ay);
ctx.restore();
});
}
// buildings
const b=world.buildingAt(x,y);
if(b && b.x===x && b.y===y){ // draw once at NW corner tile
const spr=WK.Sprites.getBuilding(b.type);
const def=D.BUILDING[b.type];
const cxT=b.x+def.w/2, cyT=b.y+def.h/2;
const pc=cam.project(cxT,cyT);
push(b.x+b.y+def.w+def.h-1.2,()=>{
ctx.drawImage(spr.cv,pc.x-spr.ax,pc.y-spr.ay);
});
}
}
// entities
for(const a of game.animals){
push(a.x+a.y+0.55,()=>a.draw(ctx,cam,t,game.world.gid(Math.round(a.x),Math.round(a.y))));
}
for(const g of game.guests){
push(g.x+g.y+0.56,()=>g.draw(ctx,cam,t));
}
for(const s of game.staff){
push(s.x+s.y+0.57,()=>s.draw(ctx,cam,t));
}
draws.sort((A,B)=>A.d-B.d);
for(const it of draws) it.fn();
// particles above world
game.particles.draw(ctx,cam,t);
// ghost preview & hover handled by input via callbacks
if(opt.overlay) opt.overlay(ctx,cam,{minX,maxX,minY,maxY});
// selection highlight
if(game.selected&&game.selected.alive!==false&&game.selected.kind==='animal'){
const s=game.selected;
const p=cam.project(s.x,s.y);
const rr=(18*s.size)*(1+Math.sin(t*5)*0.08);
ctx.strokeStyle='rgba(255,201,60,.95)'; ctx.lineWidth=2.5;
ctx.beginPath(); ctx.ellipse(p.x,p.y+1,rr,rr*0.42,0,0,TAU); ctx.stroke();
}
// lamps glow + night tint
const dark=1-dl;
if(dark>0.05){
const lamps=this.lampsFor(world);
ctx.save();
ctx.globalCompositeOperation='screen';
for(const [lx,ly] of lamps){
const p=cam.project(lx,ly);
if(!cam.isVisible(p.x,p.y,80)) continue;
const rr=46*cam.zoom;
const gg=ctx.createRadialGradient(p.x,p.y-52*cam.zoom,2,p.x,p.y-52*cam.zoom,rr);
gg.addColorStop(0,'rgba(255,220,130,'+(0.5*dark)+')');
gg.addColorStop(1,'rgba(255,220,130,0)');
ctx.fillStyle=gg;
ctx.beginPath(); ctx.arc(p.x,p.y-52*cam.zoom,rr,0,TAU); ctx.fill();
}
ctx.restore();
ctx.fillStyle='rgba(24,34,72,'+(dark*0.42)+')';
ctx.fillRect(0,0,this.cssW,this.cssH);
}
// gentle vignette
const vg=ctx.createRadialGradient(this.cssW/2,this.cssH/2,Math.min(this.cssW,this.cssH)*0.62,this.cssW/2,this.cssH/2,Math.max(this.cssW,this.cssH)*0.78);
vg.addColorStop(0,'rgba(30,25,10,0)'); vg.addColorStop(1,'rgba(30,25,10,.18)');
ctx.fillStyle=vg; ctx.fillRect(0,0,this.cssW,this.cssH);
}
daylight(minutes){
// 0 at midnight → 1 at noon, warm golden hours
const m=minutes/1440;
const sun=Math.sin((m-0.25)*TAU)*0.5+0.5; // peak at noon
return Math.pow(WK.clamp(sun*1.15,0,1),0.8);
}
/* ---- ghost preview ---- */
drawGhost(ctx,cam,g){
if(!g||g.x==null) return;
const ok=g.valid;
const col= ok?'rgba(110,220,120,.85)':'rgba(230,80,70,.9)';
const fill= ok?'rgba(140,230,150,.28)':'rgba(230,90,80,.3)';
if(g.mode==='tile'){
const p=cam.project(g.x,g.y);
WK.Sprites.diamondPath(ctx,p.x,p.y,1);
ctx.fillStyle=fill; ctx.fill();
ctx.strokeStyle=col; ctx.lineWidth=2; ctx.stroke();
if(g.sprite){
ctx.save(); ctx.globalAlpha=0.75;
ctx.drawImage(g.sprite.cv,p.x-g.sprite.ax,p.y-g.sprite.ay);
ctx.restore();
}
}else if(g.mode==='foot'){
const def=D.BUILDING[g.item];
for(let j=0;j<def.h;j++)for(let i=0;i<def.w;i++){
const p=cam.project(g.x+i,g.y+j);
WK.Sprites.diamondPath(ctx,p.x,p.y,1);
ctx.fillStyle=fill; ctx.fill();
ctx.strokeStyle=col; ctx.lineWidth=1.6; ctx.stroke();
}
const spr=WK.Sprites.getBuilding(g.item);
const pc=cam.project(g.x+def.w/2,g.y+def.h/2);
ctx.save(); ctx.globalAlpha=0.75;
ctx.drawImage(spr.cv,pc.x-spr.ax,pc.y-spr.ay);
ctx.restore();
}else if(g.mode==='edge'){
const p=cam.project(g.x,g.y);
const gz=cam.zoom;
let ax,ay,bx,by;
if(g.edge==='S'){ ax=p.x-HW*gz; ay=p.y; bx=p.x; by=p.y+HH*gz; }
else{ ax=p.x; ay=p.y+HH*gz; bx=p.x+HW*gz; by=p.y; }
ctx.strokeStyle=col; ctx.lineWidth=6; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(ax,ay-6); ctx.lineTo(bx,by-6); ctx.stroke();
ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(ax,ay-12); ctx.lineTo(bx,by-12); ctx.stroke();
}
}
}
WK.Renderer = Renderer;
})();