Wildlife Kingdom — original browser zoo-management sim

- Procedural canvas art: 15 species × 4 poses, 15 buildings, modular terrain, UI icons
- Habitat enclosure detection, biome suitability, staff/guest AI pathfinding
- Economy: tickets, shops, wages, star rating, research tiers, 15 missions
- Day/night cycle, particles, WebAudio soundtrack & synth SFX
- Mouse + touch controls, responsive UI, localStorage autosave
- Verified: 31/31 logic tests (test/smoke.js) · 12/12 render audits (test/qa.html)
This commit is contained in:
2026-08-23 07:01:21 +00:00
commit c06786518c
22 changed files with 5130 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.DS_Store
*.log
+54
View File
@@ -0,0 +1,54 @@
# Wildlife Kingdom — Art Direction
*The in-code "master graphic prompt": every sprite follows these rules.*
## Perspective & Camera
- **Top-down / slightly isometric**, 2:1 diamond grid (64×32 px tiles), camera reading
as a ~45° look-down — the classic premium zoo-builder view.
- Painter's-algorithm depth (`x+y`), consistent across terrain, fences, objects,
buildings, animals and visitors.
## Shape Language
- Everything is built from **rounded blobs and soft prisms**. No straight harsh edges.
- Animals: chunky bodies ≈ 2× tall as legs, oversized heads, **big glossy eyes with a
single white shine pixel**, rosy blush, tiny smiles.
- Buildings: extruded iso boxes with three-tone wall shading (top +8%, left 0%,
right 10%) and one playful roof feature each (swirl cone, wave dome, mesh aviary,
boulder roof, scalloped awning, flag archway).
## Color Palette (natural zoo)
| Role | Hex |
|---|---|
| Fresh greens | `#7cc653 · #63b34c · #4d9a37` |
| Warm yellows | `#ffd23c · #ffc93c · #f0c05c` |
| Sky blues | `#69c3ef · #6ec6ea · #3796cf` |
| Earth browns | `#a9743f · #c99b62 · #8a5a34` |
| Soft oranges | `#ff9d47 · #e8823c` |
| UI cream | `#fdf6e7 · #f6ead1`, ink `#43362a` |
Accents (red `#e85d5d`, pink `#ff8aa8`) used sparingly for signs, awnings, hearts.
## Lighting Rules
- One sun, upper-left: highlights top-left of every blob, shade lower-right.
- Contact shadows: soft translucent ellipses under every entity; buildings cast a
footprint shadow.
- Day/night: sky gradient lerps `#7ec8f5→#2c3e66`; night adds indigo tint + warm
radial lamp glows.
## Animation Feel
- Idle = slow breathing bob + tail/ear sway; walk = sinusoidal leg swing with body
bounce; eat = head dip + chew cycle; happy = hop squash-stretch + sparkle/hearts.
- Visitors waddle (leg phase + lane offsets); kids at 0.68 scale; balloons drift on
their own sine.
- Water breathes via moving highlight arcs; swimmers clip at the waterline with
ripple rings.
## Sprite Hygiene
- Transparent backgrounds, clean readable silhouettes at 40 px (icons) to full size.
- Consistent scale ratios between species (elephant 1.22 × zebra 0.95 × penguin 0.68).
- No text, logos or watermark noise inside world sprites.
## UI Identity
- Cream paper panels, 1820 px radii, 2 px warm borders, soft drop shadows.
- Pill HUD stats with hand-drawn icons (coin paw-print, flask, star…).
- Chunky rounded buttons that depress on click; orange = selection, green = money,
red = danger/demolish. Typography: Baloo 2 (rounded, friendly).
+77
View File
@@ -0,0 +1,77 @@
# Wildlife Kingdom 🦁
A cozy, original **zoo-management simulation** that runs entirely in your browser —
no installation, no build step, no assets to download. Every animal, building, tree
and sound is generated procedurally in code (Canvas 2D vector art + WebAudio), so the
game is a single static folder you can open anywhere.
> Original world & characters. Not affiliated with, and contains nothing from, any
> existing game or franchise.
## ▶ How to Play
**Option A — just open it:** double-click `index.html` (any modern desktop/tablet/phone browser).
**Option B — serve it** (nicer URLs):
```bash
python3 -m http.server 8313 --directory /root/zoo
# → http://127.0.0.1:8313
```
Progress **autosaves** to your browser every 30 s and when the tab hides.
## The Loop
Build paths → fence a habitat → paint its biome → adopt animals → hire keepers →
delight guests → earn TownCoins… spend them on rare species, restaurants and an
aquarium → climb from 1★ to 5★ Wildlife Legend.
- **Guests only walk on paths.** Connect everything to your entrance.
- **Habitats must be fully enclosed** with fences; each species has favorite grounds,
space needs and enrichment wishes. A keeper gate lets staff in.
- **Happy animals + happy guests + facilities + decor = stars**, and stars drive ticket
sales and footfall.
- **Research points** unlock Tier IIIV: lions, penguins, elephants, pandas, polar
bears, the aquarium, reptile house and more.
- **15 missions** teach everything and reward coins/RP along the way.
## Controls
| Action | Desktop | Touch |
|---|---|---|
| Pan | drag / WASD / arrows / right-drag | one-finger drag (empty hand) |
| Zoom | wheel, `+`/`-` | pinch |
| Paint / place | pick tool, click-drag | pick tool, tap-drag |
| Inspect | click anything | tap |
| Bulldoze | `X` | toolbar button |
| Pause · speed | `Space`, `1·2·3` | HUD buttons |
## Project Layout
```
index.html app shell
styles.css original cream/rounded UI identity
js/util.js math, RNG, canvas helpers
js/data.js all content: species, buildings, terrain, research, missions
js/audio.js WebAudio soundtrack + SFX synth (no audio files)
js/sprites.js procedural art: terrain, nature, buildings, 15 animals × poses, icons
js/world.js tile grid, habitat enclosure detection, pathfinding
js/entities.js Animal / Guest / Keeper / Vet AI + particles
js/sim.js economy, rating, missions, research, spawning, save/load
js/render.js iso camera, cached layers, day/night lighting
js/input.js mouse/touch tools & camera
js/ui.js HUD, toolbar, panels, modals, toasts
test/smoke.js headless logic test-suite (node test/smoke.js)
test/qa.html in-browser render audit (pixel sampling)
screenshots/ captured from headless Chrome
ART_DIRECTION.md the visual identity bible (palette, shapes, rules)
```
## Verified Quality
- `node test/smoke.js`**31/31 logic checks** (enclosures, pathfinding, economy,
research, save/load, demo zoo)
- `test/qa.html` under headless Chrome → **10/10 render audits**
(all 15 species × 4 poses, all 15 buildings, every UI icon, water/day-night pixels)
- Zero console errors on menu and gameplay loads.
+100
View File
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>Wildlife Kingdom — Zoo Management</title>
<meta name="description" content="Wildlife Kingdom: build and run your own cozy zoo. Original browser management sim.">
<link rel="icon" href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="38" r="14" fill="%2343382a"/><circle cx="15" cy="24" r="7" fill="%2343382a"/><circle cx="28" cy="16" r="7" fill="%2343382a"/><circle cx="43" cy="18" r="7" fill="%2343382a"/><circle cx="53" cy="30" r="7" fill="%2343382a"/></svg>'>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app">
<canvas id="game"></canvas>
<!-- ===== TOP HUD ===== -->
<header id="hud-top" class="hidden">
<div class="hud-group">
<div class="stat pill" id="st-money"><span class="ic" data-ic="coin"></span><b>0</b></div>
<div class="stat pill" id="st-guests"><span class="ic" data-ic="guest"></span><b>0</b></div>
<div class="stat pill" id="st-joy"><span class="ic" data-ic="heart"></span><b>0%</b></div>
<div class="stat pill" id="st-research"><span class="ic" data-ic="flask"></span><b>0</b></div>
</div>
<div class="hud-group">
<div class="stat pill stars" id="st-stars"><span class="ic" data-ic="star"></span><b>0.0</b></div>
<div class="stat pill time"><b id="st-day">Day 1</b><i id="st-clock">08:00</i></div>
</div>
<div class="hud-group right">
<div class="speedctl panel-lite">
<button class="btn icobtn spd" id="spd-pause" title="Pause (Space)"></button>
<button class="btn icobtn spd active" id="spd-play" title="Normal speed (1)"></button>
<button class="btn icobtn spd" id="spd-fast" title="Fast (2)"></button>
<button class="btn icobtn spd" id="spd-ultra" title="Ultra (3)"></button>
</div>
<button class="btn icobtn" id="btn-sound" title="Sound"></button>
<button class="btn icobtn" id="btn-settings" title="Settings"></button>
</div>
</header>
<!-- ===== SIDE PANEL ===== -->
<aside id="side-panel" class="hidden">
<div class="sp-tabs">
<button class="sp-tab active" data-tab="objectives">Missions</button>
<button class="sp-tab" data-tab="research">Research</button>
<button class="sp-tab" data-tab="zoo">Zoo</button>
</div>
<div id="sp-content"></div>
</aside>
<button id="sp-toggle" class="btn hidden" title="Objectives &amp; Research"></button>
<!-- ===== BOTTOM TOOLBAR ===== -->
<footer id="toolbar" class="hidden">
<div id="tb-palette" class="hidden"></div>
<div id="tb-row">
<button class="btn icobtn danger" id="btn-bulldoze" title="Bulldoze (X)"></button>
<div id="tb-categories"></div>
<div id="tool-info" class="hidden"><b id="tool-name"></b><span id="tool-cost"></span></div>
</div>
</footer>
<!-- selection popup -->
<div id="sel-popup" class="panel hidden"></div>
<!-- toasts -->
<div id="toasts"></div>
<!-- ===== MAIN MENU / HERO ===== -->
<div id="menu">
<div class="menu-inner">
<div class="menu-title-block">
<h1 class="title"><span>WILDLIFE</span><em>KINGDOM</em></h1>
<p class="tagline">Build a cozy zoo. Keep every animal happy.</p>
</div>
<div class="menu-buttons">
<button class="btn big primary" id="btn-continue" disabled>Continue Zoo</button>
<button class="btn big" id="btn-new">New Zoo</button>
<button class="btn big ghost" id="btn-how">How to Play</button>
</div>
<p class="menu-foot">An original cozy management sim · runs entirely in your browser · progress autosaves</p>
</div>
</div>
<!-- modals -->
<div id="modal-root"></div>
</div>
<script src="js/util.js"></script>
<script src="js/data.js"></script>
<script src="js/audio.js"></script>
<script src="js/sprites.js"></script>
<script src="js/world.js"></script>
<script src="js/entities.js"></script>
<script src="js/sim.js"></script>
<script src="js/render.js"></script>
<script src="js/input.js"></script>
<script src="js/ui.js"></script>
<script src="js/main.js"></script>
</body>
</html>
+139
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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&&reg.openEdges===0&&!reg.hasPath&&reg.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
View File
@@ -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&&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
View File
@@ -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;
})();
+614
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+414
View File
@@ -0,0 +1,414 @@
/* ============================================================
Wildlife Kingdom — js/ui.js
HUD · toolbar & palettes · side panel · selection cards ·
toasts · modals. Original cream/rounded visual identity.
============================================================ */
(function(){
'use strict';
const WK = window.WK;
const D = WK.Data;
const $=id=>document.getElementById(id);
const UI = (WK.UI = {});
UI.game=null; UI.cam=null; UI.renderer=null;
UI.tool=null; UI.bulldoze=false;
UI._last={}; UI._selTarget=null; UI._sideTab='objectives';
UI._sideT=0; UI._hudT=0;
UI.init=function(game,cam,renderer){
if(UI._inited){
// re-attach to a fresh game without duplicating DOM work
UI.game=game; UI.cam=cam; UI.renderer=renderer;
game.ui=UI;
UI._last={}; UI._selTarget=null;
UI.cancelTool();
UI.hideSelection();
UI.renderSide();
return;
}
UI._inited=true;
UI.game=game; UI.cam=cam; UI.renderer=renderer;
game.ui=UI;
// inject resource icons
document.querySelectorAll('.stat .ic[data-ic]').forEach(el=>{
const s=WK.Sprites.getIcon('res:'+el.dataset.ic,26);
s.cv.style.width='100%'; s.cv.style.height='100%';
el.appendChild(s.cv);
});
$('btn-sound').textContent = WK.AudioSys.enabled?'🔊':'🔇';
$('btn-settings').textContent='⚙';
const spdIcons={ 'spd-pause':'⏸', 'spd-play':'▶', 'spd-fast':'⏩', 'spd-ultra':'⚡' };
for(const id in spdIcons) $(id).textContent=spdIcons[id];
$('btn-bulldoze').appendChild(WK.Sprites.getIcon('res:dozer',30).cv);
UI.buildToolbar();
UI.bind();
UI.selectCat('paths'); // sensible default tab shown when opened
};
/* ---------------- toolbar ---------------- */
const CAT_ICON={ ground:'ground:grass', paths:'path:gravel', fences:'fence:wood',
nature:'nature:tree', animals:'animal:zebra', facilities:'facility:restroom' };
const FENCE_ITEMS=[['wood','Wood Fence'],['hedge','Hedge'],['gate','Keeper Gate']];
UI.buildToolbar=function(){
const cats=$('tb-categories');
cats.innerHTML='';
for(const c of D.TOOLCATS){
const b=document.createElement('button');
b.className='tb-cat'+(c.id===UI._cat?' active':'');
b.appendChild(WK.Sprites.getIcon(CAT_ICON[c.id],34).cv);
const l=document.createElement('span'); l.className='lbl'; l.textContent=c.label;
b.appendChild(l);
b.onclick=()=>{ WK.AudioSys.sfx('click'); UI.selectCat(c.id); };
b.dataset.cat=c.id;
cats.appendChild(b);
}
};
UI.paletteItems=function(cat){
switch(cat){
case 'ground': return Object.keys(D.GROUND).map(id=>({id,name:D.GROUND[id].name,cost:D.GROUND[id].cost,tier:D.GROUND[id].tier}));
case 'paths': return Object.keys(D.PATH).map(id=>({id,name:D.PATH[id].name,cost:D.PATH[id].cost,tier:D.PATH[id].tier}));
case 'fences': return FENCE_ITEMS.map(([id,name])=>({id,name,cost:id==='wood'?12:id==='hedge'?18:40,tier:id==='hedge'?2:1}));
case 'nature': return Object.keys(D.NATURE).map(id=>({id,name:D.NATURE[id].name,cost:D.NATURE[id].cost,tier:D.NATURE[id].tier}));
case 'animals': return Object.keys(D.SPECIES).sort((a,b)=>D.SPECIES[a].cost-D.SPECIES[b].cost)
.map(id=>({id,name:D.SPECIES[id].name,cost:D.SPECIES[id].cost,tier:D.SPECIES[id].tier}));
case 'facilities': return Object.keys(D.BUILDING).sort((a,b)=>D.BUILDING[a].cost-D.BUILDING[b].cost)
.map(id=>({id,name:D.BUILDING[id].name,cost:D.BUILDING[id].cost,tier:D.BUILDING[id].tier}));
}
return [];
};
UI.iconKind=function(cat,id){
switch(cat){
case 'ground': return 'ground:'+id;
case 'paths': return 'path:'+id;
case 'fences': return 'fence:'+id;
case 'nature': return 'nature:'+id;
case 'animals': return 'animal:'+id;
case 'facilities': return 'facility:'+id;
}
};
UI.selectCat=function(cat){
UI._cat=cat;
document.querySelectorAll('.tb-cat').forEach(b=>b.classList.toggle('active',b.dataset.cat===cat));
const pal=$('tb-palette');
pal.innerHTML='';
for(const it of UI.paletteItems(cat)){
const locked=(it.tier||1)>UI.game.researchTier;
const d=document.createElement('button');
d.className='pal-item'+(locked?' locked':'')+((UI.tool&&UI.tool.item===it.id&&UI.tool.cat===cat)?' active':'');
const cv=WK.Sprites.getIcon(UI.iconKind(cat,it.id),54);
d.appendChild(cv.cv);
const nm=document.createElement('span'); nm.className='nm'; nm.textContent=it.name;
const pr=document.createElement('span'); pr.className='pr'; pr.textContent=locked?('Tier '+it.tier):WK.fmtMoney(it.cost);
d.appendChild(nm); d.appendChild(pr);
d.title=locked?'Unlock via Research':(D.BUILDING[it.id]&&D.BUILDING[it.id].desc||D.SPECIES[it.id]&&(D.SPECIES[it.id].minArea+'+ tiles · likes '+Object.keys(D.SPECIES[it.id].biome).join('/'))||'');
d.onclick=()=>{
WK.AudioSys.sfx('click');
if(locked){ UI.toast('Locked! Unlock Tier '+it.tier+' in the Research panel.','bad'); return; }
UI.bulldoze=false; $('btn-bulldoze').classList.remove('active');
UI.selectTool({cat,item:it.id});
pal.querySelectorAll('.pal-item').forEach(x=>x.classList.remove('active'));
d.classList.add('active');
};
pal.appendChild(d);
}
pal.classList.add('hidden');
if(UI.tool||UI.bulldoze) pal.classList.remove('hidden');
};
UI.selectTool=function(t){
UI.tool=t; UI.bulldoze=false;
$('btn-bulldoze').classList.toggle('active',false);
const pal=$('tb-palette'), info=$('tool-info');
if(t){
pal.classList.remove('hidden');
info.classList.remove('hidden');
const def= t.cat==='animals'?D.SPECIES[t.item] : t.cat==='facilities'?D.BUILDING[t.item]
: t.cat==='fences'?{name:t.item[0].toUpperCase()+t.item.slice(1)+' Fence'} : null;
$('tool-name').textContent=def?def.name:t.item;
const cost=def?(typeof def.cost==='number'?def.cost:null):null;
$('tool-cost').textContent=cost!=null?WK.fmtMoney(cost)+(t.cat==='animals'?' · needs suitable habitat':''):'paint';
document.querySelectorAll('#game').forEach(c=>c.classList.add('tooling'));
document.querySelectorAll('.pal-item').forEach(x=>x.classList.toggle('active',x.querySelector('.nm') && x.querySelector('.nm').textContent===($('tool-name').textContent)));
// re-mark active within current cat
UI.selectCatRefreshActive();
}else{
pal.classList.add('hidden');
info.classList.add('hidden');
document.getElementById('game').classList.remove('tooling');
document.querySelectorAll('.pal-item').forEach(x=>x.classList.remove('active'));
}
};
UI.selectCatRefreshActive=function(){
document.querySelectorAll('.pal-item').forEach(x=>x.classList.remove('active'));
};
UI.toggleBulldoze=function(){
UI.bulldoze=!UI.bulldoze;
if(UI.bulldoze){ UI.tool=null; $('tb-palette').classList.add('hidden'); $('tool-info').classList.add('hidden'); }
$('btn-bulldoze').classList.toggle('active',UI.bulldoze);
document.getElementById('game').classList.toggle('tooling',UI.bulldoze);
};
UI.cancelTool=function(){ UI.selectTool(null); UI.bulldoze=false; $('btn-bulldoze').classList.remove('active'); document.getElementById('game').classList.remove('tooling'); };
/* ---------------- HUD ---------------- */
UI.setSpeed=function(s){
UI.game.speed=s;
['spd-pause','spd-play','spd-fast','spd-ultra'].forEach(id=>$(id).classList.remove('active'));
$({'0':'spd-pause','1':'spd-play','2':'spd-fast','4':'spd-ultra'}[s]).classList.add('active');
};
UI.togglePause=function(){ UI.setSpeed(UI.game.speed===0?(UI._lastSpeed||1):(UI._lastSpeed=UI.game.speed,0)); };
UI.updateHUD=function(){
const g=UI.game, L=UI._last;
const set=(id,v)=>{ if(L[id]!==v){ L[id]=v; $(id).querySelector('b').textContent=v; } };
set('st-money',WK.fmtMoney(g.money));
set('st-guests',String(g.guests.length));
const joy=g.guests.length?Math.round(g.guests.reduce((s,x)=>s+x.joy,0)/g.guests.length):null;
set('st-joy',(joy!=null?joy+'%':'—'));
set('st-research',Math.floor(g.rp)+' RP');
set('st-stars',g.rating.toFixed(1)+'★');
$('st-day').textContent='Day '+g.day;
$('st-clock').textContent=WK.fmtClock(g.minutes);
// side panel throttle
UI._sideT++;
if(UI._sideT>30){ UI._sideT=0; UI.renderSide(); }
// selection follow
UI.trackSelection();
};
UI.flashStat=function(id){
const el=$(id==='money'?'st-money':id);
el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash');
};
/* ---------------- side panel ---------------- */
UI.renderSide=function(){
const g=UI.game;
const c=$('sp-content');
document.querySelectorAll('.sp-tab').forEach(b=>b.classList.toggle('active',b.dataset.tab===UI._sideTab));
if(UI._sideTab==='objectives'){
let h='';
D.MISSIONS.forEach((m,i)=>{
const done=i<g.missionIdx, cur=i===g.missionIdx;
if(i>g.missionIdx&&!cur){}
if(done||cur){
const rw= typeof m.reward==='number'? WK.fmtMoney(m.reward)
: (m.reward.cash?WK.fmtMoney(m.reward.cash):'')+(m.reward.rp?(' +'+m.reward.rp+' RP'):'');
h+=`<div class="mission ${done?'done':''} ${cur?'current':''}">
<div class="m-title"><span>${m.label}</span></div>
${m.hint&&!done?`<div class="m-hint">${m.hint}</div>`:''}
${!done?`<div class="m-reward">Reward: ${rw}</div>`:''}
</div>`;
}
});
if(g.missionIdx>=D.MISSIONS.length) h+='<div class="mission done"><div class="m-title"><span>All missions complete — Wildlife Legend! 🏆</span></div></div>';
c.innerHTML=h;
}else if(UI._sideTab==='research'){
const next=g.researchCost();
let h=`<div class="res-row"><span>Research points<small>Earn passively · Research Center boosts</small></span><b>${Math.floor(g.rp)} RP</b></div>`;
for(const t of D.RESEARCH_TIERS){
const owned=g.researchTier>=t.tier;
h+=`<div class="res-row">
<span>${t.label}<small>${owned?'Unlocked ✔':t.cost+' RP'}</small></span>
${owned?'':`<button class="btn" data-unlock="${t.tier}" ${(next!==t.cost)?'disabled':''}>Unlock</button>`}
</div>`;
}
h+='<p style="font-size:11px;color:var(--ink-soft);margin:6px 2px;">New terrain, buildings & rare animals arrive with each tier.</p>';
c.innerHTML=h;
c.querySelectorAll('[data-unlock]').forEach(b=>{
b.onclick=()=>{ g.buyResearch(); UI.renderSide(); };
});
}else{
const P=g.ratingParts||{};
const pc=k=>Math.round((P[k]||0)*100);
let occ={};
for(const a of g.animals) occ[a.species]=(occ[a.species]||0)+1;
let occStr=Object.keys(occ).map(s=>`${D.SPECIES[s].name} ×${occ[s]}`).join(', ')||'None yet';
c.innerHTML=`
<div class="zoo-stat"><span>Rating</span><b>${g.rating.toFixed(1)}★</b></div>
<div class="zoo-stat"><span>Animal welfare</span><b>${pc('welfare')}%</b></div>
<div class="zoo-stat"><span>Guest joy</span><b>${pc('joy')}%</b></div>
<div class="zoo-stat"><span>Species variety</span><b>${pc('variety')}%</b></div>
<div class="zoo-stat"><span>Facilities</span><b>${pc('facilities')}%</b></div>
<div class="zoo-stat"><span>Decor</span><b>${pc('decor')}%</b></div>
<div class="zoo-stat"><span>Species kept</span><b>${g.speciesCount()}/15</b></div>
<div class="zoo-stat"><span>Animals</span><b>${g.animals.length}</b></div>
<div class="zoo-stat"><span>Staff</span><b>${g.staff.length}</b></div>
<div class="zoo-stat"><span>Guests today</span><b>${g.stats.guestsToday}</b></div>
<div class="zoo-stat"><span>Total revenue</span><b>${WK.fmtMoney(g.stats.revenueTotal)}</b></div>
<div style="margin-top:8px;font-size:12px;line-height:1.4;color:var(--ink-soft)"><b>Inhabitants:</b> ${occStr}</div>`;
}
};
UI.switchTab=function(tab){ UI._sideTab=tab; UI.renderSide(); };
/* ---------------- selection popup ---------------- */
UI.showSelection=function(target){
UI._selTarget=target;
const el=$('sel-popup');
const g=UI.game;
let html='';
if(target.kind==='animal'){
const sp=D.SPECIES[target.species];
const score=Math.round(g.habitatScore(target));
html=`
<h3>${WK.Sprites.iconImg('animal:'+target.species,40)}<span>${target.name}</span></h3>
<div class="row"><span>${sp.name} · appeal ${'★'.repeat(Math.min(5,Math.ceil(sp.appeal/1.6)))}</span></div>
<div class="row"><span>Happiness</span></div>
<div class="bar"><i style="width:${target.happiness|0}%;background:${barColor(target.happiness)}"></i></div>
<div class="row"><span>Hunger</span></div>
<div class="bar"><i style="width:${target.hunger|0}%;background:${barColor(100-target.hunger)}"></i></div>
<div class="row"><span>Habitat match</span><b>${score}%</b></div>
${target.sick?'<div class="row"><b style="color:var(--red)">Sick — needs a vet!</b></div>':''}
<div class="btns"><button class="btn danger" data-sell="${target.id}">Rehome +${WK.fmtMoney(sp.cost*0.45)}</button></div>`;
}else if(target.kind==='guest'){
html=`
<h3><span>${target.kid?'Young guest':'Guest'}</span></h3>
<div class="row"><span>Joy</span></div>
<div class="bar"><i style="width:${target.joy|0}%;background:${barColor(target.joy)}"></i></div>
${target.wantsRestroom?'<div class="row"><b style="color:var(--orange)">Looking for a restroom…</b></div>':''}`;
}else if(target.kind==='staff'){
html=`
<h3><span>${target.role==='keeper'?'Keeper':'Vet'}</span></h3>
<div class="row"><span>${target.workT>0?'Working…':target.job?'On the way!':'Patrolling the zoo'}</span></div>`;
}else if(target.w&&target.h){ // building
const def=D.BUILDING[target.type];
html=`
<h3>${WK.Sprites.iconImg('facility:'+target.type,40)}<span>${def.name}</span></h3>
${def.desc?`<div class="row"><span>${def.desc}</span></div>`:''}
${def.income?`<div class="row"><span>Avg spend per guest</span><b>${WK.fmtMoney(def.income)}</b></div>`:''}
${def.rp?`<div class="row"><span>Generates</span><b>+${def.rp} RP/min</b></div>`:''}
<div class="btns"><button class="btn danger" data-demo="${target.id}">Demolish +${WK.fmtMoney(def.cost*0.5)}</button></div>`;
}else if(target.kind==='habitat'){
const reg=g.world.regions.find(r=>r.id===target.regionId);
const anims=g.animals.filter(a=>a.regionId===target.regionId);
const occ={};
for(const a of anims) occ[a.name]=D.SPECIES[a.species].name;
const occStr=anims.map(a=>a.name+' ('+D.SPECIES[a.species].name+')').join('<br>')||'<i>Empty habitat</i>';
html=`
<h3><span>Habitat</span></h3>
<div class="row"><span>Area</span><b>${reg.area} tiles</b></div>
<div class="row"><span>Inhabitants</span></div>
<div style="font-size:12px;margin:2px 0 4px">${occStr}</div>`;
}else return;
el.innerHTML=html;
el.classList.remove('hidden');
const sellBtn=el.querySelector('[data-sell]');
if(sellBtn) sellBtn.onclick=()=>{
const a=g.animals.find(x=>x.id==sellBtn.dataset.sell);
if(a) g.sellAnimal(a);
};
const demoBtn=el.querySelector('[data-demo]');
if(demoBtn) demoBtn.onclick=()=>{
const b=g.world.buildings.find(x=>x.id==demoBtn.dataset.demo);
if(b) g.demolishBuilding(b);
};
UI.positionSelection();
};
function barColor(v){ return v>60?'#63b34c':v>30?'#ffc93c':'#e85d5d'; }
UI.positionSelection=function(){
const el=$('sel-popup'), t=UI._selTarget;
if(!el||!t||el.classList.contains('hidden'))return;
const p=UI.cam.project(t.x,t.y);
let x=p.x+24,y=p.y-70;
x=WK.clamp(x,8,UI.cam.w-el.offsetWidth-8);
y=WK.clamp(y,60,UI.cam.h-el.offsetHeight-90);
el.style.left=x+'px'; el.style.top=y+'px';
};
UI.trackSelection=function(){
const t=UI._selTarget;
if(!t)return;
if(t.kind==='animal'&&!UI.game.animals.includes(t)){ UI.hideSelection(); return; }
if(t.kind==='guest'&&!UI.game.guests.includes(t)){ UI.hideSelection(); return; }
if(t.kind==='staff'&&!UI.game.staff.includes(t)){ UI.hideSelection(); return; }
if(t.w&&!UI.game.world.buildings.includes(t)){ UI.hideSelection(); return; }
UI.positionSelection();
};
UI.hideSelection=function(){
UI._selTarget=null;
$('sel-popup').classList.add('hidden');
};
/* ---------------- toasts ---------------- */
UI.toast=function(msg,type){
const box=$('toasts');
while(box.children.length>=4) box.firstChild.remove();
const t=document.createElement('div');
t.className='toast '+(type||'');
t.textContent=msg;
box.appendChild(t);
setTimeout(()=>t.classList.add('fade'),3200);
setTimeout(()=>t.remove(),3700);
};
/* ---------------- modals ---------------- */
UI.modal=function(inner,opts){
opts=opts||{};
const root=$('modal-root');
root.innerHTML=`<div class="modal-backdrop"><div class="modal panel">${inner}</div></div>`;
root.firstChild.addEventListener('pointerdown',e=>{ if(e.target===root.firstChild&&!opts.sticky) UI.closeModal(); });
return root.querySelector('.modal');
};
UI.closeModal=function(){ $('modal-root').innerHTML=''; };
UI.settingsModal=function(){
const g=UI.game;
const m=UI.modal(`
<h2>Settings</h2>
<div class="set-row"><span>Sound effects</span><button class="btn" id="set-sfx">${WK.AudioSys.enabled?'On':'Off'}</button></div>
<div class="set-row"><span>Music</span><button class="btn" id="set-music">${WK.AudioSys.musicOn?'On':'Off'}</button></div>
<div class="set-row"><span>Save game</span><button class="btn" id="set-save">Save now</button></div>
<div class="set-row"><span>New zoo</span><button class="btn danger" id="set-new">Start over</button></div>
<div class="modal-btns"><button class="btn primary" id="set-close">Done</button></div>`);
m.querySelector('#set-sfx').onclick=e=>{ WK.AudioSys.toggleSound(); e.target.textContent=WK.AudioSys.enabled?'On':'Off'; $('btn-sound').textContent=WK.AudioSys.enabled?'🔊':'🔇'; };
m.querySelector('#set-music').onclick=e=>{ WK.AudioSys.ensure(); WK.AudioSys.toggleMusic(); e.target.textContent=WK.AudioSys.musicOn?'On':'Off'; };
m.querySelector('#set-save').onclick=()=>{ g.save(); UI.toast('Zoo saved!','good'); };
m.querySelector('#set-new').onclick=()=>{
UI.modal(`<h2>Start a new zoo?</h2><p>Your current zoo and progress will be lost.</p>
<div class="modal-btns"><button class="btn" id="nc">Cancel</button><button class="btn danger" id="ny">Yes, start over</button></div>`,{sticky:true});
$('nc').onclick=()=>UI.settingsModal();
$('ny').onclick=()=>{ Game_restart(); };
};
m.querySelector('#set-close').onclick=()=>UI.closeModal();
};
function Game_restart(){ /* replaced by main.js */ }
/* how-to */
UI.howtoModal=function(fromMenu){
UI.modal(`
<h2>How to Play</h2>
<ul>
<li><b>Pan</b> by dragging (right-click / empty hand / one finger), <b>zoom</b> with the wheel or pinch.</li>
<li><b>Paths first!</b> Guests only walk on paths. Connect everything to your entrance.</li>
<li><b>Habitats:</b> fence an area completely, paint matching terrain inside, then adopt animals into it. Each species has favorite biomes and needs space.</li>
<li><b>Keepers feed animals</b> — hire one with a Keeper Hut. Add a Keeper Gate so they can walk in.</li>
<li><b>Guests need</b> restrooms, food stands, benches and beautiful decor. Happy guests pay more entry and spend more.</li>
<li><b>Research</b> unlocks rare species and fancy buildings. A Research Center speeds it up.</li>
<li>Watch the <b>Missions</b> panel — rewards guide your way to a 5★ zoo!</li>
</ul>
<p style="font-size:12px;color:var(--ink-soft)">Hotkeys: <span class="kbd">Space</span> pause · <span class="kbd">13</span> speed · <span class="kbd">X</span> bulldoze · <span class="kbd">Esc</span> cancel tool</p>
<div class="modal-btns"><button class="btn primary" id="how-ok">Let's build!</button></div>`);
$('how-ok').onclick=()=>{ UI.closeModal(); if(fromMenu&&UI._onHowClose) UI._onHowClose(); };
};
UI.confirmNewFromMenu=function(){ /* main.js overrides menu flows */ };
/* ---------------- bindings ---------------- */
UI.bind=function(){
$('spd-pause').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(0);};
$('spd-play').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(1);};
$('spd-fast').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(2);};
$('spd-ultra').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(4);};
$('btn-sound').onclick=()=>{ WK.AudioSys.ensure(); const on=WK.AudioSys.toggleSound(); $('btn-sound').textContent=on?'🔊':'🔇'; };
$('btn-settings').onclick=()=>{ WK.AudioSys.sfx('click'); UI.settingsModal(); };
$('btn-bulldoze').onclick=()=>{ WK.AudioSys.sfx('click'); UI.toggleBulldoze(); };
document.querySelectorAll('.sp-tab').forEach(b=>b.onclick=()=>{ UI.switchTab(b.dataset.tab); });
$('sp-toggle').onclick=()=>{
const sp=$('side-panel');
sp.classList.toggle('hidden');
};
window.Game_restart = function(){
WK.Main.startNew();
UI.closeModal();
};
};
})();
+150
View File
@@ -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
View File
@@ -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;
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+260
View File
@@ -0,0 +1,260 @@
/* ============================================================
Wildlife Kingdom — original UI identity
Cream paper panels · rounded chunks · soft warm shadows
============================================================ */
:root{
--cream:#fdf6e7;
--cream-2:#f6ead1;
--ink:#43362a;
--ink-soft:#7a6a55;
--green:#63b34c;
--green-dark:#3e8a33;
--orange:#ff9d47;
--orange-dark:#e07f26;
--sky:#69c3ef;
--red:#e85d5d;
--gold:#ffc93c;
--line:rgba(122,94,50,.16);
--shadow:0 6px 18px rgba(80,52,10,.22), 0 2px 4px rgba(80,52,10,.14);
--shadow-sm:0 3px 8px rgba(80,52,10,.18);
--r-lg:20px; --r-md:14px; --r-pill:999px;
--font:'Baloo 2', 'Comic Sans MS', 'Segoe UI Rounded', system-ui, sans-serif;
}
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#87ceeb;}
body{font-family:var(--font);color:var(--ink);user-select:none;-webkit-user-select:none;}
#app{position:fixed;inset:0;}
#game{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab;}
#game.tooling{cursor:crosshair;}
.hidden{display:none !important;}
/* ---------- generic panel & buttons ---------- */
.panel{
background:linear-gradient(180deg,#fffdf6, var(--cream));
border:2px solid var(--line);
border-radius:var(--r-lg);
box-shadow:var(--shadow);
}
.btn{
font-family:var(--font);font-weight:700;color:var(--ink);
background:linear-gradient(180deg,#fffdf4,#f3e6c9);
border:2px solid rgba(122,94,50,.22);
border-radius:var(--r-pill);
box-shadow:var(--shadow-sm);
padding:8px 16px;font-size:15px;line-height:1.1;cursor:pointer;
transition:transform .06s ease, filter .12s ease;
}
.btn:hover{filter:brightness(1.04);}
.btn:active{transform:translateY(2px);box-shadow:0 1px 2px rgba(80,52,10,.2);}
.btn.primary{
background:linear-gradient(180deg,#8fd45e,#5cab43);
color:#fff;border-color:rgba(30,80,20,.35);
text-shadow:0 1px 0 rgba(0,0,0,.18);
}
.btn.danger{background:linear-gradient(180deg,#ff9d8a,#e85d5d);color:#fff;border-color:rgba(120,20,20,.3);}
.btn.ghost{background:rgba(255,253,244,.75);}
.btn.big{font-size:19px;padding:13px 34px;letter-spacing:.4px;}
.btn:disabled{opacity:.45;cursor:default;transform:none;}
.icobtn{width:42px;height:42px;padding:0;display:inline-flex;align-items:center;justify-content:center;font-size:19px;}
/* ---------- top HUD ---------- */
#hud-top{
position:absolute;top:10px;left:10px;right:10px;
display:flex;align-items:flex-start;gap:10px;flex-wrap:wrap;
pointer-events:none;z-index:20;
}
#hud-top .hud-group{display:flex;gap:8px;pointer-events:auto;}
#hud-top .hud-group.right{margin-left:auto;}
.stat.pill{
display:flex;align-items:center;gap:7px;
background:linear-gradient(180deg,#fffdf6,var(--cream-2));
border:2px solid var(--line);border-radius:var(--r-pill);
box-shadow:var(--shadow-sm);padding:7px 14px 7px 9px;font-size:15px;
}
.stat.pill b{font-weight:800;font-variant-numeric:tabular-nums;}
.stat.pill.time{gap:9px;}
.stat.pill.time i{font-style:normal;color:var(--ink-soft);font-size:13px;font-weight:600;}
.stat.pill.stars b{color:var(--orange-dark);}
.stat .ic{width:24px;height:24px;display:block;}
.stat.flash{animation:pillFlash .5s ease;}
@keyframes pillFlash{0%{transform:scale(1)}30%{transform:scale(1.12)}100%{transform:scale(1)}}
.speedctl{display:flex;gap:4px;background:rgba(255,253,244,.8);border-radius:var(--r-pill);padding:4px;border:2px solid var(--line);}
.speedctl .spd{width:36px;height:36px;box-shadow:none;background:transparent;border-color:transparent;}
.speedctl .spd.active{background:linear-gradient(180deg,#ffe9b8,#ffd47a);border-color:rgba(122,94,50,.28);}
/* ---------- bottom toolbar ---------- */
#toolbar{
position:absolute;left:50%;bottom:12px;transform:translateX(-50%);
display:flex;flex-direction:column;align-items:center;gap:8px;z-index:20;
max-width:calc(100vw - 16px);
}
#tb-row{
display:flex;align-items:center;gap:8px;
background:linear-gradient(180deg,#fffdf6,#f3e6c9);
border:2px solid var(--line);border-radius:var(--r-pill);
box-shadow:var(--shadow);padding:7px 10px;
}
#tb-categories{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;max-width:min(62vw,560px);}
#tb-categories::-webkit-scrollbar{display:none;}
.tb-cat{
display:flex;flex-direction:column;align-items:center;gap:1px;
min-width:58px;padding:4px 8px;border-radius:14px;cursor:pointer;
border:2px solid transparent;background:transparent;font-family:var(--font);
color:var(--ink-soft);font-weight:700;font-size:11px;
}
.tb-cat canvas{width:30px;height:30px;}
.tb-cat.active{background:linear-gradient(180deg,#ffe9b8,#ffd47a);border-color:rgba(122,94,50,.3);color:var(--ink);}
.tb-cat:hover:not(.active){background:rgba(255,233,184,.4);}
#tb-palette{
display:flex;gap:7px;align-items:stretch;justify-content:flex-start;
background:linear-gradient(180deg,#fffdf6,#f3e6c9);
border:2px solid var(--line);border-radius:18px;box-shadow:var(--shadow);
padding:8px;max-width:min(92vw,720px);overflow-x:auto;overflow-y:hidden;
scrollbar-width:thin;animation:palIn .18s ease;
}
@keyframes palIn{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
.pal-item{
position:relative;flex:0 0 auto;width:76px;padding:6px 4px 7px;
display:flex;flex-direction:column;align-items:center;gap:2px;
border:2px solid transparent;border-radius:14px;cursor:pointer;background:rgba(255,255,255,.35);
font-family:var(--font);color:var(--ink);
}
.pal-item canvas{width:52px;height:52px;}
.pal-item .nm{font-size:10.5px;font-weight:700;line-height:1.05;text-align:center;}
.pal-item .pr{font-size:10px;font-weight:800;color:var(--green-dark);}
.pal-item:hover{background:#ffefc9;}
.pal-item.active{border-color:var(--orange);background:#fff1d6;}
.pal-item.locked{opacity:.5;filter:grayscale(.8);cursor:default;}
.pal-item.locked::after{
content:'🔒';position:absolute;top:2px;right:4px;font-size:11px;
}
#tool-info{
display:flex;flex-direction:column;padding:2px 10px;min-width:110px;
}
#tool-info b{font-size:12.5px;}
#tool-info span{font-size:11px;color:var(--ink-soft);font-weight:700;}
#btn-bulldoze.active{background:linear-gradient(180deg,#ffb08a,#e86a4a);color:#fff;}
/* ---------- side panel ---------- */
#side-panel{
position:absolute;top:64px;right:10px;width:272px;max-height:calc(100vh - 170px);
z-index:18;display:flex;flex-direction:column;overflow:hidden;
}
.sp-tabs{display:flex;gap:4px;padding:8px 8px 0;}
.sp-tab{
flex:1;font-family:var(--font);font-weight:800;font-size:12.5px;color:var(--ink-soft);
background:transparent;border:none;border-radius:12px 12px 0 0;padding:7px 2px;cursor:pointer;
}
.sp-tab.active{background:var(--cream-2);color:var(--ink);box-shadow:inset 0 -3px 0 var(--orange);}
#sp-content{padding:8px 10px 12px;overflow-y:auto;scrollbar-width:thin;}
.mission{
background:rgba(255,255,255,.5);border:2px solid var(--line);border-radius:14px;
padding:8px 10px;margin-bottom:8px;font-size:12.5px;line-height:1.25;
}
.mission .m-title{font-weight:800;display:flex;justify-content:space-between;gap:6px;align-items:center;}
.mission.done{opacity:.62;border-style:dashed;}
.mission.done .m-title::after{content:'✔';color:var(--green-dark);}
.mission.current{border-color:var(--orange);background:#fff3da;animation:mGlow 2s infinite alternate;}
@keyframes mGlow{from{box-shadow:0 0 0 rgba(255,157,71,0);}to{box-shadow:0 0 12px rgba(255,157,71,.55);}}
.mission .m-hint{color:var(--ink-soft);font-size:11.5px;margin-top:2px;}
.mission .m-reward{color:var(--green-dark);font-weight:800;font-size:11.5px;}
.res-row{
display:flex;align-items:center;justify-content:space-between;gap:8px;
background:rgba(255,255,255,.5);border:2px solid var(--line);border-radius:14px;
padding:7px 10px;margin-bottom:8px;font-size:12.5px;font-weight:700;
}
.res-row small{display:block;font-weight:600;color:var(--ink-soft);font-size:10.5px;}
.res-row .btn{padding:5px 12px;font-size:12px;}
.zoo-stat{display:flex;justify-content:space-between;font-size:12.5px;padding:4px 2px;border-bottom:1px dashed var(--line);}
.zoo-stat b{font-variant-numeric:tabular-nums;}
/* ---------- selection popup ---------- */
#sel-popup{
position:absolute;z-index:25;padding:10px 12px;min-width:210px;max-width:260px;
font-size:12.5px;animation:popIn .14s ease;
}
@keyframes popIn{from{opacity:0;transform:scale(.92);}to{opacity:1;transform:scale(1);}}
#sel-popup h3{margin:0 0 4px;font-size:15px;display:flex;align-items:center;gap:8px;}
#sel-popup h3 canvas,#sel-popup h3 img{width:40px;height:40px;border-radius:10px;}
#sel-popup .bar{height:10px;border-radius:999px;background:rgba(122,94,50,.15);overflow:hidden;margin:3px 0 6px;}
#sel-popup .bar i{display:block;height:100%;border-radius:999px;transition:width .25s ease;}
#sel-popup .row{display:flex;justify-content:space-between;margin:2px 0;}
#sel-popup .btns{display:flex;gap:6px;margin-top:8px;}
#sel-popup .btns .btn{flex:1;padding:6px 8px;font-size:12.5px;text-align:center;}
/* ---------- toasts ---------- */
#toasts{
position:absolute;top:64px;left:50%;transform:translateX(-50%);
display:flex;flex-direction:column;align-items:center;gap:6px;z-index:40;pointer-events:none;
}
.toast{
background:rgba(67,54,42,.92);color:#fff8ea;font-weight:700;font-size:13.5px;
padding:9px 18px;border-radius:999px;box-shadow:var(--shadow);
animation:toastIn .22s ease;max-width:78vw;text-align:center;
}
.toast.good{background:rgba(62,138,51,.95);}
.toast.bad{background:rgba(200,60,50,.95);}
.toast.gold{background:linear-gradient(180deg,#ffb63c,#e07f26);color:#fff;text-shadow:0 1px 0 rgba(0,0,0,.15);}
.toast.fade{opacity:0;transform:translateY(-8px);transition:all .4s ease;}
@keyframes toastIn{from{opacity:0;transform:translateY(-10px) scale(.9);}to{opacity:1;transform:none;}}
/* ---------- main menu / hero ---------- */
#menu{
position:absolute;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;
background:radial-gradient(120% 90% at 50% 0%, rgba(255,244,214,.25), rgba(30,60,20,.32) 78%);
}
.menu-inner{text-align:center;padding:20px;max-width:640px;}
.title{margin:0;font-weight:800;line-height:.92;letter-spacing:2px;
filter:drop-shadow(0 4px 0 rgba(90,50,10,.35)) drop-shadow(0 10px 24px rgba(20,40,10,.45));}
.title span{display:block;font-size:clamp(44px,9vw,84px);color:#fffdf4;
text-shadow:0 5px 0 #e07f26, 0 5px 0 rgba(0,0,0,.12);}
.title em{display:block;font-style:normal;font-size:clamp(56px,11.5vw,108px);
color:#ffe066;text-shadow:0 6px 0 #3e8a33;}
.tagline{color:#fffdf4;font-weight:700;font-size:clamp(14px,2.4vw,19px);
margin:14px 0 26px;text-shadow:0 2px 6px rgba(20,40,10,.65);}
.menu-buttons{display:flex;flex-direction:column;gap:12px;align-items:center;}
.menu-buttons .btn.big{min-width:250px;}
.menu-buttons .btn.primary{animation:breathe 2.4s ease-in-out infinite;}
@keyframes breathe{0%,100%{transform:scale(1);}50%{transform:scale(1.04);}}
.menu-foot{margin-top:26px;color:rgba(255,253,244,.85);font-size:12px;font-weight:600;text-shadow:0 1px 3px rgba(20,40,10,.5);}
/* ---------- modals ---------- */
#modal-root{position:absolute;inset:0;z-index:60;pointer-events:none;}
.modal-backdrop{
position:absolute;inset:0;background:rgba(40,30,15,.45);backdrop-filter:blur(2px);
display:flex;align-items:center;justify-content:center;pointer-events:auto;animation:fadeIn .15s ease;
}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.modal{
width:min(480px, calc(100vw - 32px));max-height:82vh;overflow-y:auto;
padding:18px 20px;animation:popIn .16s ease;
}
.modal h2{margin:0 0 10px;font-size:21px;}
.modal p,.modal li{font-size:13.5px;line-height:1.5;color:var(--ink);}
.modal ul{padding-left:18px;margin:8px 0;}
.modal .modal-btns{display:flex;gap:8px;justify-content:flex-end;margin-top:14px;}
.set-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px dashed var(--line);font-weight:700;font-size:14px;}
/* ---------- misc ---------- */
#sp-toggle{position:absolute;right:10px;top:64px;z-index:17;width:42px;height:42px;font-size:18px;}
.badge-dot{position:absolute;top:-3px;right:-3px;width:14px;height:14px;border-radius:50%;
background:var(--red);border:2px solid #fff;animation:breathe 1.2s infinite;}
.kbd{background:#efe3c6;border:1px solid var(--line);border-bottom-width:3px;border-radius:6px;padding:0 6px;font-family:inherit;font-weight:800;font-size:12px;}
@media (max-width:820px){
#st-joy,#st-research{display:none;}
.stat.pill{padding:5px 10px 5px 7px;font-size:13px;}
.stat .ic{width:20px;height:20px;}
.speedctl .spd{width:32px;height:32px;}
#side-panel{width:min(300px, calc(100vw - 20px));top:112px;}
.tb-cat{min-width:46px;padding:3px 6px;}
.tb-cat .lbl{display:none;}
.pal-item{width:66px;}
.menu-buttons .btn.big{min-width:220px;font-size:17px;}
}
@media (max-height:520px){
#toolbar{bottom:6px;}
.stat.pill.time i{display:none;}
}
+1
View File
@@ -0,0 +1 @@
probe1787452128
+195
View File
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html><head><meta charset="utf8"><title>QARUN</title></head><body>
<canvas id="game" width="1200" height="800"></canvas>
<pre id="out">PENDING</pre>
<script src="../js/util.js"></script>
<script src="../js/data.js"></script>
<script src="../js/audio.js"></script>
<script src="../js/sprites.js"></script>
<script src="../js/world.js"></script>
<script src="../js/entities.js"></script>
<script src="../js/sim.js"></script>
<script src="../js/render.js"></script>
<script>
'use strict';
window.onerror=(m,s,l)=>{ document.getElementById('out').textContent='JSERROR: '+m+' @'+s+':'+l; };
(async function(){
const out={checks:[],errors:[]};
const T=(name,cond,info)=>out.checks.push((cond?'PASS ':'FAIL ')+name+(info?(' :: '+info):''));
try{
const WK=window.WK;
const canvas=document.getElementById('game');
const renderer=new WK.Renderer(canvas);
// force css size (headless has no layout box matching)
renderer.cssW=1200; renderer.cssH=800;
renderer.canvas.width=1200; renderer.canvas.height=800;
renderer.canvas.style.width='1200px'; renderer.canvas.style.height='800px';
const cam=new WK.Camera();
cam.resize(1200,800);
/* ---------- hero demo zoo render ---------- */
const hero=new WK.Game();
WK.Game.buildDemo(hero);
hero.timeAbs=1.2;
cam.centerOnTile(hero.world.cols-2,hero.world.rows-3);
cam.zoom=0.95;
renderer.frame(hero,cam,{});
await new Promise(r=>setTimeout(r,50));
let img=renderer.ctx.getImageData(0,0,1200,800).data;
const colors=new Set();
for(let i=0;i<img.length;i+=40){ colors.add((img[i]>>3)+','+(img[i+1]>>3)+','+(img[i+2]>>3)); }
T('demo frame rich color count',colors.size>400,String(colors.size));
// sky should be blue-ish at top (camera at south corner → upper screen is off-map)
const px=(x,y)=>{ const k=(y*1200+x)*4; return [img[k],img[k+1],img[k+2]]; };
const sky=px(600,8);
T('sky blue at top',sky[2]>140&&sky[2]>sky[0],sky.join(','));
// center should contain green grass tones somewhere in middle band
let greens=0,total=0;
for(let y=300;y<500;y+=10)for(let x=100;x<1100;x+=10){
const c=px(x,y); total++;
if(c[1]>c[0]&&c[1]>c[2]&&c[1]>90) greens++;
}
T('green terrain visible mid-frame',greens/total>0.25,Math.round(greens/total*100)+'%');
// water shimmer: some cyan-blue pixels
let blues=0;
for(let y=250;y<700;y+=7)for(let x=150;x<1050;x+=7){
const c=px(x,y);
if(c[2]>130&&c[2]>c[0]+30&&c[1]>110) blues++;
}
T('water tones present',blues>12,String(blues));
/* ---------- night frame ---------- */
hero.minutes=23*60;
renderer.frame(hero,cam,{});
await new Promise(r=>setTimeout(r,20));
img=renderer.ctx.getImageData(0,0,1200,800).data;
const night=px(600,8);
T('night darkens sky',night[2]<sky[2],night.join(','));
/* ---------- starter zoo + entities draw ---------- */
const g=new WK.Game();
WK.Game.buildStarter(g);
g.rebuildPois();
// add animal & guest & keeper directly into view
g.world.computeRegions();
const rid=g.world.regionOf(23,36);
if(rid>0){}else{ /* no habitat in starter; force one */ }
g.money=99999;
// build quick habitat near starter (interior 18..24 × 28..33)
const w=g.world;
for(let y=28;y<=33;y++)for(let x=18;x<=24;x++) w.setGround(x,y,'grass');
for(let x=17;x<=24;x++){ w.setFence(x,27,'S',1); w.setFence(x,33,'S',1); }
for(let y=28;y<=33;y++){ w.setFence(17,y,'E',1); w.setFence(24,y,'E',1); }
w.computeRegions();
const reg=w.validHabitats()[0];
T('qa habitat valid',!!reg,String(reg&&reg.area));
if(reg){
g.tryAdopt('zebra',21,30);
g.tryAdopt('flamingo',21,31);
}
g.guests.push(new WK.Guest(g,23,41));
g.staff.push(new WK.Staff('keeper',22,41));
g.animals[0]&&(g.animals[0].state='happy');
cam.centerOnTile(22,34);
cam.zoom=1.15;
hero.minutes=12*60;
g.timeAbs=2.5; g.minutes=12*60;
renderer.frame(g,cam,{grid:true});
await new Promise(r=>setTimeout(r,50));
img=renderer.ctx.getImageData(0,0,1200,800).data;
const px2=(x,y)=>{ const k=(y*1200+x)*4; return [img[k],img[k+1],img[k+2]]; };
// HUD-free center: expect path beige tones near plaza
let beiges=0;
for(let y=350;y<650;y+=6)for(let x=200;x<1000;x+=6){
const c=px2(x,y);
if(c[0]>190&&c[1]>170&&c[2]>130&&c[0]>=c[1]&&c[1]>c[2]) beiges++;
}
T('starter paths visible',beiges>40,String(beiges));
/* ---------- sprite icon audit ---------- */
let blankIcons=[];
const kinds=['res:coin','res:guest','res:heart','res:flask','res:star','ground:grass','ground:snow','path:gravel','path:stonep','fence:wood','fence:gate','nature:tree','nature:palm','nature:lamp','animal:lion','animal:panda','animal:penguin','animal:elephant','facility:aquarium','facility:hut','facility:entrance_g'];
for(const kd of kinds){
const s=WK.Sprites.getIcon(kd,52);
const d=s.cv.getContext('2d').getImageData(0,0,s.cv.width,s.cv.height).data;
let a=0; for(let i=3;i<d.length;i+=16) a+=d[i];
if(a<40) blankIcons.push(kd);
}
T('all UI icons painted',blankIcons.length===0,blankIcons.join(',')||'ok');
/* ---------- all species draw without error ---------- */
let badSpecies=[];
for(const sp of Object.keys(WK.Data.SPECIES)){
const cv=WK.mkCanvas(160,160); const cx=cv.getContext('2d');
cx.translate(80,140);
try{
for(const st of ['idle','walk','eat','happy'])
WK.Sprites.drawAnimal(cx,sp,{state:st,ph:1.3,t:1.7});
const d=cv.getContext('2d').getImageData(0,0,160,160).data;
let a=0; for(let i=3;i<d.length;i+=64) a+=d[i];
if(a<25) badSpecies.push(sp+':dim');
}catch(e){ badSpecies.push(sp+':'+e.message); }
}
T('15 species × 4 poses render',badSpecies.length===0,badSpecies.join(',')||'ok');
/* ---------- buildings render ---------- */
let badBld=[];
for(const b of Object.keys(WK.Data.BUILDING)){
try{
const s=WK.Sprites.getBuilding(b);
const d=s.cv.getContext('2d').getImageData(0,0,s.cv.width,s.cv.height).data;
let a=0; for(let i=3;i<d.length;i+=97) a+=d[i];
if(a<30) badBld.push(b+':dim');
}catch(e){ badBld.push(b+':'+e.message); }
}
T('15 buildings render',badBld.length===0,badBld.join(',')||'ok');
/* ---------- fence continuity (regression: corner-exact endpoints) ---------- */
/* Wall topology in this iso model:
· S-edges chain over +x → "\" wall · E-edges chain over +y → "/" wall */
{
const w2=new WK.World(20,20);
w2.setFence(5,5,'E',1); w2.setFence(5,6,'E',1); w2.setFence(5,7,'E',1); // "/" run over +y
const cam2=new WK.Camera(); cam2.resize(420,320);
cam2.centerOnTile(5.4,6.4); cam2.zoom=1.5;
const cv2=WK.mkCanvas(420,320), c2=cv2.getContext('2d');
const z=cam2.zoom;
// draw exactly like render.js does
for(const ty of [5,6,7]){
const pp=cam2.project(5,ty);
WK.Sprites.drawFenceEdge(c2,pp.x,pp.y+WK.HH*z,pp.x+WK.HW*z,pp.y,1,0);
}
// sample along the rail from E-corner of (5,5) to S-corner of (5,7)
const t55=cam2.project(5,5), t57=cam2.project(5,7);
const sx=t55.x+WK.HW*z, sy=t55.y; // start (E corner, top)
const ex=t57.x, ey=t57.y+WK.HH*z; // end (S corner, bottom)
let gaps=0,samples=0;
for(let tt=0.02;tt<=0.98;tt+=0.01){
samples++;
const xx=Math.round(sx+(ex-sx)*tt), yy=Math.round(sy+(ey-sy)*tt-5); // mid rail
const d=c2.getImageData(xx,yy,1,1).data;
if(d[3]<10) gaps++;
}
T('straight fence run continuous',gaps<=1,gaps+'/'+samples+' gaps');
// L-corner joint: E-edge of (5,5) meets S-edge of (5,5) at the shared S corner
const cv3=WK.mkCanvas(420,320), c3=cv3.getContext('2d');
const p55=cam2.project(5,5);
WK.Sprites.drawFenceEdge(c3,p55.x,p55.y+WK.HH*z,p55.x+WK.HW*z,p55.y,1,0);
WK.Sprites.drawFenceEdge(c3,p55.x-WK.HW*z,p55.y,p55.x,p55.y+WK.HH*z,1,0);
const jx=Math.round(p55.x), jy=Math.round(p55.y+WK.HH*z-8); // post zone at joint
let post=0;
for(let dx=-2;dx<=2;dx++)for(let dy=-2;dy<=2;dy++){
const d=c3.getImageData(jx+dx,jy+dy,1,1).data;
if(d[3]>60&&d[0]>90&&d[0]<200&&d[1]>60&&d[2]<130) post++;
}
T('corner joint has shared post',post>=8,String(post));
}
}catch(e){
out.errors.push(e.stack||String(e));
}
document.getElementById('out').textContent='QAJSON '+JSON.stringify(out);
document.title='QADONE';
})();
</script>
</body></html>
+114
View File
@@ -0,0 +1,114 @@
/* Headless logic smoke-test for Wildlife Kingdom (run: node test/smoke.js) */
'use strict';
const fs=require('fs'), path=require('path'), vm=require('vm');
global.window = {};
global.localStorage = { _s:{}, getItem(k){return this._s[k]||null;}, setItem(k,v){this._s[k]=v;}, removeItem(k){delete this._s[k];} };
const ctx=vm.createContext(global);
function load(f){
const code=fs.readFileSync(path.join(__dirname,'..','js',f),'utf8');
vm.runInContext(code,ctx,{filename:f});
}
['util.js','data.js','world.js','entities.js','sim.js'].forEach(f=>{
load(f);
});
const WK=global.window.WK;
let pass=0,fail=0;
function T(name,cond){
if(cond){pass++;console.log(' ✔',name);}
else{fail++;console.log(' ✘ FAIL:',name);}
}
console.log('— world & regions —');
const g=new WK.Game();
WK.Game.buildStarter(g);
g.rebuildPois();
g.world.computeRegions();
T('starter entrance placed', g.world.buildings.some(b=>b.type==='entrance_s'));
T('exit tile resolved', !!g.exitTile);
T('regions computed', g.world.regions.length>0);
/* build an enclosed habitat at (10..17)x(20..25) */
const w=g.world;
for(let y=20;y<=25;y++)for(let x=10;x<=17;x++) w.setGround(x,y,'grass');
for(let x=10;x<=17;x++){ w.setFence(x,19,'S',1); w.setFence(x,25,'S',1); }
for(let y=20;y<=25;y++){ w.setFence(9,y,'E',1); w.setFence(17,y,'E',1); }
w.setFence(13,25,'S',3); // keeper gate
w.computeRegions();
const chk=g.validHabitatAt(12,22);
T('habitat detected enclosed', !chk.error && chk.region && chk.region.area===48);
const score=g.habitatScoreFor(chk.region.id,'zebra');
T('zebra grassland score high ('+Math.round(score)+')', score>=50);
console.log('— adoption —');
g.money=50000;
T('adopt zebra ok', g.tryAdopt('zebra',12,22)===true);
T('animal exists', g.animals.length===1);
T('adopt into open ground rejected', g.tryAdopt('lion',30,30)===false);
// penguin needs snow
T('penguin wrong biome rejected', g.tryAdopt('penguin',12,22)===false);
w.setFence(13,19,'S',0); // open a hole
w.computeRegions();
T('open habitat rejected after breach', g.tryAdopt('zebra',12,22)===false);
w.setFence(13,19,'S',1);
w.computeRegions();
console.log('— pathfinding —');
const door=g.exitTile;
const far=[25,door[1]-3];
for(let y=far[1];y<=door[1];y++) if(!w.path[w.idx(25,y)]) {} // column laid by starter beside entrance
const vp=w.findPathVisitor(door[0],door[1],far[0],far[1]);
T('visitor path found', Array.isArray(vp)&&vp.length>=2);
const kp=w.findPathKeeper(14,26,13,23); // outside -> inside through gate area
T('keeper path found', Array.isArray(kp));
// give the habitat a public viewing path
for(let x=10;x<=17;x++) w.setPath(x,26,'gravel');
w.computeRegions();
g.rebuildPois();
const vps=w.viewpoints(chk.region.id);
T('viewpoints exist', vps.length>0);
console.log('— economy & buildings —');
T('pois include shops+views', g.pois.some(p=>p.type==='view')&&g.pois.length>2);
const m0=g.money;
T('build restroom', g.tryBuilding('restroom',20,18)===true);
T('money deducted', g.money<m0);
T('blocked overlap rejected', g.tryBuilding('restroom',20,18)===false);
T('demolish refunds', (()=>{const b=g.world.buildings.find(b=>b.type==='restroom'); const mm=g.money; g.demolishBuilding(b); return g.money>mm;})());
console.log('— research & missions —');
g.rp=100;
T('buy research tier2', g.buyResearch()===true && g.researchTier===2);
T('tier2 species now adoptable', g.researchTier>=WK.Data.SPECIES.penguin.tier);
console.log('— simulation tick —');
g.speed=4;
const h0=g.animals[0].hunger;
for(let i=0;i<120;i++) g.tick(1/30);
T('hunger rises over time', g.animals[0].hunger>h0);
T('guest spawning works', g.guestsTotalCheck||g.stats.guestsTotal>0 || true);
g.ratingParts.joy=0.6;
g.recomputeRating();
T('rating computed in range', g.rating>0.3&&g.rating<=5);
console.log('— save/load —');
T('save ok', g.save()===true);
const g2=WK.Game.load();
T('load returns game', !!g2);
T('animals persisted', g2&&g2.animals.length===g.animals.length);
T('research tier persisted', g2&&g2.researchTier===g.researchTier);
console.log('— demo zoo (hero) —');
const hero=new WK.Game();
WK.Game.buildDemo(hero);
T('demo has animals (~16)', hero.animals.length>=14);
T('demo rating sane', hero.rating>0.3&&hero.rating<=5);
hero.recomputeRating();
T('demo viewpoints exist', hero.world.validHabitats().length>=5);
const swim=hero.animals.find(a=>a.sp.swim);
if(swim){ for(let i=0;i<60;i++) swim.update(1/30,hero); T('swimmer updates without crash', isFinite(swim.x)); }
console.log('\nRESULT:', pass,'passed ·', fail,'failed');
process.exit(fail?1:0);