- 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)
196 lines
8.4 KiB
HTML
196 lines
8.4 KiB
HTML
<!DOCTYPE html>
|
||
<html><head><meta charset="utf8"><title>QARUN</title></head><body>
|
||
<canvas id="game" width="1200" height="800"></canvas>
|
||
<pre id="out">PENDING</pre>
|
||
<script src="../js/util.js"></script>
|
||
<script src="../js/data.js"></script>
|
||
<script src="../js/audio.js"></script>
|
||
<script src="../js/sprites.js"></script>
|
||
<script src="../js/world.js"></script>
|
||
<script src="../js/entities.js"></script>
|
||
<script src="../js/sim.js"></script>
|
||
<script src="../js/render.js"></script>
|
||
<script>
|
||
'use strict';
|
||
window.onerror=(m,s,l)=>{ document.getElementById('out').textContent='JSERROR: '+m+' @'+s+':'+l; };
|
||
(async function(){
|
||
const out={checks:[],errors:[]};
|
||
const T=(name,cond,info)=>out.checks.push((cond?'PASS ':'FAIL ')+name+(info?(' :: '+info):''));
|
||
try{
|
||
const WK=window.WK;
|
||
const canvas=document.getElementById('game');
|
||
const renderer=new WK.Renderer(canvas);
|
||
// force css size (headless has no layout box matching)
|
||
renderer.cssW=1200; renderer.cssH=800;
|
||
renderer.canvas.width=1200; renderer.canvas.height=800;
|
||
renderer.canvas.style.width='1200px'; renderer.canvas.style.height='800px';
|
||
const cam=new WK.Camera();
|
||
cam.resize(1200,800);
|
||
|
||
/* ---------- hero demo zoo render ---------- */
|
||
const hero=new WK.Game();
|
||
WK.Game.buildDemo(hero);
|
||
hero.timeAbs=1.2;
|
||
cam.centerOnTile(hero.world.cols-2,hero.world.rows-3);
|
||
cam.zoom=0.95;
|
||
renderer.frame(hero,cam,{});
|
||
await new Promise(r=>setTimeout(r,50));
|
||
let img=renderer.ctx.getImageData(0,0,1200,800).data;
|
||
const colors=new Set();
|
||
for(let i=0;i<img.length;i+=40){ colors.add((img[i]>>3)+','+(img[i+1]>>3)+','+(img[i+2]>>3)); }
|
||
T('demo frame rich color count',colors.size>400,String(colors.size));
|
||
// sky should be blue-ish at top (camera at south corner → upper screen is off-map)
|
||
const px=(x,y)=>{ const k=(y*1200+x)*4; return [img[k],img[k+1],img[k+2]]; };
|
||
const sky=px(600,8);
|
||
T('sky blue at top',sky[2]>140&&sky[2]>sky[0],sky.join(','));
|
||
// center should contain green grass tones somewhere in middle band
|
||
let greens=0,total=0;
|
||
for(let y=300;y<500;y+=10)for(let x=100;x<1100;x+=10){
|
||
const c=px(x,y); total++;
|
||
if(c[1]>c[0]&&c[1]>c[2]&&c[1]>90) greens++;
|
||
}
|
||
T('green terrain visible mid-frame',greens/total>0.25,Math.round(greens/total*100)+'%');
|
||
// water shimmer: some cyan-blue pixels
|
||
let blues=0;
|
||
for(let y=250;y<700;y+=7)for(let x=150;x<1050;x+=7){
|
||
const c=px(x,y);
|
||
if(c[2]>130&&c[2]>c[0]+30&&c[1]>110) blues++;
|
||
}
|
||
T('water tones present',blues>12,String(blues));
|
||
|
||
/* ---------- night frame ---------- */
|
||
hero.minutes=23*60;
|
||
renderer.frame(hero,cam,{});
|
||
await new Promise(r=>setTimeout(r,20));
|
||
img=renderer.ctx.getImageData(0,0,1200,800).data;
|
||
const night=px(600,8);
|
||
T('night darkens sky',night[2]<sky[2],night.join(','));
|
||
|
||
/* ---------- starter zoo + entities draw ---------- */
|
||
const g=new WK.Game();
|
||
WK.Game.buildStarter(g);
|
||
g.rebuildPois();
|
||
// add animal & guest & keeper directly into view
|
||
g.world.computeRegions();
|
||
const rid=g.world.regionOf(23,36);
|
||
if(rid>0){}else{ /* no habitat in starter; force one */ }
|
||
g.money=99999;
|
||
// build quick habitat near starter (interior 18..24 × 28..33)
|
||
const w=g.world;
|
||
for(let y=28;y<=33;y++)for(let x=18;x<=24;x++) w.setGround(x,y,'grass');
|
||
for(let x=17;x<=24;x++){ w.setFence(x,27,'S',1); w.setFence(x,33,'S',1); }
|
||
for(let y=28;y<=33;y++){ w.setFence(17,y,'E',1); w.setFence(24,y,'E',1); }
|
||
w.computeRegions();
|
||
const reg=w.validHabitats()[0];
|
||
T('qa habitat valid',!!reg,String(reg&®.area));
|
||
if(reg){
|
||
g.tryAdopt('zebra',21,30);
|
||
g.tryAdopt('flamingo',21,31);
|
||
}
|
||
g.guests.push(new WK.Guest(g,23,41));
|
||
g.staff.push(new WK.Staff('keeper',22,41));
|
||
g.animals[0]&&(g.animals[0].state='happy');
|
||
cam.centerOnTile(22,34);
|
||
cam.zoom=1.15;
|
||
hero.minutes=12*60;
|
||
g.timeAbs=2.5; g.minutes=12*60;
|
||
renderer.frame(g,cam,{grid:true});
|
||
await new Promise(r=>setTimeout(r,50));
|
||
img=renderer.ctx.getImageData(0,0,1200,800).data;
|
||
const px2=(x,y)=>{ const k=(y*1200+x)*4; return [img[k],img[k+1],img[k+2]]; };
|
||
// HUD-free center: expect path beige tones near plaza
|
||
let beiges=0;
|
||
for(let y=350;y<650;y+=6)for(let x=200;x<1000;x+=6){
|
||
const c=px2(x,y);
|
||
if(c[0]>190&&c[1]>170&&c[2]>130&&c[0]>=c[1]&&c[1]>c[2]) beiges++;
|
||
}
|
||
T('starter paths visible',beiges>40,String(beiges));
|
||
|
||
/* ---------- sprite icon audit ---------- */
|
||
let blankIcons=[];
|
||
const kinds=['res:coin','res:guest','res:heart','res:flask','res:star','ground:grass','ground:snow','path:gravel','path:stonep','fence:wood','fence:gate','nature:tree','nature:palm','nature:lamp','animal:lion','animal:panda','animal:penguin','animal:elephant','facility:aquarium','facility:hut','facility:entrance_g'];
|
||
for(const kd of kinds){
|
||
const s=WK.Sprites.getIcon(kd,52);
|
||
const d=s.cv.getContext('2d').getImageData(0,0,s.cv.width,s.cv.height).data;
|
||
let a=0; for(let i=3;i<d.length;i+=16) a+=d[i];
|
||
if(a<40) blankIcons.push(kd);
|
||
}
|
||
T('all UI icons painted',blankIcons.length===0,blankIcons.join(',')||'ok');
|
||
|
||
/* ---------- all species draw without error ---------- */
|
||
let badSpecies=[];
|
||
for(const sp of Object.keys(WK.Data.SPECIES)){
|
||
const cv=WK.mkCanvas(160,160); const cx=cv.getContext('2d');
|
||
cx.translate(80,140);
|
||
try{
|
||
for(const st of ['idle','walk','eat','happy'])
|
||
WK.Sprites.drawAnimal(cx,sp,{state:st,ph:1.3,t:1.7});
|
||
const d=cv.getContext('2d').getImageData(0,0,160,160).data;
|
||
let a=0; for(let i=3;i<d.length;i+=64) a+=d[i];
|
||
if(a<25) badSpecies.push(sp+':dim');
|
||
}catch(e){ badSpecies.push(sp+':'+e.message); }
|
||
}
|
||
T('15 species × 4 poses render',badSpecies.length===0,badSpecies.join(',')||'ok');
|
||
|
||
/* ---------- buildings render ---------- */
|
||
let badBld=[];
|
||
for(const b of Object.keys(WK.Data.BUILDING)){
|
||
try{
|
||
const s=WK.Sprites.getBuilding(b);
|
||
const d=s.cv.getContext('2d').getImageData(0,0,s.cv.width,s.cv.height).data;
|
||
let a=0; for(let i=3;i<d.length;i+=97) a+=d[i];
|
||
if(a<30) badBld.push(b+':dim');
|
||
}catch(e){ badBld.push(b+':'+e.message); }
|
||
}
|
||
T('15 buildings render',badBld.length===0,badBld.join(',')||'ok');
|
||
|
||
/* ---------- fence continuity (regression: corner-exact endpoints) ---------- */
|
||
/* Wall topology in this iso model:
|
||
· S-edges chain over +x → "\" wall · E-edges chain over +y → "/" wall */
|
||
{
|
||
const w2=new WK.World(20,20);
|
||
w2.setFence(5,5,'E',1); w2.setFence(5,6,'E',1); w2.setFence(5,7,'E',1); // "/" run over +y
|
||
const cam2=new WK.Camera(); cam2.resize(420,320);
|
||
cam2.centerOnTile(5.4,6.4); cam2.zoom=1.5;
|
||
const cv2=WK.mkCanvas(420,320), c2=cv2.getContext('2d');
|
||
const z=cam2.zoom;
|
||
// draw exactly like render.js does
|
||
for(const ty of [5,6,7]){
|
||
const pp=cam2.project(5,ty);
|
||
WK.Sprites.drawFenceEdge(c2,pp.x,pp.y+WK.HH*z,pp.x+WK.HW*z,pp.y,1,0);
|
||
}
|
||
// sample along the rail from E-corner of (5,5) to S-corner of (5,7)
|
||
const t55=cam2.project(5,5), t57=cam2.project(5,7);
|
||
const sx=t55.x+WK.HW*z, sy=t55.y; // start (E corner, top)
|
||
const ex=t57.x, ey=t57.y+WK.HH*z; // end (S corner, bottom)
|
||
let gaps=0,samples=0;
|
||
for(let tt=0.02;tt<=0.98;tt+=0.01){
|
||
samples++;
|
||
const xx=Math.round(sx+(ex-sx)*tt), yy=Math.round(sy+(ey-sy)*tt-5); // mid rail
|
||
const d=c2.getImageData(xx,yy,1,1).data;
|
||
if(d[3]<10) gaps++;
|
||
}
|
||
T('straight fence run continuous',gaps<=1,gaps+'/'+samples+' gaps');
|
||
// L-corner joint: E-edge of (5,5) meets S-edge of (5,5) at the shared S corner
|
||
const cv3=WK.mkCanvas(420,320), c3=cv3.getContext('2d');
|
||
const p55=cam2.project(5,5);
|
||
WK.Sprites.drawFenceEdge(c3,p55.x,p55.y+WK.HH*z,p55.x+WK.HW*z,p55.y,1,0);
|
||
WK.Sprites.drawFenceEdge(c3,p55.x-WK.HW*z,p55.y,p55.x,p55.y+WK.HH*z,1,0);
|
||
const jx=Math.round(p55.x), jy=Math.round(p55.y+WK.HH*z-8); // post zone at joint
|
||
let post=0;
|
||
for(let dx=-2;dx<=2;dx++)for(let dy=-2;dy<=2;dy++){
|
||
const d=c3.getImageData(jx+dx,jy+dy,1,1).data;
|
||
if(d[3]>60&&d[0]>90&&d[0]<200&&d[1]>60&&d[2]<130) post++;
|
||
}
|
||
T('corner joint has shared post',post>=8,String(post));
|
||
}
|
||
|
||
}catch(e){
|
||
out.errors.push(e.stack||String(e));
|
||
}
|
||
document.getElementById('out').textContent='QAJSON '+JSON.stringify(out);
|
||
document.title='QADONE';
|
||
})();
|
||
</script>
|
||
</body></html>
|