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:
+139
@@ -0,0 +1,139 @@
|
||||
/* ============================================================
|
||||
Wildlife Kingdom — js/audio.js
|
||||
Procedural WebAudio: gentle marimba soundtrack + synthesized
|
||||
SFX. No audio files, fully original. Global: WK.AudioSys
|
||||
============================================================ */
|
||||
(function(){
|
||||
'use strict';
|
||||
const WK = window.WK;
|
||||
|
||||
const A = {
|
||||
ctx:null, master:null, musicBus:null, sfxBus:null,
|
||||
enabled:true, musicOn:true, scene:'menu',
|
||||
timer:null, nextNote:0, step:0,
|
||||
};
|
||||
|
||||
function ensure(){
|
||||
if(A.ctx){ if(A.ctx.state==='suspended') A.ctx.resume(); return true; }
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if(!AC) return false;
|
||||
A.ctx = new AC();
|
||||
A.master = A.ctx.createGain(); A.master.gain.value=0.9; A.master.connect(A.ctx.destination);
|
||||
A.musicBus = A.ctx.createGain(); A.musicBus.gain.value = 0.5; A.musicBus.connect(A.master);
|
||||
A.sfxBus = A.ctx.createGain(); A.sfxBus.gain.value = 0.85; A.sfxBus.connect(A.master);
|
||||
loadPrefs();
|
||||
startScheduler();
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadPrefs(){
|
||||
try{
|
||||
const p = JSON.parse(localStorage.getItem('wk.audio')||'{}');
|
||||
if(p.enabled===false) A.enabled=false;
|
||||
if(p.musicOn===false) A.musicOn=false;
|
||||
}catch(e){}
|
||||
}
|
||||
function savePrefs(){
|
||||
try{ localStorage.setItem('wk.audio', JSON.stringify({enabled:A.enabled,musicOn:A.musicOn})); }catch(e){}
|
||||
}
|
||||
|
||||
/* ---------- tiny synth helpers ---------- */
|
||||
function tone(opt){
|
||||
// {f, f2, t='sine', dur, vol, bus, at, attack, release}
|
||||
const c=A.ctx; if(!c) return;
|
||||
const at = opt.at || c.currentTime;
|
||||
const o=c.createOscillator(), g=c.createGain();
|
||||
o.type=opt.t||'sine';
|
||||
o.frequency.setValueAtTime(opt.f,at);
|
||||
if(opt.f2) o.frequency.exponentialRampToValueAtTime(Math.max(20,opt.f2), at+(opt.dur||0.2));
|
||||
const v=(opt.vol==null?0.2:opt.vol);
|
||||
g.gain.setValueAtTime(0.0001,at);
|
||||
g.gain.linearRampToValueAtTime(v, at+(opt.attack||0.008));
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, at+(opt.dur||0.2));
|
||||
o.connect(g); g.connect(opt.bus||A.sfxBus);
|
||||
o.start(at); o.stop(at+(opt.dur||0.2)+0.05);
|
||||
}
|
||||
function noiseHit(opt){
|
||||
// {dur,vol,f,q,bus,at}
|
||||
const c=A.ctx; if(!c) return;
|
||||
const at=opt.at||c.currentTime;
|
||||
const len=Math.max(1,Math.floor(c.sampleRate*(opt.dur||0.15)));
|
||||
const buf=c.createBuffer(1,len,c.sampleRate);
|
||||
const d=buf.getChannelData(0);
|
||||
for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*(1-i/len);
|
||||
const src=c.createBufferSource(); src.buffer=buf;
|
||||
const flt=c.createBiquadFilter(); flt.type='bandpass'; flt.frequency.value=opt.f||800; flt.Q.value=opt.q||1;
|
||||
const g=c.createGain(); g.gain.value=opt.vol==null?0.25:opt.vol;
|
||||
src.connect(flt); flt.connect(g); g.connect(opt.bus||A.sfxBus);
|
||||
src.start(at);
|
||||
}
|
||||
|
||||
/* ---------- SFX vocabulary (all synthesized) ---------- */
|
||||
const SFX = {
|
||||
click(){ tone({f:660,f2:880,t:'triangle',dur:0.07,vol:0.12}); },
|
||||
place(){ noiseHit({dur:0.09,vol:0.3,f:420,q:0.8}); tone({f:180,f2:120,t:'sine',dur:0.12,vol:0.22}); },
|
||||
dig(){ noiseHit({dur:0.14,vol:0.22,f:300,q:0.7}); },
|
||||
coin(){ tone({f:988,t:'square',dur:0.06,vol:0.08}); tone({f:1319,t:'square',dur:0.16,vol:0.09,at:A.ctx&&A.ctx.currentTime+0.06}); },
|
||||
error(){ tone({f:220,f2:160,t:'sawtooth',dur:0.18,vol:0.12}); },
|
||||
pop(){ tone({f:520,f2:1040,t:'sine',dur:0.09,vol:0.15}); },
|
||||
cheer(){ [523,659,784,1047].forEach((f,i)=>tone({f,t:'triangle',dur:0.18,vol:0.1,at:A.ctx&&A.ctx.currentTime+i*0.07})); },
|
||||
roar(){ tone({f:150,f2:70,t:'sawtooth',dur:0.5,vol:0.14}); noiseHit({dur:0.4,vol:0.1,f:200,q:0.6}); },
|
||||
chirp(){ tone({f:1400,f2:2100,t:'sine',dur:0.12,vol:0.1}); tone({f:1800,f2:2400,t:'sine',dur:0.1,vol:0.08,at:A.ctx&&A.ctx.currentTime+0.1}); },
|
||||
splash(){ noiseHit({dur:0.3,vol:0.28,f:900,q:0.4}); },
|
||||
trash(){ noiseHit({dur:0.12,vol:0.25,f:250,q:0.9}); },
|
||||
unlock(){ [392,523,659,784].forEach((f,i)=>tone({f,t:'sine',dur:0.25,vol:0.11,at:A.ctx&&A.ctx.currentTime+i*0.09})); },
|
||||
};
|
||||
WK.AudioSys = {
|
||||
ensure,
|
||||
get enabled(){return A.enabled;},
|
||||
get musicOn(){return A.musicOn;},
|
||||
sfx(name){
|
||||
if(!A.enabled) return;
|
||||
if(!ensure()) return;
|
||||
const fn=SFX[name]; if(fn) fn();
|
||||
},
|
||||
setScene(s){ A.scene=s; },
|
||||
toggleSound(){ A.enabled=!A.enabled; savePrefs(); if(A.enabled) ensure(); WK.UI&&WK.UI.refreshSoundBtn&&WK.UI.refreshSoundBtn(); return A.enabled; },
|
||||
toggleMusic(){ A.musicOn=!A.musicOn; savePrefs(); return A.musicOn; },
|
||||
};
|
||||
|
||||
/* ---------- music scheduler ----------
|
||||
Relaxed pentatonic marimba + warm pad, randomized gentle walk.
|
||||
Menu: slower & dreamier. Game: slightly brighter tempo. */
|
||||
const PENTA=[0,2,4,7,9];
|
||||
function nfreq(semi, base){ return base*Math.pow(2,semi/12); }
|
||||
|
||||
function startScheduler(){
|
||||
if(A.timer) return;
|
||||
A.timer=setInterval(()=>{
|
||||
if(!A.ctx || !A.enabled || !A.musicOn) return;
|
||||
const ahead = 0.45;
|
||||
const menu = A.scene==='menu';
|
||||
const stepDur = menu? 60/68/2 : 60/76/2; // eighth notes
|
||||
const root = menu? 220 : 246.94; // A3 / B3
|
||||
while(A.nextNote < A.ctx.currentTime + ahead){
|
||||
const t=Math.max(A.nextNote, A.ctx.currentTime+0.02);
|
||||
const bar=Math.floor(A.step/8), beat=A.step%8;
|
||||
// pad chord every bar
|
||||
if(beat===0){
|
||||
const deg=PENTA[(bar*2)%5];
|
||||
[0,7,12].forEach(iv=>tone({f:nfreq(deg+iv,root/2)*2,t:'sine',dur:stepDur*8,vol:0.035,bus:A.musicBus,at:t,attack:0.6}));
|
||||
tone({f:nfreq(PENTA[bar%5],root)/2,t:'triangle',dur:stepDur*3.6,vol:0.10,bus:A.musicBus,at:t,attack:0.01});
|
||||
}
|
||||
// pluck melody: sparse random walk on pentatonic
|
||||
const density = menu?0.42:0.55;
|
||||
if(WK.hash2(A.step,bar,7) < density){
|
||||
const idx = Math.floor(WK.hash2(A.step*13,bar,3)*PENTA.length);
|
||||
const oct = (WK.hash2(A.step,bar*3,11)<0.3)?12:0;
|
||||
tone({f:nfreq(PENTA[idx]+oct,root),t:'triangle',dur:stepDur*2.4,vol:0.085,bus:A.musicBus,at:t});
|
||||
if(WK.hash2(A.step,bar,23)<0.25)
|
||||
tone({f:nfreq(PENTA[idx]+oct+12,root)*2,t:'sine',dur:stepDur*1.6,vol:0.04,bus:A.musicBus,at:t+stepDur*0.5});
|
||||
}
|
||||
// soft shaker
|
||||
if(beat%2===1 && !menu) noiseHit({dur:0.03,vol:0.018,f:6000,q:1.2,bus:A.musicBus,at:t});
|
||||
A.nextNote = t + stepDur;
|
||||
A.step++;
|
||||
}
|
||||
},120);
|
||||
}
|
||||
})();
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/* ============================================================
|
||||
Wildlife Kingdom — js/data.js
|
||||
All game content: terrain, paths, fences, nature, 15 species,
|
||||
15 buildings, research tiers, missions, name pools, palettes.
|
||||
============================================================ */
|
||||
(function(){
|
||||
'use strict';
|
||||
const WK = window.WK;
|
||||
const D = (WK.Data = {});
|
||||
|
||||
D.GRID = { COLS:46, ROWS:46 };
|
||||
D.START_MONEY = 25000;
|
||||
D.SPEEDS = [0,1,2,4];
|
||||
D.CAPS = { guests:140, keepersMax:6 };
|
||||
|
||||
/* ---------------- ground / terrain ---------------- */
|
||||
/* walk: humans passable · water: 0 none,1 shallow,2 deep */
|
||||
D.GROUND = {
|
||||
grass:{ name:'Grass', tier:1, cost:6, colors:['#7cc653','#71ba48'], walk:1, water:0 },
|
||||
dirt: { name:'Dirt', tier:1, cost:6, colors:['#c99b62','#bd8f57'], walk:1, water:0 },
|
||||
sand: { name:'Sand', tier:1, cost:8, colors:['#eed9a0','#e5cd8d'], walk:1, water:0 },
|
||||
stone:{ name:'Stone', tier:1, cost:8, colors:['#bfc3bf','#b2b6b2'], walk:1, water:0 },
|
||||
water:{ name:'Shallow Water', tier:1, cost:22, colors:['#6ec6ea','#5cb8e0'], walk:0, water:1 },
|
||||
deep: { name:'Deep Water', tier:2, cost:30, colors:['#3796cf','#2f89c0'], walk:0, water:2 },
|
||||
snow: { name:'Snow', tier:2, cost:14, colors:['#eef3f6','#e0e9f0'], walk:1, water:0 },
|
||||
ice: { name:'Ice', tier:2, cost:18, colors:['#cfeaf6','#bcdef0'], walk:1, water:0 },
|
||||
};
|
||||
|
||||
/* ---------------- paths ---------------- */
|
||||
D.PATH = {
|
||||
gravel:{ name:'Gravel Path', tier:1, cost:10, base:'#d9c69d', dark:'#c4ad80' },
|
||||
wood: { name:'Boardwalk', tier:1, cost:16, base:'#cf9457', dark:'#b97f43' },
|
||||
stonep:{ name:'Stone Path', tier:2, cost:22, base:'#d3d7da', dark:'#bcc0c4' },
|
||||
};
|
||||
|
||||
/* ---------------- fences (tile edges) ----------------
|
||||
edge codes stored per tile: 0 none, 1 wood, 2 hedge, 3 gate */
|
||||
D.FENCE = {
|
||||
1:{ name:'Wood Fence', tier:1, cost:12 },
|
||||
2:{ name:'Hedge', tier:2, cost:18 },
|
||||
3:{ name:'Keeper Gate',tier:1, cost:40 },
|
||||
};
|
||||
|
||||
/* ---------------- nature & decor ---------------- */
|
||||
/* enrich: habitat enrichment value · block: blocks walking/placement */
|
||||
D.NATURE = {
|
||||
tree: { name:'Oak Tree', tier:1, cost:30, enrich:1, block:1 },
|
||||
bush: { name:'Bush', tier:1, cost:15, enrich:0.5, block:0 },
|
||||
flower:{ name:'Flower Bed', tier:1, cost:8, enrich:0, block:0 },
|
||||
rock: { name:'Boulder', tier:1, cost:20, enrich:1, block:1 },
|
||||
palm: { name:'Palm Tree', tier:2, cost:35, enrich:1.2, block:1 },
|
||||
bamboo:{ name:'Bamboo', tier:2, cost:28, enrich:1.5, block:1 },
|
||||
bench: { name:'Bench', tier:1, cost:25, enrich:0, block:1, seat:1 },
|
||||
bin: { name:'Trash Bin', tier:1, cost:10, enrich:0, block:1 },
|
||||
lamp: { name:'Lamp Post', tier:1, cost:20, enrich:0, block:1, light:1 },
|
||||
};
|
||||
|
||||
/* ---------------- species ----------------
|
||||
biome: groundId -> weight · swim: may enter water tiles ·
|
||||
appeal: guest draw · upkeep: $/day · speed: tiles/sec */
|
||||
D.SPECIES = {
|
||||
deer: { name:'Deer', cost:800, tier:1, appeal:2, minArea:10, biome:{grass:1, dirt:.85}, swim:0, enrichNeed:1, upkeep:8, speed:1.0 },
|
||||
zebra: { name:'Zebra', cost:900, tier:1, appeal:3, minArea:12, biome:{grass:1, sand:.6}, swim:0, enrichNeed:1, upkeep:10, speed:1.05 },
|
||||
monkey: { name:'Monkey', cost:1000, tier:1, appeal:3, minArea:10, biome:{grass:.8, dirt:1}, swim:0, enrichNeed:2, upkeep:9, speed:1.35 },
|
||||
flamingo: { name:'Flamingo', cost:1100, tier:1, appeal:3, minArea:10, biome:{water:.95, sand:1, deep:.5}, swim:1, enrichNeed:0, upkeep:9, speed:0.75 },
|
||||
penguin: { name:'Penguin', cost:1400, tier:2, appeal:4, minArea:10, biome:{snow:1, ice:.95, water:.7, deep:.7}, swim:1, enrichNeed:1, upkeep:11, speed:0.8 },
|
||||
crocodile:{ name:'Crocodile', cost:1600, tier:2, appeal:4, minArea:12, biome:{water:1, sand:.8, deep:.6}, swim:1, enrichNeed:1, upkeep:12, speed:0.55 },
|
||||
giraffe: { name:'Giraffe', cost:2000, tier:2, appeal:5, minArea:16, biome:{grass:1, sand:.5}, swim:0, enrichNeed:2, upkeep:14, speed:0.9 },
|
||||
lion: { name:'Lion', cost:2400, tier:2, appeal:5, minArea:16, biome:{grass:1, sand:.8}, swim:0, enrichNeed:2, upkeep:16, speed:1.0 },
|
||||
hippo: { name:'Hippopotamus',cost:1800,tier:3, appeal:4, minArea:14, biome:{water:1, deep:.85, grass:.6}, swim:1, enrichNeed:0, upkeep:13, speed:0.6 },
|
||||
tiger: { name:'Tiger', cost:2600, tier:3, appeal:6, minArea:14, biome:{grass:.7, dirt:1}, swim:0, enrichNeed:3, upkeep:16, speed:1.1 },
|
||||
rhino: { name:'Rhinoceros', cost:2800, tier:3, appeal:5, minArea:16, biome:{grass:.8, dirt:.8, sand:1}, swim:0, enrichNeed:1, upkeep:17, speed:0.8 },
|
||||
elephant: { name:'Elephant', cost:3500, tier:3, appeal:7, minArea:20, biome:{grass:1, sand:.7, dirt:.5}, swim:0, enrichNeed:3, upkeep:20, speed:0.7 },
|
||||
gorilla: { name:'Gorilla', cost:3000, tier:4, appeal:6, minArea:12, biome:{grass:.7, dirt:1}, swim:0, enrichNeed:3, upkeep:18, speed:0.9 },
|
||||
polarbear:{ name:'Polar Bear', cost:3800, tier:4, appeal:6, minArea:16, biome:{snow:1, ice:.9, deep:1, water:.8}, swim:1, enrichNeed:2, upkeep:19, speed:0.85 },
|
||||
panda: { name:'Giant Panda',cost:4200, tier:4, appeal:8, minArea:12, biome:{grass:.8, dirt:1}, swim:0, enrichNeed:4, upkeep:22, speed:0.7 },
|
||||
};
|
||||
D.SPECIES_LIST = Object.keys(D.SPECIES);
|
||||
|
||||
D.ANIMAL_NAMES = ['Mango','Pip','Biscuit','Clover','Noodle','Willow','Juno','Pepper','Maple','Tango','Olive','Bramble','Ziggy','Poppy','Rufus','Cinnamon','Echo','Marble','Sunny','Truffle','Basil','Peanut','Comet','Waffle','Hazel','Boomer','Petal','Snacks','Fig','Dot','Rascal','Muffin','Sprout','Bandit','Honey'];
|
||||
|
||||
/* ---------------- buildings ----------------
|
||||
w,h footprint in tiles · door faces NORTH (front tile is at y-1)
|
||||
joy: guest happiness on visit · income: avg guest spend ·
|
||||
staff: hires granted · rp: research points/min */
|
||||
const B = D.BUILDING = {};
|
||||
B.hut = { name:'Keeper Hut', cost:600, tier:1, w:2, h:2, wall:['#e8d9b8','#c9b28a','#b49a72'], roof:{style:'slant',color:'#a06a3c'}, staff:'keeper', joy:1, income:0, desc:'+1 keeper joins your staff.' };
|
||||
B.restroom = { name:'Restroom', cost:500, tier:1, w:1, h:1, wall:['#bfe3ef','#93c6dc','#7fb3cb'], roof:{style:'flat',color:'#5d90a8'}, joy:2, income:0, desc:'Guests need these after food & drink!' };
|
||||
B.burger = { name:'Burger Stand', cost:700, tier:1, w:2, h:2, wall:['#ffe9b0','#ecc678','#dab262'], roof:{style:'awning',color:'#e8624e'},joy:3, income:7, desc:'Sells burgers. Tasty income.' };
|
||||
B.icecream = { name:'Ice Cream Stand',cost:650, tier:1, w:2, h:2, wall:['#ffdff0','#f2bcd9','#e2aac6'], roof:{style:'cone',color:'#f08bb1'}, joy:3, income:6, desc:'Cool treats on warm days.' };
|
||||
B.gift = { name:'Gift Shop', cost:1200, tier:1, w:2, h:2, wall:['#dff0d0','#b8d9a0','#a2c789'], roof:{style:'awning',color:'#63b34c'},joy:4, income:12, desc:'Plush toys & souvenirs.' };
|
||||
B.visitor = { name:'Visitor Center', cost:1400, tier:1, w:3, h:2, wall:['#f2e3c4','#dcc394','#c8ac7c'], roof:{style:'gable',color:'#c96f4a'}, joy:3, income:0, desc:'A welcoming landmark.' };
|
||||
B.staff = { name:'Staff Room', cost:800, tier:1, w:2, h:2, wall:['#d8d3ca','#b8b2a5','#a29b8c'], roof:{style:'flat',color:'#8a8474'}, joy:0, income:0, boost:'staff', desc:'Staff work 20% faster.' };
|
||||
B.research = { name:'Research Center',cost:3000, tier:1, w:3, h:2, wall:['#d7e8f5','#adcbe4','#94b7d2'], roof:{style:'dome',color:'#7fa8cc'}, joy:1, income:0, rp:5, desc:'+5 research/min. Unlock new species & buildings.' };
|
||||
B.entrance_s = { name:'Small Entrance', cost:800, tier:1, w:2, h:1, wall:['#f5d9a0','#dfba76','#caa15c'], roof:{style:'archway',color:'#e07f26'}, entrance:1, joy:2, income:0, desc:'Guests arrive here. Front side must touch a path.' };
|
||||
B.vet = { name:'Vet Clinic', cost:1600, tier:2, w:3, h:2, wall:['#ffffff','#e4ded4','#cfc7b9'], roof:{style:'gable',color:'#e86a6a'}, staff:'vet', joy:1, income:0, desc:'+1 vet heals sick animals.' };
|
||||
B.entrance_g = { name:'Grand Entrance', cost:2600, tier:2, w:4, h:2, wall:['#f7ddb0','#e0bd82','#cba763'], roof:{style:'archway',color:'#c96f4a'}, entrance:1, joy:5, income:0, big:1, desc:'Impressive gate — more guests per day.' };
|
||||
B.restaurant = { name:'Restaurant', cost:2400, tier:2, w:3, h:3, wall:['#f6ddc0','#e0bd92','#cba674'], roof:{style:'awning',color:'#8a5fb0'}, joy:6, income:18, desc:'Sit-down dining, big spend.' };
|
||||
B.aviary = { name:'Aviary', cost:2800, tier:2, w:3, h:3, wall:['#e6f2f7','#c2dbe8','#a9c8d8'], roof:{style:'mesh',color:'#9fb9c9'}, joy:8, income:3, exhibit:1, desc:'Walk-around bird wonderland.' };
|
||||
B.reptile = { name:'Reptile House', cost:3200, tier:3, w:3, h:2, wall:['#dfe8cf','#c0cfa6','#a8bb8c'], roof:{style:'rock',color:'#7f9166'}, joy:9, income:4, exhibit:1, desc:'Slithery stars indoors.' };
|
||||
B.aquarium = { name:'Aquarium', cost:4200, tier:3, w:4, h:3, wall:['#cfe9f5','#a3cede','#87bad0'], roof:{style:'wave',color:'#4f9fd0'}, joy:12,income:6, exhibit:1, desc:'Undersea tunnel. Crowd favorite.' };
|
||||
D.BUILDING_LIST = Object.keys(B);
|
||||
|
||||
/* ---------------- research tiers ---------------- */
|
||||
D.RESEARCH_TIERS = [
|
||||
{ tier:2, cost:25, label:'Tier II — Savanna & Polar' },
|
||||
{ tier:3, cost:60, label:'Tier III — Giants & Reptiles' },
|
||||
{ tier:4, cost:120, label:'Tier IV — Rare Icons' },
|
||||
];
|
||||
|
||||
/* ---------------- missions (sequential) ---------------- */
|
||||
D.MISSIONS = [
|
||||
{ id:'paths', label:'Lay 12 path tiles', reward:300, hint:'Paths category → Gravel Path. Guests can only walk on paths.', check:g=>g.stats.paths>=12 },
|
||||
{ id:'fences', label:'Place 20 fence pieces', reward:300, hint:'Fences category → drag along tile edges.', check:g=>g.stats.fences>=20 },
|
||||
{ id:'habitat', label:'Build an enclosed habitat of 12+ tiles', reward:500, hint:'Fully surround an area with fence — no gaps!', check:g=>g.animals.length>0 || g.maxRegion>=12 },
|
||||
{ id:'adopt1', label:'Adopt your first animal', reward:400, hint:'Animals category → pick a species → click inside your habitat.', check:g=>g.stats.adopted>=1 },
|
||||
{ id:'hut', label:'Build a Keeper Hut', reward:500, hint:'Facilities → Keeper Hut hires a keeper who feeds animals.', check:g=>g.buildings.some(b=>b.type==='hut') },
|
||||
{ id:'happy3', label:'Keep 3 animals above 70% happiness', reward:800, hint:'Feed them and match their favorite terrain.', check:g=>g.animals.filter(a=>a.happiness>70).length>=3 },
|
||||
{ id:'stars3', label:'Reach a 3.0★ zoo rating', reward:{cash:1000,rp:20}, hint:'More species, happy animals, restrooms & decor raise stars.', check:g=>g.rating>=3 },
|
||||
{ id:'guests', label:'Welcome 150 total guests', reward:1200, hint:'Higher stars attract bigger crowds.', check:g=>g.stats.guestsTotal>=150 },
|
||||
{ id:'res2', label:'Unlock Research Tier II', reward:800, hint:'Research panel → unlock for 40 RP. A Research Center earns RP faster.', check:g=>g.researchTier>=2 },
|
||||
{ id:'species5',label:'Care for 5 different species',reward:1500, hint:'Variety is the spice of zoos.', check:g=>g.speciesCount()>=5 },
|
||||
{ id:'resto', label:'Open a Restaurant', reward:1000, hint:'Tier II building. Hungry guests spend well.', check:g=>g.buildings.some(b=>b.type==='restaurant') },
|
||||
{ id:'stars4', label:'Reach a 4.0★ zoo rating', reward:{cash:2500,rp:40}, check:g=>g.rating>=4 },
|
||||
{ id:'rev20k', label:'Earn $20,000 total revenue', reward:3000, hint:'Tickets, food and gifts all count.', check:g=>g.stats.revenueTotal>=20000 },
|
||||
{ id:'species8',label:'Care for 8 different species',reward:4000, check:g=>g.speciesCount()>=8 },
|
||||
{ id:'stars5', label:'Reach 5.0★ — Wildlife Legend!', reward:{cash:10000,rp:60}, check:g=>g.rating>=4.95 },
|
||||
];
|
||||
|
||||
/* ---------------- palette / brand ---------------- */
|
||||
D.PAL = {
|
||||
grass1:'#7cc653', cream:'#fdf6e7', ink:'#43362a',
|
||||
orange:'#ff9d47', sky:'#69c3ef', gold:'#ffc93c', red:'#e85d5d',
|
||||
};
|
||||
D.NIGHT_SKY = '#2c3e66';
|
||||
|
||||
/* tool categories shown in bottom toolbar */
|
||||
D.TOOLCATS = [
|
||||
{ id:'ground', label:'Ground' },
|
||||
{ id:'paths', label:'Paths' },
|
||||
{ id:'fences', label:'Fences' },
|
||||
{ id:'nature', label:'Nature' },
|
||||
{ id:'animals', label:'Animals' },
|
||||
{ id:'facilities',label:'Zoo' },
|
||||
];
|
||||
|
||||
/* starter layout seeds (relative to south-center) built by sim.newGame */
|
||||
D.STARTER = { entranceW:3 };
|
||||
})();
|
||||
+483
@@ -0,0 +1,483 @@
|
||||
/* ============================================================
|
||||
Wildlife Kingdom — js/entities.js
|
||||
Animals · Guests · Keepers · Vets · Particles
|
||||
All coordinates are float tile-space; drawing projects them.
|
||||
============================================================ */
|
||||
(function(){
|
||||
'use strict';
|
||||
const WK = window.WK;
|
||||
const D = WK.Data;
|
||||
const HW = WK.HW, HH = WK.HH;
|
||||
|
||||
WK.ANIMAL_SIZE = {
|
||||
deer:.95, zebra:.95, monkey:.78, flamingo:.78, penguin:.68, crocodile:.9,
|
||||
giraffe:1.12, lion:1, hippo:1.08, tiger:.98, rhino:1.05, elephant:1.22,
|
||||
gorilla:.92, polarbear:1.08, panda:.88,
|
||||
};
|
||||
|
||||
let NEXT_ID = 1;
|
||||
const R = Math.random;
|
||||
|
||||
/* ============================================================
|
||||
ANIMAL
|
||||
============================================================ */
|
||||
class Animal{
|
||||
constructor(speciesId, x, y, regionId){
|
||||
const sp = D.SPECIES[speciesId];
|
||||
this.id = NEXT_ID++;
|
||||
this.kind='animal';
|
||||
this.species = speciesId;
|
||||
this.sp = sp;
|
||||
this.name = D.ANIMAL_NAMES[(R()*D.ANIMAL_NAMES.length)|0];
|
||||
this.x=x; this.y=y;
|
||||
this.face = R()<.5?-1:1;
|
||||
this.state='idle';
|
||||
this.stateT = 1+R()*4;
|
||||
this.phase = R()*10;
|
||||
this.tOff = R()*100;
|
||||
this.hunger = 20+R()*20;
|
||||
this.happiness = 62;
|
||||
this.happyT = 0; // remaining happy-burst time
|
||||
this.eatT = 0;
|
||||
this.sick = false;
|
||||
this.regionId = regionId;
|
||||
this.wp=null;
|
||||
}
|
||||
get size(){ return WK.ANIMAL_SIZE[this.species]||1; }
|
||||
|
||||
startEating(){
|
||||
this.eatT = 3;
|
||||
this.hunger = 4;
|
||||
this.happyT = 3.5;
|
||||
this._taken = false;
|
||||
}
|
||||
makeHappy(){ this.happyT = Math.max(this.happyT, 2.6); }
|
||||
|
||||
update(sdt, game){
|
||||
const sp=this.sp;
|
||||
// needs drift
|
||||
this.hunger = WK.clamp(this.hunger + sdt*0.19, 0, 100);
|
||||
let target = game.habitatScore(this);
|
||||
if(this.hunger>55) target -= (this.hunger-55)*0.85;
|
||||
if(this.sick) target -= 38;
|
||||
target = WK.clamp(target,5,98);
|
||||
this.happiness += (target-this.happiness)*Math.min(1,sdt*0.07);
|
||||
|
||||
if(this.happyT>0) this.happyT-=sdt;
|
||||
if(this.eatT>0){ this.eatT-=sdt; this.state='eat'; return; }
|
||||
if(this.happyT>0 && this.state!=='walk'){ this.state='happy'; this.stateT-=sdt; if(this.stateT<=0)this.state='idle'; return; }
|
||||
|
||||
this.stateT -= sdt;
|
||||
if(this.state==='idle'){
|
||||
if(this.stateT<=0){
|
||||
// choose a stroll
|
||||
const dest = game.world.randomRegionTile(this.regionId, sp, R);
|
||||
if(dest){
|
||||
const path = game.world.findPathAnimal(sp, this.x, this.y, dest[0], dest[1], this.regionId);
|
||||
if(path&&path.length){ this.wp=path; this.state='walk'; }
|
||||
}
|
||||
this.stateT = 3.5+R()*7;
|
||||
}
|
||||
}else if(this.state==='walk'){
|
||||
if(!this.wp||!this.wp.length){ this.state='idle'; this.stateT=2+R()*4; return; }
|
||||
const [tx,ty]=this.wp[0];
|
||||
const dx=tx-this.x, dy=ty-this.y;
|
||||
const d=Math.hypot(dx,dy);
|
||||
const step=sp.speed*sdt;
|
||||
if(d<=step){ this.x=tx; this.y=ty; this.wp.shift(); if(!this.wp.length){this.state='idle'; this.stateT=2.5+R()*6;} }
|
||||
else{ this.x+=dx/d*step; this.y+=dy/d*step; }
|
||||
const screenDx=(tx-this.x)-(ty-this.y);
|
||||
if(Math.abs(screenDx)>0.05) this.face = screenDx>0?1:-1;
|
||||
this.phase += step*5.2;
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx, cam, time, groundId){
|
||||
const p = cam.project(this.x,this.y);
|
||||
if(!cam.isVisible(p.x,p.y,80)) return;
|
||||
const s=this.size;
|
||||
const g = WK.Data.GROUND[groundId||'grass'];
|
||||
const swimming = g.water && this.sp.swim;
|
||||
ctx.save();
|
||||
ctx.translate(p.x,p.y);
|
||||
// shadow (not for swimmers)
|
||||
if(!swimming){
|
||||
ctx.fillStyle='rgba(50,40,15,.26)';
|
||||
ctx.beginPath(); ctx.ellipse(0,1,15*s,5.5*s,0,0,WK.TAU); ctx.fill();
|
||||
}
|
||||
ctx.scale(this.face*s, s);
|
||||
if(swimming){
|
||||
ctx.save();
|
||||
ctx.beginPath(); ctx.rect(-48,-72,96,64); ctx.clip(); // hide feet under waterline
|
||||
WK.Sprites.drawAnimal(ctx, this.species, {
|
||||
state:this.state==='walk'?'walk':this.state==='eat'?'eat':this.state==='happy'?'happy':'idle',
|
||||
ph:this.phase, t:time+this.tOff,
|
||||
});
|
||||
ctx.restore();
|
||||
// ripples at waterline
|
||||
ctx.strokeStyle='rgba(255,255,255,.55)'; ctx.lineWidth=1.6;
|
||||
const rr=(14+Math.sin(time*3+this.tOff)*2)*s;
|
||||
ctx.beginPath(); ctx.ellipse(0,-6*s,rr,rr*0.36,0,0,WK.TAU); ctx.stroke();
|
||||
ctx.beginPath(); ctx.ellipse(0,-6*s,rr*.6,rr*.2,0,0,WK.TAU); ctx.stroke();
|
||||
}else{
|
||||
WK.Sprites.drawAnimal(ctx, this.species, {
|
||||
state:this.state==='walk'?'walk':this.state==='eat'?'eat':this.state==='happy'?'happy':'idle',
|
||||
ph:this.phase, t:time+this.tOff,
|
||||
});
|
||||
}
|
||||
ctx.restore();
|
||||
// status markers
|
||||
if(this.sick || this.hunger>82){
|
||||
ctx.font='bold 13px sans-serif'; ctx.textAlign='center';
|
||||
ctx.fillText(this.sick?'🤒':'🍖', p.x, p.y-46*s + Math.sin(time*4)*1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WK.Animal = Animal;
|
||||
|
||||
/* ============================================================
|
||||
GUEST
|
||||
============================================================ */
|
||||
const SKINS=['#f5d5b8','#eec39a','#d9a06b','#a9744f','#8a5a3a'];
|
||||
const SHIRTS=['#e8624e','#ffc93c','#63b34c','#69c3ef','#b78ae8','#ff8aa8','#ff9d47','#5fb8a8'];
|
||||
const PANTS=['#4a6a8a','#6b4a8a','#7a6a55','#43362a','#5a7a4a'];
|
||||
const HAIRS=['#5a3c22','#2e2a26','#a9743f','#d9b97e','#8a3a2a','#e8e2d4'];
|
||||
|
||||
class Guest{
|
||||
constructor(game, ex, ey){
|
||||
this.id=NEXT_ID++;
|
||||
this.kind='guest';
|
||||
this.game=game;
|
||||
this.x=ex; this.y=ey;
|
||||
this.skin=SKINS[(R()*SKINS.length)|0];
|
||||
this.shirt=SHIRTS[(R()*SHIRTS.length)|0];
|
||||
this.pants=PANTS[(R()*PANTS.length)|0];
|
||||
this.hair=HAIRS[(R()*HAIRS.length)|0];
|
||||
this.kid=R()<0.3;
|
||||
this.balloon=R()<0.22?(SHIRTS[(R()*SHIRTS.length)|0]):null;
|
||||
this.joy=58+R()*10;
|
||||
this.speed=(this.kid?1.25:1.6)*(0.9+R()*0.25);
|
||||
this.lane=(R()-0.5)*0.24;
|
||||
this.state='walk';
|
||||
this.phase=R()*10;
|
||||
this.itin=[];
|
||||
const stops=2+(R()*4|0);
|
||||
for(let i=0;i<stops;i++) this.itin.push('poi');
|
||||
this.leaving=false;
|
||||
this.wantsRestroom=false;
|
||||
this.restroomWarned=false;
|
||||
this.dwellT=0;
|
||||
this.poi=null;
|
||||
this.wp=null;
|
||||
this.waitT=0.5+R();
|
||||
}
|
||||
setPathTo(tx,ty){
|
||||
const w=this.game.world;
|
||||
this.wp=w.findPathVisitor(Math.round(this.x),Math.round(this.y),tx,ty);
|
||||
if(this.wp&&this.wp.length===0) this.wp=[[tx,ty]];
|
||||
return !!this.wp;
|
||||
}
|
||||
pickNext(){
|
||||
const pois=this.game.pois;
|
||||
if(!pois||!pois.length){ this.leave(); return; }
|
||||
let pool=[];
|
||||
if(this.wantsRestroom){
|
||||
const rr=pois.filter(p=>p.type==='restroom');
|
||||
if(rr.length) pool=rr.map(p=>({p,w:50}));
|
||||
}
|
||||
if(!pool.length){
|
||||
for(const p of pois){
|
||||
let wgt=0;
|
||||
if(p.type==='view') wgt=6+p.appeal*2.2;
|
||||
else if(p.type==='shop') wgt=3+p.income*0.5+p.joy;
|
||||
else if(p.type==='visit') wgt=1.5+p.joy*0.5;
|
||||
else if(p.type==='bench') wgt=2;
|
||||
else if(p.type==='restroom') wgt=this.wantsRestroom?40:1.2;
|
||||
pool.push({p,w:wgt||0.5});
|
||||
}
|
||||
}
|
||||
const pick=WK.weightedPick(pool,R).p;
|
||||
this.poi=pick;
|
||||
if(this.setPathTo(pick.x,pick.y)){
|
||||
this.state='walk';
|
||||
}else{
|
||||
// unreachable: skip stop
|
||||
if(this.itin.length)this.itin.pop();
|
||||
if(!this.itin.length) this.leave();
|
||||
else this.pickNext();
|
||||
}
|
||||
}
|
||||
leave(){
|
||||
this.leaving=true; this.poi=null;
|
||||
const e=this.game.exitTile;
|
||||
if(e) this.setPathTo(e[0],e[1]);
|
||||
}
|
||||
arriveAtPoi(){
|
||||
const p=this.poi;
|
||||
this.dwellT=2.2+R()*3.5;
|
||||
this.state='dwell';
|
||||
this.act='stand';
|
||||
if(R()<0.3) this.act='photo';
|
||||
if(!p) return;
|
||||
const g=this.game;
|
||||
if(p.type==='view'){
|
||||
this.joy=WK.clamp(this.joy+3.5+p.appeal*0.85*(p.quality||1),0,100);
|
||||
this.act=R()<0.45?'photo':'point';
|
||||
if(R()<0.3){ g.puffHeart(this.x,this.y); }
|
||||
}else if(p.type==='shop'){
|
||||
const def=D.BUILDING[p.btype];
|
||||
this.joy=WK.clamp(this.joy+def.joy*0.65,0,100);
|
||||
const spend=Math.round(def.income*(0.8+R()*0.4));
|
||||
g.earnMoney(spend, 'shop:'+p.btype);
|
||||
g.coinBurst(p.x,p.y);
|
||||
if(['burger','icecream','restaurant'].includes(p.btype)) this.wantsRestroom=true;
|
||||
}else if(p.type==='visit'){
|
||||
const def=D.BUILDING[p.btype];
|
||||
this.joy=WK.clamp(this.joy+def.joy*0.4,0,100);
|
||||
}else if(p.type==='restroom'){
|
||||
this.wantsRestroom=false;
|
||||
this.joy=WK.clamp(this.joy+3,0,100);
|
||||
}else if(p.type==='bench'){
|
||||
this.joy=WK.clamp(this.joy+2.5,0,100);
|
||||
this.act='sit';
|
||||
this.dwellT=3.5+R()*3;
|
||||
}
|
||||
}
|
||||
update(sdt){
|
||||
if(this.waitT>0){ this.waitT-=sdt; return; }
|
||||
// slow boredom / crowd stress
|
||||
this.joy=WK.clamp(this.joy - sdt*0.16 - (this.game.crowdStress||0)*sdt, 0, 100);
|
||||
if(this.state==='walk'){
|
||||
if(!this.wp){
|
||||
if(this.leaving){ this.done=true; return; }
|
||||
this.pickNext(); if(!this.wp) return;
|
||||
}
|
||||
if(!this.wp.length){
|
||||
if(this.leaving){ this.done=true; return; }
|
||||
this.arriveAtPoi();
|
||||
return;
|
||||
}
|
||||
const [tx,ty]=this.wp[0];
|
||||
const dx=tx-this.x, dy=ty-this.y;
|
||||
const d=Math.hypot(dx,dy);
|
||||
const step=this.speed*sdt;
|
||||
if(d<=step){ this.x=tx; this.y=ty; this.wp.shift(); }
|
||||
else{
|
||||
// gentle lane offset for organic crowds
|
||||
const px=-dy/d*this.lane, py=dx/d*this.lane;
|
||||
this.x+=(dx/d)*step+px*sdt*0.6;
|
||||
this.y+=(dy/d)*step+py*sdt*0.6;
|
||||
const sdx=dx-dy;
|
||||
if(Math.abs(sdx)>0.05) this.faceDir=sdx>0?1:-1;
|
||||
}
|
||||
this.phase+=step*5.5;
|
||||
this.act='walk';
|
||||
}else if(this.state==='dwell'){
|
||||
this.dwellT-=sdt;
|
||||
this.phase+=sdt*1.4;
|
||||
if(this.dwellT<=0){
|
||||
this.poi=null;
|
||||
if(this.itin.length){ this.itin.pop(); this.pickNext(); }
|
||||
else this.leave();
|
||||
this.state='walk';
|
||||
}
|
||||
}
|
||||
if(this.leaving && (!this.wp || !this.wp.length) && this.state!=='dwell'){
|
||||
this.done=true;
|
||||
}
|
||||
}
|
||||
draw(ctx,cam,time){
|
||||
const p=cam.project(this.x,this.y);
|
||||
if(!cam.isVisible(p.x,p.y,40)) return;
|
||||
ctx.save();
|
||||
ctx.translate(p.x,p.y);
|
||||
let act=this.act||'stand';
|
||||
if(this.state==='walk') act='walk';
|
||||
if(act==='sit') ctx.translate(0,-6);
|
||||
WK.Sprites.drawPerson(ctx,{
|
||||
phase:this.phase, state:act,
|
||||
shirt:this.shirt, pants:this.pants, skin:this.skin, hair:this.hair,
|
||||
kid:this.kid, balloon:this.balloon, face:this.faceDir||1,
|
||||
smile:this.joy>45,
|
||||
});
|
||||
if(this.joy<25 && R()<0.02){
|
||||
ctx.fillStyle='rgba(90,110,220,.8)';
|
||||
ctx.font='bold 10px sans-serif'; ctx.textAlign='center';
|
||||
ctx.fillText('💧',8,-34);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
WK.Guest = Guest;
|
||||
|
||||
/* ============================================================
|
||||
STAFF (keeper / vet)
|
||||
============================================================ */
|
||||
class Staff{
|
||||
constructor(role,x,y){
|
||||
this.id=NEXT_ID++;
|
||||
this.kind='staff'; this.role=role; // 'keeper'|'vet'
|
||||
this.x=x; this.y=y;
|
||||
this.speed=role==='keeper'?2.1:2.3;
|
||||
this.state='idle';
|
||||
this.job=null;
|
||||
this.wp=null;
|
||||
this.workT=0;
|
||||
this.phase=R()*10;
|
||||
this.shirt=role==='keeper'?'#5fb868':'#f5f5f5';
|
||||
this.pants='#43362a';
|
||||
this.skin=SKINS[(R()*SKINS.length)|0];
|
||||
this.hair=HAIRS[(R()*HAIRS.length)|0];
|
||||
this.face=1;
|
||||
this.idleT=1+R()*2;
|
||||
}
|
||||
update(sdt,game){
|
||||
if(this.workT>0){
|
||||
this.workT-=sdt;
|
||||
if(this.workT<=0){
|
||||
if(this.role==='keeper'&&this.job&&this.job.alive){
|
||||
this.job.startEating();
|
||||
game.puffHeart(this.job.x,this.job.y);
|
||||
this.job.makeHappy();
|
||||
}
|
||||
if(this.role==='vet'&&this.job&&this.job.alive){
|
||||
this.job.sick=false;
|
||||
this.job.makeHappy();
|
||||
game.puffHeart(this.job.x,this.job.y);
|
||||
game.notify(this.job.name+' the '+D.SPECIES[this.job.species].name+' was healed!','good');
|
||||
}
|
||||
this.job=null;
|
||||
this.state='idle';
|
||||
this.idleT=0.5;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(!this.job){
|
||||
this.idleT-=sdt;
|
||||
// ask dispatcher
|
||||
const j=game.requestJob(this.role,this);
|
||||
if(j){
|
||||
this.job=j;
|
||||
const w=game.world;
|
||||
this.wp=w.findPathKeeper(Math.round(this.x),Math.round(this.y),Math.round(j.x),Math.round(j.y));
|
||||
if(!this.wp){ game.abandonJob(this.role,j); this.job=null; this.idleT=2; }
|
||||
else { this.state='walk'; }
|
||||
return;
|
||||
}
|
||||
if(this.idleT<=0){
|
||||
// casual wander
|
||||
const w=game.world;
|
||||
const b=w.buildings.find(bb=>bb.type===(this.role==='keeper'?'hut':'vet'))||w.buildings[0];
|
||||
let tx,ty;
|
||||
if(b){ const d=w.doorTile(b); tx=d[0]+((R()*7)|0)-3; ty=d[1]+((R()*7)|0)-3; }
|
||||
else { tx=(R()*w.cols)|0; ty=(R()*w.rows)|0; }
|
||||
tx=WK.clamp(tx,1,w.cols-2); ty=WK.clamp(ty,1,w.rows-2);
|
||||
if(w.path[w.idx(tx,ty)]||D.GROUND[w.gid(tx,ty)].walk){
|
||||
this.wp=w.findPathKeeper(Math.round(this.x),Math.round(this.y),tx,ty);
|
||||
if(this.wp&&this.wp.length) this.state='walk';
|
||||
}
|
||||
this.idleT=4+R()*5;
|
||||
}
|
||||
if(this.state==='idle') return;
|
||||
}
|
||||
if(this.state==='walk'){
|
||||
if(!this.wp||!this.wp.length){
|
||||
if(this.job){ this.state='work'; this.workT=this.role==='keeper'?2.4:3; this.face=this.job.x>=this.x?1:-1; }
|
||||
else { this.state='idle'; }
|
||||
return;
|
||||
}
|
||||
const [tx,ty]=this.wp[0];
|
||||
const dx=tx-this.x, dy=ty-this.y;
|
||||
const d=Math.hypot(dx,dy);
|
||||
const step=this.speed*sdt;
|
||||
if(d<=step){ this.x=tx; this.y=ty; this.wp.shift(); }
|
||||
else{ this.x+=dx/d*step; this.y+=dy/d*step; }
|
||||
const sdx=(tx-this.x)-(ty-this.y);
|
||||
if(Math.abs(sdx)>0.05) this.face=sdx>0?1:-1;
|
||||
this.phase+=step*5.5;
|
||||
}
|
||||
}
|
||||
draw(ctx,cam,time){
|
||||
const p=cam.project(this.x,this.y);
|
||||
if(!cam.isVisible(p.x,p.y,40)) return;
|
||||
ctx.save();
|
||||
ctx.translate(p.x,p.y);
|
||||
const act=this.workT>0?'feed':(this.state==='walk'?'walk':'stand');
|
||||
WK.Sprites.drawPerson(ctx,{
|
||||
phase:this.phase,state:act,shirt:this.shirt,pants:this.pants,
|
||||
skin:this.skin,hair:this.hair,kid:false,balloon:null,face:this.face,smile:true,
|
||||
});
|
||||
if(this.workT>0){
|
||||
// work bubble
|
||||
ctx.font='bold 12px sans-serif'; ctx.textAlign='center';
|
||||
ctx.fillText(this.role==='keeper'?'🍎':'🩺',0,-36+Math.sin(time*6));
|
||||
}
|
||||
// role cap
|
||||
ctx.fillStyle=this.role==='keeper'?'#3e8a33':'#e86a6a';
|
||||
ctx.beginPath(); ctx.ellipse(0,-29,4.6,2,0,0,WK.TAU); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
WK.Staff = Staff;
|
||||
|
||||
/* ============================================================
|
||||
PARTICLES
|
||||
============================================================ */
|
||||
class Particles{
|
||||
constructor(){ this.list=[]; }
|
||||
add(o){ o.age=0; this.list.push(o); if(this.list.length>240) this.list.splice(0,this.list.length-240); }
|
||||
coins(x,y){ for(let i=0;i<3;i++) this.add({t:'coin',x:x+(R()-.5)*.4,y,vz:0,z:14+R()*8,vx:(R()-.5)*0.8,vy:(R()-.5)*0.4,dur:0.9}); }
|
||||
hearts(x,y){ for(let i=0;i<2;i++) this.add({t:'heart',x:x+(R()-.5)*.5,y:y-0.2,z:20,vx:(R()-.5)*0.3,vy:-0.5-R()*0.4,dur:1.2}); }
|
||||
puff(x,y,c){ this.add({t:'puff',x,y,z:4,vx:0,vy:0,dur:0.5,c:c||'rgba(180,160,130,'}); }
|
||||
splash(x,y){ this.add({t:'splash',x,y,z:2,vx:0,vy:0,dur:0.5}); }
|
||||
note(x,y){ this.add({t:'note',x,y,z:18,vx:0.2,vy:-0.6,dur:1}); }
|
||||
update(sdt){
|
||||
const L=this.list;
|
||||
for(let i=L.length-1;i>=0;i--){
|
||||
const p=L[i];
|
||||
p.age+=sdt;
|
||||
if(p.age>=p.dur){ L.splice(i,1); continue; }
|
||||
p.x+=(p.vx||0)*sdt; p.y+=(p.vy||0)*sdt;
|
||||
}
|
||||
}
|
||||
draw(ctx,cam,time){
|
||||
for(const p of this.list){
|
||||
const k=p.age/p.dur;
|
||||
const pr=cam.project(p.x,p.y);
|
||||
if(!cam.isVisible(pr.x,pr.y,30)) continue;
|
||||
const z=p.z*(1-k)+ (p.z||0)*0;
|
||||
ctx.save();
|
||||
ctx.translate(pr.x,pr.y-(p.z||0)*(0.4+k*1.4)*16);
|
||||
ctx.globalAlpha=1-k*k;
|
||||
if(p.t==='coin'){
|
||||
ctx.fillStyle='#ffc93c';
|
||||
ctx.beginPath(); ctx.ellipse(0,0,4.4*Math.abs(Math.cos(k*9)),4.4,0,0,WK.TAU); ctx.fill();
|
||||
ctx.strokeStyle='#d99a1e'; ctx.lineWidth=1.2; ctx.stroke();
|
||||
}else if(p.t==='heart'){
|
||||
ctx.fillStyle='#ff6b81';
|
||||
const s=3.2+k*2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0,s*0.9); ctx.bezierCurveTo(-s*1.6,-s*0.3,-s*0.9,-s*1.4,0,-s*0.4);
|
||||
ctx.bezierCurveTo(s*0.9,-s*1.4,s*1.6,-s*0.3,0,s*0.9);
|
||||
ctx.fill();
|
||||
}else if(p.t==='puff'){
|
||||
ctx.fillStyle=p.c+(0.5*(1-k))+')';
|
||||
ctx.beginPath(); ctx.arc(0,0,4+k*14,0,WK.TAU); ctx.fill();
|
||||
}else if(p.t==='splash'){
|
||||
ctx.strokeStyle='rgba(200,235,250,'+(0.8*(1-k))+')';
|
||||
ctx.lineWidth=1.6;
|
||||
ctx.beginPath(); ctx.arc(0,0,3+k*13,0,WK.TAU); ctx.stroke();
|
||||
}else if(p.t==='note'){
|
||||
ctx.fillStyle='rgba(90,60,120,'+(1-k)+')';
|
||||
ctx.font='bold 12px sans-serif'; ctx.textAlign='center';
|
||||
ctx.fillText(Math.sin(p.x*7)>0?'♪':'♫',0,0);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.globalAlpha=1;
|
||||
}
|
||||
}
|
||||
WK.Particles = Particles;
|
||||
|
||||
})();
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
/* ============================================================
|
||||
Wildlife Kingdom — js/input.js
|
||||
Mouse + touch controls · tool painting · placement ghosts ·
|
||||
selection picking · camera pan/zoom · hotkeys
|
||||
============================================================ */
|
||||
(function(){
|
||||
'use strict';
|
||||
const WK = window.WK;
|
||||
const D = WK.Data;
|
||||
|
||||
class Input{
|
||||
constructor(canvas,game,cam,renderer,ui){
|
||||
this.canvas=canvas;
|
||||
this.game=game;
|
||||
this.cam=cam;
|
||||
this.renderer=renderer;
|
||||
this.ui=ui;
|
||||
this.pointers=new Map();
|
||||
this.drag=null; // {mode:'pan'|'paint', lastX,lastY,lastTile}
|
||||
this.pinch=null;
|
||||
this.keys=new Set();
|
||||
this.spaceHeld=false;
|
||||
this.hover=null; // {x,y}
|
||||
this.hoverEdge=null; // {x,y,edge}
|
||||
this._bind();
|
||||
}
|
||||
|
||||
/* ---------------- tool application ---------------- */
|
||||
toolValid(cat,item,x,y){
|
||||
const g=this.game, w=g.world;
|
||||
if(!w.inBounds(x,y)) return false;
|
||||
const k=w.idx(x,y);
|
||||
switch(cat){
|
||||
case 'ground':{
|
||||
const def=D.GROUND[item];
|
||||
return def.tier<=g.researchTier && w.gid(x,y)!==item && w.occ[k]<0;
|
||||
}
|
||||
case 'paths':
|
||||
return D.PATH[item].tier<=g.researchTier &&
|
||||
!D.GROUND[w.gid(x,y)].water && w.occ[k]<0 &&
|
||||
w.pid(x,y)!==item;
|
||||
case 'nature':{
|
||||
const def=D.NATURE[item];
|
||||
return def.tier<=g.researchTier && w.occ[k]<0 && !D.GROUND[w.gid(x,y)].water;
|
||||
}
|
||||
case 'fences':
|
||||
return true;
|
||||
case 'animals':{
|
||||
const sp=D.SPECIES[item];
|
||||
if(sp.tier>g.researchTier) return false;
|
||||
const chk=g.validHabitatAt(x,y);
|
||||
if(chk.error) return false;
|
||||
return chk.region.area>=sp.minArea && g.habitatScoreFor(chk.region.id,item)>=50;
|
||||
}
|
||||
case 'facilities':{
|
||||
const def=D.BUILDING[item];
|
||||
if(def.tier>g.researchTier) return false;
|
||||
return w.canPlaceBuilding(x,y,def.w,def.h);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
edgeFromEvent(sx,sy){
|
||||
const t=this.cam.screenToTile(sx,sy);
|
||||
if(!this.game.world.inBounds(t.x,t.y)) return null;
|
||||
const p=this.cam.project(t.x,t.y);
|
||||
const lx=(sx-p.x)/WK.HW, ly=(sy-p.y)/WK.HH;
|
||||
// distances to four diamond sides
|
||||
const dSE=1-(lx+ly), dSW=1+(lx-ly), dNW=1+(lx+ly), dNE=1-(lx-ly);
|
||||
const m=Math.min(dSE,dSW,dNW,dNE);
|
||||
const w=this.game.world;
|
||||
if(m===dSE) return {x:t.x,y:t.y,edge:'S'};
|
||||
if(m===dSW) return w.inBounds(t.x-1,t.y)?{x:t.x-1,y:t.y,edge:'E'}:{x:t.x,y:t.y,edge:'S'};
|
||||
if(m===dNW) return w.inBounds(t.x-1,t.y)?{x:t.x-1,y:t.y,edge:'E'}:null;
|
||||
return w.inBounds(t.x,t.y-1)?{x:t.x,y:t.y-1,edge:'S'}:null;
|
||||
}
|
||||
|
||||
applyAt(h){
|
||||
const ui=this.ui, g=this.game;
|
||||
if(ui.bulldoze){
|
||||
if(this.hoverEdge&&g.tryBulldozeEdge(this.hoverEdge.x,this.hoverEdge.y,this.hoverEdge.edge)) return;
|
||||
g.tryBulldoze(h.x,h.y);
|
||||
return;
|
||||
}
|
||||
const t=ui.tool;
|
||||
if(!t) return;
|
||||
switch(t.cat){
|
||||
case 'ground': g.tryGround(h.x,h.y,t.item); break;
|
||||
case 'paths': g.tryPath(h.x,h.y,t.item); break;
|
||||
case 'nature': g.tryNature(h.x,h.y,t.item); break;
|
||||
case 'fences':
|
||||
if(this.hoverEdge) g.tryFence(this.hoverEdge.x,this.hoverEdge.y,this.hoverEdge.edge,t.item);
|
||||
break;
|
||||
case 'animals':
|
||||
g.tryAdopt(t.item,h.x,h.y);
|
||||
break;
|
||||
case 'facilities':
|
||||
if(g.tryBuilding(t.item,h.x,h.y)){ /* tool stays active for combos */ }
|
||||
break;
|
||||
}
|
||||
}
|
||||
applyLine(a,b){
|
||||
/* Wall-aware drag painting. Iso fence topology:
|
||||
S-edges chain over +x ("\") · E-edges chain over +y ("/").
|
||||
So a drag paints the SIDE edge of each visited tile → a straight,
|
||||
fully-connected wall hugging the swept line. */
|
||||
let x=a.x,y=a.y;
|
||||
let guard=80;
|
||||
const w=this.game.world;
|
||||
const fenceMode=(this.ui.tool&&this.ui.tool.cat==='fences')||this.ui.bulldoze;
|
||||
while(guard--&&(x!==b.x||y!==b.y)){
|
||||
const movedX=b.x!==x;
|
||||
if(b.x>x) x++; else if(b.x<x) x--;
|
||||
else if(b.y>y) y++; else y--;
|
||||
if(!w.inBounds(x,y)){ continue; }
|
||||
if(fenceMode){
|
||||
this.hoverEdge=movedX?{x,y,edge:'S'}:{x,y,edge:'E'};
|
||||
}
|
||||
this.applyAt({x,y});
|
||||
}
|
||||
this.hoverEdge=null;
|
||||
}
|
||||
|
||||
/* ---------------- picking ---------------- */
|
||||
pick(sx,sy){
|
||||
const g=this.game, cam=this.cam;
|
||||
let best=null,bd=34*34;
|
||||
const consider=(ent,rad)=>{
|
||||
const p=cam.project(ent.x,ent.y);
|
||||
const dd=(p.x-sx)**2+(p.y-sy+14)**2;
|
||||
if(dd<bd*(rad||1)){ best=ent; bd=dd; }
|
||||
};
|
||||
bd=34*34; for(const a of g.animals) consider(a,(a.size||1)*1.3);
|
||||
bd=26*26; for(const st of g.staff) consider(st,1);
|
||||
bd=22*22; for(const gu of g.guests) consider(gu,1);
|
||||
if(best){ this.ui.showSelection(best); return true; }
|
||||
const t=cam.screenToTile(sx,sy);
|
||||
const b=g.world.buildingAt(t.x,t.y);
|
||||
if(b){ this.ui.showSelection(b); return true; }
|
||||
// habitat summary
|
||||
g.world.ensureRegions();
|
||||
const rid=g.world.regionOf(t.x,t.y);
|
||||
if(rid>=0){
|
||||
const reg=g.world.regions.find(r=>r.id===rid);
|
||||
if(reg&®.openEdges===0&&!reg.hasPath&®.area>=8){
|
||||
this.ui.showSelection({kind:'habitat',regionId:rid,x:t.x,y:t.y});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
this.ui.hideSelection();
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---------------- events ---------------- */
|
||||
_bind(){
|
||||
const cv=this.canvas;
|
||||
cv.addEventListener('pointerdown',e=>{
|
||||
WK.AudioSys.ensure();
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
this.pointers.set(e.pointerId,{x:e.offsetX,y:e.offsetY});
|
||||
if(this.pointers.size===2){ this._startPinch(); return; }
|
||||
const sx=e.offsetX,sy=e.offsetY;
|
||||
const wantPan = e.button===1||e.button===2||this.spaceHeld||
|
||||
(e.pointerType==='touch'&&(!this.ui.tool&&!this.ui.bulldoze));
|
||||
if(wantPan){
|
||||
this.drag={mode:'pan',lastX:sx,lastY:sy};
|
||||
cv.style.cursor='grabbing';
|
||||
return;
|
||||
}
|
||||
if(e.button===2){ this.ui.cancelTool(); return; }
|
||||
const t=this.cam.screenToTile(sx,sy);
|
||||
if(!this.game.world.inBounds(t.x,t.y)) return;
|
||||
this.hoverEdge=this.toolActiveFence()?this.edgeFromEvent(sx,sy):null;
|
||||
if(this.ui.tool||this.ui.bulldoze){
|
||||
this.drag={mode:'paint',lastTile:t,startTile:t};
|
||||
this.applyAt(t);
|
||||
}else{
|
||||
this.pick(sx,sy);
|
||||
}
|
||||
});
|
||||
cv.addEventListener('pointermove',e=>{
|
||||
const sx=e.offsetX,sy=e.offsetY;
|
||||
if(this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId,{x:sx,y:sy});
|
||||
if(this.pinch&&this.pointers.size===2){ this._movePinch(); return; }
|
||||
const t=this.cam.screenToTile(sx,sy);
|
||||
this.hover=t;
|
||||
this.hoverEdge=this.toolActiveFence()?this.edgeFromEvent(sx,sy):null;
|
||||
if(!this.drag) return;
|
||||
if(this.drag.mode==='pan'){
|
||||
this.cam.panBy(sx-this.drag.lastX,sy-this.drag.lastY);
|
||||
this.drag.lastX=sx; this.drag.lastY=sy;
|
||||
}else{
|
||||
if(!this.game.world.inBounds(t.x,t.y)) return;
|
||||
if(t.x!==this.drag.lastTile.x||t.y!==this.drag.lastTile.y){
|
||||
if(e.buttons&2){ }
|
||||
this.applyLine(this.drag.lastTile,t);
|
||||
this.drag.lastTile=t;
|
||||
}
|
||||
}
|
||||
});
|
||||
const up=e=>{
|
||||
this.pointers.delete(e.pointerId);
|
||||
if(this.pointers.size<2) this.pinch=null;
|
||||
if(this.drag&&this.pointers.size===0){
|
||||
this.drag=null;
|
||||
cv.style.cursor=this.ui.tool?'crosshair':'grab';
|
||||
}
|
||||
};
|
||||
cv.addEventListener('pointerup',up);
|
||||
cv.addEventListener('pointercancel',up);
|
||||
cv.addEventListener('pointerleave',e=>{ this.hover=null; });
|
||||
cv.addEventListener('contextmenu',e=>e.preventDefault());
|
||||
cv.addEventListener('wheel',e=>{
|
||||
e.preventDefault();
|
||||
const f=Math.pow(1.0016,-e.deltaY);
|
||||
this.cam.zoomAt(e.offsetX,e.offsetY,f);
|
||||
},{passive:false});
|
||||
|
||||
window.addEventListener('keydown',e=>{
|
||||
if(e.target&&(e.target.tagName==='INPUT')) return;
|
||||
this.keys.add(e.key);
|
||||
if(e.code==='Space'){ this.spaceHeld=true; e.preventDefault(); }
|
||||
const ui=this.ui;
|
||||
switch(e.key){
|
||||
case 'Escape': ui.cancelTool(); break;
|
||||
case 'x': case 'X': ui.toggleBulldoze(); break;
|
||||
case ' ': ui.togglePause(); e.preventDefault(); break;
|
||||
case '1': ui.setSpeed(1); break;
|
||||
case '2': ui.setSpeed(2); break;
|
||||
case '3': ui.setSpeed(4); break;
|
||||
case '+': case '=': this.cam.zoomAt(this.cam.w/2,this.cam.h/2,1.15); break;
|
||||
case '-': this.cam.zoomAt(this.cam.w/2,this.cam.h/2,0.87); break;
|
||||
}
|
||||
});
|
||||
window.addEventListener('keyup',e=>{
|
||||
this.keys.delete(e.key);
|
||||
if(e.code==='Space') this.spaceHeld=false;
|
||||
});
|
||||
}
|
||||
toolActiveFence(){
|
||||
return (this.ui.tool&&this.ui.tool.cat==='fences')||(this.ui.bulldoze);
|
||||
}
|
||||
_startPinch(){
|
||||
const pts=[...this.pointers.values()];
|
||||
this.pinch={
|
||||
d:Math.hypot(pts[0].x-pts[1].x,pts[0].y-pts[1].y),
|
||||
mx:(pts[0].x+pts[1].x)/2, my:(pts[0].y+pts[1].y)/2,
|
||||
};
|
||||
this.drag=null;
|
||||
}
|
||||
_movePinch(){
|
||||
const pts=[...this.pointers.values()];
|
||||
if(pts.length<2) return;
|
||||
const d=Math.hypot(pts[0].x-pts[1].x,pts[0].y-pts[1].y);
|
||||
const mx=(pts[0].x+pts[1].x)/2, my=(pts[0].y+pts[1].y)/2;
|
||||
const f=WK.clamp(d/Math.max(20,this.pinch.d),0.5,2);
|
||||
this.cam.zoomAt(mx,my,f);
|
||||
this.cam.panBy(mx-this.pinch.mx,my-this.pinch.my);
|
||||
this.pinch={d,mx,my};
|
||||
}
|
||||
update(dt){
|
||||
// keyboard panning
|
||||
const sp=520*dt/this.cam.zoom;
|
||||
let dx=0,dy=0;
|
||||
if(this.keys.has('ArrowLeft')||this.keys.has('a')||this.keys.has('A')) dx-=sp;
|
||||
if(this.keys.has('ArrowRight')||this.keys.has('d')||this.keys.has('D')) dx+=sp;
|
||||
if(this.keys.has('ArrowUp')||this.keys.has('w')||this.keys.has('W')) dy-=sp;
|
||||
if(this.keys.has('ArrowDown')||this.keys.has('s')||this.keys.has('S')) dy+=sp;
|
||||
if(dx||dy) this.cam.panBy(dx,dy);
|
||||
}
|
||||
/* overlay hook for renderer */
|
||||
overlay(ctx,cam,range){
|
||||
const ui=this.ui;
|
||||
if(ui.bulldoze){
|
||||
if(this.hover){
|
||||
const p=cam.project(this.hover.x,this.hover.y);
|
||||
WK.Sprites.diamondPath(ctx,p.x,p.y,1);
|
||||
ctx.fillStyle='rgba(230,80,70,.25)'; ctx.fill();
|
||||
ctx.strokeStyle='rgba(230,80,70,.9)'; ctx.lineWidth=2; ctx.stroke();
|
||||
if(this.hoverEdge){
|
||||
const h=this.hoverEdge;
|
||||
const p2=cam.project(h.x,h.y);
|
||||
const zz=cam.zoom;
|
||||
let ax,ay,bx,by;
|
||||
if(h.edge==='S'){ ax=p2.x-WK.HW*zz; ay=p2.y; bx=p2.x; by=p2.y+WK.HH*zz; }
|
||||
else{ ax=p2.x; ay=p2.y+WK.HH*zz; bx=p2.x+WK.HW*zz; by=p2.y; }
|
||||
ctx.lineWidth=7; ctx.beginPath(); ctx.moveTo(ax,ay-6); ctx.lineTo(bx,by-6); ctx.stroke();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const t=ui.tool;
|
||||
if(!t||!this.hover) return;
|
||||
const h=this.hover;
|
||||
const valid=this.toolValid(t.cat,t.item,h.x,h.y);
|
||||
let g={x:h.x,y:h.y,valid};
|
||||
if(t.cat==='facilities'){ g.mode='foot'; g.item=t.item; }
|
||||
else if(t.cat==='fences'){
|
||||
if(this.hoverEdge){ g={...this.hoverEdge,mode:'edge',valid:true}; }
|
||||
else g=null;
|
||||
}
|
||||
else{
|
||||
g.mode='tile';
|
||||
if(t.cat==='nature') g.sprite=WK.Sprites.getObject(t.item,0);
|
||||
else if(t.cat==='animals'){
|
||||
g.sprite=null;
|
||||
// animal ghost: draw mini animal
|
||||
const p=cam.project(h.x,h.y);
|
||||
WK.Sprites.diamondPath(ctx,p.x,p.y,1);
|
||||
ctx.fillStyle=valid?'rgba(140,230,150,.28)':'rgba(230,90,80,.3)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle=valid?'rgba(110,220,120,.85)':'rgba(230,80,70,.9)';
|
||||
ctx.lineWidth=2; ctx.stroke();
|
||||
ctx.save();
|
||||
ctx.translate(p.x,p.y);
|
||||
ctx.globalAlpha=.85;
|
||||
const sc=(WK.ANIMAL_SIZE[t.item]||1)*(valid?1:0.8);
|
||||
ctx.scale(sc,sc);
|
||||
WK.Sprites.drawAnimal(ctx,t.item,{state:'idle',ph:0,t:this.game.timeAbs});
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.renderer.drawGhost(ctx,cam,g);
|
||||
}
|
||||
}
|
||||
WK.Input = Input;
|
||||
})();
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
/* ============================================================
|
||||
Wildlife Kingdom — js/main.js
|
||||
Boot · animated hero menu · game loop · autosave · resizing
|
||||
============================================================ */
|
||||
(function(){
|
||||
'use strict';
|
||||
const WK = window.WK;
|
||||
|
||||
const Main = (WK.Main = {});
|
||||
let canvas, renderer, cam, input;
|
||||
let game=null;
|
||||
let mode='menu';
|
||||
let heroGame=null;
|
||||
let heroT=0;
|
||||
let lastT=performance.now();
|
||||
let saveT=0;
|
||||
|
||||
/* ---------------- boot ---------------- */
|
||||
function boot(){
|
||||
canvas=document.getElementById('game');
|
||||
renderer=new WK.Renderer(canvas);
|
||||
cam=new WK.Camera();
|
||||
cam.resize(renderer.cssW,renderer.cssH);
|
||||
const small=Math.min(window.innerWidth,window.innerHeight)<720;
|
||||
cam.zoom=small?0.8:1.05;
|
||||
input=new WK.Input(canvas,null,cam,renderer,WK.UI);
|
||||
|
||||
// QA hooks: ?autostart=1&fast=NN jumps straight into a running zoo
|
||||
const qa=new URLSearchParams(location.search);
|
||||
Main.qaMode=qa.has('autostart');
|
||||
|
||||
window.addEventListener('resize',onResize);
|
||||
document.addEventListener('visibilitychange',()=>{
|
||||
if(document.hidden&&mode==='game'&&game){ game.save(); }
|
||||
});
|
||||
|
||||
// menu buttons
|
||||
const cont=document.getElementById('btn-continue');
|
||||
cont.disabled=!WK.Game.hasSave();
|
||||
cont.onclick=()=>{ click(); const g=WK.Game.load(); if(g) enterGame(g); else { startNew(); } };
|
||||
document.getElementById('btn-new').onclick=()=>{ click(); startNew(); };
|
||||
document.getElementById('btn-how').onclick=()=>{ click(); WK.UI._onHowClose=()=>{}; WK.UI.howtoModal(true); };
|
||||
function click(){ WK.AudioSys.ensure(); WK.AudioSys.sfx('click'); }
|
||||
|
||||
startHero();
|
||||
if(Main.qaMode){
|
||||
startNew();
|
||||
const steps=parseInt(qa.get('fast')||'0',10)||0;
|
||||
for(let i=0;i<steps;i++) game.tick(1/30);
|
||||
const focus=qa.get('focus');
|
||||
if(focus){
|
||||
const [fx,fy]=focus.split(',').map(Number);
|
||||
cam.centerOnTile(fx,fy);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function onResize(){
|
||||
renderer.resize();
|
||||
cam.resize(renderer.cssW,renderer.cssH);
|
||||
}
|
||||
|
||||
/* ---------------- hero (menu) zoo ---------------- */
|
||||
function startHero(){
|
||||
heroGame=new WK.Game();
|
||||
WK.Game.buildDemo(heroGame);
|
||||
heroGame.speed=1;
|
||||
// pre-seed wandering guests on the paths
|
||||
const w=heroGame.world;
|
||||
const pathTiles=[];
|
||||
for(let y=0;y<w.rows;y++)for(let x=0;x<w.cols;x++) if(w.pid(x,y)) pathTiles.push([x,y]);
|
||||
for(let i=0;i<70&&pathTiles.length;i++){
|
||||
const [x,y]=pathTiles[(Math.random()*pathTiles.length)|0];
|
||||
heroGame.guests.push(new WK.Guest(heroGame,x+0.5,y+0.5));
|
||||
}
|
||||
heroGame.rebuildPois();
|
||||
cam.centerOnTile(w.cols/2,w.rows*0.42);
|
||||
}
|
||||
function heroDrift(dt){
|
||||
heroT+=dt;
|
||||
const w=heroGame.world;
|
||||
const cx=w.cols/2, cy=w.rows*0.44;
|
||||
cam.centerOnTile(
|
||||
cx+Math.sin(heroT*0.11)*w.cols*0.16,
|
||||
cy+Math.cos(heroT*0.07)*w.rows*0.10);
|
||||
}
|
||||
|
||||
/* ---------------- game lifecycle ---------------- */
|
||||
function startNew(){
|
||||
const g=new WK.Game();
|
||||
WK.Game.buildStarter(g);
|
||||
g.world.computeRegions();
|
||||
g.rebuildPois();
|
||||
enterGame(g,true);
|
||||
}
|
||||
Main.startNew=startNew;
|
||||
|
||||
function enterGame(g,fresh){
|
||||
game=g;
|
||||
mode='game';
|
||||
document.getElementById('menu').classList.add('hidden');
|
||||
document.getElementById('hud-top').classList.remove('hidden');
|
||||
document.getElementById('toolbar').classList.remove('hidden');
|
||||
document.getElementById('side-panel').classList.remove('hidden');
|
||||
document.getElementById('sp-toggle').classList.remove('hidden');
|
||||
if(window.innerWidth<1100) document.getElementById('side-panel').classList.add('hidden');
|
||||
|
||||
input.game=game;
|
||||
WK.UI.init(game,cam,renderer);
|
||||
if(fresh&&!Main.qaMode){
|
||||
g.save();
|
||||
setTimeout(()=>{ WK.UI.howtoModal(false); },350);
|
||||
g.notify('Welcome to Wildlife Kingdom! Follow the missions panel.','gold');
|
||||
}
|
||||
// camera on the entrance
|
||||
const e=g.exitTile||[g.world.cols/2,g.world.rows/2];
|
||||
cam.centerOnTile(e[0],e[1]-4);
|
||||
cam.clampToWorld(g.world);
|
||||
WK.AudioSys.setScene('game');
|
||||
if(WK.AudioSys.enabled&&!WK.AudioSys.musicOn===false){} /* music pref respected internally */
|
||||
}
|
||||
|
||||
function exitToMenu(){ /* kept simple: no in-game exit button besides reload */
|
||||
}
|
||||
|
||||
/* ---------------- main loop ---------------- */
|
||||
function loop(now){
|
||||
requestAnimationFrame(loop);
|
||||
let dt=(now-lastT)/1000;
|
||||
lastT=now;
|
||||
if(dt>0.12) dt=0.12;
|
||||
|
||||
input.update(dt);
|
||||
|
||||
if(mode==='menu'){
|
||||
if(heroGame){
|
||||
heroGame.timeAbs+=dt;
|
||||
const sdt=dt; // gentle life even on menu
|
||||
heroGame.minutes+=sdt*2;
|
||||
if(heroGame.minutes>=1440)heroGame.minutes-=1440;
|
||||
heroGame.spawnAcc+=sdt*0.02;
|
||||
for(const a of heroGame.animals) a.update(sdt*0.6,heroGame);
|
||||
for(const gu of heroGame.guests) gu.update(sdt*0.75);
|
||||
for(const s of heroGame.staff) s.update(sdt,heroGame);
|
||||
heroGame.particles.update(sdt);
|
||||
heroDrift(dt);
|
||||
renderer.frame(heroGame,cam,{});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// gameplay
|
||||
game.tick(dt);
|
||||
const grid=!!(WK.UI.tool||WK.UI.bulldoze);
|
||||
let tint=null;
|
||||
if(input.hover&&(WK.UI.tool&&['fences','animals'].includes(WK.UI.tool.cat)||WK.UI.bulldoze)){
|
||||
game.world.ensureRegions();
|
||||
const rid=game.world.regionOf(input.hover.x,input.hover.y);
|
||||
if(rid>=0){
|
||||
const reg=game.world.regions.find(r=>r.id===rid);
|
||||
if(reg&®.openEdges===0&&!reg.hasPath) tint=rid;
|
||||
else if(WK.UI.tool&&WK.UI.tool.cat==='animals') tint=rid;
|
||||
}
|
||||
}
|
||||
renderer.frame(game,cam,{
|
||||
grid,
|
||||
tintRegionId:tint,
|
||||
overlay:(ctx,c)=>input.overlay(ctx,c),
|
||||
});
|
||||
WK.UI.updateHUD();
|
||||
|
||||
saveT+=dt;
|
||||
if(saveT>30){ saveT=0; if(game.save()){} }
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded',boot);
|
||||
})();
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
/* ============================================================
|
||||
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;
|
||||
})();
|
||||
@@ -0,0 +1,614 @@
|
||||
/* ============================================================
|
||||
Wildlife Kingdom — js/sim.js
|
||||
The Game: time · money · research · missions · rating ·
|
||||
guest spawning · staff dispatch · placement rules · save/load
|
||||
============================================================ */
|
||||
(function(){
|
||||
'use strict';
|
||||
const WK = window.WK;
|
||||
const D = WK.Data;
|
||||
|
||||
const SAVE_KEY='wildlifeKingdom.save.v1';
|
||||
|
||||
class Game{
|
||||
constructor(){
|
||||
const g=D.GRID;
|
||||
this.world=new WK.World(g.COLS,g.ROWS);
|
||||
this.money=D.START_MONEY;
|
||||
this.rp=0;
|
||||
this.researchTier=1;
|
||||
this.day=1;
|
||||
this.minutes=8*60;
|
||||
this.speed=1;
|
||||
this.animals=[];
|
||||
this.guests=[];
|
||||
this.staff=[];
|
||||
this.particles=new WK.Particles();
|
||||
this.stats={paths:0,fences:0,adopted:0,guestsTotal:0,guestsToday:0,revenueTotal:0,revenueToday:0,expensesTotal:0,sold:0};
|
||||
this.missionIdx=0;
|
||||
this.rating=1;
|
||||
this.ratingParts={};
|
||||
this.spawnAcc=0;
|
||||
this.sfx=(n)=>{ if(WK.AudioSys&&WK.AudioSys.enabled) WK.AudioSys.sfx(n); };
|
||||
this._scoreCache={ver:-1,map:new Map()};
|
||||
this._poiVer=-1;
|
||||
this.pois=[];
|
||||
this.exitTile=null;
|
||||
this.crowdStress=0;
|
||||
this.selected=null;
|
||||
this.timeAbs=0;
|
||||
}
|
||||
|
||||
/* ---------------- helpers ---------------- */
|
||||
get maxRegion(){
|
||||
this.world.ensureRegions();
|
||||
let m=0;
|
||||
for(const r of this.world.regions) if(r.openEdges===0&&!r.hasPath) m=Math.max(m,r.area);
|
||||
return m;
|
||||
}
|
||||
speciesCount(){ return new Set(this.animals.map(a=>a.species)).size; }
|
||||
notify(msg,type){ if(this.ui&&this.ui.toast) this.ui.toast(msg,type); }
|
||||
earnMoney(n,src){
|
||||
this.money+=n;
|
||||
this.stats.revenueTotal+=n; this.stats.revenueToday+=n;
|
||||
if(this.ui&&this.ui.flashStat) this.ui.flashStat('money');
|
||||
}
|
||||
spendMoney(n){
|
||||
if(this.money<n){ this.notify('Not enough coins!','bad'); this.sfx('error'); return false; }
|
||||
this.money-=n; return true;
|
||||
}
|
||||
|
||||
/* ---------------- habitat scoring ---------------- */
|
||||
habitatScoreFor(regionId,species){
|
||||
const ver=this.world.ver||0;
|
||||
if(this._scoreCache.ver!==ver){ this._scoreCache.ver=ver; this._scoreCache.map.clear(); }
|
||||
const key=regionId+':'+species;
|
||||
if(this._scoreCache.map.has(key)) return this._scoreCache.map.get(key);
|
||||
const reg=this.world.regions.find(r=>r.id===regionId);
|
||||
let score=8;
|
||||
if(reg){
|
||||
const sp=D.SPECIES[species];
|
||||
const area=reg.area;
|
||||
let bio=0, enrich=0, water=0;
|
||||
for(const k of reg.tiles){
|
||||
const gid=WK.GIDS[this.world.ground[k]];
|
||||
bio+= (sp.biome[gid]!=null)? sp.biome[gid] : 0.25;
|
||||
const o=this.world.obj[k];
|
||||
if(o){ enrich+=D.NATURE[WK.NIDS[o-1]].enrich||0; }
|
||||
if(D.GROUND[gid].water) water++;
|
||||
}
|
||||
bio/=area;
|
||||
const areaF=WK.clamp(area/Math.max(6,sp.minArea),0,1);
|
||||
const enr=WK.clamp(enrich/Math.max(0.5,sp.enrichNeed||0.5),0,1);
|
||||
score = WK.clamp(bio*52 + areaF*22 + enr*16 + (sp.swim?(water>0?10:(bio>0.75?6:0)):4), 5, 100);
|
||||
}
|
||||
this._scoreCache.map.set(key,score);
|
||||
return score;
|
||||
}
|
||||
habitatScore(animal){
|
||||
const s=this.habitatScoreFor(animal.regionId,animal.species);
|
||||
// crowd of same species penalty (max 4 comfortable per species)
|
||||
const same=this.animals.filter(a=>a.regionId===animal.regionId&&a.species===animal.species).length;
|
||||
return WK.clamp(s-(same>4?(same-4)*4:0),5,100);
|
||||
}
|
||||
validHabitatAt(x,y){
|
||||
this.world.ensureRegions();
|
||||
const rid=this.world.regionOf(x,y);
|
||||
if(rid<0) return {error:'Can\u2019t place that here.'};
|
||||
const reg=this.world.regions.find(r=>r.id===rid);
|
||||
if(!reg) return {error:'No region here.'};
|
||||
if(reg.hasPath) return {error:'Paths run through this area — habitats must be fence-only.'};
|
||||
if(reg.openEdges>0) return {error:'Not enclosed! Fully surround the area with fences.'};
|
||||
return {region:reg};
|
||||
}
|
||||
|
||||
/* ---------------- placement operations ---------------- */
|
||||
tryGround(x,y,gid){
|
||||
const def=D.GROUND[gid];
|
||||
if(def.tier>this.researchTier) return false;
|
||||
const cur=this.world.gid(x,y);
|
||||
if(cur===gid) return false;
|
||||
if(!this.spendMoney(def.cost)) return false;
|
||||
if(this.world.setGround(x,y,gid)){ this.sfx('dig'); return true; }
|
||||
this.money+=def.cost; return false;
|
||||
}
|
||||
tryPath(x,y,pid){
|
||||
const def=D.PATH[pid];
|
||||
if(def.tier>this.researchTier) return false;
|
||||
if(this.world.path[this.world.idx(x,y)]) { // replace path: charge diff
|
||||
const cur=this.world.pid(x,y);
|
||||
if(cur===pid) return false;
|
||||
const diff=Math.max(0,def.cost-D.PATH[cur].cost);
|
||||
if(diff&&!this.spendMoney(diff)) return false;
|
||||
this.world.setPath(x,y,pid); this.sfx('place');
|
||||
this.stats.paths++; return true;
|
||||
}
|
||||
if(!this.spendMoney(def.cost)) return false;
|
||||
if(this.world.setPath(x,y,pid)){ this.sfx('place'); this.stats.paths++; return true; }
|
||||
this.money+=def.cost; return false;
|
||||
}
|
||||
tryNature(x,y,nid){
|
||||
const def=D.NATURE[nid];
|
||||
if(def.tier>this.researchTier) return false;
|
||||
if(this.world.oid(x,y)) { this.tryBulldozeObject(x,y,true); }
|
||||
if(!this.spendMoney(def.cost)) return false;
|
||||
if(this.world.setObj(x,y,nid,(Math.random()*3)|0)){ this.sfx('place'); return true; }
|
||||
this.money+=def.cost; return false;
|
||||
}
|
||||
tryFence(x,y,edge,kindName){
|
||||
const kind= kindName==='gate'?3: kindName==='hedge'?2:1;
|
||||
const def=D.FENCE[kind];
|
||||
if(def.tier>this.researchTier) return false;
|
||||
const cur= edge==='S'?this.world.fenceS[this.world.idx(x,y)]:this.world.fenceE[this.world.idx(x,y)];
|
||||
if(cur===kind) return false;
|
||||
if(!this.spendMoney(def.cost)) return false;
|
||||
this.world.setFence(x,y,edge,kind);
|
||||
this.sfx('place'); this.stats.fences++;
|
||||
return true;
|
||||
}
|
||||
tryBulldoze(x,y){
|
||||
const b=this.world.buildingAt(x,y);
|
||||
if(b){ return this.demolishBuilding(b); }
|
||||
if(this.world.obj[this.world.idx(x,y)]) return this.tryBulldozeObject(x,y);
|
||||
if(this.world.path[this.world.idx(x,y)]){
|
||||
this.world.setPath(x,y,null); this.sfx('trash'); return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
tryBulldozeObject(x,y,silent){
|
||||
if(!this.world.obj[this.world.idx(x,y)]) return false;
|
||||
this.money+=Math.round(D.NATURE[this.world.oid(x,y)].cost*0.4);
|
||||
this.world.setObj(x,y,null);
|
||||
if(!silent) this.sfx('trash');
|
||||
return true;
|
||||
}
|
||||
tryBulldozeEdge(x,y,edge){
|
||||
const cur= edge==='S'?this.world.fenceS[this.world.idx(x,y)]:this.world.fenceE[this.world.idx(x,y)];
|
||||
if(!cur) return false;
|
||||
this.money+=Math.round(D.FENCE[cur].cost*0.4);
|
||||
this.world.setFence(x,y,edge,0);
|
||||
this.sfx('trash');
|
||||
return true;
|
||||
}
|
||||
tryBuilding(type,x,y){
|
||||
const def=D.BUILDING[type];
|
||||
if(def.tier>this.researchTier){ this.notify('Locked — unlock more research tiers.','bad'); return false; }
|
||||
if(!this.world.canPlaceBuilding(x,y,def.w,def.h)){ this.notify('Space is blocked — clear the area first.','bad'); this.sfx('error'); return false; }
|
||||
if(!this.spendMoney(def.cost)) return false;
|
||||
const b=this.world.placeBuilding(type,x,y);
|
||||
if(!b){ this.money+=def.cost; return false; }
|
||||
this.sfx('place');
|
||||
if(def.staff==='keeper'){ this.addStaff('keeper',b); this.notify('A keeper joined your staff!','good'); }
|
||||
if(def.staff==='vet'){ this.addStaff('vet',b); this.notify('A vet joined your staff!','good'); }
|
||||
this.refreshPois();
|
||||
return true;
|
||||
}
|
||||
demolishBuilding(b){
|
||||
const def=D.BUILDING[b.type];
|
||||
this.money+=Math.round(def.cost*0.5);
|
||||
if(def.staff){
|
||||
const idx=this.staff.findIndex(s=>s.role===def.staff&&!s._homeRemoved);
|
||||
if(idx>=0){ this.staff.splice(idx,1); this.notify(def.staff==='keeper'?'A keeper left your staff.':'A vet left your staff.','bad'); }
|
||||
}
|
||||
this.world.removeBuildingById(b.id);
|
||||
this.sfx('trash');
|
||||
this.refreshPois();
|
||||
if(this.ui&&this.ui.hideSelection) this.ui.hideSelection();
|
||||
return true;
|
||||
}
|
||||
addStaff(role,b){
|
||||
const d=this.world.doorTile(b);
|
||||
this.staff.push(new WK.Staff(role,d[0],d[1]));
|
||||
}
|
||||
|
||||
tryAdopt(species,x,y){
|
||||
const sp=D.SPECIES[species];
|
||||
if(sp.tier>this.researchTier){ this.notify('Locked — research more tiers first.','bad'); return false; }
|
||||
const chk=this.validHabitatAt(x,y);
|
||||
if(chk.error){ this.notify(chk.error,'bad'); this.sfx('error'); return false; }
|
||||
const reg=chk.region;
|
||||
if(reg.area<sp.minArea){
|
||||
this.notify(sp.name+' needs a habitat of at least '+sp.minArea+' tiles.','bad');
|
||||
this.sfx('error'); return false;
|
||||
}
|
||||
const score=this.habitatScoreFor(reg.id,species);
|
||||
if(score<50){
|
||||
this.notify('Terrain unsuitable for a '+sp.name+'! Match its favorite biomes (needs '+Math.round(score)+'%, want 50%+).','bad');
|
||||
this.sfx('error'); return false;
|
||||
}
|
||||
if(!this.spendMoney(sp.cost)) return false;
|
||||
// drop the animal onto a passable nearby tile inside region
|
||||
let px=x,py=y;
|
||||
const g0=D.GROUND[this.world.gid(x,y)];
|
||||
if((g0.water&&!sp.swim)||(this.world.oid(x,y)&&D.NATURE[this.world.oid(x,y)].block)){
|
||||
const alt=this.world.randomRegionTile(reg.id,sp,Math.random);
|
||||
if(alt){px=alt[0];py=alt[1];}
|
||||
}
|
||||
const a=new WK.Animal(species,px+0.5-0.5,py+0.5-0.5,reg.id);
|
||||
a.x=px; a.y=py;
|
||||
this.animals.push(a);
|
||||
this.stats.adopted++;
|
||||
this.sfx('chirp');
|
||||
this.notify(a.name+' the '+sp.name+' joins the zoo!','good');
|
||||
this.particles.hearts(px,py);
|
||||
return true;
|
||||
}
|
||||
sellAnimal(a){
|
||||
const i=this.animals.indexOf(a);
|
||||
if(i<0)return;
|
||||
this.animals.splice(i,1);
|
||||
const refund=Math.round(D.SPECIES[a.species].cost*0.45);
|
||||
this.money+=refund;
|
||||
this.stats.sold++;
|
||||
this.notify(a.name+' found a new home (+'+WK.fmtMoney(refund)+').','good');
|
||||
this.sfx('coin');
|
||||
if(this.ui&&this.ui.hideSelection) this.ui.hideSelection();
|
||||
}
|
||||
|
||||
/* ---------------- jobs dispatcher ---------------- */
|
||||
requestJob(role){
|
||||
if(role==='keeper'){
|
||||
let best=null;
|
||||
for(const a of this.animals){
|
||||
if(a._taken||a.eatT>0||a.sick) continue;
|
||||
if(a.hunger>62 && (!best||a.hunger>best.hunger)) best=a;
|
||||
}
|
||||
if(best){ best._taken=true; return best; }
|
||||
}else{
|
||||
for(const a of this.animals){
|
||||
if(a.sick&&!a._taken){ a._taken=true; return a; }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
abandonJob(role,a){ a._taken=false; }
|
||||
|
||||
/* ---------------- POIs ---------------- */
|
||||
refreshPois(){
|
||||
this._poiVer=-1;
|
||||
}
|
||||
rebuildPois(){
|
||||
this.world.ensureRegions();
|
||||
const pois=[];
|
||||
const regionAppeal=new Map();
|
||||
for(const a of this.animals){
|
||||
const cur=regionAppeal.get(a.regionId)||{appeal:0,q:0,n:0};
|
||||
cur.appeal=Math.max(cur.appeal,D.SPECIES[a.species].appeal);
|
||||
cur.q+=a.happiness/100; cur.n++;
|
||||
regionAppeal.set(a.regionId,cur);
|
||||
}
|
||||
for(const [rid,info] of regionAppeal){
|
||||
const vps=this.world.viewpoints(rid);
|
||||
const q=info.q/info.n;
|
||||
for(const vp of vps.slice(0,10)){
|
||||
pois.push({type:'view',x:vp[0],y:vp[1],regionId:rid,appeal:info.appeal,quality:q});
|
||||
}
|
||||
}
|
||||
for(const b of this.world.buildings){
|
||||
const def=D.BUILDING[b.type];
|
||||
const d=this.world.doorTile(b);
|
||||
if(def.entrance) continue;
|
||||
if(def.restroom){}
|
||||
pois.push({
|
||||
type: def.income>0?'shop':(b.type==='restroom'?'restroom':'visit'),
|
||||
btype:b.type, x:d[0], y:d[1], income:def.income, joy:def.joy,
|
||||
});
|
||||
if(b.type==='restroom'){ pois.pop(); pois.push({type:'restroom',btype:b.type,x:d[0],y:d[1]}); }
|
||||
}
|
||||
this.pois=pois;
|
||||
// exits
|
||||
const ent=this.world.buildings.filter(b=>D.BUILDING[b.type].entrance);
|
||||
this.exitTile = ent.length ? this.world.doorTile(ent[0]) : null;
|
||||
this.entrances=ent.length;
|
||||
this.entranceCap = ent.reduce((s,b)=>s+(D.BUILDING[b.type].big?90:45),0);
|
||||
this._poiVer=this.world.ver||0;
|
||||
}
|
||||
|
||||
/* ---------------- rating ---------------- */
|
||||
recomputeRating(){
|
||||
const P={};
|
||||
P.welfare = this.animals.length ? this.animals.reduce((s,a)=>s+a.happiness,0)/(this.animals.length*100) : 0.55;
|
||||
P.variety = WK.clamp(this.speciesCount()/10,0,1);
|
||||
P.joy = this.guests.length ? this.guests.reduce((s,g)=>s+g.joy,0)/(this.guests.length*100) : (this.ratingParts.joy!=null?this.ratingParts.joy:0.5);
|
||||
const guests=this.guests.length;
|
||||
const restrooms=this.world.buildings.filter(b=>b.type==='restroom').length;
|
||||
const food=this.world.buildings.filter(b=>['burger','icecream','restaurant'].includes(b.type)).length;
|
||||
const benches=this.countObj('bench');
|
||||
P.facilities = WK.clamp(
|
||||
0.1 + 0.45*Math.min(restrooms/Math.max(1,guests/28),1) +
|
||||
0.25*Math.min(food/Math.max(1,guests/45),1) +
|
||||
0.2*Math.min(benches/Math.max(1,guests/14),1),
|
||||
0,1);
|
||||
P.decor = WK.clamp(this.countNature()/55,0,1);
|
||||
P.exhibits = WK.clamp(this.world.buildings.filter(b=>D.BUILDING[b.type].exhibit).length/3,0,1);
|
||||
this.ratingParts=P;
|
||||
this.rating = WK.clamp((P.welfare*1.5 + P.variety*1.2 + P.joy*1.0 + P.facilities*0.8 + P.decor*0.4 + P.exhibits*0.6)/5.5*5, 0.4, 5);
|
||||
}
|
||||
countObj(id){
|
||||
let n=0; const arr=this.world.obj;
|
||||
for(let i=0;i<arr.length;i++) if(arr[i]===WK.NIDS.indexOf(id)+1) n++;
|
||||
return n;
|
||||
}
|
||||
countNature(){
|
||||
let n=0; const arr=this.world.obj;
|
||||
for(let i=0;i<arr.length;i++) if(arr[i]) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/* ---------------- main tick ---------------- */
|
||||
tick(dt){
|
||||
this.timeAbs+=dt;
|
||||
const spd=this.speed;
|
||||
const sdt=dt*spd;
|
||||
if(sdt>0){
|
||||
this.minutes+=sdt*4;
|
||||
while(this.minutes>=1440){ this.minutes-=1440; this.newDay(); }
|
||||
// world version → caches
|
||||
if((this.world.ver||0)!==this._poiVer) this.rebuildPois();
|
||||
// research
|
||||
const centers=this.world.buildings.filter(b=>b.type==='research').length;
|
||||
this.rp += sdt*(0.003 + centers*(5/60));
|
||||
// entities
|
||||
for(let i=this.animals.length-1;i>=0;i--){
|
||||
const a=this.animals[i];
|
||||
a.update(sdt,this);
|
||||
// sickness rolls
|
||||
if(!a.sick){
|
||||
const p=a.happiness<45?0.0011:0.00013;
|
||||
if(Math.random()<p*sdt*4){ a.sick=true; a._taken=false;
|
||||
this.notify(a.name+' the '+D.SPECIES[a.species].name+' feels sick! Build a Vet Clinic.','bad'); this.sfx('error'); }
|
||||
}else{
|
||||
a._sickT=(a._sickT||0)+sdt;
|
||||
if(a._sickT>150){ a.sick=false; a._sickT=0; this.notify(a.name+' recovered on their own.','good'); }
|
||||
}
|
||||
if(a.eatT<=0&&a.state!=='eat'&&!a.sick){ /* _taken cleared on feeding */ }
|
||||
}
|
||||
// escape check (throttled)
|
||||
this._escT=(this._escT||0)+sdt;
|
||||
if(this._escT>2){
|
||||
this._escT=0;
|
||||
for(const a of this.animals){
|
||||
const reg=this.world.regions.find(r=>r.id===a.regionId);
|
||||
if(!reg||(reg.openEdges>0)||reg.hasPath){
|
||||
a.happiness=WK.clamp(a.happiness-sdt*8,5,100);
|
||||
if(!a._escWarned){ a._escWarned=true;
|
||||
this.notify(a.name+'\u2019s habitat is not secure! Check fences & paths.','bad'); }
|
||||
}else a._escWarned=false;
|
||||
}
|
||||
}
|
||||
// guests
|
||||
this.crowdStress = this.guests.length > 25 + this.world.buildings.filter(b=>b.type==='restroom').length*15 ? 0.06 : 0;
|
||||
const cap=Math.min(D.CAPS.guests, 12 + (this.entranceCap||0) + Math.round(this.rating*8));
|
||||
const rate=(0.05 + this.rating*0.09);
|
||||
this.spawnAcc+=sdt*rate;
|
||||
if(this.spawnAcc>=1){
|
||||
this.spawnAcc-=1;
|
||||
if(this.guests.length<cap && this.exitTile){
|
||||
const e=this.exitTile;
|
||||
const gs=new WK.Guest(this,e[0]+0.5,e[1]+0.5);
|
||||
this.guests.push(gs);
|
||||
const fee=8+Math.round(this.rating*4);
|
||||
this.earnMoney(fee,'ticket');
|
||||
this.stats.guestsTotal++; this.stats.guestsToday++;
|
||||
if(this.guests.length%10===0) this.coinFloat(e[0],e[1]);
|
||||
}
|
||||
}
|
||||
for(let i=this.guests.length-1;i>=0;i--){
|
||||
const g=this.guests[i];
|
||||
g.update(sdt);
|
||||
if(g.done){
|
||||
if(g.joy>85) this.ratingParts.joy=Math.min(1,(this.ratingParts.joy==null?0.6:this.ratingParts.joy)+0.01);
|
||||
if(g.joy<28) this.ratingParts.joy=Math.max(0.1,(this.ratingParts.joy==null?0.5:this.ratingParts.joy)-0.02);
|
||||
this.guests.splice(i,1);
|
||||
}
|
||||
}
|
||||
for(const s of this.staff) s.update(sdt,this);
|
||||
this.particles.update(sdt);
|
||||
// rating throttle
|
||||
this._rateT=(this._rateT||0)+dt;
|
||||
if(this._rateT>1){ this._rateT=0; this.recomputeRating(); }
|
||||
// missions
|
||||
this._misT=(this._misT||0)+dt;
|
||||
if(this._misT>0.8){
|
||||
this._misT=0;
|
||||
this.checkMission();
|
||||
}
|
||||
}
|
||||
}
|
||||
coinFloat(x,y){ this.particles.coins(x,y); }
|
||||
coinBurst(x,y){ this.particles.coins(x,y); }
|
||||
puffHeart(x,y){ this.particles.hearts(x,y); }
|
||||
|
||||
newDay(){
|
||||
this.day++;
|
||||
let wages=0;
|
||||
wages += this.staff.filter(s=>s.role==='keeper').length*70;
|
||||
wages += this.staff.filter(s=>s.role==='vet').length*90;
|
||||
let upkeep=0;
|
||||
for(const b of this.world.buildings) upkeep+=Math.round(D.BUILDING[b.type].cost*0.006);
|
||||
let animalUpkeep=0;
|
||||
for(const a of this.animals) animalUpkeep+=D.SPECIES[a.species].upkeep;
|
||||
const total=wages+upkeep+animalUpkeep;
|
||||
this.money-=total;
|
||||
this.stats.expensesTotal+=total;
|
||||
const profit=this.stats.revenueToday-total;
|
||||
this.notify('Day '+this.day+' — wages '+WK.fmtMoney(wages)+' · upkeep '+WK.fmtMoney(upkeep+animalUpkeep)+(profit>=0?' · profit '+WK.fmtMoney(profit):' · loss '+WK.fmtMoney(-profit)), profit>=0?'good':'bad');
|
||||
this.stats.revenueToday=0;
|
||||
this.stats.guestsToday=0;
|
||||
this.sfx(profit>=0?'coin':'error');
|
||||
}
|
||||
|
||||
/* ---------------- missions & research ---------------- */
|
||||
checkMission(){
|
||||
const m=D.MISSIONS[this.missionIdx];
|
||||
if(!m) return;
|
||||
if(m.check(this)){
|
||||
let msg='Mission complete: '+m.label+'!';
|
||||
if(typeof m.reward==='number'){ this.money+=m.reward; msg+=' +'+WK.fmtMoney(m.reward); }
|
||||
else{
|
||||
if(m.reward.cash){ this.money+=m.reward.cash; msg+=' +'+WK.fmtMoney(m.reward.cash); }
|
||||
if(m.reward.rp){ this.rp+=m.reward.rp; msg+=' +'+m.reward.rp+' RP'; }
|
||||
}
|
||||
this.notify(msg,'gold');
|
||||
this.sfx('unlock');
|
||||
this.missionIdx++;
|
||||
}
|
||||
}
|
||||
researchCost(){
|
||||
const next=D.RESEARCH_TIERS.find(t=>t.tier===this.researchTier+1);
|
||||
return next?next.cost:null;
|
||||
}
|
||||
buyResearch(){
|
||||
const cost=this.researchCost();
|
||||
if(cost==null) return false;
|
||||
if(this.rp<cost){ this.notify('Need '+Math.ceil(cost-this.rp)+' more RP.','bad'); this.sfx('error'); return false; }
|
||||
this.rp-=cost;
|
||||
this.researchTier++;
|
||||
this.notify('Research unlocked: '+D.RESEARCH_TIERS.find(t=>t.tier===this.researchTier).label+'!','gold');
|
||||
this.sfx('unlock');
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------------- persistence ---------------- */
|
||||
save(){
|
||||
try{
|
||||
const data={
|
||||
v:1, money:this.money, rp:this.rp, researchTier:this.researchTier,
|
||||
day:this.day, minutes:this.minutes, missionIdx:this.missionIdx,
|
||||
stats:this.stats, ratingJoy:this.ratingParts.joy,
|
||||
world:this.world.serialize(),
|
||||
animals:this.animals.map(a=>({s:a.species,n:a.name,x:a.x,y:a.y,h:a.hunger,p:a.happiness,k:a.sick,r:a.regionId})),
|
||||
staff:this.staff.map(s=>({r:s.role,x:s.x,y:s.y})),
|
||||
};
|
||||
localStorage.setItem(SAVE_KEY,JSON.stringify(data));
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
}
|
||||
static hasSave(){ try{ return !!localStorage.getItem(SAVE_KEY); }catch(e){ return false; } }
|
||||
static load(){
|
||||
try{
|
||||
const raw=localStorage.getItem(SAVE_KEY);
|
||||
if(!raw) return null;
|
||||
const d=JSON.parse(raw);
|
||||
const g=new Game();
|
||||
g.world.deserialize(d.world);
|
||||
g.money=d.money; g.rp=d.rp; g.researchTier=d.researchTier;
|
||||
g.day=d.day; g.minutes=d.minutes; g.missionIdx=d.missionIdx;
|
||||
Object.assign(g.stats,d.stats||{});
|
||||
g.ratingParts.joy=d.ratingJoy;
|
||||
for(const a of d.animals||[]){
|
||||
const an=new WK.Animal(a.s,a.x,a.y,a.r||0);
|
||||
an.name=a.n; an.hunger=a.h; an.happiness=a.p; an.sick=!!a.k;
|
||||
g.animals.push(an);
|
||||
}
|
||||
for(const s of d.staff||[]) g.staff.push(new WK.Staff(s.r,s.x,s.y));
|
||||
g.recomputeRating();
|
||||
g.rebuildPois();
|
||||
return g;
|
||||
}catch(e){ return null; }
|
||||
}
|
||||
static clearSave(){ try{ localStorage.removeItem(SAVE_KEY); }catch(e){} }
|
||||
}
|
||||
|
||||
/* starter zoo: cozy beginning — entrance, plaza path, some greenery */
|
||||
Game.buildStarter = function(game){
|
||||
const w=game.world;
|
||||
const cx=(w.cols/2)|0, sy=w.rows-6;
|
||||
const b=w.placeBuilding('entrance_s',cx-1,sy);
|
||||
// plaza
|
||||
for(let x=cx-4;x<=cx+5;x++){
|
||||
w.setGround(x,sy+1,'grass'); w.setPath(x,sy+1,'gravel');
|
||||
w.setGround(x,sy+2,'grass'); w.setPath(x,sy+2,'gravel');
|
||||
}
|
||||
for(let y=sy-8;y<=sy+2;y++){ w.setGround(cx+2,y,'grass'); w.setPath(cx+2,y,'gravel'); }
|
||||
// greenery
|
||||
const deco=[[cx-6,sy-2,'tree'],[cx+7,sy-1,'tree'],[cx-5,sy+2,'bush'],[cx+6,sy+2,'bush'],
|
||||
[cx-3,sy-4,'flower'],[cx+3,sy-4,'flower'],[cx-8,sy,'tree'],[cx+9,sy,'tree'],
|
||||
[cx-7,sy+3,'bench'],[cx+8,sy+3,'bench'],[cx-2,sy-5,'rock']];
|
||||
for(const [x,y,o] of deco){ if(w.inBounds(x,y)&&w.occ[w.idx(x,y)]<0) w.setObj(x,y,o,(Math.random()*3)|0); }
|
||||
game.refreshPois();
|
||||
return b;
|
||||
};
|
||||
|
||||
/* demo zoo for the menu hero scene */
|
||||
Game.buildDemo = function(game){
|
||||
const w=game.world;
|
||||
const C=w.cols, Rws=w.rows;
|
||||
const cx=(C/2)|0;
|
||||
const rnd=WK.mulberry32(20240001);
|
||||
// grand entrance south
|
||||
w.placeBuilding('entrance_g',cx-2,Rws-5);
|
||||
for(let x=cx-6;x<=cx+7;x++){ w.setPath(x,Rws-4,'stonep'); w.setPath(x,Rws-3,'stonep'); }
|
||||
// ring promenade
|
||||
const yN=8, yS=Rws-6, xW=6, xE=C-7;
|
||||
for(let x=xW;x<=xE;x++){ w.setPath(x,yN,'stonep'); w.setPath(x,yS,'stonep'); }
|
||||
for(let y=yN;y<=yS;y++){ w.setPath(xW,y,'stonep'); w.setPath(xE,y,'stonep'); }
|
||||
for(let y=yS;y<=Rws-3;y++) w.setPath(cx,y,'stonep');
|
||||
// helper: rectangular fenced habitat with terrain + animals + gate on given side path
|
||||
function habitat(x0,y0,x1,y1,grounds,species,waterRect){
|
||||
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++) w.setGround(x,y,grounds[(rnd()*grounds.length)|0]);
|
||||
if(waterRect){
|
||||
for(let y=waterRect[1];y<=waterRect[3];y++)for(let x=waterRect[0];x<=waterRect[2];x++) w.setGround(x,y, waterRect[4]||'water');
|
||||
}
|
||||
for(let x=x0;x<=x1;x++){ w.setFence(x,y0-1,'S',1); w.setFence(x,y1,'S',1); }
|
||||
for(let y=y0;y<=y1;y++){ w.setFence(x0-1,y,'E',1); w.setFence(x1,y,'E',1); }
|
||||
// gate on south side middle connecting to nearest path below
|
||||
const gx=((x0+x1)/2)|0;
|
||||
w.setFence(gx,y1,'S',3);
|
||||
const rid=w.regionOf(((x0+x1)/2)|0,((y0+y1)/2)|0);
|
||||
w.computeRegions();
|
||||
for(const sp of species){
|
||||
const t=w.randomRegionTile(rid,D.SPECIES[sp],rnd)||[x0+1,y0+1];
|
||||
const a=new WK.Animal(sp,t[0],t[1],rid);
|
||||
game.animals.push(a);
|
||||
}
|
||||
// interior decor
|
||||
for(let i=0;i<Math.max(2,((x1-x0)*(y1-y0))/18|0);i++){
|
||||
const rx=x0+1+((rnd()*(x1-x0-1))|0), ry=y0+1+((rnd()*(y1-y0-1))|0);
|
||||
const o=grounds.includes('snow')?'rock':(grounds.includes('sand')?'palm':(species.includes('panda')?'bamboo':'tree'));
|
||||
if(!w.path[w.idx(rx,ry)]) w.setObj(rx,ry,o,0);
|
||||
}
|
||||
}
|
||||
// savanna center-left: lions+zebra+giraffe
|
||||
habitat(9,11,17,17,['grass','grass','sand'],['lion','zebra','giraffe']);
|
||||
// elephants right of it
|
||||
habitat(19,11,27,17,['grass','dirt'],['elephant','elephant']);
|
||||
// arctic NW corner
|
||||
habitat(9,20,16,26,['snow','ice'],['penguin','penguin','polarbear'],[11,22,14,25,'water']);
|
||||
// wetland SW
|
||||
habitat(19,21,26,27,['sand','grass'],['flamingo','flamingo'],[21,23,24,25,'water']);
|
||||
// jungle SE quadrant
|
||||
habitat(29,20,38,28,['dirt','grass'],['tiger','gorilla','monkey']);
|
||||
for(let i=0;i<8;i++){ const rx=30+((rnd()*8)|0), ry=21+((rnd()*7)|0); w.setObj(rx,ry,'bamboo',0); }
|
||||
// panda grove NE
|
||||
habitat(29,10,36,16,['grass','dirt'],['panda','panda']);
|
||||
for(let i=0;i<6;i++){ const rx=30+((rnd()*6)|0), ry=11+((rnd()*5)|0); w.setObj(rx,ry,'bamboo',0); }
|
||||
// croc lagoon far east
|
||||
habitat(39,12,C-2,18,['sand'],['crocodile'],[40,14,C-3,17,'deep']);
|
||||
|
||||
w.computeRegions();
|
||||
|
||||
// facilities along paths
|
||||
const put=(t,x,y)=>{ w.canPlaceBuilding(x,y,D.BUILDING[t].w,D.BUILDING[t].h)&&w.placeBuilding(t,x,y); };
|
||||
put('aquarium',31,29); put('restaurant',8,29); put('gift',13,29); put('icecream',18,30);
|
||||
put('burger',24,30); put('restroom',28,29); put('restroom',7,19); put('aviary',40,20);
|
||||
put('reptile',40,29); put('visitor',20,4); put('research',27,3); put('vet',13,3); put('hut',37,3);
|
||||
// street life
|
||||
for(let i=0;i<40;i++){
|
||||
const x=xW+((rnd()*(xE-xW))|0), y=rnd()<0.5?yN:yS;
|
||||
const o=['tree','bush','flower','lamp','bench'][(rnd()*5)|0];
|
||||
if(w.inBounds(x,y)&&!w.path[w.idx(x,y)]) w.setObj(x,y,o,(rnd()*3)|0);
|
||||
}
|
||||
for(let i=0;i<26;i++){
|
||||
const ang=rnd()*Math.PI*2, rr=6+rnd()*14;
|
||||
const x=cx+Math.cos(ang)*rr|0, y=((yN+yS)/2+Math.sin(ang)*rr*0.7)|0;
|
||||
if(w.inBounds(x,y)&&w.occ[w.idx(x,y)]<0&&!w.path[w.idx(x,y)]) w.setObj(x,y,['tree','tree','bush','flower'][(rnd()*4)|0],(rnd()*3)|0);
|
||||
}
|
||||
w.computeRegions();
|
||||
game.rebuildPois();
|
||||
game.recomputeRating();
|
||||
};
|
||||
|
||||
Game.SAVE_KEY=SAVE_KEY;
|
||||
WK.Game = Game;
|
||||
})();
|
||||
+1161
File diff suppressed because it is too large
Load Diff
@@ -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">1–3</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();
|
||||
};
|
||||
};
|
||||
})();
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/* ============================================================
|
||||
Wildlife Kingdom — js/util.js
|
||||
Math, RNG, color & canvas helpers. Exposes global WK.
|
||||
============================================================ */
|
||||
(function(){
|
||||
'use strict';
|
||||
const WK = (window.WK = window.WK || {});
|
||||
|
||||
WK.TILE_W = 64; // iso diamond width
|
||||
WK.TILE_H = 32; // iso diamond height
|
||||
WK.HW = WK.TILE_W / 2;
|
||||
WK.HH = WK.TILE_H / 2;
|
||||
|
||||
/* ---- math ---- */
|
||||
WK.clamp = (v,a,b)=> v<a?a:(v>b?b:v);
|
||||
WK.lerp = (a,b,t)=> a+(b-a)*t;
|
||||
WK.dist2 = (ax,ay,bx,by)=>{const dx=ax-bx,dy=ay-by;return dx*dx+dy*dy;};
|
||||
WK.dist = (ax,ay,bx,by)=> Math.hypot(ax-bx,ay-by);
|
||||
WK.TAU = Math.PI*2;
|
||||
WK.easeOutCubic = t=>1-Math.pow(1-t,3);
|
||||
WK.easeInOut = t=> t<0.5 ? 2*t*t : 1-Math.pow(-2*t+2,2)/2;
|
||||
|
||||
/* deterministic RNG */
|
||||
WK.mulberry32 = function(seed){
|
||||
let a = seed>>>0;
|
||||
return function(){
|
||||
a |= 0; a = (a + 0x6D2B79F5)|0;
|
||||
let t = Math.imul(a ^ (a>>>15), 1|a);
|
||||
t = (t + Math.imul(t ^ (t>>>7), 61|t)) ^ t;
|
||||
return ((t ^ (t>>>14)) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
WK.hash2 = function(x,y,s){
|
||||
let h = (x*374761393 + y*668265263) ^ (s|0)*1442695041;
|
||||
h = (h ^ (h>>>13)) >>> 0;
|
||||
h = Math.imul(h, 1274126177) >>> 0;
|
||||
return ((h ^ (h>>>16)) % 100000)/100000; // 0..1
|
||||
};
|
||||
|
||||
/* ---- formatting ---- */
|
||||
WK.fmtMoney = n=>{
|
||||
const neg = n<0; n = Math.round(Math.abs(n));
|
||||
let s = String(n).replace(/\B(?=(\d{3})+(?!\d))/g,',');
|
||||
return (neg?'-$':'$')+s;
|
||||
};
|
||||
WK.fmtClock = min=>{ // minutes in day
|
||||
min = ((min%1440)+1440)%1440;
|
||||
const h=Math.floor(min/60), m=Math.floor(min%60);
|
||||
return String(h).padStart(2,'0')+':'+String(m).padStart(2,'0');
|
||||
};
|
||||
|
||||
/* ---- colors ---- */
|
||||
WK.hexToRgb = hex=>{
|
||||
hex = hex.replace('#','');
|
||||
if(hex.length===3) hex = hex.split('').map(c=>c+c).join('');
|
||||
const n=parseInt(hex,16);
|
||||
return [(n>>16)&255,(n>>8)&255,n&255];
|
||||
};
|
||||
WK.shade = (hex,amt)=>{ // amt -1..1 (negative darker)
|
||||
const [r,g,b]=WK.hexToRgb(hex);
|
||||
const f=c=> WK.clamp(Math.round(amt>=0 ? c+(255-c)*amt : c*(1+amt)),0,255);
|
||||
return `rgb(${f(r)},${f(g)},${f(b)})`;
|
||||
};
|
||||
WK.rgba = (hex,a)=>{
|
||||
const [r,g,b]=WK.hexToRgb(hex); return `rgba(${r},${g},${b},${a})`;
|
||||
};
|
||||
WK.mix = (h1,h2,t)=>{
|
||||
const a=WK.hexToRgb(h1), b=WK.hexToRgb(h2);
|
||||
return `rgb(${Math.round(WK.lerp(a[0],b[0],t))},${Math.round(WK.lerp(a[1],b[1],t))},${Math.round(WK.lerp(a[2],b[2],t))})`;
|
||||
};
|
||||
|
||||
/* ---- canvas helpers ---- */
|
||||
WK.mkCanvas = (w,h)=>{ const c=document.createElement('canvas'); c.width=Math.max(1,Math.ceil(w)); c.height=Math.max(1,Math.ceil(h)); return c; };
|
||||
|
||||
WK.rr = function(ctx,x,y,w,h,r){ // rounded rect path
|
||||
if(typeof r==='number') r={tl:r,tr:r,br:r,bl:r};
|
||||
else r = Object.assign({tl:0,tr:0,br:0,bl:0},r);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x+r.tl,y);
|
||||
ctx.lineTo(x+w-r.tr,y); ctx.arcTo(x+w,y,x+w,y+r.tr,r.tr);
|
||||
ctx.lineTo(x+w,y+h-r.br); ctx.arcTo(x+w,y+h,x+w-r.br,y+h,r.br);
|
||||
ctx.lineTo(x+r.bl,y+h); ctx.arcTo(x,y+h,x,y+h-r.bl,r.bl);
|
||||
ctx.lineTo(x,y+r.tl); ctx.arcTo(x,y,x+r.tl,y,r.tl);
|
||||
ctx.closePath();
|
||||
};
|
||||
|
||||
WK.softShadow = function(ctx, blur, color, offY){
|
||||
ctx.shadowBlur=blur; ctx.shadowColor=color||'rgba(70,45,10,.35)';
|
||||
ctx.shadowOffsetY = offY==null?2:offY;
|
||||
};
|
||||
WK.clearShadow = ctx=>{ ctx.shadowBlur=0; ctx.shadowColor='transparent'; ctx.shadowOffsetY=0; };
|
||||
|
||||
/* ellipse blob with vertical gradient for cute volume */
|
||||
WK.blob = function(ctx,x,y,rx,ry,color,lighten){
|
||||
const g = ctx.createRadialGradient(x-rx*0.35,y-ry*0.55,ry*0.15,x,y,Math.max(rx,ry));
|
||||
g.addColorStop(0, lighten || WK.shade(color,0.28));
|
||||
g.addColorStop(1, color);
|
||||
ctx.fillStyle=g;
|
||||
ctx.beginPath(); ctx.ellipse(x,y,rx,ry,0,0,WK.TAU); ctx.fill();
|
||||
};
|
||||
|
||||
/* simple eye: white + pupil + shine */
|
||||
WK.eye = function(ctx,x,y,r,opt){
|
||||
opt=opt||{};
|
||||
if(opt.closed){
|
||||
ctx.strokeStyle=opt.lidColor||'#3a2c20'; ctx.lineWidth=Math.max(1.5,r*0.5);
|
||||
ctx.beginPath(); ctx.moveTo(x-r,y*0.98); ctx.quadraticCurveTo(x,y+r*0.9,x+r,y*0.98); ctx.stroke();
|
||||
return;
|
||||
}
|
||||
ctx.fillStyle='#fff';
|
||||
ctx.beginPath(); ctx.ellipse(x,y,r,r*(opt.tall||1),0,0,WK.TAU); ctx.fill();
|
||||
const px = x + (opt.lookX||0)*r*0.28, py = y + (opt.lookY||0.08)*r*0.4;
|
||||
ctx.fillStyle=opt.pupil||'#33261a';
|
||||
ctx.beginPath(); ctx.ellipse(px,py,r*0.52,r*0.62*(opt.tall||1),0,0,WK.TAU); ctx.fill();
|
||||
ctx.fillStyle='#fff';
|
||||
ctx.beginPath(); ctx.arc(px-r*0.18,py-r*0.24,r*0.2,0,WK.TAU); ctx.fill();
|
||||
};
|
||||
|
||||
/* tiny smile / open mouth */
|
||||
WK.mouth = function(ctx,x,y,w,open,color){
|
||||
ctx.fillStyle = color||'#7c4a3a';
|
||||
if(open>0.05){
|
||||
ctx.beginPath(); ctx.ellipse(x,y,w,w*(0.5+open),0,0,Math.PI); ctx.fill();
|
||||
ctx.fillStyle='#e8756f';
|
||||
ctx.beginPath(); ctx.ellipse(x,y+w*(0.35+open*0.5),w*0.55,w*0.4*open,0,0,Math.PI); ctx.fill();
|
||||
}else{
|
||||
ctx.strokeStyle=color||'#7c4a3a'; ctx.lineWidth=Math.max(1.2,w*0.22); ctx.lineCap='round';
|
||||
ctx.beginPath(); ctx.moveTo(x-w,y-w*0.25); ctx.quadraticCurveTo(x,y+w*0.65,x+w,y-w*0.25); ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
WK.blush = function(ctx,x,y,r,color,alpha){
|
||||
ctx.globalAlpha = alpha==null?0.35:alpha;
|
||||
ctx.fillStyle=color||'#ff9d8a';
|
||||
ctx.beginPath(); ctx.ellipse(x,y,r,r*0.62,0,0,WK.TAU); ctx.fill();
|
||||
ctx.globalAlpha=1;
|
||||
};
|
||||
|
||||
/* object pool-ish array clear */
|
||||
WK.clearArr = a=>{ a.length=0; return a; };
|
||||
|
||||
/* pick weighted {id:w} map or [ [item,w],... ] */
|
||||
WK.weightedPick = function(entries,rnd){
|
||||
rnd = rnd||Math.random;
|
||||
let tot=0; for(const e of entries) tot+=e.w;
|
||||
let r=rnd()*tot;
|
||||
for(const e of entries){ r-=e.w; if(r<=0) return e; }
|
||||
return entries[entries.length-1];
|
||||
};
|
||||
})();
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
/* ============================================================
|
||||
Wildlife Kingdom — js/world.js
|
||||
Tile grid · terrain/paths/fences/objects · buildings ·
|
||||
habitat region detection · pathfinding (visitors/staff/animals)
|
||||
============================================================ */
|
||||
(function(){
|
||||
'use strict';
|
||||
const WK = window.WK;
|
||||
const D = WK.Data;
|
||||
|
||||
WK.GIDS = Object.keys(D.GROUND);
|
||||
WK.PIDS = Object.keys(D.PATH);
|
||||
WK.NIDS = Object.keys(D.NATURE);
|
||||
|
||||
class World{
|
||||
constructor(cols,rows){
|
||||
this.cols=cols; this.rows=rows;
|
||||
const n=cols*rows;
|
||||
this.ground=new Uint8Array(n);
|
||||
this.path=new Uint8Array(n);
|
||||
this.obj=new Uint8Array(n);
|
||||
this.objVar=new Uint8Array(n);
|
||||
this.fenceS=new Uint8Array(n); // edge between (x,y)-(x,y+1)
|
||||
this.fenceE=new Uint8Array(n); // edge between (x,y)-(x+1,y)
|
||||
this.occ=new Int16Array(n); // building index or -1
|
||||
this.region=new Int16Array(n); // habitat region id or -1
|
||||
this.occ.fill(-1);
|
||||
this.region.fill(-1);
|
||||
this.buildings=[];
|
||||
this.regions=[]; // [{id,tiles,area,openEdges,hasPath}]
|
||||
this.regionsDirty=true;
|
||||
this.terrainDirty=true;
|
||||
this.viewCache=null;
|
||||
this.nextBid=1;
|
||||
}
|
||||
idx(x,y){ return y*this.cols+x; }
|
||||
inBounds(x,y){ return x>=0&&y>=0&&x<this.cols&&y<this.rows; }
|
||||
gid(x,y){ return WK.GIDS[this.ground[this.idx(x,y)]]; }
|
||||
pid(x,y){ const p=this.path[this.idx(x,y)]; return p?WK.PIDS[p-1]:null; }
|
||||
oid(x,y){ const o=this.obj[this.idx(x,y)]; return o?WK.NIDS[o-1]:null; }
|
||||
|
||||
/* ---------- raw setters (cost/validation in Game) ---------- */
|
||||
setGround(x,y,gid){
|
||||
if(!this.inBounds(x,y)) return false;
|
||||
this.ground[this.idx(x,y)]=WK.GIDS.indexOf(gid);
|
||||
this.path[this.idx(x,y)]=0; // path destroyed by terrain change
|
||||
this.terrainDirty=true; this.touch();
|
||||
return true;
|
||||
}
|
||||
setPath(x,y,pid){
|
||||
if(!this.inBounds(x,y)) return false;
|
||||
const i=this.idx(x,y);
|
||||
if(D.GROUND[this.gid(x,y)].water||this.occ[i]>=0) return false;
|
||||
this.path[i]=pid?(WK.PIDS.indexOf(pid)+1):0;
|
||||
if(!pid&&this.obj[i]){} // keep objects
|
||||
this.terrainDirty=true; this.touch();
|
||||
return true;
|
||||
}
|
||||
setObj(x,y,oid,varr){
|
||||
if(!this.inBounds(x,y)) return false;
|
||||
const i=this.idx(x,y);
|
||||
if(this.occ[i]>=0) return false;
|
||||
this.obj[i]=oid?(WK.NIDS.indexOf(oid)+1):0;
|
||||
this.objVar[i]=varr||0;
|
||||
this.terrainDirty=true; this.touch();
|
||||
return true;
|
||||
}
|
||||
setFence(x,y,edge,kind){
|
||||
if(!this.inBounds(x,y)) return false;
|
||||
const i=this.idx(x,y);
|
||||
if(edge==='S') this.fenceS[i]=kind; else this.fenceE[i]=kind;
|
||||
this.terrainDirty=true; this.touch();
|
||||
return true;
|
||||
}
|
||||
touch(){ this.ver=(this.ver||0)+1; this.regionsDirty=true; this.viewCache=null; }
|
||||
|
||||
fenceBetween(ax,ay,bx,by){
|
||||
if(bx===ax&&by===ay+1) return this.fenceS[this.idx(ax,ay)];
|
||||
if(bx===ax&&by===ay-1) return this.fenceS[this.idx(bx,by)];
|
||||
if(by===ay&&bx===ax+1) return this.fenceE[this.idx(ax,ay)];
|
||||
if(by===ay&&bx===ax-1) return this.fenceE[this.idx(bx,by)];
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---------- buildings ---------- */
|
||||
canPlaceBuilding(bx,by,w,h){
|
||||
for(let j=0;j<h;j++)for(let i=0;i<w;i++){
|
||||
const x=bx+i,y=by+j;
|
||||
if(!this.inBounds(x,y)) return false;
|
||||
const k=this.idx(x,y);
|
||||
if(this.occ[k]>=0) return false;
|
||||
if(D.GROUND[this.gid(x,y)].water) return false;
|
||||
if(this.obj[k]){ const o=D.NATURE[WK.NIDS[this.obj[k]-1]]; if(o.block) return false; }
|
||||
if(this.path[k]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
placeBuilding(type,x,y){
|
||||
const def=D.BUILDING[type];
|
||||
if(!this.canPlaceBuilding(x,y,def.w,def.h)) return null;
|
||||
const b={id:this.nextBid++, type, x, y, w:def.w, h:def.h};
|
||||
this.buildings.push(b);
|
||||
for(let j=0;j<def.h;j++)for(let i=0;i<def.w;i++) this.occ[this.idx(x+i,y+j)]=b.id;
|
||||
this.terrainDirty=true; this.touch();
|
||||
return b;
|
||||
}
|
||||
removeBuildingById(id){
|
||||
const bi=this.buildings.findIndex(b=>b.id===id);
|
||||
if(bi<0) return null;
|
||||
const b=this.buildings[bi];
|
||||
for(let j=0;j<b.h;j++)for(let i=0;i<b.w;i++){
|
||||
const k=this.idx(b.x+i,b.y+j);
|
||||
if(this.occ[k]===b.id) this.occ[k]=-1;
|
||||
}
|
||||
this.buildings.splice(bi,1);
|
||||
this.terrainDirty=true; this.touch();
|
||||
return b;
|
||||
}
|
||||
buildingAt(x,y){
|
||||
const id=this.occ[this.idx(x,y)];
|
||||
return id>=0 ? this.buildings.find(b=>b.id===id) : null;
|
||||
}
|
||||
/* best guest-facing door tile: prefer front-south perimeter on a path */
|
||||
doorTile(b){
|
||||
const cands=[];
|
||||
for(let i=0;i<b.w;i++){
|
||||
cands.push([b.x+i,b.y+b.h]); // south side
|
||||
}
|
||||
for(let j=b.h-1;j>=0;j--) cands.push([b.x+b.w,b.y+j]); // east side
|
||||
for(const [x,y] of cands){
|
||||
if(this.inBounds(x,y)&&this.path[this.idx(x,y)]) return [x,y];
|
||||
}
|
||||
for(const [x,y] of cands) if(this.inBounds(x,y)&&!D.GROUND[this.gid(x,y)].water&&!this.occ[this.idx(x,y)]) return [x,y];
|
||||
return [b.x,b.y+b.h];
|
||||
}
|
||||
|
||||
/* ---------- habitat regions ---------- */
|
||||
computeRegions(){
|
||||
const {cols,rows}=this;
|
||||
this.region.fill(-1);
|
||||
this.regions=[];
|
||||
let id=0;
|
||||
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
|
||||
const start=this.idx(x,y);
|
||||
if(this.region[start]!==-1) continue;
|
||||
if(this.occ[start]>=0) continue;
|
||||
id++;
|
||||
const tiles=[];
|
||||
let openEdges=0, hasPath=false;
|
||||
const stack=[start];
|
||||
this.region[start]=id;
|
||||
while(stack.length){
|
||||
const k=stack.pop();
|
||||
tiles.push(k);
|
||||
const cx=k%cols, cy=(k/cols)|0;
|
||||
if(this.path[k]) hasPath=true;
|
||||
const nb=[[cx+1,cy],[cx-1,cy],[cx,cy+1],[cx,cy-1]];
|
||||
for(let d=0;d<4;d++){
|
||||
const nx=nb[d][0], ny=nb[d][1];
|
||||
if(!this.inBounds(nx,ny)){ continue; } // map border = wall
|
||||
const ni=this.idx(nx,ny);
|
||||
if(this.occ[ni]>=0){ continue; } // building = wall
|
||||
if(this.fenceBetween(cx,cy,nx,ny)){ continue; } // fence = wall
|
||||
if(this.region[ni]===-1){ this.region[ni]=id; stack.push(ni); }
|
||||
}
|
||||
// open-edge audit (no fence to the outside world)
|
||||
for(let d=0;d<4;d++){
|
||||
const nx=nb[d][0], ny=nb[d][1];
|
||||
if(!this.inBounds(nx,ny)) continue;
|
||||
const ni=this.idx(nx,ny);
|
||||
if(this.region[ni]===id) continue;
|
||||
if(this.occ[ni]>=0) continue;
|
||||
if(this.fenceBetween(cx,cy,nx,ny)) continue;
|
||||
openEdges++;
|
||||
}
|
||||
}
|
||||
this.regions.push({id, tiles, area:tiles.length, openEdges, hasPath});
|
||||
}
|
||||
this.regionsDirty=false;
|
||||
this.viewCache=null;
|
||||
return this.regions;
|
||||
}
|
||||
ensureRegions(){ if(this.regionsDirty) this.computeRegions(); }
|
||||
regionOf(x,y){ return this.region[this.idx(x,y)]; }
|
||||
validHabitats(){
|
||||
this.ensureRegions();
|
||||
return this.regions.filter(r=>r.openEdges===0&&!r.hasPath);
|
||||
}
|
||||
|
||||
/* viewpoints: path tiles adjacent to a habitat region */
|
||||
viewpoints(regionId){
|
||||
this.ensureRegions();
|
||||
if(!this.viewCache) this.viewCache=new Map();
|
||||
let v=this.viewCache.get(regionId);
|
||||
if(v) return v;
|
||||
v=[];
|
||||
const seen=new Set();
|
||||
const reg=this.regions.find(r=>r.id===regionId);
|
||||
if(reg){
|
||||
for(const k of reg.tiles){
|
||||
const cx=k%this.cols, cy=(k/this.cols)|0;
|
||||
for(const [nx,ny] of [[cx+1,cy],[cx-1,cy],[cx,cy+1],[cx,cy-1]]){
|
||||
if(!this.inBounds(nx,ny)) continue;
|
||||
const ni=this.idx(nx,ny);
|
||||
if(this.region[ni]!==regionId && this.path[ni] && !seen.has(ni)){
|
||||
seen.add(ni); v.push([nx,ny]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.viewCache.set(regionId,v);
|
||||
return v;
|
||||
}
|
||||
|
||||
/* ---------- pathfinding ---------- */
|
||||
static NB4=[[1,0],[-1,0],[0,1],[0,-1]];
|
||||
findPathVisitor(sx,sy,tx,ty){
|
||||
return this._bfs(sx,sy,tx,ty,(x,y)=>{
|
||||
if(!this.inBounds(x,y)||!this.path[this.idx(x,y)]) return false;
|
||||
return true;
|
||||
},(ax,ay,bx,by)=> this.fenceBetween(ax,ay,bx,by)!==0 ); // visitors blocked by ANY fence incl gates
|
||||
}
|
||||
findPathKeeper(sx,sy,tx,ty){
|
||||
// Dijkstra-lite: fences climbable at high cost, gates cheap
|
||||
const cols=this.cols, n=cols*this.rows;
|
||||
const dist=new Float32Array(n).fill(Infinity);
|
||||
const prev=new Int32Array(n).fill(-1);
|
||||
const si=this.idx(sx,sy), ti=this.idx(tx,ty);
|
||||
dist[si]=0;
|
||||
const open=[[0,si]];
|
||||
const passable=(x,y)=>{
|
||||
if(!this.inBounds(x,y)) return false;
|
||||
const k=this.idx(x,y);
|
||||
if(this.occ[k]>=0) return false;
|
||||
const g=D.GROUND[this.gid(x,y)];
|
||||
if(g.water===2) return false;
|
||||
if(this.obj[k]){ const o=D.NATURE[WK.NIDS[this.obj[k]-1]]; if(o.block) return false; }
|
||||
return true;
|
||||
};
|
||||
if(!passable(tx,ty)||!passable(sx,sy)) return null;
|
||||
while(open.length){
|
||||
let bi=0; for(let i=1;i<open.length;i++) if(open[i][0]<open[bi][0]) bi=i;
|
||||
const [d,k]=open.splice(bi,1)[0];
|
||||
if(k===ti) break;
|
||||
if(d>dist[k]) continue;
|
||||
const cx=k%cols, cy=(k/cols)|0;
|
||||
for(const [dx,dy] of World.NB4){
|
||||
const nx=cx+dx, ny=cy+dy;
|
||||
if(!passable(nx,ny)) continue;
|
||||
const ni=this.idx(nx,ny);
|
||||
let c=1;
|
||||
const f=this.fenceBetween(cx,cy,nx,ny);
|
||||
if(f===1||f===2) c+=9;
|
||||
if(f===3) c+=0.5;
|
||||
if(D.GROUND[this.gid(nx,ny)].water===1) c+=2;
|
||||
const nd=d+c;
|
||||
if(nd<dist[ni]-1e-6){ dist[ni]=nd; prev[ni]=k; open.push([nd,ni]); }
|
||||
}
|
||||
}
|
||||
if(prev[ti]===-1&&ti!==si) return null;
|
||||
const out=[];
|
||||
let cur=ti;
|
||||
while(cur!==-1&&cur!==si){ out.push([cur%cols,(cur/cols)|0]); cur=prev[cur]; }
|
||||
out.reverse();
|
||||
return out;
|
||||
}
|
||||
_bfs(sx,sy,tx,ty,passable,blockedEdge){
|
||||
if(!passable(sx,sy)||!passable(tx,ty)) return null;
|
||||
const cols=this.cols, n=cols*this.rows;
|
||||
const prev=new Int32Array(n).fill(-2);
|
||||
const si=this.idx(sx,sy), ti=this.idx(tx,ty);
|
||||
prev[si]=-1;
|
||||
let q=[si];
|
||||
while(q.length){
|
||||
const nq=[];
|
||||
for(const k of q){
|
||||
if(k===ti){
|
||||
const out=[]; let cur=ti;
|
||||
while(cur!==-1){ out.push([cur%cols,(cur/cols)|0]); cur=prev[cur]; }
|
||||
out.reverse(); out.shift();
|
||||
return out.length?out:[[tx,ty]];
|
||||
}
|
||||
const cx=k%cols, cy=(k/cols)|0;
|
||||
for(const [dx,dy] of World.NB4){
|
||||
const nx=cx+dx, ny=cy+dy;
|
||||
if(!passable(nx,ny)) continue;
|
||||
const ni=this.idx(nx,ny);
|
||||
if(prev[ni]!==-2) continue;
|
||||
if(blockedEdge&&blockedEdge(cx,cy,nx,ny)) continue;
|
||||
prev[ni]=k; nq.push(ni);
|
||||
}
|
||||
}
|
||||
q=nq;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/* path for an animal confined to its region */
|
||||
findPathAnimal(species,x0,y0,x1,y1,regionId){
|
||||
const sp=species;
|
||||
const passable=(x,y)=>{
|
||||
if(!this.inBounds(x,y)) return false;
|
||||
if(this.regionOf(x,y)!==regionId) return false;
|
||||
const g=D.GROUND[this.gid(x,y)];
|
||||
if(g.water){
|
||||
if(!sp.swim) return false;
|
||||
}
|
||||
const o=this.oid(x,y);
|
||||
if(o&&D.NATURE[o].block) return false;
|
||||
return true;
|
||||
};
|
||||
return this._bfs(Math.round(x0),Math.round(y0),Math.round(x1),Math.round(y1),
|
||||
(x,y)=>passable(x,y)||(x===Math.round(x0)&&y===Math.round(y0)),
|
||||
(ax,ay,bx,by)=> this.fenceBetween(ax,ay,bx,by)!==0 );
|
||||
}
|
||||
/* random passable tile inside region for a species */
|
||||
randomRegionTile(regionId,sp,rnd){
|
||||
const reg=this.regions.find(r=>r.id===regionId);
|
||||
if(!reg) return null;
|
||||
for(let tries=0;tries<24;tries++){
|
||||
const k=reg.tiles[(rnd()*reg.tiles.length)|0];
|
||||
const x=k%this.cols, y=(k/this.cols)|0;
|
||||
const g=D.GROUND[this.gid(x,y)];
|
||||
if(g.water&&!sp.swim) continue;
|
||||
const o=this.oid(x,y);
|
||||
if(o&&D.NATURE[o].block) continue;
|
||||
return [x,y];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- persistence ---------- */
|
||||
serialize(){
|
||||
return {
|
||||
cols:this.cols, rows:this.rows,
|
||||
ground:Array.from(this.ground), path:Array.from(this.path),
|
||||
obj:Array.from(this.obj), objVar:Array.from(this.objVar),
|
||||
fenceS:Array.from(this.fenceS), fenceE:Array.from(this.fenceE),
|
||||
buildings:this.buildings.map(b=>({...b})), nextBid:this.nextBid,
|
||||
};
|
||||
}
|
||||
deserialize(s){
|
||||
this.ground.set(s.ground); this.path.set(s.path);
|
||||
this.obj.set(s.obj); this.objVar.set(s.objVar);
|
||||
this.fenceS.set(s.fenceS); this.fenceE.set(s.fenceE);
|
||||
this.buildings=s.buildings.map(b=>({...b}));
|
||||
this.nextBid=s.nextBid||this.buildings.length+1;
|
||||
this.occ.fill(-1);
|
||||
for(const b of this.buildings)
|
||||
for(let j=0;j<b.h;j++)for(let i=0;i<b.w;i++)
|
||||
this.occ[this.idx(b.x+i,b.y+j)]=b.id;
|
||||
this.terrainDirty=true; this.touch();
|
||||
}
|
||||
}
|
||||
WK.World = World;
|
||||
})();
|
||||
Reference in New Issue
Block a user