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