Files
wildlife-kingdom/js/ui.js
T
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

415 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
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();
};
};
})();