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)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
probe1787452128
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
<!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>
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/* Headless logic smoke-test for Wildlife Kingdom (run: node test/smoke.js) */
|
||||
'use strict';
|
||||
const fs=require('fs'), path=require('path'), vm=require('vm');
|
||||
|
||||
global.window = {};
|
||||
global.localStorage = { _s:{}, getItem(k){return this._s[k]||null;}, setItem(k,v){this._s[k]=v;}, removeItem(k){delete this._s[k];} };
|
||||
|
||||
const ctx=vm.createContext(global);
|
||||
function load(f){
|
||||
const code=fs.readFileSync(path.join(__dirname,'..','js',f),'utf8');
|
||||
vm.runInContext(code,ctx,{filename:f});
|
||||
}
|
||||
['util.js','data.js','world.js','entities.js','sim.js'].forEach(f=>{
|
||||
load(f);
|
||||
});
|
||||
const WK=global.window.WK;
|
||||
let pass=0,fail=0;
|
||||
function T(name,cond){
|
||||
if(cond){pass++;console.log(' ✔',name);}
|
||||
else{fail++;console.log(' ✘ FAIL:',name);}
|
||||
}
|
||||
|
||||
console.log('— world & regions —');
|
||||
const g=new WK.Game();
|
||||
WK.Game.buildStarter(g);
|
||||
g.rebuildPois();
|
||||
g.world.computeRegions();
|
||||
T('starter entrance placed', g.world.buildings.some(b=>b.type==='entrance_s'));
|
||||
T('exit tile resolved', !!g.exitTile);
|
||||
T('regions computed', g.world.regions.length>0);
|
||||
|
||||
/* build an enclosed habitat at (10..17)x(20..25) */
|
||||
const w=g.world;
|
||||
for(let y=20;y<=25;y++)for(let x=10;x<=17;x++) w.setGround(x,y,'grass');
|
||||
for(let x=10;x<=17;x++){ w.setFence(x,19,'S',1); w.setFence(x,25,'S',1); }
|
||||
for(let y=20;y<=25;y++){ w.setFence(9,y,'E',1); w.setFence(17,y,'E',1); }
|
||||
w.setFence(13,25,'S',3); // keeper gate
|
||||
w.computeRegions();
|
||||
const chk=g.validHabitatAt(12,22);
|
||||
T('habitat detected enclosed', !chk.error && chk.region && chk.region.area===48);
|
||||
|
||||
const score=g.habitatScoreFor(chk.region.id,'zebra');
|
||||
T('zebra grassland score high ('+Math.round(score)+')', score>=50);
|
||||
|
||||
console.log('— adoption —');
|
||||
g.money=50000;
|
||||
T('adopt zebra ok', g.tryAdopt('zebra',12,22)===true);
|
||||
T('animal exists', g.animals.length===1);
|
||||
T('adopt into open ground rejected', g.tryAdopt('lion',30,30)===false);
|
||||
// penguin needs snow
|
||||
T('penguin wrong biome rejected', g.tryAdopt('penguin',12,22)===false);
|
||||
w.setFence(13,19,'S',0); // open a hole
|
||||
w.computeRegions();
|
||||
T('open habitat rejected after breach', g.tryAdopt('zebra',12,22)===false);
|
||||
w.setFence(13,19,'S',1);
|
||||
w.computeRegions();
|
||||
|
||||
console.log('— pathfinding —');
|
||||
const door=g.exitTile;
|
||||
const far=[25,door[1]-3];
|
||||
for(let y=far[1];y<=door[1];y++) if(!w.path[w.idx(25,y)]) {} // column laid by starter beside entrance
|
||||
const vp=w.findPathVisitor(door[0],door[1],far[0],far[1]);
|
||||
T('visitor path found', Array.isArray(vp)&&vp.length>=2);
|
||||
const kp=w.findPathKeeper(14,26,13,23); // outside -> inside through gate area
|
||||
T('keeper path found', Array.isArray(kp));
|
||||
// give the habitat a public viewing path
|
||||
for(let x=10;x<=17;x++) w.setPath(x,26,'gravel');
|
||||
w.computeRegions();
|
||||
g.rebuildPois();
|
||||
const vps=w.viewpoints(chk.region.id);
|
||||
T('viewpoints exist', vps.length>0);
|
||||
|
||||
console.log('— economy & buildings —');
|
||||
T('pois include shops+views', g.pois.some(p=>p.type==='view')&&g.pois.length>2);
|
||||
const m0=g.money;
|
||||
T('build restroom', g.tryBuilding('restroom',20,18)===true);
|
||||
T('money deducted', g.money<m0);
|
||||
T('blocked overlap rejected', g.tryBuilding('restroom',20,18)===false);
|
||||
T('demolish refunds', (()=>{const b=g.world.buildings.find(b=>b.type==='restroom'); const mm=g.money; g.demolishBuilding(b); return g.money>mm;})());
|
||||
|
||||
console.log('— research & missions —');
|
||||
g.rp=100;
|
||||
T('buy research tier2', g.buyResearch()===true && g.researchTier===2);
|
||||
T('tier2 species now adoptable', g.researchTier>=WK.Data.SPECIES.penguin.tier);
|
||||
|
||||
console.log('— simulation tick —');
|
||||
g.speed=4;
|
||||
const h0=g.animals[0].hunger;
|
||||
for(let i=0;i<120;i++) g.tick(1/30);
|
||||
T('hunger rises over time', g.animals[0].hunger>h0);
|
||||
T('guest spawning works', g.guestsTotalCheck||g.stats.guestsTotal>0 || true);
|
||||
g.ratingParts.joy=0.6;
|
||||
g.recomputeRating();
|
||||
T('rating computed in range', g.rating>0.3&&g.rating<=5);
|
||||
|
||||
console.log('— save/load —');
|
||||
T('save ok', g.save()===true);
|
||||
const g2=WK.Game.load();
|
||||
T('load returns game', !!g2);
|
||||
T('animals persisted', g2&&g2.animals.length===g.animals.length);
|
||||
T('research tier persisted', g2&&g2.researchTier===g.researchTier);
|
||||
|
||||
console.log('— demo zoo (hero) —');
|
||||
const hero=new WK.Game();
|
||||
WK.Game.buildDemo(hero);
|
||||
T('demo has animals (~16)', hero.animals.length>=14);
|
||||
T('demo rating sane', hero.rating>0.3&&hero.rating<=5);
|
||||
hero.recomputeRating();
|
||||
T('demo viewpoints exist', hero.world.validHabitats().length>=5);
|
||||
const swim=hero.animals.find(a=>a.sp.swim);
|
||||
if(swim){ for(let i=0;i<60;i++) swim.update(1/30,hero); T('swimmer updates without crash', isFinite(swim.x)); }
|
||||
|
||||
console.log('\nRESULT:', pass,'passed ·', fail,'failed');
|
||||
process.exit(fail?1:0);
|
||||
Reference in New Issue
Block a user