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:
2026-08-23 07:01:21 +00:00
commit c06786518c
22 changed files with 5130 additions and 0 deletions
+414
View File
@@ -0,0 +1,414 @@
/* ============================================================
Wildlife Kingdom — js/ui.js
HUD · toolbar & palettes · side panel · selection cards ·
toasts · modals. Original cream/rounded visual identity.
============================================================ */
(function(){
'use strict';
const WK = window.WK;
const D = WK.Data;
const $=id=>document.getElementById(id);
const UI = (WK.UI = {});
UI.game=null; UI.cam=null; UI.renderer=null;
UI.tool=null; UI.bulldoze=false;
UI._last={}; UI._selTarget=null; UI._sideTab='objectives';
UI._sideT=0; UI._hudT=0;
UI.init=function(game,cam,renderer){
if(UI._inited){
// re-attach to a fresh game without duplicating DOM work
UI.game=game; UI.cam=cam; UI.renderer=renderer;
game.ui=UI;
UI._last={}; UI._selTarget=null;
UI.cancelTool();
UI.hideSelection();
UI.renderSide();
return;
}
UI._inited=true;
UI.game=game; UI.cam=cam; UI.renderer=renderer;
game.ui=UI;
// inject resource icons
document.querySelectorAll('.stat .ic[data-ic]').forEach(el=>{
const s=WK.Sprites.getIcon('res:'+el.dataset.ic,26);
s.cv.style.width='100%'; s.cv.style.height='100%';
el.appendChild(s.cv);
});
$('btn-sound').textContent = WK.AudioSys.enabled?'🔊':'🔇';
$('btn-settings').textContent='⚙';
const spdIcons={ 'spd-pause':'⏸', 'spd-play':'▶', 'spd-fast':'⏩', 'spd-ultra':'⚡' };
for(const id in spdIcons) $(id).textContent=spdIcons[id];
$('btn-bulldoze').appendChild(WK.Sprites.getIcon('res:dozer',30).cv);
UI.buildToolbar();
UI.bind();
UI.selectCat('paths'); // sensible default tab shown when opened
};
/* ---------------- toolbar ---------------- */
const CAT_ICON={ ground:'ground:grass', paths:'path:gravel', fences:'fence:wood',
nature:'nature:tree', animals:'animal:zebra', facilities:'facility:restroom' };
const FENCE_ITEMS=[['wood','Wood Fence'],['hedge','Hedge'],['gate','Keeper Gate']];
UI.buildToolbar=function(){
const cats=$('tb-categories');
cats.innerHTML='';
for(const c of D.TOOLCATS){
const b=document.createElement('button');
b.className='tb-cat'+(c.id===UI._cat?' active':'');
b.appendChild(WK.Sprites.getIcon(CAT_ICON[c.id],34).cv);
const l=document.createElement('span'); l.className='lbl'; l.textContent=c.label;
b.appendChild(l);
b.onclick=()=>{ WK.AudioSys.sfx('click'); UI.selectCat(c.id); };
b.dataset.cat=c.id;
cats.appendChild(b);
}
};
UI.paletteItems=function(cat){
switch(cat){
case 'ground': return Object.keys(D.GROUND).map(id=>({id,name:D.GROUND[id].name,cost:D.GROUND[id].cost,tier:D.GROUND[id].tier}));
case 'paths': return Object.keys(D.PATH).map(id=>({id,name:D.PATH[id].name,cost:D.PATH[id].cost,tier:D.PATH[id].tier}));
case 'fences': return FENCE_ITEMS.map(([id,name])=>({id,name,cost:id==='wood'?12:id==='hedge'?18:40,tier:id==='hedge'?2:1}));
case 'nature': return Object.keys(D.NATURE).map(id=>({id,name:D.NATURE[id].name,cost:D.NATURE[id].cost,tier:D.NATURE[id].tier}));
case 'animals': return Object.keys(D.SPECIES).sort((a,b)=>D.SPECIES[a].cost-D.SPECIES[b].cost)
.map(id=>({id,name:D.SPECIES[id].name,cost:D.SPECIES[id].cost,tier:D.SPECIES[id].tier}));
case 'facilities': return Object.keys(D.BUILDING).sort((a,b)=>D.BUILDING[a].cost-D.BUILDING[b].cost)
.map(id=>({id,name:D.BUILDING[id].name,cost:D.BUILDING[id].cost,tier:D.BUILDING[id].tier}));
}
return [];
};
UI.iconKind=function(cat,id){
switch(cat){
case 'ground': return 'ground:'+id;
case 'paths': return 'path:'+id;
case 'fences': return 'fence:'+id;
case 'nature': return 'nature:'+id;
case 'animals': return 'animal:'+id;
case 'facilities': return 'facility:'+id;
}
};
UI.selectCat=function(cat){
UI._cat=cat;
document.querySelectorAll('.tb-cat').forEach(b=>b.classList.toggle('active',b.dataset.cat===cat));
const pal=$('tb-palette');
pal.innerHTML='';
for(const it of UI.paletteItems(cat)){
const locked=(it.tier||1)>UI.game.researchTier;
const d=document.createElement('button');
d.className='pal-item'+(locked?' locked':'')+((UI.tool&&UI.tool.item===it.id&&UI.tool.cat===cat)?' active':'');
const cv=WK.Sprites.getIcon(UI.iconKind(cat,it.id),54);
d.appendChild(cv.cv);
const nm=document.createElement('span'); nm.className='nm'; nm.textContent=it.name;
const pr=document.createElement('span'); pr.className='pr'; pr.textContent=locked?('Tier '+it.tier):WK.fmtMoney(it.cost);
d.appendChild(nm); d.appendChild(pr);
d.title=locked?'Unlock via Research':(D.BUILDING[it.id]&&D.BUILDING[it.id].desc||D.SPECIES[it.id]&&(D.SPECIES[it.id].minArea+'+ tiles · likes '+Object.keys(D.SPECIES[it.id].biome).join('/'))||'');
d.onclick=()=>{
WK.AudioSys.sfx('click');
if(locked){ UI.toast('Locked! Unlock Tier '+it.tier+' in the Research panel.','bad'); return; }
UI.bulldoze=false; $('btn-bulldoze').classList.remove('active');
UI.selectTool({cat,item:it.id});
pal.querySelectorAll('.pal-item').forEach(x=>x.classList.remove('active'));
d.classList.add('active');
};
pal.appendChild(d);
}
pal.classList.add('hidden');
if(UI.tool||UI.bulldoze) pal.classList.remove('hidden');
};
UI.selectTool=function(t){
UI.tool=t; UI.bulldoze=false;
$('btn-bulldoze').classList.toggle('active',false);
const pal=$('tb-palette'), info=$('tool-info');
if(t){
pal.classList.remove('hidden');
info.classList.remove('hidden');
const def= t.cat==='animals'?D.SPECIES[t.item] : t.cat==='facilities'?D.BUILDING[t.item]
: t.cat==='fences'?{name:t.item[0].toUpperCase()+t.item.slice(1)+' Fence'} : null;
$('tool-name').textContent=def?def.name:t.item;
const cost=def?(typeof def.cost==='number'?def.cost:null):null;
$('tool-cost').textContent=cost!=null?WK.fmtMoney(cost)+(t.cat==='animals'?' · needs suitable habitat':''):'paint';
document.querySelectorAll('#game').forEach(c=>c.classList.add('tooling'));
document.querySelectorAll('.pal-item').forEach(x=>x.classList.toggle('active',x.querySelector('.nm') && x.querySelector('.nm').textContent===($('tool-name').textContent)));
// re-mark active within current cat
UI.selectCatRefreshActive();
}else{
pal.classList.add('hidden');
info.classList.add('hidden');
document.getElementById('game').classList.remove('tooling');
document.querySelectorAll('.pal-item').forEach(x=>x.classList.remove('active'));
}
};
UI.selectCatRefreshActive=function(){
document.querySelectorAll('.pal-item').forEach(x=>x.classList.remove('active'));
};
UI.toggleBulldoze=function(){
UI.bulldoze=!UI.bulldoze;
if(UI.bulldoze){ UI.tool=null; $('tb-palette').classList.add('hidden'); $('tool-info').classList.add('hidden'); }
$('btn-bulldoze').classList.toggle('active',UI.bulldoze);
document.getElementById('game').classList.toggle('tooling',UI.bulldoze);
};
UI.cancelTool=function(){ UI.selectTool(null); UI.bulldoze=false; $('btn-bulldoze').classList.remove('active'); document.getElementById('game').classList.remove('tooling'); };
/* ---------------- HUD ---------------- */
UI.setSpeed=function(s){
UI.game.speed=s;
['spd-pause','spd-play','spd-fast','spd-ultra'].forEach(id=>$(id).classList.remove('active'));
$({'0':'spd-pause','1':'spd-play','2':'spd-fast','4':'spd-ultra'}[s]).classList.add('active');
};
UI.togglePause=function(){ UI.setSpeed(UI.game.speed===0?(UI._lastSpeed||1):(UI._lastSpeed=UI.game.speed,0)); };
UI.updateHUD=function(){
const g=UI.game, L=UI._last;
const set=(id,v)=>{ if(L[id]!==v){ L[id]=v; $(id).querySelector('b').textContent=v; } };
set('st-money',WK.fmtMoney(g.money));
set('st-guests',String(g.guests.length));
const joy=g.guests.length?Math.round(g.guests.reduce((s,x)=>s+x.joy,0)/g.guests.length):null;
set('st-joy',(joy!=null?joy+'%':'—'));
set('st-research',Math.floor(g.rp)+' RP');
set('st-stars',g.rating.toFixed(1)+'★');
$('st-day').textContent='Day '+g.day;
$('st-clock').textContent=WK.fmtClock(g.minutes);
// side panel throttle
UI._sideT++;
if(UI._sideT>30){ UI._sideT=0; UI.renderSide(); }
// selection follow
UI.trackSelection();
};
UI.flashStat=function(id){
const el=$(id==='money'?'st-money':id);
el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash');
};
/* ---------------- side panel ---------------- */
UI.renderSide=function(){
const g=UI.game;
const c=$('sp-content');
document.querySelectorAll('.sp-tab').forEach(b=>b.classList.toggle('active',b.dataset.tab===UI._sideTab));
if(UI._sideTab==='objectives'){
let h='';
D.MISSIONS.forEach((m,i)=>{
const done=i<g.missionIdx, cur=i===g.missionIdx;
if(i>g.missionIdx&&!cur){}
if(done||cur){
const rw= typeof m.reward==='number'? WK.fmtMoney(m.reward)
: (m.reward.cash?WK.fmtMoney(m.reward.cash):'')+(m.reward.rp?(' +'+m.reward.rp+' RP'):'');
h+=`<div class="mission ${done?'done':''} ${cur?'current':''}">
<div class="m-title"><span>${m.label}</span></div>
${m.hint&&!done?`<div class="m-hint">${m.hint}</div>`:''}
${!done?`<div class="m-reward">Reward: ${rw}</div>`:''}
</div>`;
}
});
if(g.missionIdx>=D.MISSIONS.length) h+='<div class="mission done"><div class="m-title"><span>All missions complete — Wildlife Legend! 🏆</span></div></div>';
c.innerHTML=h;
}else if(UI._sideTab==='research'){
const next=g.researchCost();
let h=`<div class="res-row"><span>Research points<small>Earn passively · Research Center boosts</small></span><b>${Math.floor(g.rp)} RP</b></div>`;
for(const t of D.RESEARCH_TIERS){
const owned=g.researchTier>=t.tier;
h+=`<div class="res-row">
<span>${t.label}<small>${owned?'Unlocked ✔':t.cost+' RP'}</small></span>
${owned?'':`<button class="btn" data-unlock="${t.tier}" ${(next!==t.cost)?'disabled':''}>Unlock</button>`}
</div>`;
}
h+='<p style="font-size:11px;color:var(--ink-soft);margin:6px 2px;">New terrain, buildings & rare animals arrive with each tier.</p>';
c.innerHTML=h;
c.querySelectorAll('[data-unlock]').forEach(b=>{
b.onclick=()=>{ g.buyResearch(); UI.renderSide(); };
});
}else{
const P=g.ratingParts||{};
const pc=k=>Math.round((P[k]||0)*100);
let occ={};
for(const a of g.animals) occ[a.species]=(occ[a.species]||0)+1;
let occStr=Object.keys(occ).map(s=>`${D.SPECIES[s].name} ×${occ[s]}`).join(', ')||'None yet';
c.innerHTML=`
<div class="zoo-stat"><span>Rating</span><b>${g.rating.toFixed(1)}★</b></div>
<div class="zoo-stat"><span>Animal welfare</span><b>${pc('welfare')}%</b></div>
<div class="zoo-stat"><span>Guest joy</span><b>${pc('joy')}%</b></div>
<div class="zoo-stat"><span>Species variety</span><b>${pc('variety')}%</b></div>
<div class="zoo-stat"><span>Facilities</span><b>${pc('facilities')}%</b></div>
<div class="zoo-stat"><span>Decor</span><b>${pc('decor')}%</b></div>
<div class="zoo-stat"><span>Species kept</span><b>${g.speciesCount()}/15</b></div>
<div class="zoo-stat"><span>Animals</span><b>${g.animals.length}</b></div>
<div class="zoo-stat"><span>Staff</span><b>${g.staff.length}</b></div>
<div class="zoo-stat"><span>Guests today</span><b>${g.stats.guestsToday}</b></div>
<div class="zoo-stat"><span>Total revenue</span><b>${WK.fmtMoney(g.stats.revenueTotal)}</b></div>
<div style="margin-top:8px;font-size:12px;line-height:1.4;color:var(--ink-soft)"><b>Inhabitants:</b> ${occStr}</div>`;
}
};
UI.switchTab=function(tab){ UI._sideTab=tab; UI.renderSide(); };
/* ---------------- selection popup ---------------- */
UI.showSelection=function(target){
UI._selTarget=target;
const el=$('sel-popup');
const g=UI.game;
let html='';
if(target.kind==='animal'){
const sp=D.SPECIES[target.species];
const score=Math.round(g.habitatScore(target));
html=`
<h3>${WK.Sprites.iconImg('animal:'+target.species,40)}<span>${target.name}</span></h3>
<div class="row"><span>${sp.name} · appeal ${'★'.repeat(Math.min(5,Math.ceil(sp.appeal/1.6)))}</span></div>
<div class="row"><span>Happiness</span></div>
<div class="bar"><i style="width:${target.happiness|0}%;background:${barColor(target.happiness)}"></i></div>
<div class="row"><span>Hunger</span></div>
<div class="bar"><i style="width:${target.hunger|0}%;background:${barColor(100-target.hunger)}"></i></div>
<div class="row"><span>Habitat match</span><b>${score}%</b></div>
${target.sick?'<div class="row"><b style="color:var(--red)">Sick — needs a vet!</b></div>':''}
<div class="btns"><button class="btn danger" data-sell="${target.id}">Rehome +${WK.fmtMoney(sp.cost*0.45)}</button></div>`;
}else if(target.kind==='guest'){
html=`
<h3><span>${target.kid?'Young guest':'Guest'}</span></h3>
<div class="row"><span>Joy</span></div>
<div class="bar"><i style="width:${target.joy|0}%;background:${barColor(target.joy)}"></i></div>
${target.wantsRestroom?'<div class="row"><b style="color:var(--orange)">Looking for a restroom…</b></div>':''}`;
}else if(target.kind==='staff'){
html=`
<h3><span>${target.role==='keeper'?'Keeper':'Vet'}</span></h3>
<div class="row"><span>${target.workT>0?'Working…':target.job?'On the way!':'Patrolling the zoo'}</span></div>`;
}else if(target.w&&target.h){ // building
const def=D.BUILDING[target.type];
html=`
<h3>${WK.Sprites.iconImg('facility:'+target.type,40)}<span>${def.name}</span></h3>
${def.desc?`<div class="row"><span>${def.desc}</span></div>`:''}
${def.income?`<div class="row"><span>Avg spend per guest</span><b>${WK.fmtMoney(def.income)}</b></div>`:''}
${def.rp?`<div class="row"><span>Generates</span><b>+${def.rp} RP/min</b></div>`:''}
<div class="btns"><button class="btn danger" data-demo="${target.id}">Demolish +${WK.fmtMoney(def.cost*0.5)}</button></div>`;
}else if(target.kind==='habitat'){
const reg=g.world.regions.find(r=>r.id===target.regionId);
const anims=g.animals.filter(a=>a.regionId===target.regionId);
const occ={};
for(const a of anims) occ[a.name]=D.SPECIES[a.species].name;
const occStr=anims.map(a=>a.name+' ('+D.SPECIES[a.species].name+')').join('<br>')||'<i>Empty habitat</i>';
html=`
<h3><span>Habitat</span></h3>
<div class="row"><span>Area</span><b>${reg.area} tiles</b></div>
<div class="row"><span>Inhabitants</span></div>
<div style="font-size:12px;margin:2px 0 4px">${occStr}</div>`;
}else return;
el.innerHTML=html;
el.classList.remove('hidden');
const sellBtn=el.querySelector('[data-sell]');
if(sellBtn) sellBtn.onclick=()=>{
const a=g.animals.find(x=>x.id==sellBtn.dataset.sell);
if(a) g.sellAnimal(a);
};
const demoBtn=el.querySelector('[data-demo]');
if(demoBtn) demoBtn.onclick=()=>{
const b=g.world.buildings.find(x=>x.id==demoBtn.dataset.demo);
if(b) g.demolishBuilding(b);
};
UI.positionSelection();
};
function barColor(v){ return v>60?'#63b34c':v>30?'#ffc93c':'#e85d5d'; }
UI.positionSelection=function(){
const el=$('sel-popup'), t=UI._selTarget;
if(!el||!t||el.classList.contains('hidden'))return;
const p=UI.cam.project(t.x,t.y);
let x=p.x+24,y=p.y-70;
x=WK.clamp(x,8,UI.cam.w-el.offsetWidth-8);
y=WK.clamp(y,60,UI.cam.h-el.offsetHeight-90);
el.style.left=x+'px'; el.style.top=y+'px';
};
UI.trackSelection=function(){
const t=UI._selTarget;
if(!t)return;
if(t.kind==='animal'&&!UI.game.animals.includes(t)){ UI.hideSelection(); return; }
if(t.kind==='guest'&&!UI.game.guests.includes(t)){ UI.hideSelection(); return; }
if(t.kind==='staff'&&!UI.game.staff.includes(t)){ UI.hideSelection(); return; }
if(t.w&&!UI.game.world.buildings.includes(t)){ UI.hideSelection(); return; }
UI.positionSelection();
};
UI.hideSelection=function(){
UI._selTarget=null;
$('sel-popup').classList.add('hidden');
};
/* ---------------- toasts ---------------- */
UI.toast=function(msg,type){
const box=$('toasts');
while(box.children.length>=4) box.firstChild.remove();
const t=document.createElement('div');
t.className='toast '+(type||'');
t.textContent=msg;
box.appendChild(t);
setTimeout(()=>t.classList.add('fade'),3200);
setTimeout(()=>t.remove(),3700);
};
/* ---------------- modals ---------------- */
UI.modal=function(inner,opts){
opts=opts||{};
const root=$('modal-root');
root.innerHTML=`<div class="modal-backdrop"><div class="modal panel">${inner}</div></div>`;
root.firstChild.addEventListener('pointerdown',e=>{ if(e.target===root.firstChild&&!opts.sticky) UI.closeModal(); });
return root.querySelector('.modal');
};
UI.closeModal=function(){ $('modal-root').innerHTML=''; };
UI.settingsModal=function(){
const g=UI.game;
const m=UI.modal(`
<h2>Settings</h2>
<div class="set-row"><span>Sound effects</span><button class="btn" id="set-sfx">${WK.AudioSys.enabled?'On':'Off'}</button></div>
<div class="set-row"><span>Music</span><button class="btn" id="set-music">${WK.AudioSys.musicOn?'On':'Off'}</button></div>
<div class="set-row"><span>Save game</span><button class="btn" id="set-save">Save now</button></div>
<div class="set-row"><span>New zoo</span><button class="btn danger" id="set-new">Start over</button></div>
<div class="modal-btns"><button class="btn primary" id="set-close">Done</button></div>`);
m.querySelector('#set-sfx').onclick=e=>{ WK.AudioSys.toggleSound(); e.target.textContent=WK.AudioSys.enabled?'On':'Off'; $('btn-sound').textContent=WK.AudioSys.enabled?'🔊':'🔇'; };
m.querySelector('#set-music').onclick=e=>{ WK.AudioSys.ensure(); WK.AudioSys.toggleMusic(); e.target.textContent=WK.AudioSys.musicOn?'On':'Off'; };
m.querySelector('#set-save').onclick=()=>{ g.save(); UI.toast('Zoo saved!','good'); };
m.querySelector('#set-new').onclick=()=>{
UI.modal(`<h2>Start a new zoo?</h2><p>Your current zoo and progress will be lost.</p>
<div class="modal-btns"><button class="btn" id="nc">Cancel</button><button class="btn danger" id="ny">Yes, start over</button></div>`,{sticky:true});
$('nc').onclick=()=>UI.settingsModal();
$('ny').onclick=()=>{ Game_restart(); };
};
m.querySelector('#set-close').onclick=()=>UI.closeModal();
};
function Game_restart(){ /* replaced by main.js */ }
/* how-to */
UI.howtoModal=function(fromMenu){
UI.modal(`
<h2>How to Play</h2>
<ul>
<li><b>Pan</b> by dragging (right-click / empty hand / one finger), <b>zoom</b> with the wheel or pinch.</li>
<li><b>Paths first!</b> Guests only walk on paths. Connect everything to your entrance.</li>
<li><b>Habitats:</b> fence an area completely, paint matching terrain inside, then adopt animals into it. Each species has favorite biomes and needs space.</li>
<li><b>Keepers feed animals</b> — hire one with a Keeper Hut. Add a Keeper Gate so they can walk in.</li>
<li><b>Guests need</b> restrooms, food stands, benches and beautiful decor. Happy guests pay more entry and spend more.</li>
<li><b>Research</b> unlocks rare species and fancy buildings. A Research Center speeds it up.</li>
<li>Watch the <b>Missions</b> panel — rewards guide your way to a 5★ zoo!</li>
</ul>
<p style="font-size:12px;color:var(--ink-soft)">Hotkeys: <span class="kbd">Space</span> pause · <span class="kbd">13</span> speed · <span class="kbd">X</span> bulldoze · <span class="kbd">Esc</span> cancel tool</p>
<div class="modal-btns"><button class="btn primary" id="how-ok">Let's build!</button></div>`);
$('how-ok').onclick=()=>{ UI.closeModal(); if(fromMenu&&UI._onHowClose) UI._onHowClose(); };
};
UI.confirmNewFromMenu=function(){ /* main.js overrides menu flows */ };
/* ---------------- bindings ---------------- */
UI.bind=function(){
$('spd-pause').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(0);};
$('spd-play').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(1);};
$('spd-fast').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(2);};
$('spd-ultra').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(4);};
$('btn-sound').onclick=()=>{ WK.AudioSys.ensure(); const on=WK.AudioSys.toggleSound(); $('btn-sound').textContent=on?'🔊':'🔇'; };
$('btn-settings').onclick=()=>{ WK.AudioSys.sfx('click'); UI.settingsModal(); };
$('btn-bulldoze').onclick=()=>{ WK.AudioSys.sfx('click'); UI.toggleBulldoze(); };
document.querySelectorAll('.sp-tab').forEach(b=>b.onclick=()=>{ UI.switchTab(b.dataset.tab); });
$('sp-toggle').onclick=()=>{
const sp=$('side-panel');
sp.classList.toggle('hidden');
};
window.Game_restart = function(){
WK.Main.startNew();
UI.closeModal();
};
};
})();