Wildlife Kingdom — original browser zoo-management sim
- Procedural canvas art: 15 species × 4 poses, 15 buildings, modular terrain, UI icons - Habitat enclosure detection, biome suitability, staff/guest AI pathfinding - Economy: tickets, shops, wages, star rating, research tiers, 15 missions - Day/night cycle, particles, WebAudio soundtrack & synth SFX - Mouse + touch controls, responsive UI, localStorage autosave - Verified: 31/31 logic tests (test/smoke.js) · 12/12 render audits (test/qa.html)
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -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, 18–20 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).
|
||||||
@@ -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 II–IV: 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
@@ -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 & 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
@@ -0,0 +1,139 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Wildlife Kingdom — js/audio.js
|
||||||
|
Procedural WebAudio: gentle marimba soundtrack + synthesized
|
||||||
|
SFX. No audio files, fully original. Global: WK.AudioSys
|
||||||
|
============================================================ */
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const WK = window.WK;
|
||||||
|
|
||||||
|
const A = {
|
||||||
|
ctx:null, master:null, musicBus:null, sfxBus:null,
|
||||||
|
enabled:true, musicOn:true, scene:'menu',
|
||||||
|
timer:null, nextNote:0, step:0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensure(){
|
||||||
|
if(A.ctx){ if(A.ctx.state==='suspended') A.ctx.resume(); return true; }
|
||||||
|
const AC = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if(!AC) return false;
|
||||||
|
A.ctx = new AC();
|
||||||
|
A.master = A.ctx.createGain(); A.master.gain.value=0.9; A.master.connect(A.ctx.destination);
|
||||||
|
A.musicBus = A.ctx.createGain(); A.musicBus.gain.value = 0.5; A.musicBus.connect(A.master);
|
||||||
|
A.sfxBus = A.ctx.createGain(); A.sfxBus.gain.value = 0.85; A.sfxBus.connect(A.master);
|
||||||
|
loadPrefs();
|
||||||
|
startScheduler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPrefs(){
|
||||||
|
try{
|
||||||
|
const p = JSON.parse(localStorage.getItem('wk.audio')||'{}');
|
||||||
|
if(p.enabled===false) A.enabled=false;
|
||||||
|
if(p.musicOn===false) A.musicOn=false;
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function savePrefs(){
|
||||||
|
try{ localStorage.setItem('wk.audio', JSON.stringify({enabled:A.enabled,musicOn:A.musicOn})); }catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- tiny synth helpers ---------- */
|
||||||
|
function tone(opt){
|
||||||
|
// {f, f2, t='sine', dur, vol, bus, at, attack, release}
|
||||||
|
const c=A.ctx; if(!c) return;
|
||||||
|
const at = opt.at || c.currentTime;
|
||||||
|
const o=c.createOscillator(), g=c.createGain();
|
||||||
|
o.type=opt.t||'sine';
|
||||||
|
o.frequency.setValueAtTime(opt.f,at);
|
||||||
|
if(opt.f2) o.frequency.exponentialRampToValueAtTime(Math.max(20,opt.f2), at+(opt.dur||0.2));
|
||||||
|
const v=(opt.vol==null?0.2:opt.vol);
|
||||||
|
g.gain.setValueAtTime(0.0001,at);
|
||||||
|
g.gain.linearRampToValueAtTime(v, at+(opt.attack||0.008));
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, at+(opt.dur||0.2));
|
||||||
|
o.connect(g); g.connect(opt.bus||A.sfxBus);
|
||||||
|
o.start(at); o.stop(at+(opt.dur||0.2)+0.05);
|
||||||
|
}
|
||||||
|
function noiseHit(opt){
|
||||||
|
// {dur,vol,f,q,bus,at}
|
||||||
|
const c=A.ctx; if(!c) return;
|
||||||
|
const at=opt.at||c.currentTime;
|
||||||
|
const len=Math.max(1,Math.floor(c.sampleRate*(opt.dur||0.15)));
|
||||||
|
const buf=c.createBuffer(1,len,c.sampleRate);
|
||||||
|
const d=buf.getChannelData(0);
|
||||||
|
for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*(1-i/len);
|
||||||
|
const src=c.createBufferSource(); src.buffer=buf;
|
||||||
|
const flt=c.createBiquadFilter(); flt.type='bandpass'; flt.frequency.value=opt.f||800; flt.Q.value=opt.q||1;
|
||||||
|
const g=c.createGain(); g.gain.value=opt.vol==null?0.25:opt.vol;
|
||||||
|
src.connect(flt); flt.connect(g); g.connect(opt.bus||A.sfxBus);
|
||||||
|
src.start(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- SFX vocabulary (all synthesized) ---------- */
|
||||||
|
const SFX = {
|
||||||
|
click(){ tone({f:660,f2:880,t:'triangle',dur:0.07,vol:0.12}); },
|
||||||
|
place(){ noiseHit({dur:0.09,vol:0.3,f:420,q:0.8}); tone({f:180,f2:120,t:'sine',dur:0.12,vol:0.22}); },
|
||||||
|
dig(){ noiseHit({dur:0.14,vol:0.22,f:300,q:0.7}); },
|
||||||
|
coin(){ tone({f:988,t:'square',dur:0.06,vol:0.08}); tone({f:1319,t:'square',dur:0.16,vol:0.09,at:A.ctx&&A.ctx.currentTime+0.06}); },
|
||||||
|
error(){ tone({f:220,f2:160,t:'sawtooth',dur:0.18,vol:0.12}); },
|
||||||
|
pop(){ tone({f:520,f2:1040,t:'sine',dur:0.09,vol:0.15}); },
|
||||||
|
cheer(){ [523,659,784,1047].forEach((f,i)=>tone({f,t:'triangle',dur:0.18,vol:0.1,at:A.ctx&&A.ctx.currentTime+i*0.07})); },
|
||||||
|
roar(){ tone({f:150,f2:70,t:'sawtooth',dur:0.5,vol:0.14}); noiseHit({dur:0.4,vol:0.1,f:200,q:0.6}); },
|
||||||
|
chirp(){ tone({f:1400,f2:2100,t:'sine',dur:0.12,vol:0.1}); tone({f:1800,f2:2400,t:'sine',dur:0.1,vol:0.08,at:A.ctx&&A.ctx.currentTime+0.1}); },
|
||||||
|
splash(){ noiseHit({dur:0.3,vol:0.28,f:900,q:0.4}); },
|
||||||
|
trash(){ noiseHit({dur:0.12,vol:0.25,f:250,q:0.9}); },
|
||||||
|
unlock(){ [392,523,659,784].forEach((f,i)=>tone({f,t:'sine',dur:0.25,vol:0.11,at:A.ctx&&A.ctx.currentTime+i*0.09})); },
|
||||||
|
};
|
||||||
|
WK.AudioSys = {
|
||||||
|
ensure,
|
||||||
|
get enabled(){return A.enabled;},
|
||||||
|
get musicOn(){return A.musicOn;},
|
||||||
|
sfx(name){
|
||||||
|
if(!A.enabled) return;
|
||||||
|
if(!ensure()) return;
|
||||||
|
const fn=SFX[name]; if(fn) fn();
|
||||||
|
},
|
||||||
|
setScene(s){ A.scene=s; },
|
||||||
|
toggleSound(){ A.enabled=!A.enabled; savePrefs(); if(A.enabled) ensure(); WK.UI&&WK.UI.refreshSoundBtn&&WK.UI.refreshSoundBtn(); return A.enabled; },
|
||||||
|
toggleMusic(){ A.musicOn=!A.musicOn; savePrefs(); return A.musicOn; },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- music scheduler ----------
|
||||||
|
Relaxed pentatonic marimba + warm pad, randomized gentle walk.
|
||||||
|
Menu: slower & dreamier. Game: slightly brighter tempo. */
|
||||||
|
const PENTA=[0,2,4,7,9];
|
||||||
|
function nfreq(semi, base){ return base*Math.pow(2,semi/12); }
|
||||||
|
|
||||||
|
function startScheduler(){
|
||||||
|
if(A.timer) return;
|
||||||
|
A.timer=setInterval(()=>{
|
||||||
|
if(!A.ctx || !A.enabled || !A.musicOn) return;
|
||||||
|
const ahead = 0.45;
|
||||||
|
const menu = A.scene==='menu';
|
||||||
|
const stepDur = menu? 60/68/2 : 60/76/2; // eighth notes
|
||||||
|
const root = menu? 220 : 246.94; // A3 / B3
|
||||||
|
while(A.nextNote < A.ctx.currentTime + ahead){
|
||||||
|
const t=Math.max(A.nextNote, A.ctx.currentTime+0.02);
|
||||||
|
const bar=Math.floor(A.step/8), beat=A.step%8;
|
||||||
|
// pad chord every bar
|
||||||
|
if(beat===0){
|
||||||
|
const deg=PENTA[(bar*2)%5];
|
||||||
|
[0,7,12].forEach(iv=>tone({f:nfreq(deg+iv,root/2)*2,t:'sine',dur:stepDur*8,vol:0.035,bus:A.musicBus,at:t,attack:0.6}));
|
||||||
|
tone({f:nfreq(PENTA[bar%5],root)/2,t:'triangle',dur:stepDur*3.6,vol:0.10,bus:A.musicBus,at:t,attack:0.01});
|
||||||
|
}
|
||||||
|
// pluck melody: sparse random walk on pentatonic
|
||||||
|
const density = menu?0.42:0.55;
|
||||||
|
if(WK.hash2(A.step,bar,7) < density){
|
||||||
|
const idx = Math.floor(WK.hash2(A.step*13,bar,3)*PENTA.length);
|
||||||
|
const oct = (WK.hash2(A.step,bar*3,11)<0.3)?12:0;
|
||||||
|
tone({f:nfreq(PENTA[idx]+oct,root),t:'triangle',dur:stepDur*2.4,vol:0.085,bus:A.musicBus,at:t});
|
||||||
|
if(WK.hash2(A.step,bar,23)<0.25)
|
||||||
|
tone({f:nfreq(PENTA[idx]+oct+12,root)*2,t:'sine',dur:stepDur*1.6,vol:0.04,bus:A.musicBus,at:t+stepDur*0.5});
|
||||||
|
}
|
||||||
|
// soft shaker
|
||||||
|
if(beat%2===1 && !menu) noiseHit({dur:0.03,vol:0.018,f:6000,q:1.2,bus:A.musicBus,at:t});
|
||||||
|
A.nextNote = t + stepDur;
|
||||||
|
A.step++;
|
||||||
|
}
|
||||||
|
},120);
|
||||||
|
}
|
||||||
|
})();
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Wildlife Kingdom — js/data.js
|
||||||
|
All game content: terrain, paths, fences, nature, 15 species,
|
||||||
|
15 buildings, research tiers, missions, name pools, palettes.
|
||||||
|
============================================================ */
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const WK = window.WK;
|
||||||
|
const D = (WK.Data = {});
|
||||||
|
|
||||||
|
D.GRID = { COLS:46, ROWS:46 };
|
||||||
|
D.START_MONEY = 25000;
|
||||||
|
D.SPEEDS = [0,1,2,4];
|
||||||
|
D.CAPS = { guests:140, keepersMax:6 };
|
||||||
|
|
||||||
|
/* ---------------- ground / terrain ---------------- */
|
||||||
|
/* walk: humans passable · water: 0 none,1 shallow,2 deep */
|
||||||
|
D.GROUND = {
|
||||||
|
grass:{ name:'Grass', tier:1, cost:6, colors:['#7cc653','#71ba48'], walk:1, water:0 },
|
||||||
|
dirt: { name:'Dirt', tier:1, cost:6, colors:['#c99b62','#bd8f57'], walk:1, water:0 },
|
||||||
|
sand: { name:'Sand', tier:1, cost:8, colors:['#eed9a0','#e5cd8d'], walk:1, water:0 },
|
||||||
|
stone:{ name:'Stone', tier:1, cost:8, colors:['#bfc3bf','#b2b6b2'], walk:1, water:0 },
|
||||||
|
water:{ name:'Shallow Water', tier:1, cost:22, colors:['#6ec6ea','#5cb8e0'], walk:0, water:1 },
|
||||||
|
deep: { name:'Deep Water', tier:2, cost:30, colors:['#3796cf','#2f89c0'], walk:0, water:2 },
|
||||||
|
snow: { name:'Snow', tier:2, cost:14, colors:['#eef3f6','#e0e9f0'], walk:1, water:0 },
|
||||||
|
ice: { name:'Ice', tier:2, cost:18, colors:['#cfeaf6','#bcdef0'], walk:1, water:0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- paths ---------------- */
|
||||||
|
D.PATH = {
|
||||||
|
gravel:{ name:'Gravel Path', tier:1, cost:10, base:'#d9c69d', dark:'#c4ad80' },
|
||||||
|
wood: { name:'Boardwalk', tier:1, cost:16, base:'#cf9457', dark:'#b97f43' },
|
||||||
|
stonep:{ name:'Stone Path', tier:2, cost:22, base:'#d3d7da', dark:'#bcc0c4' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- fences (tile edges) ----------------
|
||||||
|
edge codes stored per tile: 0 none, 1 wood, 2 hedge, 3 gate */
|
||||||
|
D.FENCE = {
|
||||||
|
1:{ name:'Wood Fence', tier:1, cost:12 },
|
||||||
|
2:{ name:'Hedge', tier:2, cost:18 },
|
||||||
|
3:{ name:'Keeper Gate',tier:1, cost:40 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- nature & decor ---------------- */
|
||||||
|
/* enrich: habitat enrichment value · block: blocks walking/placement */
|
||||||
|
D.NATURE = {
|
||||||
|
tree: { name:'Oak Tree', tier:1, cost:30, enrich:1, block:1 },
|
||||||
|
bush: { name:'Bush', tier:1, cost:15, enrich:0.5, block:0 },
|
||||||
|
flower:{ name:'Flower Bed', tier:1, cost:8, enrich:0, block:0 },
|
||||||
|
rock: { name:'Boulder', tier:1, cost:20, enrich:1, block:1 },
|
||||||
|
palm: { name:'Palm Tree', tier:2, cost:35, enrich:1.2, block:1 },
|
||||||
|
bamboo:{ name:'Bamboo', tier:2, cost:28, enrich:1.5, block:1 },
|
||||||
|
bench: { name:'Bench', tier:1, cost:25, enrich:0, block:1, seat:1 },
|
||||||
|
bin: { name:'Trash Bin', tier:1, cost:10, enrich:0, block:1 },
|
||||||
|
lamp: { name:'Lamp Post', tier:1, cost:20, enrich:0, block:1, light:1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- species ----------------
|
||||||
|
biome: groundId -> weight · swim: may enter water tiles ·
|
||||||
|
appeal: guest draw · upkeep: $/day · speed: tiles/sec */
|
||||||
|
D.SPECIES = {
|
||||||
|
deer: { name:'Deer', cost:800, tier:1, appeal:2, minArea:10, biome:{grass:1, dirt:.85}, swim:0, enrichNeed:1, upkeep:8, speed:1.0 },
|
||||||
|
zebra: { name:'Zebra', cost:900, tier:1, appeal:3, minArea:12, biome:{grass:1, sand:.6}, swim:0, enrichNeed:1, upkeep:10, speed:1.05 },
|
||||||
|
monkey: { name:'Monkey', cost:1000, tier:1, appeal:3, minArea:10, biome:{grass:.8, dirt:1}, swim:0, enrichNeed:2, upkeep:9, speed:1.35 },
|
||||||
|
flamingo: { name:'Flamingo', cost:1100, tier:1, appeal:3, minArea:10, biome:{water:.95, sand:1, deep:.5}, swim:1, enrichNeed:0, upkeep:9, speed:0.75 },
|
||||||
|
penguin: { name:'Penguin', cost:1400, tier:2, appeal:4, minArea:10, biome:{snow:1, ice:.95, water:.7, deep:.7}, swim:1, enrichNeed:1, upkeep:11, speed:0.8 },
|
||||||
|
crocodile:{ name:'Crocodile', cost:1600, tier:2, appeal:4, minArea:12, biome:{water:1, sand:.8, deep:.6}, swim:1, enrichNeed:1, upkeep:12, speed:0.55 },
|
||||||
|
giraffe: { name:'Giraffe', cost:2000, tier:2, appeal:5, minArea:16, biome:{grass:1, sand:.5}, swim:0, enrichNeed:2, upkeep:14, speed:0.9 },
|
||||||
|
lion: { name:'Lion', cost:2400, tier:2, appeal:5, minArea:16, biome:{grass:1, sand:.8}, swim:0, enrichNeed:2, upkeep:16, speed:1.0 },
|
||||||
|
hippo: { name:'Hippopotamus',cost:1800,tier:3, appeal:4, minArea:14, biome:{water:1, deep:.85, grass:.6}, swim:1, enrichNeed:0, upkeep:13, speed:0.6 },
|
||||||
|
tiger: { name:'Tiger', cost:2600, tier:3, appeal:6, minArea:14, biome:{grass:.7, dirt:1}, swim:0, enrichNeed:3, upkeep:16, speed:1.1 },
|
||||||
|
rhino: { name:'Rhinoceros', cost:2800, tier:3, appeal:5, minArea:16, biome:{grass:.8, dirt:.8, sand:1}, swim:0, enrichNeed:1, upkeep:17, speed:0.8 },
|
||||||
|
elephant: { name:'Elephant', cost:3500, tier:3, appeal:7, minArea:20, biome:{grass:1, sand:.7, dirt:.5}, swim:0, enrichNeed:3, upkeep:20, speed:0.7 },
|
||||||
|
gorilla: { name:'Gorilla', cost:3000, tier:4, appeal:6, minArea:12, biome:{grass:.7, dirt:1}, swim:0, enrichNeed:3, upkeep:18, speed:0.9 },
|
||||||
|
polarbear:{ name:'Polar Bear', cost:3800, tier:4, appeal:6, minArea:16, biome:{snow:1, ice:.9, deep:1, water:.8}, swim:1, enrichNeed:2, upkeep:19, speed:0.85 },
|
||||||
|
panda: { name:'Giant Panda',cost:4200, tier:4, appeal:8, minArea:12, biome:{grass:.8, dirt:1}, swim:0, enrichNeed:4, upkeep:22, speed:0.7 },
|
||||||
|
};
|
||||||
|
D.SPECIES_LIST = Object.keys(D.SPECIES);
|
||||||
|
|
||||||
|
D.ANIMAL_NAMES = ['Mango','Pip','Biscuit','Clover','Noodle','Willow','Juno','Pepper','Maple','Tango','Olive','Bramble','Ziggy','Poppy','Rufus','Cinnamon','Echo','Marble','Sunny','Truffle','Basil','Peanut','Comet','Waffle','Hazel','Boomer','Petal','Snacks','Fig','Dot','Rascal','Muffin','Sprout','Bandit','Honey'];
|
||||||
|
|
||||||
|
/* ---------------- buildings ----------------
|
||||||
|
w,h footprint in tiles · door faces NORTH (front tile is at y-1)
|
||||||
|
joy: guest happiness on visit · income: avg guest spend ·
|
||||||
|
staff: hires granted · rp: research points/min */
|
||||||
|
const B = D.BUILDING = {};
|
||||||
|
B.hut = { name:'Keeper Hut', cost:600, tier:1, w:2, h:2, wall:['#e8d9b8','#c9b28a','#b49a72'], roof:{style:'slant',color:'#a06a3c'}, staff:'keeper', joy:1, income:0, desc:'+1 keeper joins your staff.' };
|
||||||
|
B.restroom = { name:'Restroom', cost:500, tier:1, w:1, h:1, wall:['#bfe3ef','#93c6dc','#7fb3cb'], roof:{style:'flat',color:'#5d90a8'}, joy:2, income:0, desc:'Guests need these after food & drink!' };
|
||||||
|
B.burger = { name:'Burger Stand', cost:700, tier:1, w:2, h:2, wall:['#ffe9b0','#ecc678','#dab262'], roof:{style:'awning',color:'#e8624e'},joy:3, income:7, desc:'Sells burgers. Tasty income.' };
|
||||||
|
B.icecream = { name:'Ice Cream Stand',cost:650, tier:1, w:2, h:2, wall:['#ffdff0','#f2bcd9','#e2aac6'], roof:{style:'cone',color:'#f08bb1'}, joy:3, income:6, desc:'Cool treats on warm days.' };
|
||||||
|
B.gift = { name:'Gift Shop', cost:1200, tier:1, w:2, h:2, wall:['#dff0d0','#b8d9a0','#a2c789'], roof:{style:'awning',color:'#63b34c'},joy:4, income:12, desc:'Plush toys & souvenirs.' };
|
||||||
|
B.visitor = { name:'Visitor Center', cost:1400, tier:1, w:3, h:2, wall:['#f2e3c4','#dcc394','#c8ac7c'], roof:{style:'gable',color:'#c96f4a'}, joy:3, income:0, desc:'A welcoming landmark.' };
|
||||||
|
B.staff = { name:'Staff Room', cost:800, tier:1, w:2, h:2, wall:['#d8d3ca','#b8b2a5','#a29b8c'], roof:{style:'flat',color:'#8a8474'}, joy:0, income:0, boost:'staff', desc:'Staff work 20% faster.' };
|
||||||
|
B.research = { name:'Research Center',cost:3000, tier:1, w:3, h:2, wall:['#d7e8f5','#adcbe4','#94b7d2'], roof:{style:'dome',color:'#7fa8cc'}, joy:1, income:0, rp:5, desc:'+5 research/min. Unlock new species & buildings.' };
|
||||||
|
B.entrance_s = { name:'Small Entrance', cost:800, tier:1, w:2, h:1, wall:['#f5d9a0','#dfba76','#caa15c'], roof:{style:'archway',color:'#e07f26'}, entrance:1, joy:2, income:0, desc:'Guests arrive here. Front side must touch a path.' };
|
||||||
|
B.vet = { name:'Vet Clinic', cost:1600, tier:2, w:3, h:2, wall:['#ffffff','#e4ded4','#cfc7b9'], roof:{style:'gable',color:'#e86a6a'}, staff:'vet', joy:1, income:0, desc:'+1 vet heals sick animals.' };
|
||||||
|
B.entrance_g = { name:'Grand Entrance', cost:2600, tier:2, w:4, h:2, wall:['#f7ddb0','#e0bd82','#cba763'], roof:{style:'archway',color:'#c96f4a'}, entrance:1, joy:5, income:0, big:1, desc:'Impressive gate — more guests per day.' };
|
||||||
|
B.restaurant = { name:'Restaurant', cost:2400, tier:2, w:3, h:3, wall:['#f6ddc0','#e0bd92','#cba674'], roof:{style:'awning',color:'#8a5fb0'}, joy:6, income:18, desc:'Sit-down dining, big spend.' };
|
||||||
|
B.aviary = { name:'Aviary', cost:2800, tier:2, w:3, h:3, wall:['#e6f2f7','#c2dbe8','#a9c8d8'], roof:{style:'mesh',color:'#9fb9c9'}, joy:8, income:3, exhibit:1, desc:'Walk-around bird wonderland.' };
|
||||||
|
B.reptile = { name:'Reptile House', cost:3200, tier:3, w:3, h:2, wall:['#dfe8cf','#c0cfa6','#a8bb8c'], roof:{style:'rock',color:'#7f9166'}, joy:9, income:4, exhibit:1, desc:'Slithery stars indoors.' };
|
||||||
|
B.aquarium = { name:'Aquarium', cost:4200, tier:3, w:4, h:3, wall:['#cfe9f5','#a3cede','#87bad0'], roof:{style:'wave',color:'#4f9fd0'}, joy:12,income:6, exhibit:1, desc:'Undersea tunnel. Crowd favorite.' };
|
||||||
|
D.BUILDING_LIST = Object.keys(B);
|
||||||
|
|
||||||
|
/* ---------------- research tiers ---------------- */
|
||||||
|
D.RESEARCH_TIERS = [
|
||||||
|
{ tier:2, cost:25, label:'Tier II — Savanna & Polar' },
|
||||||
|
{ tier:3, cost:60, label:'Tier III — Giants & Reptiles' },
|
||||||
|
{ tier:4, cost:120, label:'Tier IV — Rare Icons' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------------- missions (sequential) ---------------- */
|
||||||
|
D.MISSIONS = [
|
||||||
|
{ id:'paths', label:'Lay 12 path tiles', reward:300, hint:'Paths category → Gravel Path. Guests can only walk on paths.', check:g=>g.stats.paths>=12 },
|
||||||
|
{ id:'fences', label:'Place 20 fence pieces', reward:300, hint:'Fences category → drag along tile edges.', check:g=>g.stats.fences>=20 },
|
||||||
|
{ id:'habitat', label:'Build an enclosed habitat of 12+ tiles', reward:500, hint:'Fully surround an area with fence — no gaps!', check:g=>g.animals.length>0 || g.maxRegion>=12 },
|
||||||
|
{ id:'adopt1', label:'Adopt your first animal', reward:400, hint:'Animals category → pick a species → click inside your habitat.', check:g=>g.stats.adopted>=1 },
|
||||||
|
{ id:'hut', label:'Build a Keeper Hut', reward:500, hint:'Facilities → Keeper Hut hires a keeper who feeds animals.', check:g=>g.buildings.some(b=>b.type==='hut') },
|
||||||
|
{ id:'happy3', label:'Keep 3 animals above 70% happiness', reward:800, hint:'Feed them and match their favorite terrain.', check:g=>g.animals.filter(a=>a.happiness>70).length>=3 },
|
||||||
|
{ id:'stars3', label:'Reach a 3.0★ zoo rating', reward:{cash:1000,rp:20}, hint:'More species, happy animals, restrooms & decor raise stars.', check:g=>g.rating>=3 },
|
||||||
|
{ id:'guests', label:'Welcome 150 total guests', reward:1200, hint:'Higher stars attract bigger crowds.', check:g=>g.stats.guestsTotal>=150 },
|
||||||
|
{ id:'res2', label:'Unlock Research Tier II', reward:800, hint:'Research panel → unlock for 40 RP. A Research Center earns RP faster.', check:g=>g.researchTier>=2 },
|
||||||
|
{ id:'species5',label:'Care for 5 different species',reward:1500, hint:'Variety is the spice of zoos.', check:g=>g.speciesCount()>=5 },
|
||||||
|
{ id:'resto', label:'Open a Restaurant', reward:1000, hint:'Tier II building. Hungry guests spend well.', check:g=>g.buildings.some(b=>b.type==='restaurant') },
|
||||||
|
{ id:'stars4', label:'Reach a 4.0★ zoo rating', reward:{cash:2500,rp:40}, check:g=>g.rating>=4 },
|
||||||
|
{ id:'rev20k', label:'Earn $20,000 total revenue', reward:3000, hint:'Tickets, food and gifts all count.', check:g=>g.stats.revenueTotal>=20000 },
|
||||||
|
{ id:'species8',label:'Care for 8 different species',reward:4000, check:g=>g.speciesCount()>=8 },
|
||||||
|
{ id:'stars5', label:'Reach 5.0★ — Wildlife Legend!', reward:{cash:10000,rp:60}, check:g=>g.rating>=4.95 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------------- palette / brand ---------------- */
|
||||||
|
D.PAL = {
|
||||||
|
grass1:'#7cc653', cream:'#fdf6e7', ink:'#43362a',
|
||||||
|
orange:'#ff9d47', sky:'#69c3ef', gold:'#ffc93c', red:'#e85d5d',
|
||||||
|
};
|
||||||
|
D.NIGHT_SKY = '#2c3e66';
|
||||||
|
|
||||||
|
/* tool categories shown in bottom toolbar */
|
||||||
|
D.TOOLCATS = [
|
||||||
|
{ id:'ground', label:'Ground' },
|
||||||
|
{ id:'paths', label:'Paths' },
|
||||||
|
{ id:'fences', label:'Fences' },
|
||||||
|
{ id:'nature', label:'Nature' },
|
||||||
|
{ id:'animals', label:'Animals' },
|
||||||
|
{ id:'facilities',label:'Zoo' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* starter layout seeds (relative to south-center) built by sim.newGame */
|
||||||
|
D.STARTER = { entranceW:3 };
|
||||||
|
})();
|
||||||
+483
@@ -0,0 +1,483 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Wildlife Kingdom — js/entities.js
|
||||||
|
Animals · Guests · Keepers · Vets · Particles
|
||||||
|
All coordinates are float tile-space; drawing projects them.
|
||||||
|
============================================================ */
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const WK = window.WK;
|
||||||
|
const D = WK.Data;
|
||||||
|
const HW = WK.HW, HH = WK.HH;
|
||||||
|
|
||||||
|
WK.ANIMAL_SIZE = {
|
||||||
|
deer:.95, zebra:.95, monkey:.78, flamingo:.78, penguin:.68, crocodile:.9,
|
||||||
|
giraffe:1.12, lion:1, hippo:1.08, tiger:.98, rhino:1.05, elephant:1.22,
|
||||||
|
gorilla:.92, polarbear:1.08, panda:.88,
|
||||||
|
};
|
||||||
|
|
||||||
|
let NEXT_ID = 1;
|
||||||
|
const R = Math.random;
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
ANIMAL
|
||||||
|
============================================================ */
|
||||||
|
class Animal{
|
||||||
|
constructor(speciesId, x, y, regionId){
|
||||||
|
const sp = D.SPECIES[speciesId];
|
||||||
|
this.id = NEXT_ID++;
|
||||||
|
this.kind='animal';
|
||||||
|
this.species = speciesId;
|
||||||
|
this.sp = sp;
|
||||||
|
this.name = D.ANIMAL_NAMES[(R()*D.ANIMAL_NAMES.length)|0];
|
||||||
|
this.x=x; this.y=y;
|
||||||
|
this.face = R()<.5?-1:1;
|
||||||
|
this.state='idle';
|
||||||
|
this.stateT = 1+R()*4;
|
||||||
|
this.phase = R()*10;
|
||||||
|
this.tOff = R()*100;
|
||||||
|
this.hunger = 20+R()*20;
|
||||||
|
this.happiness = 62;
|
||||||
|
this.happyT = 0; // remaining happy-burst time
|
||||||
|
this.eatT = 0;
|
||||||
|
this.sick = false;
|
||||||
|
this.regionId = regionId;
|
||||||
|
this.wp=null;
|
||||||
|
}
|
||||||
|
get size(){ return WK.ANIMAL_SIZE[this.species]||1; }
|
||||||
|
|
||||||
|
startEating(){
|
||||||
|
this.eatT = 3;
|
||||||
|
this.hunger = 4;
|
||||||
|
this.happyT = 3.5;
|
||||||
|
this._taken = false;
|
||||||
|
}
|
||||||
|
makeHappy(){ this.happyT = Math.max(this.happyT, 2.6); }
|
||||||
|
|
||||||
|
update(sdt, game){
|
||||||
|
const sp=this.sp;
|
||||||
|
// needs drift
|
||||||
|
this.hunger = WK.clamp(this.hunger + sdt*0.19, 0, 100);
|
||||||
|
let target = game.habitatScore(this);
|
||||||
|
if(this.hunger>55) target -= (this.hunger-55)*0.85;
|
||||||
|
if(this.sick) target -= 38;
|
||||||
|
target = WK.clamp(target,5,98);
|
||||||
|
this.happiness += (target-this.happiness)*Math.min(1,sdt*0.07);
|
||||||
|
|
||||||
|
if(this.happyT>0) this.happyT-=sdt;
|
||||||
|
if(this.eatT>0){ this.eatT-=sdt; this.state='eat'; return; }
|
||||||
|
if(this.happyT>0 && this.state!=='walk'){ this.state='happy'; this.stateT-=sdt; if(this.stateT<=0)this.state='idle'; return; }
|
||||||
|
|
||||||
|
this.stateT -= sdt;
|
||||||
|
if(this.state==='idle'){
|
||||||
|
if(this.stateT<=0){
|
||||||
|
// choose a stroll
|
||||||
|
const dest = game.world.randomRegionTile(this.regionId, sp, R);
|
||||||
|
if(dest){
|
||||||
|
const path = game.world.findPathAnimal(sp, this.x, this.y, dest[0], dest[1], this.regionId);
|
||||||
|
if(path&&path.length){ this.wp=path; this.state='walk'; }
|
||||||
|
}
|
||||||
|
this.stateT = 3.5+R()*7;
|
||||||
|
}
|
||||||
|
}else if(this.state==='walk'){
|
||||||
|
if(!this.wp||!this.wp.length){ this.state='idle'; this.stateT=2+R()*4; return; }
|
||||||
|
const [tx,ty]=this.wp[0];
|
||||||
|
const dx=tx-this.x, dy=ty-this.y;
|
||||||
|
const d=Math.hypot(dx,dy);
|
||||||
|
const step=sp.speed*sdt;
|
||||||
|
if(d<=step){ this.x=tx; this.y=ty; this.wp.shift(); if(!this.wp.length){this.state='idle'; this.stateT=2.5+R()*6;} }
|
||||||
|
else{ this.x+=dx/d*step; this.y+=dy/d*step; }
|
||||||
|
const screenDx=(tx-this.x)-(ty-this.y);
|
||||||
|
if(Math.abs(screenDx)>0.05) this.face = screenDx>0?1:-1;
|
||||||
|
this.phase += step*5.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(ctx, cam, time, groundId){
|
||||||
|
const p = cam.project(this.x,this.y);
|
||||||
|
if(!cam.isVisible(p.x,p.y,80)) return;
|
||||||
|
const s=this.size;
|
||||||
|
const g = WK.Data.GROUND[groundId||'grass'];
|
||||||
|
const swimming = g.water && this.sp.swim;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x,p.y);
|
||||||
|
// shadow (not for swimmers)
|
||||||
|
if(!swimming){
|
||||||
|
ctx.fillStyle='rgba(50,40,15,.26)';
|
||||||
|
ctx.beginPath(); ctx.ellipse(0,1,15*s,5.5*s,0,0,WK.TAU); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.scale(this.face*s, s);
|
||||||
|
if(swimming){
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath(); ctx.rect(-48,-72,96,64); ctx.clip(); // hide feet under waterline
|
||||||
|
WK.Sprites.drawAnimal(ctx, this.species, {
|
||||||
|
state:this.state==='walk'?'walk':this.state==='eat'?'eat':this.state==='happy'?'happy':'idle',
|
||||||
|
ph:this.phase, t:time+this.tOff,
|
||||||
|
});
|
||||||
|
ctx.restore();
|
||||||
|
// ripples at waterline
|
||||||
|
ctx.strokeStyle='rgba(255,255,255,.55)'; ctx.lineWidth=1.6;
|
||||||
|
const rr=(14+Math.sin(time*3+this.tOff)*2)*s;
|
||||||
|
ctx.beginPath(); ctx.ellipse(0,-6*s,rr,rr*0.36,0,0,WK.TAU); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.ellipse(0,-6*s,rr*.6,rr*.2,0,0,WK.TAU); ctx.stroke();
|
||||||
|
}else{
|
||||||
|
WK.Sprites.drawAnimal(ctx, this.species, {
|
||||||
|
state:this.state==='walk'?'walk':this.state==='eat'?'eat':this.state==='happy'?'happy':'idle',
|
||||||
|
ph:this.phase, t:time+this.tOff,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
// status markers
|
||||||
|
if(this.sick || this.hunger>82){
|
||||||
|
ctx.font='bold 13px sans-serif'; ctx.textAlign='center';
|
||||||
|
ctx.fillText(this.sick?'🤒':'🍖', p.x, p.y-46*s + Math.sin(time*4)*1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WK.Animal = Animal;
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
GUEST
|
||||||
|
============================================================ */
|
||||||
|
const SKINS=['#f5d5b8','#eec39a','#d9a06b','#a9744f','#8a5a3a'];
|
||||||
|
const SHIRTS=['#e8624e','#ffc93c','#63b34c','#69c3ef','#b78ae8','#ff8aa8','#ff9d47','#5fb8a8'];
|
||||||
|
const PANTS=['#4a6a8a','#6b4a8a','#7a6a55','#43362a','#5a7a4a'];
|
||||||
|
const HAIRS=['#5a3c22','#2e2a26','#a9743f','#d9b97e','#8a3a2a','#e8e2d4'];
|
||||||
|
|
||||||
|
class Guest{
|
||||||
|
constructor(game, ex, ey){
|
||||||
|
this.id=NEXT_ID++;
|
||||||
|
this.kind='guest';
|
||||||
|
this.game=game;
|
||||||
|
this.x=ex; this.y=ey;
|
||||||
|
this.skin=SKINS[(R()*SKINS.length)|0];
|
||||||
|
this.shirt=SHIRTS[(R()*SHIRTS.length)|0];
|
||||||
|
this.pants=PANTS[(R()*PANTS.length)|0];
|
||||||
|
this.hair=HAIRS[(R()*HAIRS.length)|0];
|
||||||
|
this.kid=R()<0.3;
|
||||||
|
this.balloon=R()<0.22?(SHIRTS[(R()*SHIRTS.length)|0]):null;
|
||||||
|
this.joy=58+R()*10;
|
||||||
|
this.speed=(this.kid?1.25:1.6)*(0.9+R()*0.25);
|
||||||
|
this.lane=(R()-0.5)*0.24;
|
||||||
|
this.state='walk';
|
||||||
|
this.phase=R()*10;
|
||||||
|
this.itin=[];
|
||||||
|
const stops=2+(R()*4|0);
|
||||||
|
for(let i=0;i<stops;i++) this.itin.push('poi');
|
||||||
|
this.leaving=false;
|
||||||
|
this.wantsRestroom=false;
|
||||||
|
this.restroomWarned=false;
|
||||||
|
this.dwellT=0;
|
||||||
|
this.poi=null;
|
||||||
|
this.wp=null;
|
||||||
|
this.waitT=0.5+R();
|
||||||
|
}
|
||||||
|
setPathTo(tx,ty){
|
||||||
|
const w=this.game.world;
|
||||||
|
this.wp=w.findPathVisitor(Math.round(this.x),Math.round(this.y),tx,ty);
|
||||||
|
if(this.wp&&this.wp.length===0) this.wp=[[tx,ty]];
|
||||||
|
return !!this.wp;
|
||||||
|
}
|
||||||
|
pickNext(){
|
||||||
|
const pois=this.game.pois;
|
||||||
|
if(!pois||!pois.length){ this.leave(); return; }
|
||||||
|
let pool=[];
|
||||||
|
if(this.wantsRestroom){
|
||||||
|
const rr=pois.filter(p=>p.type==='restroom');
|
||||||
|
if(rr.length) pool=rr.map(p=>({p,w:50}));
|
||||||
|
}
|
||||||
|
if(!pool.length){
|
||||||
|
for(const p of pois){
|
||||||
|
let wgt=0;
|
||||||
|
if(p.type==='view') wgt=6+p.appeal*2.2;
|
||||||
|
else if(p.type==='shop') wgt=3+p.income*0.5+p.joy;
|
||||||
|
else if(p.type==='visit') wgt=1.5+p.joy*0.5;
|
||||||
|
else if(p.type==='bench') wgt=2;
|
||||||
|
else if(p.type==='restroom') wgt=this.wantsRestroom?40:1.2;
|
||||||
|
pool.push({p,w:wgt||0.5});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pick=WK.weightedPick(pool,R).p;
|
||||||
|
this.poi=pick;
|
||||||
|
if(this.setPathTo(pick.x,pick.y)){
|
||||||
|
this.state='walk';
|
||||||
|
}else{
|
||||||
|
// unreachable: skip stop
|
||||||
|
if(this.itin.length)this.itin.pop();
|
||||||
|
if(!this.itin.length) this.leave();
|
||||||
|
else this.pickNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
leave(){
|
||||||
|
this.leaving=true; this.poi=null;
|
||||||
|
const e=this.game.exitTile;
|
||||||
|
if(e) this.setPathTo(e[0],e[1]);
|
||||||
|
}
|
||||||
|
arriveAtPoi(){
|
||||||
|
const p=this.poi;
|
||||||
|
this.dwellT=2.2+R()*3.5;
|
||||||
|
this.state='dwell';
|
||||||
|
this.act='stand';
|
||||||
|
if(R()<0.3) this.act='photo';
|
||||||
|
if(!p) return;
|
||||||
|
const g=this.game;
|
||||||
|
if(p.type==='view'){
|
||||||
|
this.joy=WK.clamp(this.joy+3.5+p.appeal*0.85*(p.quality||1),0,100);
|
||||||
|
this.act=R()<0.45?'photo':'point';
|
||||||
|
if(R()<0.3){ g.puffHeart(this.x,this.y); }
|
||||||
|
}else if(p.type==='shop'){
|
||||||
|
const def=D.BUILDING[p.btype];
|
||||||
|
this.joy=WK.clamp(this.joy+def.joy*0.65,0,100);
|
||||||
|
const spend=Math.round(def.income*(0.8+R()*0.4));
|
||||||
|
g.earnMoney(spend, 'shop:'+p.btype);
|
||||||
|
g.coinBurst(p.x,p.y);
|
||||||
|
if(['burger','icecream','restaurant'].includes(p.btype)) this.wantsRestroom=true;
|
||||||
|
}else if(p.type==='visit'){
|
||||||
|
const def=D.BUILDING[p.btype];
|
||||||
|
this.joy=WK.clamp(this.joy+def.joy*0.4,0,100);
|
||||||
|
}else if(p.type==='restroom'){
|
||||||
|
this.wantsRestroom=false;
|
||||||
|
this.joy=WK.clamp(this.joy+3,0,100);
|
||||||
|
}else if(p.type==='bench'){
|
||||||
|
this.joy=WK.clamp(this.joy+2.5,0,100);
|
||||||
|
this.act='sit';
|
||||||
|
this.dwellT=3.5+R()*3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update(sdt){
|
||||||
|
if(this.waitT>0){ this.waitT-=sdt; return; }
|
||||||
|
// slow boredom / crowd stress
|
||||||
|
this.joy=WK.clamp(this.joy - sdt*0.16 - (this.game.crowdStress||0)*sdt, 0, 100);
|
||||||
|
if(this.state==='walk'){
|
||||||
|
if(!this.wp){
|
||||||
|
if(this.leaving){ this.done=true; return; }
|
||||||
|
this.pickNext(); if(!this.wp) return;
|
||||||
|
}
|
||||||
|
if(!this.wp.length){
|
||||||
|
if(this.leaving){ this.done=true; return; }
|
||||||
|
this.arriveAtPoi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [tx,ty]=this.wp[0];
|
||||||
|
const dx=tx-this.x, dy=ty-this.y;
|
||||||
|
const d=Math.hypot(dx,dy);
|
||||||
|
const step=this.speed*sdt;
|
||||||
|
if(d<=step){ this.x=tx; this.y=ty; this.wp.shift(); }
|
||||||
|
else{
|
||||||
|
// gentle lane offset for organic crowds
|
||||||
|
const px=-dy/d*this.lane, py=dx/d*this.lane;
|
||||||
|
this.x+=(dx/d)*step+px*sdt*0.6;
|
||||||
|
this.y+=(dy/d)*step+py*sdt*0.6;
|
||||||
|
const sdx=dx-dy;
|
||||||
|
if(Math.abs(sdx)>0.05) this.faceDir=sdx>0?1:-1;
|
||||||
|
}
|
||||||
|
this.phase+=step*5.5;
|
||||||
|
this.act='walk';
|
||||||
|
}else if(this.state==='dwell'){
|
||||||
|
this.dwellT-=sdt;
|
||||||
|
this.phase+=sdt*1.4;
|
||||||
|
if(this.dwellT<=0){
|
||||||
|
this.poi=null;
|
||||||
|
if(this.itin.length){ this.itin.pop(); this.pickNext(); }
|
||||||
|
else this.leave();
|
||||||
|
this.state='walk';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(this.leaving && (!this.wp || !this.wp.length) && this.state!=='dwell'){
|
||||||
|
this.done=true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draw(ctx,cam,time){
|
||||||
|
const p=cam.project(this.x,this.y);
|
||||||
|
if(!cam.isVisible(p.x,p.y,40)) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x,p.y);
|
||||||
|
let act=this.act||'stand';
|
||||||
|
if(this.state==='walk') act='walk';
|
||||||
|
if(act==='sit') ctx.translate(0,-6);
|
||||||
|
WK.Sprites.drawPerson(ctx,{
|
||||||
|
phase:this.phase, state:act,
|
||||||
|
shirt:this.shirt, pants:this.pants, skin:this.skin, hair:this.hair,
|
||||||
|
kid:this.kid, balloon:this.balloon, face:this.faceDir||1,
|
||||||
|
smile:this.joy>45,
|
||||||
|
});
|
||||||
|
if(this.joy<25 && R()<0.02){
|
||||||
|
ctx.fillStyle='rgba(90,110,220,.8)';
|
||||||
|
ctx.font='bold 10px sans-serif'; ctx.textAlign='center';
|
||||||
|
ctx.fillText('💧',8,-34);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WK.Guest = Guest;
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
STAFF (keeper / vet)
|
||||||
|
============================================================ */
|
||||||
|
class Staff{
|
||||||
|
constructor(role,x,y){
|
||||||
|
this.id=NEXT_ID++;
|
||||||
|
this.kind='staff'; this.role=role; // 'keeper'|'vet'
|
||||||
|
this.x=x; this.y=y;
|
||||||
|
this.speed=role==='keeper'?2.1:2.3;
|
||||||
|
this.state='idle';
|
||||||
|
this.job=null;
|
||||||
|
this.wp=null;
|
||||||
|
this.workT=0;
|
||||||
|
this.phase=R()*10;
|
||||||
|
this.shirt=role==='keeper'?'#5fb868':'#f5f5f5';
|
||||||
|
this.pants='#43362a';
|
||||||
|
this.skin=SKINS[(R()*SKINS.length)|0];
|
||||||
|
this.hair=HAIRS[(R()*HAIRS.length)|0];
|
||||||
|
this.face=1;
|
||||||
|
this.idleT=1+R()*2;
|
||||||
|
}
|
||||||
|
update(sdt,game){
|
||||||
|
if(this.workT>0){
|
||||||
|
this.workT-=sdt;
|
||||||
|
if(this.workT<=0){
|
||||||
|
if(this.role==='keeper'&&this.job&&this.job.alive){
|
||||||
|
this.job.startEating();
|
||||||
|
game.puffHeart(this.job.x,this.job.y);
|
||||||
|
this.job.makeHappy();
|
||||||
|
}
|
||||||
|
if(this.role==='vet'&&this.job&&this.job.alive){
|
||||||
|
this.job.sick=false;
|
||||||
|
this.job.makeHappy();
|
||||||
|
game.puffHeart(this.job.x,this.job.y);
|
||||||
|
game.notify(this.job.name+' the '+D.SPECIES[this.job.species].name+' was healed!','good');
|
||||||
|
}
|
||||||
|
this.job=null;
|
||||||
|
this.state='idle';
|
||||||
|
this.idleT=0.5;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!this.job){
|
||||||
|
this.idleT-=sdt;
|
||||||
|
// ask dispatcher
|
||||||
|
const j=game.requestJob(this.role,this);
|
||||||
|
if(j){
|
||||||
|
this.job=j;
|
||||||
|
const w=game.world;
|
||||||
|
this.wp=w.findPathKeeper(Math.round(this.x),Math.round(this.y),Math.round(j.x),Math.round(j.y));
|
||||||
|
if(!this.wp){ game.abandonJob(this.role,j); this.job=null; this.idleT=2; }
|
||||||
|
else { this.state='walk'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(this.idleT<=0){
|
||||||
|
// casual wander
|
||||||
|
const w=game.world;
|
||||||
|
const b=w.buildings.find(bb=>bb.type===(this.role==='keeper'?'hut':'vet'))||w.buildings[0];
|
||||||
|
let tx,ty;
|
||||||
|
if(b){ const d=w.doorTile(b); tx=d[0]+((R()*7)|0)-3; ty=d[1]+((R()*7)|0)-3; }
|
||||||
|
else { tx=(R()*w.cols)|0; ty=(R()*w.rows)|0; }
|
||||||
|
tx=WK.clamp(tx,1,w.cols-2); ty=WK.clamp(ty,1,w.rows-2);
|
||||||
|
if(w.path[w.idx(tx,ty)]||D.GROUND[w.gid(tx,ty)].walk){
|
||||||
|
this.wp=w.findPathKeeper(Math.round(this.x),Math.round(this.y),tx,ty);
|
||||||
|
if(this.wp&&this.wp.length) this.state='walk';
|
||||||
|
}
|
||||||
|
this.idleT=4+R()*5;
|
||||||
|
}
|
||||||
|
if(this.state==='idle') return;
|
||||||
|
}
|
||||||
|
if(this.state==='walk'){
|
||||||
|
if(!this.wp||!this.wp.length){
|
||||||
|
if(this.job){ this.state='work'; this.workT=this.role==='keeper'?2.4:3; this.face=this.job.x>=this.x?1:-1; }
|
||||||
|
else { this.state='idle'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [tx,ty]=this.wp[0];
|
||||||
|
const dx=tx-this.x, dy=ty-this.y;
|
||||||
|
const d=Math.hypot(dx,dy);
|
||||||
|
const step=this.speed*sdt;
|
||||||
|
if(d<=step){ this.x=tx; this.y=ty; this.wp.shift(); }
|
||||||
|
else{ this.x+=dx/d*step; this.y+=dy/d*step; }
|
||||||
|
const sdx=(tx-this.x)-(ty-this.y);
|
||||||
|
if(Math.abs(sdx)>0.05) this.face=sdx>0?1:-1;
|
||||||
|
this.phase+=step*5.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draw(ctx,cam,time){
|
||||||
|
const p=cam.project(this.x,this.y);
|
||||||
|
if(!cam.isVisible(p.x,p.y,40)) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x,p.y);
|
||||||
|
const act=this.workT>0?'feed':(this.state==='walk'?'walk':'stand');
|
||||||
|
WK.Sprites.drawPerson(ctx,{
|
||||||
|
phase:this.phase,state:act,shirt:this.shirt,pants:this.pants,
|
||||||
|
skin:this.skin,hair:this.hair,kid:false,balloon:null,face:this.face,smile:true,
|
||||||
|
});
|
||||||
|
if(this.workT>0){
|
||||||
|
// work bubble
|
||||||
|
ctx.font='bold 12px sans-serif'; ctx.textAlign='center';
|
||||||
|
ctx.fillText(this.role==='keeper'?'🍎':'🩺',0,-36+Math.sin(time*6));
|
||||||
|
}
|
||||||
|
// role cap
|
||||||
|
ctx.fillStyle=this.role==='keeper'?'#3e8a33':'#e86a6a';
|
||||||
|
ctx.beginPath(); ctx.ellipse(0,-29,4.6,2,0,0,WK.TAU); ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WK.Staff = Staff;
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
PARTICLES
|
||||||
|
============================================================ */
|
||||||
|
class Particles{
|
||||||
|
constructor(){ this.list=[]; }
|
||||||
|
add(o){ o.age=0; this.list.push(o); if(this.list.length>240) this.list.splice(0,this.list.length-240); }
|
||||||
|
coins(x,y){ for(let i=0;i<3;i++) this.add({t:'coin',x:x+(R()-.5)*.4,y,vz:0,z:14+R()*8,vx:(R()-.5)*0.8,vy:(R()-.5)*0.4,dur:0.9}); }
|
||||||
|
hearts(x,y){ for(let i=0;i<2;i++) this.add({t:'heart',x:x+(R()-.5)*.5,y:y-0.2,z:20,vx:(R()-.5)*0.3,vy:-0.5-R()*0.4,dur:1.2}); }
|
||||||
|
puff(x,y,c){ this.add({t:'puff',x,y,z:4,vx:0,vy:0,dur:0.5,c:c||'rgba(180,160,130,'}); }
|
||||||
|
splash(x,y){ this.add({t:'splash',x,y,z:2,vx:0,vy:0,dur:0.5}); }
|
||||||
|
note(x,y){ this.add({t:'note',x,y,z:18,vx:0.2,vy:-0.6,dur:1}); }
|
||||||
|
update(sdt){
|
||||||
|
const L=this.list;
|
||||||
|
for(let i=L.length-1;i>=0;i--){
|
||||||
|
const p=L[i];
|
||||||
|
p.age+=sdt;
|
||||||
|
if(p.age>=p.dur){ L.splice(i,1); continue; }
|
||||||
|
p.x+=(p.vx||0)*sdt; p.y+=(p.vy||0)*sdt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draw(ctx,cam,time){
|
||||||
|
for(const p of this.list){
|
||||||
|
const k=p.age/p.dur;
|
||||||
|
const pr=cam.project(p.x,p.y);
|
||||||
|
if(!cam.isVisible(pr.x,pr.y,30)) continue;
|
||||||
|
const z=p.z*(1-k)+ (p.z||0)*0;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(pr.x,pr.y-(p.z||0)*(0.4+k*1.4)*16);
|
||||||
|
ctx.globalAlpha=1-k*k;
|
||||||
|
if(p.t==='coin'){
|
||||||
|
ctx.fillStyle='#ffc93c';
|
||||||
|
ctx.beginPath(); ctx.ellipse(0,0,4.4*Math.abs(Math.cos(k*9)),4.4,0,0,WK.TAU); ctx.fill();
|
||||||
|
ctx.strokeStyle='#d99a1e'; ctx.lineWidth=1.2; ctx.stroke();
|
||||||
|
}else if(p.t==='heart'){
|
||||||
|
ctx.fillStyle='#ff6b81';
|
||||||
|
const s=3.2+k*2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0,s*0.9); ctx.bezierCurveTo(-s*1.6,-s*0.3,-s*0.9,-s*1.4,0,-s*0.4);
|
||||||
|
ctx.bezierCurveTo(s*0.9,-s*1.4,s*1.6,-s*0.3,0,s*0.9);
|
||||||
|
ctx.fill();
|
||||||
|
}else if(p.t==='puff'){
|
||||||
|
ctx.fillStyle=p.c+(0.5*(1-k))+')';
|
||||||
|
ctx.beginPath(); ctx.arc(0,0,4+k*14,0,WK.TAU); ctx.fill();
|
||||||
|
}else if(p.t==='splash'){
|
||||||
|
ctx.strokeStyle='rgba(200,235,250,'+(0.8*(1-k))+')';
|
||||||
|
ctx.lineWidth=1.6;
|
||||||
|
ctx.beginPath(); ctx.arc(0,0,3+k*13,0,WK.TAU); ctx.stroke();
|
||||||
|
}else if(p.t==='note'){
|
||||||
|
ctx.fillStyle='rgba(90,60,120,'+(1-k)+')';
|
||||||
|
ctx.font='bold 12px sans-serif'; ctx.textAlign='center';
|
||||||
|
ctx.fillText(Math.sin(p.x*7)>0?'♪':'♫',0,0);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha=1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WK.Particles = Particles;
|
||||||
|
|
||||||
|
})();
|
||||||
+329
@@ -0,0 +1,329 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Wildlife Kingdom — js/input.js
|
||||||
|
Mouse + touch controls · tool painting · placement ghosts ·
|
||||||
|
selection picking · camera pan/zoom · hotkeys
|
||||||
|
============================================================ */
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const WK = window.WK;
|
||||||
|
const D = WK.Data;
|
||||||
|
|
||||||
|
class Input{
|
||||||
|
constructor(canvas,game,cam,renderer,ui){
|
||||||
|
this.canvas=canvas;
|
||||||
|
this.game=game;
|
||||||
|
this.cam=cam;
|
||||||
|
this.renderer=renderer;
|
||||||
|
this.ui=ui;
|
||||||
|
this.pointers=new Map();
|
||||||
|
this.drag=null; // {mode:'pan'|'paint', lastX,lastY,lastTile}
|
||||||
|
this.pinch=null;
|
||||||
|
this.keys=new Set();
|
||||||
|
this.spaceHeld=false;
|
||||||
|
this.hover=null; // {x,y}
|
||||||
|
this.hoverEdge=null; // {x,y,edge}
|
||||||
|
this._bind();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- tool application ---------------- */
|
||||||
|
toolValid(cat,item,x,y){
|
||||||
|
const g=this.game, w=g.world;
|
||||||
|
if(!w.inBounds(x,y)) return false;
|
||||||
|
const k=w.idx(x,y);
|
||||||
|
switch(cat){
|
||||||
|
case 'ground':{
|
||||||
|
const def=D.GROUND[item];
|
||||||
|
return def.tier<=g.researchTier && w.gid(x,y)!==item && w.occ[k]<0;
|
||||||
|
}
|
||||||
|
case 'paths':
|
||||||
|
return D.PATH[item].tier<=g.researchTier &&
|
||||||
|
!D.GROUND[w.gid(x,y)].water && w.occ[k]<0 &&
|
||||||
|
w.pid(x,y)!==item;
|
||||||
|
case 'nature':{
|
||||||
|
const def=D.NATURE[item];
|
||||||
|
return def.tier<=g.researchTier && w.occ[k]<0 && !D.GROUND[w.gid(x,y)].water;
|
||||||
|
}
|
||||||
|
case 'fences':
|
||||||
|
return true;
|
||||||
|
case 'animals':{
|
||||||
|
const sp=D.SPECIES[item];
|
||||||
|
if(sp.tier>g.researchTier) return false;
|
||||||
|
const chk=g.validHabitatAt(x,y);
|
||||||
|
if(chk.error) return false;
|
||||||
|
return chk.region.area>=sp.minArea && g.habitatScoreFor(chk.region.id,item)>=50;
|
||||||
|
}
|
||||||
|
case 'facilities':{
|
||||||
|
const def=D.BUILDING[item];
|
||||||
|
if(def.tier>g.researchTier) return false;
|
||||||
|
return w.canPlaceBuilding(x,y,def.w,def.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeFromEvent(sx,sy){
|
||||||
|
const t=this.cam.screenToTile(sx,sy);
|
||||||
|
if(!this.game.world.inBounds(t.x,t.y)) return null;
|
||||||
|
const p=this.cam.project(t.x,t.y);
|
||||||
|
const lx=(sx-p.x)/WK.HW, ly=(sy-p.y)/WK.HH;
|
||||||
|
// distances to four diamond sides
|
||||||
|
const dSE=1-(lx+ly), dSW=1+(lx-ly), dNW=1+(lx+ly), dNE=1-(lx-ly);
|
||||||
|
const m=Math.min(dSE,dSW,dNW,dNE);
|
||||||
|
const w=this.game.world;
|
||||||
|
if(m===dSE) return {x:t.x,y:t.y,edge:'S'};
|
||||||
|
if(m===dSW) return w.inBounds(t.x-1,t.y)?{x:t.x-1,y:t.y,edge:'E'}:{x:t.x,y:t.y,edge:'S'};
|
||||||
|
if(m===dNW) return w.inBounds(t.x-1,t.y)?{x:t.x-1,y:t.y,edge:'E'}:null;
|
||||||
|
return w.inBounds(t.x,t.y-1)?{x:t.x,y:t.y-1,edge:'S'}:null;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAt(h){
|
||||||
|
const ui=this.ui, g=this.game;
|
||||||
|
if(ui.bulldoze){
|
||||||
|
if(this.hoverEdge&&g.tryBulldozeEdge(this.hoverEdge.x,this.hoverEdge.y,this.hoverEdge.edge)) return;
|
||||||
|
g.tryBulldoze(h.x,h.y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t=ui.tool;
|
||||||
|
if(!t) return;
|
||||||
|
switch(t.cat){
|
||||||
|
case 'ground': g.tryGround(h.x,h.y,t.item); break;
|
||||||
|
case 'paths': g.tryPath(h.x,h.y,t.item); break;
|
||||||
|
case 'nature': g.tryNature(h.x,h.y,t.item); break;
|
||||||
|
case 'fences':
|
||||||
|
if(this.hoverEdge) g.tryFence(this.hoverEdge.x,this.hoverEdge.y,this.hoverEdge.edge,t.item);
|
||||||
|
break;
|
||||||
|
case 'animals':
|
||||||
|
g.tryAdopt(t.item,h.x,h.y);
|
||||||
|
break;
|
||||||
|
case 'facilities':
|
||||||
|
if(g.tryBuilding(t.item,h.x,h.y)){ /* tool stays active for combos */ }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyLine(a,b){
|
||||||
|
/* Wall-aware drag painting. Iso fence topology:
|
||||||
|
S-edges chain over +x ("\") · E-edges chain over +y ("/").
|
||||||
|
So a drag paints the SIDE edge of each visited tile → a straight,
|
||||||
|
fully-connected wall hugging the swept line. */
|
||||||
|
let x=a.x,y=a.y;
|
||||||
|
let guard=80;
|
||||||
|
const w=this.game.world;
|
||||||
|
const fenceMode=(this.ui.tool&&this.ui.tool.cat==='fences')||this.ui.bulldoze;
|
||||||
|
while(guard--&&(x!==b.x||y!==b.y)){
|
||||||
|
const movedX=b.x!==x;
|
||||||
|
if(b.x>x) x++; else if(b.x<x) x--;
|
||||||
|
else if(b.y>y) y++; else y--;
|
||||||
|
if(!w.inBounds(x,y)){ continue; }
|
||||||
|
if(fenceMode){
|
||||||
|
this.hoverEdge=movedX?{x,y,edge:'S'}:{x,y,edge:'E'};
|
||||||
|
}
|
||||||
|
this.applyAt({x,y});
|
||||||
|
}
|
||||||
|
this.hoverEdge=null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- picking ---------------- */
|
||||||
|
pick(sx,sy){
|
||||||
|
const g=this.game, cam=this.cam;
|
||||||
|
let best=null,bd=34*34;
|
||||||
|
const consider=(ent,rad)=>{
|
||||||
|
const p=cam.project(ent.x,ent.y);
|
||||||
|
const dd=(p.x-sx)**2+(p.y-sy+14)**2;
|
||||||
|
if(dd<bd*(rad||1)){ best=ent; bd=dd; }
|
||||||
|
};
|
||||||
|
bd=34*34; for(const a of g.animals) consider(a,(a.size||1)*1.3);
|
||||||
|
bd=26*26; for(const st of g.staff) consider(st,1);
|
||||||
|
bd=22*22; for(const gu of g.guests) consider(gu,1);
|
||||||
|
if(best){ this.ui.showSelection(best); return true; }
|
||||||
|
const t=cam.screenToTile(sx,sy);
|
||||||
|
const b=g.world.buildingAt(t.x,t.y);
|
||||||
|
if(b){ this.ui.showSelection(b); return true; }
|
||||||
|
// habitat summary
|
||||||
|
g.world.ensureRegions();
|
||||||
|
const rid=g.world.regionOf(t.x,t.y);
|
||||||
|
if(rid>=0){
|
||||||
|
const reg=g.world.regions.find(r=>r.id===rid);
|
||||||
|
if(reg&®.openEdges===0&&!reg.hasPath&®.area>=8){
|
||||||
|
this.ui.showSelection({kind:'habitat',regionId:rid,x:t.x,y:t.y});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.ui.hideSelection();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- events ---------------- */
|
||||||
|
_bind(){
|
||||||
|
const cv=this.canvas;
|
||||||
|
cv.addEventListener('pointerdown',e=>{
|
||||||
|
WK.AudioSys.ensure();
|
||||||
|
cv.setPointerCapture(e.pointerId);
|
||||||
|
this.pointers.set(e.pointerId,{x:e.offsetX,y:e.offsetY});
|
||||||
|
if(this.pointers.size===2){ this._startPinch(); return; }
|
||||||
|
const sx=e.offsetX,sy=e.offsetY;
|
||||||
|
const wantPan = e.button===1||e.button===2||this.spaceHeld||
|
||||||
|
(e.pointerType==='touch'&&(!this.ui.tool&&!this.ui.bulldoze));
|
||||||
|
if(wantPan){
|
||||||
|
this.drag={mode:'pan',lastX:sx,lastY:sy};
|
||||||
|
cv.style.cursor='grabbing';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(e.button===2){ this.ui.cancelTool(); return; }
|
||||||
|
const t=this.cam.screenToTile(sx,sy);
|
||||||
|
if(!this.game.world.inBounds(t.x,t.y)) return;
|
||||||
|
this.hoverEdge=this.toolActiveFence()?this.edgeFromEvent(sx,sy):null;
|
||||||
|
if(this.ui.tool||this.ui.bulldoze){
|
||||||
|
this.drag={mode:'paint',lastTile:t,startTile:t};
|
||||||
|
this.applyAt(t);
|
||||||
|
}else{
|
||||||
|
this.pick(sx,sy);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cv.addEventListener('pointermove',e=>{
|
||||||
|
const sx=e.offsetX,sy=e.offsetY;
|
||||||
|
if(this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId,{x:sx,y:sy});
|
||||||
|
if(this.pinch&&this.pointers.size===2){ this._movePinch(); return; }
|
||||||
|
const t=this.cam.screenToTile(sx,sy);
|
||||||
|
this.hover=t;
|
||||||
|
this.hoverEdge=this.toolActiveFence()?this.edgeFromEvent(sx,sy):null;
|
||||||
|
if(!this.drag) return;
|
||||||
|
if(this.drag.mode==='pan'){
|
||||||
|
this.cam.panBy(sx-this.drag.lastX,sy-this.drag.lastY);
|
||||||
|
this.drag.lastX=sx; this.drag.lastY=sy;
|
||||||
|
}else{
|
||||||
|
if(!this.game.world.inBounds(t.x,t.y)) return;
|
||||||
|
if(t.x!==this.drag.lastTile.x||t.y!==this.drag.lastTile.y){
|
||||||
|
if(e.buttons&2){ }
|
||||||
|
this.applyLine(this.drag.lastTile,t);
|
||||||
|
this.drag.lastTile=t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const up=e=>{
|
||||||
|
this.pointers.delete(e.pointerId);
|
||||||
|
if(this.pointers.size<2) this.pinch=null;
|
||||||
|
if(this.drag&&this.pointers.size===0){
|
||||||
|
this.drag=null;
|
||||||
|
cv.style.cursor=this.ui.tool?'crosshair':'grab';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cv.addEventListener('pointerup',up);
|
||||||
|
cv.addEventListener('pointercancel',up);
|
||||||
|
cv.addEventListener('pointerleave',e=>{ this.hover=null; });
|
||||||
|
cv.addEventListener('contextmenu',e=>e.preventDefault());
|
||||||
|
cv.addEventListener('wheel',e=>{
|
||||||
|
e.preventDefault();
|
||||||
|
const f=Math.pow(1.0016,-e.deltaY);
|
||||||
|
this.cam.zoomAt(e.offsetX,e.offsetY,f);
|
||||||
|
},{passive:false});
|
||||||
|
|
||||||
|
window.addEventListener('keydown',e=>{
|
||||||
|
if(e.target&&(e.target.tagName==='INPUT')) return;
|
||||||
|
this.keys.add(e.key);
|
||||||
|
if(e.code==='Space'){ this.spaceHeld=true; e.preventDefault(); }
|
||||||
|
const ui=this.ui;
|
||||||
|
switch(e.key){
|
||||||
|
case 'Escape': ui.cancelTool(); break;
|
||||||
|
case 'x': case 'X': ui.toggleBulldoze(); break;
|
||||||
|
case ' ': ui.togglePause(); e.preventDefault(); break;
|
||||||
|
case '1': ui.setSpeed(1); break;
|
||||||
|
case '2': ui.setSpeed(2); break;
|
||||||
|
case '3': ui.setSpeed(4); break;
|
||||||
|
case '+': case '=': this.cam.zoomAt(this.cam.w/2,this.cam.h/2,1.15); break;
|
||||||
|
case '-': this.cam.zoomAt(this.cam.w/2,this.cam.h/2,0.87); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('keyup',e=>{
|
||||||
|
this.keys.delete(e.key);
|
||||||
|
if(e.code==='Space') this.spaceHeld=false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toolActiveFence(){
|
||||||
|
return (this.ui.tool&&this.ui.tool.cat==='fences')||(this.ui.bulldoze);
|
||||||
|
}
|
||||||
|
_startPinch(){
|
||||||
|
const pts=[...this.pointers.values()];
|
||||||
|
this.pinch={
|
||||||
|
d:Math.hypot(pts[0].x-pts[1].x,pts[0].y-pts[1].y),
|
||||||
|
mx:(pts[0].x+pts[1].x)/2, my:(pts[0].y+pts[1].y)/2,
|
||||||
|
};
|
||||||
|
this.drag=null;
|
||||||
|
}
|
||||||
|
_movePinch(){
|
||||||
|
const pts=[...this.pointers.values()];
|
||||||
|
if(pts.length<2) return;
|
||||||
|
const d=Math.hypot(pts[0].x-pts[1].x,pts[0].y-pts[1].y);
|
||||||
|
const mx=(pts[0].x+pts[1].x)/2, my=(pts[0].y+pts[1].y)/2;
|
||||||
|
const f=WK.clamp(d/Math.max(20,this.pinch.d),0.5,2);
|
||||||
|
this.cam.zoomAt(mx,my,f);
|
||||||
|
this.cam.panBy(mx-this.pinch.mx,my-this.pinch.my);
|
||||||
|
this.pinch={d,mx,my};
|
||||||
|
}
|
||||||
|
update(dt){
|
||||||
|
// keyboard panning
|
||||||
|
const sp=520*dt/this.cam.zoom;
|
||||||
|
let dx=0,dy=0;
|
||||||
|
if(this.keys.has('ArrowLeft')||this.keys.has('a')||this.keys.has('A')) dx-=sp;
|
||||||
|
if(this.keys.has('ArrowRight')||this.keys.has('d')||this.keys.has('D')) dx+=sp;
|
||||||
|
if(this.keys.has('ArrowUp')||this.keys.has('w')||this.keys.has('W')) dy-=sp;
|
||||||
|
if(this.keys.has('ArrowDown')||this.keys.has('s')||this.keys.has('S')) dy+=sp;
|
||||||
|
if(dx||dy) this.cam.panBy(dx,dy);
|
||||||
|
}
|
||||||
|
/* overlay hook for renderer */
|
||||||
|
overlay(ctx,cam,range){
|
||||||
|
const ui=this.ui;
|
||||||
|
if(ui.bulldoze){
|
||||||
|
if(this.hover){
|
||||||
|
const p=cam.project(this.hover.x,this.hover.y);
|
||||||
|
WK.Sprites.diamondPath(ctx,p.x,p.y,1);
|
||||||
|
ctx.fillStyle='rgba(230,80,70,.25)'; ctx.fill();
|
||||||
|
ctx.strokeStyle='rgba(230,80,70,.9)'; ctx.lineWidth=2; ctx.stroke();
|
||||||
|
if(this.hoverEdge){
|
||||||
|
const h=this.hoverEdge;
|
||||||
|
const p2=cam.project(h.x,h.y);
|
||||||
|
const zz=cam.zoom;
|
||||||
|
let ax,ay,bx,by;
|
||||||
|
if(h.edge==='S'){ ax=p2.x-WK.HW*zz; ay=p2.y; bx=p2.x; by=p2.y+WK.HH*zz; }
|
||||||
|
else{ ax=p2.x; ay=p2.y+WK.HH*zz; bx=p2.x+WK.HW*zz; by=p2.y; }
|
||||||
|
ctx.lineWidth=7; ctx.beginPath(); ctx.moveTo(ax,ay-6); ctx.lineTo(bx,by-6); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t=ui.tool;
|
||||||
|
if(!t||!this.hover) return;
|
||||||
|
const h=this.hover;
|
||||||
|
const valid=this.toolValid(t.cat,t.item,h.x,h.y);
|
||||||
|
let g={x:h.x,y:h.y,valid};
|
||||||
|
if(t.cat==='facilities'){ g.mode='foot'; g.item=t.item; }
|
||||||
|
else if(t.cat==='fences'){
|
||||||
|
if(this.hoverEdge){ g={...this.hoverEdge,mode:'edge',valid:true}; }
|
||||||
|
else g=null;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
g.mode='tile';
|
||||||
|
if(t.cat==='nature') g.sprite=WK.Sprites.getObject(t.item,0);
|
||||||
|
else if(t.cat==='animals'){
|
||||||
|
g.sprite=null;
|
||||||
|
// animal ghost: draw mini animal
|
||||||
|
const p=cam.project(h.x,h.y);
|
||||||
|
WK.Sprites.diamondPath(ctx,p.x,p.y,1);
|
||||||
|
ctx.fillStyle=valid?'rgba(140,230,150,.28)':'rgba(230,90,80,.3)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle=valid?'rgba(110,220,120,.85)':'rgba(230,80,70,.9)';
|
||||||
|
ctx.lineWidth=2; ctx.stroke();
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x,p.y);
|
||||||
|
ctx.globalAlpha=.85;
|
||||||
|
const sc=(WK.ANIMAL_SIZE[t.item]||1)*(valid?1:0.8);
|
||||||
|
ctx.scale(sc,sc);
|
||||||
|
WK.Sprites.drawAnimal(ctx,t.item,{state:'idle',ph:0,t:this.game.timeAbs});
|
||||||
|
ctx.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.renderer.drawGhost(ctx,cam,g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WK.Input = Input;
|
||||||
|
})();
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Wildlife Kingdom — js/main.js
|
||||||
|
Boot · animated hero menu · game loop · autosave · resizing
|
||||||
|
============================================================ */
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const WK = window.WK;
|
||||||
|
|
||||||
|
const Main = (WK.Main = {});
|
||||||
|
let canvas, renderer, cam, input;
|
||||||
|
let game=null;
|
||||||
|
let mode='menu';
|
||||||
|
let heroGame=null;
|
||||||
|
let heroT=0;
|
||||||
|
let lastT=performance.now();
|
||||||
|
let saveT=0;
|
||||||
|
|
||||||
|
/* ---------------- boot ---------------- */
|
||||||
|
function boot(){
|
||||||
|
canvas=document.getElementById('game');
|
||||||
|
renderer=new WK.Renderer(canvas);
|
||||||
|
cam=new WK.Camera();
|
||||||
|
cam.resize(renderer.cssW,renderer.cssH);
|
||||||
|
const small=Math.min(window.innerWidth,window.innerHeight)<720;
|
||||||
|
cam.zoom=small?0.8:1.05;
|
||||||
|
input=new WK.Input(canvas,null,cam,renderer,WK.UI);
|
||||||
|
|
||||||
|
// QA hooks: ?autostart=1&fast=NN jumps straight into a running zoo
|
||||||
|
const qa=new URLSearchParams(location.search);
|
||||||
|
Main.qaMode=qa.has('autostart');
|
||||||
|
|
||||||
|
window.addEventListener('resize',onResize);
|
||||||
|
document.addEventListener('visibilitychange',()=>{
|
||||||
|
if(document.hidden&&mode==='game'&&game){ game.save(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// menu buttons
|
||||||
|
const cont=document.getElementById('btn-continue');
|
||||||
|
cont.disabled=!WK.Game.hasSave();
|
||||||
|
cont.onclick=()=>{ click(); const g=WK.Game.load(); if(g) enterGame(g); else { startNew(); } };
|
||||||
|
document.getElementById('btn-new').onclick=()=>{ click(); startNew(); };
|
||||||
|
document.getElementById('btn-how').onclick=()=>{ click(); WK.UI._onHowClose=()=>{}; WK.UI.howtoModal(true); };
|
||||||
|
function click(){ WK.AudioSys.ensure(); WK.AudioSys.sfx('click'); }
|
||||||
|
|
||||||
|
startHero();
|
||||||
|
if(Main.qaMode){
|
||||||
|
startNew();
|
||||||
|
const steps=parseInt(qa.get('fast')||'0',10)||0;
|
||||||
|
for(let i=0;i<steps;i++) game.tick(1/30);
|
||||||
|
const focus=qa.get('focus');
|
||||||
|
if(focus){
|
||||||
|
const [fx,fy]=focus.split(',').map(Number);
|
||||||
|
cam.centerOnTile(fx,fy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize(){
|
||||||
|
renderer.resize();
|
||||||
|
cam.resize(renderer.cssW,renderer.cssH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- hero (menu) zoo ---------------- */
|
||||||
|
function startHero(){
|
||||||
|
heroGame=new WK.Game();
|
||||||
|
WK.Game.buildDemo(heroGame);
|
||||||
|
heroGame.speed=1;
|
||||||
|
// pre-seed wandering guests on the paths
|
||||||
|
const w=heroGame.world;
|
||||||
|
const pathTiles=[];
|
||||||
|
for(let y=0;y<w.rows;y++)for(let x=0;x<w.cols;x++) if(w.pid(x,y)) pathTiles.push([x,y]);
|
||||||
|
for(let i=0;i<70&&pathTiles.length;i++){
|
||||||
|
const [x,y]=pathTiles[(Math.random()*pathTiles.length)|0];
|
||||||
|
heroGame.guests.push(new WK.Guest(heroGame,x+0.5,y+0.5));
|
||||||
|
}
|
||||||
|
heroGame.rebuildPois();
|
||||||
|
cam.centerOnTile(w.cols/2,w.rows*0.42);
|
||||||
|
}
|
||||||
|
function heroDrift(dt){
|
||||||
|
heroT+=dt;
|
||||||
|
const w=heroGame.world;
|
||||||
|
const cx=w.cols/2, cy=w.rows*0.44;
|
||||||
|
cam.centerOnTile(
|
||||||
|
cx+Math.sin(heroT*0.11)*w.cols*0.16,
|
||||||
|
cy+Math.cos(heroT*0.07)*w.rows*0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- game lifecycle ---------------- */
|
||||||
|
function startNew(){
|
||||||
|
const g=new WK.Game();
|
||||||
|
WK.Game.buildStarter(g);
|
||||||
|
g.world.computeRegions();
|
||||||
|
g.rebuildPois();
|
||||||
|
enterGame(g,true);
|
||||||
|
}
|
||||||
|
Main.startNew=startNew;
|
||||||
|
|
||||||
|
function enterGame(g,fresh){
|
||||||
|
game=g;
|
||||||
|
mode='game';
|
||||||
|
document.getElementById('menu').classList.add('hidden');
|
||||||
|
document.getElementById('hud-top').classList.remove('hidden');
|
||||||
|
document.getElementById('toolbar').classList.remove('hidden');
|
||||||
|
document.getElementById('side-panel').classList.remove('hidden');
|
||||||
|
document.getElementById('sp-toggle').classList.remove('hidden');
|
||||||
|
if(window.innerWidth<1100) document.getElementById('side-panel').classList.add('hidden');
|
||||||
|
|
||||||
|
input.game=game;
|
||||||
|
WK.UI.init(game,cam,renderer);
|
||||||
|
if(fresh&&!Main.qaMode){
|
||||||
|
g.save();
|
||||||
|
setTimeout(()=>{ WK.UI.howtoModal(false); },350);
|
||||||
|
g.notify('Welcome to Wildlife Kingdom! Follow the missions panel.','gold');
|
||||||
|
}
|
||||||
|
// camera on the entrance
|
||||||
|
const e=g.exitTile||[g.world.cols/2,g.world.rows/2];
|
||||||
|
cam.centerOnTile(e[0],e[1]-4);
|
||||||
|
cam.clampToWorld(g.world);
|
||||||
|
WK.AudioSys.setScene('game');
|
||||||
|
if(WK.AudioSys.enabled&&!WK.AudioSys.musicOn===false){} /* music pref respected internally */
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitToMenu(){ /* kept simple: no in-game exit button besides reload */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- main loop ---------------- */
|
||||||
|
function loop(now){
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
let dt=(now-lastT)/1000;
|
||||||
|
lastT=now;
|
||||||
|
if(dt>0.12) dt=0.12;
|
||||||
|
|
||||||
|
input.update(dt);
|
||||||
|
|
||||||
|
if(mode==='menu'){
|
||||||
|
if(heroGame){
|
||||||
|
heroGame.timeAbs+=dt;
|
||||||
|
const sdt=dt; // gentle life even on menu
|
||||||
|
heroGame.minutes+=sdt*2;
|
||||||
|
if(heroGame.minutes>=1440)heroGame.minutes-=1440;
|
||||||
|
heroGame.spawnAcc+=sdt*0.02;
|
||||||
|
for(const a of heroGame.animals) a.update(sdt*0.6,heroGame);
|
||||||
|
for(const gu of heroGame.guests) gu.update(sdt*0.75);
|
||||||
|
for(const s of heroGame.staff) s.update(sdt,heroGame);
|
||||||
|
heroGame.particles.update(sdt);
|
||||||
|
heroDrift(dt);
|
||||||
|
renderer.frame(heroGame,cam,{});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameplay
|
||||||
|
game.tick(dt);
|
||||||
|
const grid=!!(WK.UI.tool||WK.UI.bulldoze);
|
||||||
|
let tint=null;
|
||||||
|
if(input.hover&&(WK.UI.tool&&['fences','animals'].includes(WK.UI.tool.cat)||WK.UI.bulldoze)){
|
||||||
|
game.world.ensureRegions();
|
||||||
|
const rid=game.world.regionOf(input.hover.x,input.hover.y);
|
||||||
|
if(rid>=0){
|
||||||
|
const reg=game.world.regions.find(r=>r.id===rid);
|
||||||
|
if(reg&®.openEdges===0&&!reg.hasPath) tint=rid;
|
||||||
|
else if(WK.UI.tool&&WK.UI.tool.cat==='animals') tint=rid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderer.frame(game,cam,{
|
||||||
|
grid,
|
||||||
|
tintRegionId:tint,
|
||||||
|
overlay:(ctx,c)=>input.overlay(ctx,c),
|
||||||
|
});
|
||||||
|
WK.UI.updateHUD();
|
||||||
|
|
||||||
|
saveT+=dt;
|
||||||
|
if(saveT>30){ saveT=0; if(game.save()){} }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded',boot);
|
||||||
|
})();
|
||||||
+354
@@ -0,0 +1,354 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Wildlife Kingdom — js/render.js
|
||||||
|
Isometric camera · layered renderer · cached terrain ·
|
||||||
|
water sparkle · day/night light · ghost previews · hero scene
|
||||||
|
============================================================ */
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const WK = window.WK;
|
||||||
|
const D = WK.Data;
|
||||||
|
const HW = WK.HW, HH = WK.HH, TAU = WK.TAU;
|
||||||
|
|
||||||
|
/* ---------------- Camera ---------------- */
|
||||||
|
class Camera{
|
||||||
|
constructor(){ this.x=0; this.y=0; this.zoom=1; this.w=800; this.h=600; }
|
||||||
|
resize(w,h){ this.w=w; this.h=h; }
|
||||||
|
centerOnTile(gx,gy){
|
||||||
|
const wx=(gx-gy)*HW, wy=(gx+gy)*HH;
|
||||||
|
this.x=wx; this.y=wy;
|
||||||
|
}
|
||||||
|
/* tile coords → screen px */
|
||||||
|
project(gx,gy){
|
||||||
|
const wx=(gx-gy)*HW, wy=(gx+gy)*HH;
|
||||||
|
return {
|
||||||
|
x:(wx-this.x)*this.zoom + this.w/2,
|
||||||
|
y:(wy-this.y)*this.zoom + this.h/2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
worldToScreen(wx,wy){
|
||||||
|
return { x:(wx-this.x)*this.zoom+this.w/2, y:(wy-this.y)*this.zoom+this.h/2 };
|
||||||
|
}
|
||||||
|
screenToWorld(sx,sy){
|
||||||
|
return {
|
||||||
|
x:(sx-this.w/2)/this.zoom + this.x,
|
||||||
|
y:(sy-this.h/2)/this.zoom + this.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
screenToTile(sx,sy){
|
||||||
|
const w=this.screenToWorld(sx,sy);
|
||||||
|
return {
|
||||||
|
x:Math.floor((w.x/HW + w.y/HH)/2),
|
||||||
|
y:Math.floor((w.y/HH - w.x/HW)/2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
panBy(dx,dy){ this.x+=dx/this.zoom; this.y+=dy/this.zoom; }
|
||||||
|
zoomAt(sx,sy,factor){
|
||||||
|
const before=this.screenToWorld(sx,sy);
|
||||||
|
this.zoom=WK.clamp(this.zoom*factor,0.45,2.4);
|
||||||
|
const after=this.screenToWorld(sx,sy);
|
||||||
|
this.x+=before.x-after.x; this.y+=before.y-after.y;
|
||||||
|
}
|
||||||
|
clampToWorld(world){
|
||||||
|
const maxX=((world.cols))*HW, maxY=((world.cols+world.rows))*HH;
|
||||||
|
this.x=WK.clamp(this.x,-maxX,maxX);
|
||||||
|
this.y=WK.clamp(this.y,-200,maxY+200);
|
||||||
|
}
|
||||||
|
isVisible(sx,sy,margin){
|
||||||
|
const m=(margin||40)+60;
|
||||||
|
return sx>-m && sx<this.w+m && sy>-m && sy<this.h+m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WK.Camera = Camera;
|
||||||
|
|
||||||
|
/* ---------------- Renderer ---------------- */
|
||||||
|
class Renderer{
|
||||||
|
constructor(canvas){
|
||||||
|
this.canvas=canvas;
|
||||||
|
this.ctx=canvas.getContext('2d');
|
||||||
|
this.dpr=Math.min(2,window.devicePixelRatio||1);
|
||||||
|
this.resize();
|
||||||
|
this._terrain=document.createElement('canvas');
|
||||||
|
this._terrainW=0; this._terrainH=0; this._terrainOX=0; this._terrainOY=0;
|
||||||
|
this._lampCacheVer=-1; this._lamps=[];
|
||||||
|
}
|
||||||
|
resize(){
|
||||||
|
this.dpr=Math.min(2,window.devicePixelRatio||1);
|
||||||
|
const r=this.canvas.getBoundingClientRect();
|
||||||
|
this.cssW=Math.max(320,r.width); this.cssH=Math.max(240,r.height);
|
||||||
|
this.canvas.width=Math.round(this.cssW*this.dpr);
|
||||||
|
this.canvas.height=Math.round(this.cssH*this.dpr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- static terrain layer ---- */
|
||||||
|
rebuildTerrain(world){
|
||||||
|
const W=(world.cols+world.rows)*HW+4, H=(world.cols+world.rows)*HH+80;
|
||||||
|
if(this._terrainW!==W||this._terrainH!==H){
|
||||||
|
this._terrain.width=W; this._terrain.height=H;
|
||||||
|
this._terrainW=W; this._terrainH=H;
|
||||||
|
this._terrainOX=-(world.rows-1)*HW-2;
|
||||||
|
this._terrainOY=HH-30;
|
||||||
|
}
|
||||||
|
const c=this._terrain.getContext('2d');
|
||||||
|
c.clearRect(0,0,W,H);
|
||||||
|
for(let y=0;y<world.rows;y++)for(let x=0;x<world.cols;x++){
|
||||||
|
const wx=(x-y)*HW-this._terrainOX, wy=(x+y)*HH-this._terrainOY;
|
||||||
|
WK.Sprites.paintGround(c,wx,wy,world.gid(x,y),WK.hash2(x,y,17));
|
||||||
|
}
|
||||||
|
for(let y=0;y<world.rows;y++)for(let x=0;x<world.cols;x++){
|
||||||
|
const pid=world.pid(x,y);
|
||||||
|
if(!pid) continue;
|
||||||
|
const wx=(x-y)*HW-this._terrainOX, wy=(x+y)*HH-this._terrainOY;
|
||||||
|
WK.Sprites.paintPath(c,wx,wy,pid,WK.hash2(x,y,31));
|
||||||
|
}
|
||||||
|
world.terrainDirty=false;
|
||||||
|
this._lampCacheVer=-1;
|
||||||
|
}
|
||||||
|
repaintTile(world,x,y){
|
||||||
|
const c=this._terrain.getContext('2d');
|
||||||
|
const wx=(x-y)*HW-this._terrainOX, wy=(x+y)*HH-this._terrainOY;
|
||||||
|
c.save();
|
||||||
|
WK.Sprites.diamondPath(c,wx,wy,0); c.clip();
|
||||||
|
c.clearRect(wx-HW-2,wy-HH-2,HW*2+4,HH*2+4);
|
||||||
|
WK.Sprites.paintGround(c,wx,wy,world.gid(x,y),WK.hash2(x,y,17));
|
||||||
|
const pid=world.pid(x,y);
|
||||||
|
if(pid) WK.Sprites.paintPath(c,wx,wy,pid,WK.hash2(x,y,31));
|
||||||
|
c.restore();
|
||||||
|
world.terrainDirty=false;
|
||||||
|
this._lampCacheVer=-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
lampsFor(world){
|
||||||
|
const ver=world.ver||0;
|
||||||
|
if(this._lampCacheVer===ver) return this._lamps;
|
||||||
|
this._lamps=[];
|
||||||
|
for(let y=0;y<world.rows;y++)for(let x=0;x<world.cols;x++){
|
||||||
|
if(world.oid(x,y)==='lamp') this._lamps.push([x,y]);
|
||||||
|
}
|
||||||
|
this._lampCacheVer=ver;
|
||||||
|
return this._lamps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- main frame ---- */
|
||||||
|
frame(game,cam,opt){
|
||||||
|
opt=opt||{};
|
||||||
|
const ctx=this.ctx;
|
||||||
|
ctx.setTransform(this.dpr,0,0,this.dpr,0,0);
|
||||||
|
const world=game.world;
|
||||||
|
|
||||||
|
// sky / backdrop follows daylight
|
||||||
|
const dl=this.daylight(game.minutes);
|
||||||
|
const top=WK.mix('#2c3e66','#7ec8f5',dl), bot=WK.mix('#4a5f8a','#cdeef9',dl);
|
||||||
|
const grd=ctx.createLinearGradient(0,0,0,this.cssH);
|
||||||
|
grd.addColorStop(0,top); grd.addColorStop(1,bot);
|
||||||
|
ctx.fillStyle=grd; ctx.fillRect(0,0,this.cssW,this.cssH);
|
||||||
|
|
||||||
|
if(world.terrainDirty) this.rebuildTerrain(world);
|
||||||
|
|
||||||
|
// terrain layer
|
||||||
|
const tl=cam.worldToScreen(this._terrainOX,this._terrainOY);
|
||||||
|
ctx.imageSmoothingEnabled=false;
|
||||||
|
ctx.drawImage(this._terrain,
|
||||||
|
tl.x,tl.y,
|
||||||
|
this._terrainW*cam.zoom, this._terrainH*cam.zoom);
|
||||||
|
ctx.imageSmoothingEnabled=true;
|
||||||
|
|
||||||
|
// visible tile range
|
||||||
|
const corners=[cam.screenToTile(0,0),cam.screenToTile(this.cssW,0),cam.screenToTile(0,this.cssH),cam.screenToTile(this.cssW,this.cssH)];
|
||||||
|
let minX=1e9,maxX=-1e9,minY=1e9,maxY=-1e9;
|
||||||
|
for(const c2 of corners){
|
||||||
|
minX=Math.min(minX,c2.x); maxX=Math.max(maxX,c2.x);
|
||||||
|
minY=Math.min(minY,c2.y); maxY=Math.max(maxY,c2.y);
|
||||||
|
}
|
||||||
|
minX=Math.max(0,minX-2); maxX=Math.min(world.cols-1,maxX+2);
|
||||||
|
minY=Math.max(0,minY-2); maxY=Math.min(world.rows-1,maxY+2);
|
||||||
|
|
||||||
|
// water shimmer (under everything dynamic)
|
||||||
|
ctx.save(); ctx.strokeStyle='rgba(255,255,255,.4)'; ctx.lineWidth=1.4;
|
||||||
|
const t=game.timeAbs;
|
||||||
|
for(let y=minY;y<=maxY;y++)for(let x=minX;x<=maxX;x++){
|
||||||
|
if(!D.GROUND[world.gid(x,y)].water) continue;
|
||||||
|
const h=WK.hash2(x,y,7);
|
||||||
|
const p=cam.project(x,y);
|
||||||
|
const ph=(t*(0.6+h)+h*9)%1;
|
||||||
|
const a=Math.sin(ph*Math.PI);
|
||||||
|
ctx.globalAlpha=a*0.5;
|
||||||
|
const ox=(h-0.5)*20;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x-10+ox,p.y+(h-0.5)*8);
|
||||||
|
ctx.quadraticCurveTo(p.x+ox,p.y+(h-0.5)*8-3,p.x+10+ox,p.y+(h-0.5)*8);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// build-mode grid
|
||||||
|
if(opt.grid){
|
||||||
|
ctx.save(); ctx.strokeStyle='rgba(50,40,20,.14)'; ctx.lineWidth=1;
|
||||||
|
for(let y=minY;y<=maxY;y++)for(let x=minX;x<=maxX;x++){
|
||||||
|
const p=cam.project(x,y);
|
||||||
|
WK.Sprites.diamondPath(ctx,p.x,p.y,0.5); ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// region tint overlay
|
||||||
|
if(opt.tintRegionId){
|
||||||
|
const reg=world.regions.find(r=>r.id===opt.tintRegionId);
|
||||||
|
if(reg){
|
||||||
|
ctx.fillStyle='rgba(120,220,140,.16)';
|
||||||
|
for(const k of reg.tiles){
|
||||||
|
const x=k%world.cols, y=(k/world.cols)|0;
|
||||||
|
if(x<minX||x>maxX||y<minY||y>maxY) continue;
|
||||||
|
const p=cam.project(x,y);
|
||||||
|
WK.Sprites.diamondPath(ctx,p.x,p.y,0.5); ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- collect drawables (painter's algorithm) ---------- */
|
||||||
|
const draws=[];
|
||||||
|
const z=cam.zoom;
|
||||||
|
const push=(d,fn)=>draws.push({d,fn});
|
||||||
|
for(let y=minY;y<=maxY;y++)for(let x=minX;x<=maxX;x++){
|
||||||
|
const k=world.idx(x,y);
|
||||||
|
const p=cam.project(x,y);
|
||||||
|
// fences — exact diamond-corner geometry:
|
||||||
|
// W corner = C+(-HW,0) · S corner = C+(0,+HH) · E corner = C+(+HW,0)
|
||||||
|
// so consecutive collinear edges share endpoints perfectly.
|
||||||
|
const fs=world.fenceS[k], fe=world.fenceE[k];
|
||||||
|
if(fs){
|
||||||
|
push(x+y+0.32,()=>{ WK.Sprites.drawFenceEdge(ctx,p.x-HW*z,p.y,p.x,p.y+HH*z,fs,t); });
|
||||||
|
}
|
||||||
|
if(fe){
|
||||||
|
push(x+y+0.34,()=>{ WK.Sprites.drawFenceEdge(ctx,p.x,p.y+HH*z,p.x+HW*z,p.y,fe,t); });
|
||||||
|
}
|
||||||
|
// nature objects
|
||||||
|
const oid=world.oid(x,y);
|
||||||
|
if(oid){
|
||||||
|
const spr=WK.Sprites.getObject(oid,world.objVar[k]);
|
||||||
|
const scale=(WK.Sprites.objectRealSize[oid]||1);
|
||||||
|
push(x+y+0.5+(x-y)*0.001,()=>{
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x,p.y);
|
||||||
|
const sway=Math.sin(t*1.1+x*1.7+y)*0.02;
|
||||||
|
if(scale!==1) ctx.scale(scale,scale);
|
||||||
|
ctx.rotate(sway);
|
||||||
|
ctx.drawImage(spr.cv,-spr.ax,-spr.ay);
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// buildings
|
||||||
|
const b=world.buildingAt(x,y);
|
||||||
|
if(b && b.x===x && b.y===y){ // draw once at NW corner tile
|
||||||
|
const spr=WK.Sprites.getBuilding(b.type);
|
||||||
|
const def=D.BUILDING[b.type];
|
||||||
|
const cxT=b.x+def.w/2, cyT=b.y+def.h/2;
|
||||||
|
const pc=cam.project(cxT,cyT);
|
||||||
|
push(b.x+b.y+def.w+def.h-1.2,()=>{
|
||||||
|
ctx.drawImage(spr.cv,pc.x-spr.ax,pc.y-spr.ay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// entities
|
||||||
|
for(const a of game.animals){
|
||||||
|
push(a.x+a.y+0.55,()=>a.draw(ctx,cam,t,game.world.gid(Math.round(a.x),Math.round(a.y))));
|
||||||
|
}
|
||||||
|
for(const g of game.guests){
|
||||||
|
push(g.x+g.y+0.56,()=>g.draw(ctx,cam,t));
|
||||||
|
}
|
||||||
|
for(const s of game.staff){
|
||||||
|
push(s.x+s.y+0.57,()=>s.draw(ctx,cam,t));
|
||||||
|
}
|
||||||
|
draws.sort((A,B)=>A.d-B.d);
|
||||||
|
for(const it of draws) it.fn();
|
||||||
|
|
||||||
|
// particles above world
|
||||||
|
game.particles.draw(ctx,cam,t);
|
||||||
|
|
||||||
|
// ghost preview & hover handled by input via callbacks
|
||||||
|
if(opt.overlay) opt.overlay(ctx,cam,{minX,maxX,minY,maxY});
|
||||||
|
|
||||||
|
// selection highlight
|
||||||
|
if(game.selected&&game.selected.alive!==false&&game.selected.kind==='animal'){
|
||||||
|
const s=game.selected;
|
||||||
|
const p=cam.project(s.x,s.y);
|
||||||
|
const rr=(18*s.size)*(1+Math.sin(t*5)*0.08);
|
||||||
|
ctx.strokeStyle='rgba(255,201,60,.95)'; ctx.lineWidth=2.5;
|
||||||
|
ctx.beginPath(); ctx.ellipse(p.x,p.y+1,rr,rr*0.42,0,0,TAU); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// lamps glow + night tint
|
||||||
|
const dark=1-dl;
|
||||||
|
if(dark>0.05){
|
||||||
|
const lamps=this.lampsFor(world);
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation='screen';
|
||||||
|
for(const [lx,ly] of lamps){
|
||||||
|
const p=cam.project(lx,ly);
|
||||||
|
if(!cam.isVisible(p.x,p.y,80)) continue;
|
||||||
|
const rr=46*cam.zoom;
|
||||||
|
const gg=ctx.createRadialGradient(p.x,p.y-52*cam.zoom,2,p.x,p.y-52*cam.zoom,rr);
|
||||||
|
gg.addColorStop(0,'rgba(255,220,130,'+(0.5*dark)+')');
|
||||||
|
gg.addColorStop(1,'rgba(255,220,130,0)');
|
||||||
|
ctx.fillStyle=gg;
|
||||||
|
ctx.beginPath(); ctx.arc(p.x,p.y-52*cam.zoom,rr,0,TAU); ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
ctx.fillStyle='rgba(24,34,72,'+(dark*0.42)+')';
|
||||||
|
ctx.fillRect(0,0,this.cssW,this.cssH);
|
||||||
|
}
|
||||||
|
// gentle vignette
|
||||||
|
const vg=ctx.createRadialGradient(this.cssW/2,this.cssH/2,Math.min(this.cssW,this.cssH)*0.62,this.cssW/2,this.cssH/2,Math.max(this.cssW,this.cssH)*0.78);
|
||||||
|
vg.addColorStop(0,'rgba(30,25,10,0)'); vg.addColorStop(1,'rgba(30,25,10,.18)');
|
||||||
|
ctx.fillStyle=vg; ctx.fillRect(0,0,this.cssW,this.cssH);
|
||||||
|
}
|
||||||
|
|
||||||
|
daylight(minutes){
|
||||||
|
// 0 at midnight → 1 at noon, warm golden hours
|
||||||
|
const m=minutes/1440;
|
||||||
|
const sun=Math.sin((m-0.25)*TAU)*0.5+0.5; // peak at noon
|
||||||
|
return Math.pow(WK.clamp(sun*1.15,0,1),0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- ghost preview ---- */
|
||||||
|
drawGhost(ctx,cam,g){
|
||||||
|
if(!g||g.x==null) return;
|
||||||
|
const ok=g.valid;
|
||||||
|
const col= ok?'rgba(110,220,120,.85)':'rgba(230,80,70,.9)';
|
||||||
|
const fill= ok?'rgba(140,230,150,.28)':'rgba(230,90,80,.3)';
|
||||||
|
if(g.mode==='tile'){
|
||||||
|
const p=cam.project(g.x,g.y);
|
||||||
|
WK.Sprites.diamondPath(ctx,p.x,p.y,1);
|
||||||
|
ctx.fillStyle=fill; ctx.fill();
|
||||||
|
ctx.strokeStyle=col; ctx.lineWidth=2; ctx.stroke();
|
||||||
|
if(g.sprite){
|
||||||
|
ctx.save(); ctx.globalAlpha=0.75;
|
||||||
|
ctx.drawImage(g.sprite.cv,p.x-g.sprite.ax,p.y-g.sprite.ay);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}else if(g.mode==='foot'){
|
||||||
|
const def=D.BUILDING[g.item];
|
||||||
|
for(let j=0;j<def.h;j++)for(let i=0;i<def.w;i++){
|
||||||
|
const p=cam.project(g.x+i,g.y+j);
|
||||||
|
WK.Sprites.diamondPath(ctx,p.x,p.y,1);
|
||||||
|
ctx.fillStyle=fill; ctx.fill();
|
||||||
|
ctx.strokeStyle=col; ctx.lineWidth=1.6; ctx.stroke();
|
||||||
|
}
|
||||||
|
const spr=WK.Sprites.getBuilding(g.item);
|
||||||
|
const pc=cam.project(g.x+def.w/2,g.y+def.h/2);
|
||||||
|
ctx.save(); ctx.globalAlpha=0.75;
|
||||||
|
ctx.drawImage(spr.cv,pc.x-spr.ax,pc.y-spr.ay);
|
||||||
|
ctx.restore();
|
||||||
|
}else if(g.mode==='edge'){
|
||||||
|
const p=cam.project(g.x,g.y);
|
||||||
|
const gz=cam.zoom;
|
||||||
|
let ax,ay,bx,by;
|
||||||
|
if(g.edge==='S'){ ax=p.x-HW*gz; ay=p.y; bx=p.x; by=p.y+HH*gz; }
|
||||||
|
else{ ax=p.x; ay=p.y+HH*gz; bx=p.x+HW*gz; by=p.y; }
|
||||||
|
ctx.strokeStyle=col; ctx.lineWidth=6; ctx.lineCap='round';
|
||||||
|
ctx.beginPath(); ctx.moveTo(ax,ay-6); ctx.lineTo(bx,by-6); ctx.stroke();
|
||||||
|
ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(ax,ay-12); ctx.lineTo(bx,by-12); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WK.Renderer = Renderer;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Wildlife Kingdom — js/sim.js
|
||||||
|
The Game: time · money · research · missions · rating ·
|
||||||
|
guest spawning · staff dispatch · placement rules · save/load
|
||||||
|
============================================================ */
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const WK = window.WK;
|
||||||
|
const D = WK.Data;
|
||||||
|
|
||||||
|
const SAVE_KEY='wildlifeKingdom.save.v1';
|
||||||
|
|
||||||
|
class Game{
|
||||||
|
constructor(){
|
||||||
|
const g=D.GRID;
|
||||||
|
this.world=new WK.World(g.COLS,g.ROWS);
|
||||||
|
this.money=D.START_MONEY;
|
||||||
|
this.rp=0;
|
||||||
|
this.researchTier=1;
|
||||||
|
this.day=1;
|
||||||
|
this.minutes=8*60;
|
||||||
|
this.speed=1;
|
||||||
|
this.animals=[];
|
||||||
|
this.guests=[];
|
||||||
|
this.staff=[];
|
||||||
|
this.particles=new WK.Particles();
|
||||||
|
this.stats={paths:0,fences:0,adopted:0,guestsTotal:0,guestsToday:0,revenueTotal:0,revenueToday:0,expensesTotal:0,sold:0};
|
||||||
|
this.missionIdx=0;
|
||||||
|
this.rating=1;
|
||||||
|
this.ratingParts={};
|
||||||
|
this.spawnAcc=0;
|
||||||
|
this.sfx=(n)=>{ if(WK.AudioSys&&WK.AudioSys.enabled) WK.AudioSys.sfx(n); };
|
||||||
|
this._scoreCache={ver:-1,map:new Map()};
|
||||||
|
this._poiVer=-1;
|
||||||
|
this.pois=[];
|
||||||
|
this.exitTile=null;
|
||||||
|
this.crowdStress=0;
|
||||||
|
this.selected=null;
|
||||||
|
this.timeAbs=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- helpers ---------------- */
|
||||||
|
get maxRegion(){
|
||||||
|
this.world.ensureRegions();
|
||||||
|
let m=0;
|
||||||
|
for(const r of this.world.regions) if(r.openEdges===0&&!r.hasPath) m=Math.max(m,r.area);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
speciesCount(){ return new Set(this.animals.map(a=>a.species)).size; }
|
||||||
|
notify(msg,type){ if(this.ui&&this.ui.toast) this.ui.toast(msg,type); }
|
||||||
|
earnMoney(n,src){
|
||||||
|
this.money+=n;
|
||||||
|
this.stats.revenueTotal+=n; this.stats.revenueToday+=n;
|
||||||
|
if(this.ui&&this.ui.flashStat) this.ui.flashStat('money');
|
||||||
|
}
|
||||||
|
spendMoney(n){
|
||||||
|
if(this.money<n){ this.notify('Not enough coins!','bad'); this.sfx('error'); return false; }
|
||||||
|
this.money-=n; return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- habitat scoring ---------------- */
|
||||||
|
habitatScoreFor(regionId,species){
|
||||||
|
const ver=this.world.ver||0;
|
||||||
|
if(this._scoreCache.ver!==ver){ this._scoreCache.ver=ver; this._scoreCache.map.clear(); }
|
||||||
|
const key=regionId+':'+species;
|
||||||
|
if(this._scoreCache.map.has(key)) return this._scoreCache.map.get(key);
|
||||||
|
const reg=this.world.regions.find(r=>r.id===regionId);
|
||||||
|
let score=8;
|
||||||
|
if(reg){
|
||||||
|
const sp=D.SPECIES[species];
|
||||||
|
const area=reg.area;
|
||||||
|
let bio=0, enrich=0, water=0;
|
||||||
|
for(const k of reg.tiles){
|
||||||
|
const gid=WK.GIDS[this.world.ground[k]];
|
||||||
|
bio+= (sp.biome[gid]!=null)? sp.biome[gid] : 0.25;
|
||||||
|
const o=this.world.obj[k];
|
||||||
|
if(o){ enrich+=D.NATURE[WK.NIDS[o-1]].enrich||0; }
|
||||||
|
if(D.GROUND[gid].water) water++;
|
||||||
|
}
|
||||||
|
bio/=area;
|
||||||
|
const areaF=WK.clamp(area/Math.max(6,sp.minArea),0,1);
|
||||||
|
const enr=WK.clamp(enrich/Math.max(0.5,sp.enrichNeed||0.5),0,1);
|
||||||
|
score = WK.clamp(bio*52 + areaF*22 + enr*16 + (sp.swim?(water>0?10:(bio>0.75?6:0)):4), 5, 100);
|
||||||
|
}
|
||||||
|
this._scoreCache.map.set(key,score);
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
habitatScore(animal){
|
||||||
|
const s=this.habitatScoreFor(animal.regionId,animal.species);
|
||||||
|
// crowd of same species penalty (max 4 comfortable per species)
|
||||||
|
const same=this.animals.filter(a=>a.regionId===animal.regionId&&a.species===animal.species).length;
|
||||||
|
return WK.clamp(s-(same>4?(same-4)*4:0),5,100);
|
||||||
|
}
|
||||||
|
validHabitatAt(x,y){
|
||||||
|
this.world.ensureRegions();
|
||||||
|
const rid=this.world.regionOf(x,y);
|
||||||
|
if(rid<0) return {error:'Can\u2019t place that here.'};
|
||||||
|
const reg=this.world.regions.find(r=>r.id===rid);
|
||||||
|
if(!reg) return {error:'No region here.'};
|
||||||
|
if(reg.hasPath) return {error:'Paths run through this area — habitats must be fence-only.'};
|
||||||
|
if(reg.openEdges>0) return {error:'Not enclosed! Fully surround the area with fences.'};
|
||||||
|
return {region:reg};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- placement operations ---------------- */
|
||||||
|
tryGround(x,y,gid){
|
||||||
|
const def=D.GROUND[gid];
|
||||||
|
if(def.tier>this.researchTier) return false;
|
||||||
|
const cur=this.world.gid(x,y);
|
||||||
|
if(cur===gid) return false;
|
||||||
|
if(!this.spendMoney(def.cost)) return false;
|
||||||
|
if(this.world.setGround(x,y,gid)){ this.sfx('dig'); return true; }
|
||||||
|
this.money+=def.cost; return false;
|
||||||
|
}
|
||||||
|
tryPath(x,y,pid){
|
||||||
|
const def=D.PATH[pid];
|
||||||
|
if(def.tier>this.researchTier) return false;
|
||||||
|
if(this.world.path[this.world.idx(x,y)]) { // replace path: charge diff
|
||||||
|
const cur=this.world.pid(x,y);
|
||||||
|
if(cur===pid) return false;
|
||||||
|
const diff=Math.max(0,def.cost-D.PATH[cur].cost);
|
||||||
|
if(diff&&!this.spendMoney(diff)) return false;
|
||||||
|
this.world.setPath(x,y,pid); this.sfx('place');
|
||||||
|
this.stats.paths++; return true;
|
||||||
|
}
|
||||||
|
if(!this.spendMoney(def.cost)) return false;
|
||||||
|
if(this.world.setPath(x,y,pid)){ this.sfx('place'); this.stats.paths++; return true; }
|
||||||
|
this.money+=def.cost; return false;
|
||||||
|
}
|
||||||
|
tryNature(x,y,nid){
|
||||||
|
const def=D.NATURE[nid];
|
||||||
|
if(def.tier>this.researchTier) return false;
|
||||||
|
if(this.world.oid(x,y)) { this.tryBulldozeObject(x,y,true); }
|
||||||
|
if(!this.spendMoney(def.cost)) return false;
|
||||||
|
if(this.world.setObj(x,y,nid,(Math.random()*3)|0)){ this.sfx('place'); return true; }
|
||||||
|
this.money+=def.cost; return false;
|
||||||
|
}
|
||||||
|
tryFence(x,y,edge,kindName){
|
||||||
|
const kind= kindName==='gate'?3: kindName==='hedge'?2:1;
|
||||||
|
const def=D.FENCE[kind];
|
||||||
|
if(def.tier>this.researchTier) return false;
|
||||||
|
const cur= edge==='S'?this.world.fenceS[this.world.idx(x,y)]:this.world.fenceE[this.world.idx(x,y)];
|
||||||
|
if(cur===kind) return false;
|
||||||
|
if(!this.spendMoney(def.cost)) return false;
|
||||||
|
this.world.setFence(x,y,edge,kind);
|
||||||
|
this.sfx('place'); this.stats.fences++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
tryBulldoze(x,y){
|
||||||
|
const b=this.world.buildingAt(x,y);
|
||||||
|
if(b){ return this.demolishBuilding(b); }
|
||||||
|
if(this.world.obj[this.world.idx(x,y)]) return this.tryBulldozeObject(x,y);
|
||||||
|
if(this.world.path[this.world.idx(x,y)]){
|
||||||
|
this.world.setPath(x,y,null); this.sfx('trash'); return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tryBulldozeObject(x,y,silent){
|
||||||
|
if(!this.world.obj[this.world.idx(x,y)]) return false;
|
||||||
|
this.money+=Math.round(D.NATURE[this.world.oid(x,y)].cost*0.4);
|
||||||
|
this.world.setObj(x,y,null);
|
||||||
|
if(!silent) this.sfx('trash');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
tryBulldozeEdge(x,y,edge){
|
||||||
|
const cur= edge==='S'?this.world.fenceS[this.world.idx(x,y)]:this.world.fenceE[this.world.idx(x,y)];
|
||||||
|
if(!cur) return false;
|
||||||
|
this.money+=Math.round(D.FENCE[cur].cost*0.4);
|
||||||
|
this.world.setFence(x,y,edge,0);
|
||||||
|
this.sfx('trash');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
tryBuilding(type,x,y){
|
||||||
|
const def=D.BUILDING[type];
|
||||||
|
if(def.tier>this.researchTier){ this.notify('Locked — unlock more research tiers.','bad'); return false; }
|
||||||
|
if(!this.world.canPlaceBuilding(x,y,def.w,def.h)){ this.notify('Space is blocked — clear the area first.','bad'); this.sfx('error'); return false; }
|
||||||
|
if(!this.spendMoney(def.cost)) return false;
|
||||||
|
const b=this.world.placeBuilding(type,x,y);
|
||||||
|
if(!b){ this.money+=def.cost; return false; }
|
||||||
|
this.sfx('place');
|
||||||
|
if(def.staff==='keeper'){ this.addStaff('keeper',b); this.notify('A keeper joined your staff!','good'); }
|
||||||
|
if(def.staff==='vet'){ this.addStaff('vet',b); this.notify('A vet joined your staff!','good'); }
|
||||||
|
this.refreshPois();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
demolishBuilding(b){
|
||||||
|
const def=D.BUILDING[b.type];
|
||||||
|
this.money+=Math.round(def.cost*0.5);
|
||||||
|
if(def.staff){
|
||||||
|
const idx=this.staff.findIndex(s=>s.role===def.staff&&!s._homeRemoved);
|
||||||
|
if(idx>=0){ this.staff.splice(idx,1); this.notify(def.staff==='keeper'?'A keeper left your staff.':'A vet left your staff.','bad'); }
|
||||||
|
}
|
||||||
|
this.world.removeBuildingById(b.id);
|
||||||
|
this.sfx('trash');
|
||||||
|
this.refreshPois();
|
||||||
|
if(this.ui&&this.ui.hideSelection) this.ui.hideSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
addStaff(role,b){
|
||||||
|
const d=this.world.doorTile(b);
|
||||||
|
this.staff.push(new WK.Staff(role,d[0],d[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
tryAdopt(species,x,y){
|
||||||
|
const sp=D.SPECIES[species];
|
||||||
|
if(sp.tier>this.researchTier){ this.notify('Locked — research more tiers first.','bad'); return false; }
|
||||||
|
const chk=this.validHabitatAt(x,y);
|
||||||
|
if(chk.error){ this.notify(chk.error,'bad'); this.sfx('error'); return false; }
|
||||||
|
const reg=chk.region;
|
||||||
|
if(reg.area<sp.minArea){
|
||||||
|
this.notify(sp.name+' needs a habitat of at least '+sp.minArea+' tiles.','bad');
|
||||||
|
this.sfx('error'); return false;
|
||||||
|
}
|
||||||
|
const score=this.habitatScoreFor(reg.id,species);
|
||||||
|
if(score<50){
|
||||||
|
this.notify('Terrain unsuitable for a '+sp.name+'! Match its favorite biomes (needs '+Math.round(score)+'%, want 50%+).','bad');
|
||||||
|
this.sfx('error'); return false;
|
||||||
|
}
|
||||||
|
if(!this.spendMoney(sp.cost)) return false;
|
||||||
|
// drop the animal onto a passable nearby tile inside region
|
||||||
|
let px=x,py=y;
|
||||||
|
const g0=D.GROUND[this.world.gid(x,y)];
|
||||||
|
if((g0.water&&!sp.swim)||(this.world.oid(x,y)&&D.NATURE[this.world.oid(x,y)].block)){
|
||||||
|
const alt=this.world.randomRegionTile(reg.id,sp,Math.random);
|
||||||
|
if(alt){px=alt[0];py=alt[1];}
|
||||||
|
}
|
||||||
|
const a=new WK.Animal(species,px+0.5-0.5,py+0.5-0.5,reg.id);
|
||||||
|
a.x=px; a.y=py;
|
||||||
|
this.animals.push(a);
|
||||||
|
this.stats.adopted++;
|
||||||
|
this.sfx('chirp');
|
||||||
|
this.notify(a.name+' the '+sp.name+' joins the zoo!','good');
|
||||||
|
this.particles.hearts(px,py);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sellAnimal(a){
|
||||||
|
const i=this.animals.indexOf(a);
|
||||||
|
if(i<0)return;
|
||||||
|
this.animals.splice(i,1);
|
||||||
|
const refund=Math.round(D.SPECIES[a.species].cost*0.45);
|
||||||
|
this.money+=refund;
|
||||||
|
this.stats.sold++;
|
||||||
|
this.notify(a.name+' found a new home (+'+WK.fmtMoney(refund)+').','good');
|
||||||
|
this.sfx('coin');
|
||||||
|
if(this.ui&&this.ui.hideSelection) this.ui.hideSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- jobs dispatcher ---------------- */
|
||||||
|
requestJob(role){
|
||||||
|
if(role==='keeper'){
|
||||||
|
let best=null;
|
||||||
|
for(const a of this.animals){
|
||||||
|
if(a._taken||a.eatT>0||a.sick) continue;
|
||||||
|
if(a.hunger>62 && (!best||a.hunger>best.hunger)) best=a;
|
||||||
|
}
|
||||||
|
if(best){ best._taken=true; return best; }
|
||||||
|
}else{
|
||||||
|
for(const a of this.animals){
|
||||||
|
if(a.sick&&!a._taken){ a._taken=true; return a; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
abandonJob(role,a){ a._taken=false; }
|
||||||
|
|
||||||
|
/* ---------------- POIs ---------------- */
|
||||||
|
refreshPois(){
|
||||||
|
this._poiVer=-1;
|
||||||
|
}
|
||||||
|
rebuildPois(){
|
||||||
|
this.world.ensureRegions();
|
||||||
|
const pois=[];
|
||||||
|
const regionAppeal=new Map();
|
||||||
|
for(const a of this.animals){
|
||||||
|
const cur=regionAppeal.get(a.regionId)||{appeal:0,q:0,n:0};
|
||||||
|
cur.appeal=Math.max(cur.appeal,D.SPECIES[a.species].appeal);
|
||||||
|
cur.q+=a.happiness/100; cur.n++;
|
||||||
|
regionAppeal.set(a.regionId,cur);
|
||||||
|
}
|
||||||
|
for(const [rid,info] of regionAppeal){
|
||||||
|
const vps=this.world.viewpoints(rid);
|
||||||
|
const q=info.q/info.n;
|
||||||
|
for(const vp of vps.slice(0,10)){
|
||||||
|
pois.push({type:'view',x:vp[0],y:vp[1],regionId:rid,appeal:info.appeal,quality:q});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for(const b of this.world.buildings){
|
||||||
|
const def=D.BUILDING[b.type];
|
||||||
|
const d=this.world.doorTile(b);
|
||||||
|
if(def.entrance) continue;
|
||||||
|
if(def.restroom){}
|
||||||
|
pois.push({
|
||||||
|
type: def.income>0?'shop':(b.type==='restroom'?'restroom':'visit'),
|
||||||
|
btype:b.type, x:d[0], y:d[1], income:def.income, joy:def.joy,
|
||||||
|
});
|
||||||
|
if(b.type==='restroom'){ pois.pop(); pois.push({type:'restroom',btype:b.type,x:d[0],y:d[1]}); }
|
||||||
|
}
|
||||||
|
this.pois=pois;
|
||||||
|
// exits
|
||||||
|
const ent=this.world.buildings.filter(b=>D.BUILDING[b.type].entrance);
|
||||||
|
this.exitTile = ent.length ? this.world.doorTile(ent[0]) : null;
|
||||||
|
this.entrances=ent.length;
|
||||||
|
this.entranceCap = ent.reduce((s,b)=>s+(D.BUILDING[b.type].big?90:45),0);
|
||||||
|
this._poiVer=this.world.ver||0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- rating ---------------- */
|
||||||
|
recomputeRating(){
|
||||||
|
const P={};
|
||||||
|
P.welfare = this.animals.length ? this.animals.reduce((s,a)=>s+a.happiness,0)/(this.animals.length*100) : 0.55;
|
||||||
|
P.variety = WK.clamp(this.speciesCount()/10,0,1);
|
||||||
|
P.joy = this.guests.length ? this.guests.reduce((s,g)=>s+g.joy,0)/(this.guests.length*100) : (this.ratingParts.joy!=null?this.ratingParts.joy:0.5);
|
||||||
|
const guests=this.guests.length;
|
||||||
|
const restrooms=this.world.buildings.filter(b=>b.type==='restroom').length;
|
||||||
|
const food=this.world.buildings.filter(b=>['burger','icecream','restaurant'].includes(b.type)).length;
|
||||||
|
const benches=this.countObj('bench');
|
||||||
|
P.facilities = WK.clamp(
|
||||||
|
0.1 + 0.45*Math.min(restrooms/Math.max(1,guests/28),1) +
|
||||||
|
0.25*Math.min(food/Math.max(1,guests/45),1) +
|
||||||
|
0.2*Math.min(benches/Math.max(1,guests/14),1),
|
||||||
|
0,1);
|
||||||
|
P.decor = WK.clamp(this.countNature()/55,0,1);
|
||||||
|
P.exhibits = WK.clamp(this.world.buildings.filter(b=>D.BUILDING[b.type].exhibit).length/3,0,1);
|
||||||
|
this.ratingParts=P;
|
||||||
|
this.rating = WK.clamp((P.welfare*1.5 + P.variety*1.2 + P.joy*1.0 + P.facilities*0.8 + P.decor*0.4 + P.exhibits*0.6)/5.5*5, 0.4, 5);
|
||||||
|
}
|
||||||
|
countObj(id){
|
||||||
|
let n=0; const arr=this.world.obj;
|
||||||
|
for(let i=0;i<arr.length;i++) if(arr[i]===WK.NIDS.indexOf(id)+1) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
countNature(){
|
||||||
|
let n=0; const arr=this.world.obj;
|
||||||
|
for(let i=0;i<arr.length;i++) if(arr[i]) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- main tick ---------------- */
|
||||||
|
tick(dt){
|
||||||
|
this.timeAbs+=dt;
|
||||||
|
const spd=this.speed;
|
||||||
|
const sdt=dt*spd;
|
||||||
|
if(sdt>0){
|
||||||
|
this.minutes+=sdt*4;
|
||||||
|
while(this.minutes>=1440){ this.minutes-=1440; this.newDay(); }
|
||||||
|
// world version → caches
|
||||||
|
if((this.world.ver||0)!==this._poiVer) this.rebuildPois();
|
||||||
|
// research
|
||||||
|
const centers=this.world.buildings.filter(b=>b.type==='research').length;
|
||||||
|
this.rp += sdt*(0.003 + centers*(5/60));
|
||||||
|
// entities
|
||||||
|
for(let i=this.animals.length-1;i>=0;i--){
|
||||||
|
const a=this.animals[i];
|
||||||
|
a.update(sdt,this);
|
||||||
|
// sickness rolls
|
||||||
|
if(!a.sick){
|
||||||
|
const p=a.happiness<45?0.0011:0.00013;
|
||||||
|
if(Math.random()<p*sdt*4){ a.sick=true; a._taken=false;
|
||||||
|
this.notify(a.name+' the '+D.SPECIES[a.species].name+' feels sick! Build a Vet Clinic.','bad'); this.sfx('error'); }
|
||||||
|
}else{
|
||||||
|
a._sickT=(a._sickT||0)+sdt;
|
||||||
|
if(a._sickT>150){ a.sick=false; a._sickT=0; this.notify(a.name+' recovered on their own.','good'); }
|
||||||
|
}
|
||||||
|
if(a.eatT<=0&&a.state!=='eat'&&!a.sick){ /* _taken cleared on feeding */ }
|
||||||
|
}
|
||||||
|
// escape check (throttled)
|
||||||
|
this._escT=(this._escT||0)+sdt;
|
||||||
|
if(this._escT>2){
|
||||||
|
this._escT=0;
|
||||||
|
for(const a of this.animals){
|
||||||
|
const reg=this.world.regions.find(r=>r.id===a.regionId);
|
||||||
|
if(!reg||(reg.openEdges>0)||reg.hasPath){
|
||||||
|
a.happiness=WK.clamp(a.happiness-sdt*8,5,100);
|
||||||
|
if(!a._escWarned){ a._escWarned=true;
|
||||||
|
this.notify(a.name+'\u2019s habitat is not secure! Check fences & paths.','bad'); }
|
||||||
|
}else a._escWarned=false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// guests
|
||||||
|
this.crowdStress = this.guests.length > 25 + this.world.buildings.filter(b=>b.type==='restroom').length*15 ? 0.06 : 0;
|
||||||
|
const cap=Math.min(D.CAPS.guests, 12 + (this.entranceCap||0) + Math.round(this.rating*8));
|
||||||
|
const rate=(0.05 + this.rating*0.09);
|
||||||
|
this.spawnAcc+=sdt*rate;
|
||||||
|
if(this.spawnAcc>=1){
|
||||||
|
this.spawnAcc-=1;
|
||||||
|
if(this.guests.length<cap && this.exitTile){
|
||||||
|
const e=this.exitTile;
|
||||||
|
const gs=new WK.Guest(this,e[0]+0.5,e[1]+0.5);
|
||||||
|
this.guests.push(gs);
|
||||||
|
const fee=8+Math.round(this.rating*4);
|
||||||
|
this.earnMoney(fee,'ticket');
|
||||||
|
this.stats.guestsTotal++; this.stats.guestsToday++;
|
||||||
|
if(this.guests.length%10===0) this.coinFloat(e[0],e[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for(let i=this.guests.length-1;i>=0;i--){
|
||||||
|
const g=this.guests[i];
|
||||||
|
g.update(sdt);
|
||||||
|
if(g.done){
|
||||||
|
if(g.joy>85) this.ratingParts.joy=Math.min(1,(this.ratingParts.joy==null?0.6:this.ratingParts.joy)+0.01);
|
||||||
|
if(g.joy<28) this.ratingParts.joy=Math.max(0.1,(this.ratingParts.joy==null?0.5:this.ratingParts.joy)-0.02);
|
||||||
|
this.guests.splice(i,1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for(const s of this.staff) s.update(sdt,this);
|
||||||
|
this.particles.update(sdt);
|
||||||
|
// rating throttle
|
||||||
|
this._rateT=(this._rateT||0)+dt;
|
||||||
|
if(this._rateT>1){ this._rateT=0; this.recomputeRating(); }
|
||||||
|
// missions
|
||||||
|
this._misT=(this._misT||0)+dt;
|
||||||
|
if(this._misT>0.8){
|
||||||
|
this._misT=0;
|
||||||
|
this.checkMission();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coinFloat(x,y){ this.particles.coins(x,y); }
|
||||||
|
coinBurst(x,y){ this.particles.coins(x,y); }
|
||||||
|
puffHeart(x,y){ this.particles.hearts(x,y); }
|
||||||
|
|
||||||
|
newDay(){
|
||||||
|
this.day++;
|
||||||
|
let wages=0;
|
||||||
|
wages += this.staff.filter(s=>s.role==='keeper').length*70;
|
||||||
|
wages += this.staff.filter(s=>s.role==='vet').length*90;
|
||||||
|
let upkeep=0;
|
||||||
|
for(const b of this.world.buildings) upkeep+=Math.round(D.BUILDING[b.type].cost*0.006);
|
||||||
|
let animalUpkeep=0;
|
||||||
|
for(const a of this.animals) animalUpkeep+=D.SPECIES[a.species].upkeep;
|
||||||
|
const total=wages+upkeep+animalUpkeep;
|
||||||
|
this.money-=total;
|
||||||
|
this.stats.expensesTotal+=total;
|
||||||
|
const profit=this.stats.revenueToday-total;
|
||||||
|
this.notify('Day '+this.day+' — wages '+WK.fmtMoney(wages)+' · upkeep '+WK.fmtMoney(upkeep+animalUpkeep)+(profit>=0?' · profit '+WK.fmtMoney(profit):' · loss '+WK.fmtMoney(-profit)), profit>=0?'good':'bad');
|
||||||
|
this.stats.revenueToday=0;
|
||||||
|
this.stats.guestsToday=0;
|
||||||
|
this.sfx(profit>=0?'coin':'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- missions & research ---------------- */
|
||||||
|
checkMission(){
|
||||||
|
const m=D.MISSIONS[this.missionIdx];
|
||||||
|
if(!m) return;
|
||||||
|
if(m.check(this)){
|
||||||
|
let msg='Mission complete: '+m.label+'!';
|
||||||
|
if(typeof m.reward==='number'){ this.money+=m.reward; msg+=' +'+WK.fmtMoney(m.reward); }
|
||||||
|
else{
|
||||||
|
if(m.reward.cash){ this.money+=m.reward.cash; msg+=' +'+WK.fmtMoney(m.reward.cash); }
|
||||||
|
if(m.reward.rp){ this.rp+=m.reward.rp; msg+=' +'+m.reward.rp+' RP'; }
|
||||||
|
}
|
||||||
|
this.notify(msg,'gold');
|
||||||
|
this.sfx('unlock');
|
||||||
|
this.missionIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
researchCost(){
|
||||||
|
const next=D.RESEARCH_TIERS.find(t=>t.tier===this.researchTier+1);
|
||||||
|
return next?next.cost:null;
|
||||||
|
}
|
||||||
|
buyResearch(){
|
||||||
|
const cost=this.researchCost();
|
||||||
|
if(cost==null) return false;
|
||||||
|
if(this.rp<cost){ this.notify('Need '+Math.ceil(cost-this.rp)+' more RP.','bad'); this.sfx('error'); return false; }
|
||||||
|
this.rp-=cost;
|
||||||
|
this.researchTier++;
|
||||||
|
this.notify('Research unlocked: '+D.RESEARCH_TIERS.find(t=>t.tier===this.researchTier).label+'!','gold');
|
||||||
|
this.sfx('unlock');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- persistence ---------------- */
|
||||||
|
save(){
|
||||||
|
try{
|
||||||
|
const data={
|
||||||
|
v:1, money:this.money, rp:this.rp, researchTier:this.researchTier,
|
||||||
|
day:this.day, minutes:this.minutes, missionIdx:this.missionIdx,
|
||||||
|
stats:this.stats, ratingJoy:this.ratingParts.joy,
|
||||||
|
world:this.world.serialize(),
|
||||||
|
animals:this.animals.map(a=>({s:a.species,n:a.name,x:a.x,y:a.y,h:a.hunger,p:a.happiness,k:a.sick,r:a.regionId})),
|
||||||
|
staff:this.staff.map(s=>({r:s.role,x:s.x,y:s.y})),
|
||||||
|
};
|
||||||
|
localStorage.setItem(SAVE_KEY,JSON.stringify(data));
|
||||||
|
return true;
|
||||||
|
}catch(e){ return false; }
|
||||||
|
}
|
||||||
|
static hasSave(){ try{ return !!localStorage.getItem(SAVE_KEY); }catch(e){ return false; } }
|
||||||
|
static load(){
|
||||||
|
try{
|
||||||
|
const raw=localStorage.getItem(SAVE_KEY);
|
||||||
|
if(!raw) return null;
|
||||||
|
const d=JSON.parse(raw);
|
||||||
|
const g=new Game();
|
||||||
|
g.world.deserialize(d.world);
|
||||||
|
g.money=d.money; g.rp=d.rp; g.researchTier=d.researchTier;
|
||||||
|
g.day=d.day; g.minutes=d.minutes; g.missionIdx=d.missionIdx;
|
||||||
|
Object.assign(g.stats,d.stats||{});
|
||||||
|
g.ratingParts.joy=d.ratingJoy;
|
||||||
|
for(const a of d.animals||[]){
|
||||||
|
const an=new WK.Animal(a.s,a.x,a.y,a.r||0);
|
||||||
|
an.name=a.n; an.hunger=a.h; an.happiness=a.p; an.sick=!!a.k;
|
||||||
|
g.animals.push(an);
|
||||||
|
}
|
||||||
|
for(const s of d.staff||[]) g.staff.push(new WK.Staff(s.r,s.x,s.y));
|
||||||
|
g.recomputeRating();
|
||||||
|
g.rebuildPois();
|
||||||
|
return g;
|
||||||
|
}catch(e){ return null; }
|
||||||
|
}
|
||||||
|
static clearSave(){ try{ localStorage.removeItem(SAVE_KEY); }catch(e){} }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* starter zoo: cozy beginning — entrance, plaza path, some greenery */
|
||||||
|
Game.buildStarter = function(game){
|
||||||
|
const w=game.world;
|
||||||
|
const cx=(w.cols/2)|0, sy=w.rows-6;
|
||||||
|
const b=w.placeBuilding('entrance_s',cx-1,sy);
|
||||||
|
// plaza
|
||||||
|
for(let x=cx-4;x<=cx+5;x++){
|
||||||
|
w.setGround(x,sy+1,'grass'); w.setPath(x,sy+1,'gravel');
|
||||||
|
w.setGround(x,sy+2,'grass'); w.setPath(x,sy+2,'gravel');
|
||||||
|
}
|
||||||
|
for(let y=sy-8;y<=sy+2;y++){ w.setGround(cx+2,y,'grass'); w.setPath(cx+2,y,'gravel'); }
|
||||||
|
// greenery
|
||||||
|
const deco=[[cx-6,sy-2,'tree'],[cx+7,sy-1,'tree'],[cx-5,sy+2,'bush'],[cx+6,sy+2,'bush'],
|
||||||
|
[cx-3,sy-4,'flower'],[cx+3,sy-4,'flower'],[cx-8,sy,'tree'],[cx+9,sy,'tree'],
|
||||||
|
[cx-7,sy+3,'bench'],[cx+8,sy+3,'bench'],[cx-2,sy-5,'rock']];
|
||||||
|
for(const [x,y,o] of deco){ if(w.inBounds(x,y)&&w.occ[w.idx(x,y)]<0) w.setObj(x,y,o,(Math.random()*3)|0); }
|
||||||
|
game.refreshPois();
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* demo zoo for the menu hero scene */
|
||||||
|
Game.buildDemo = function(game){
|
||||||
|
const w=game.world;
|
||||||
|
const C=w.cols, Rws=w.rows;
|
||||||
|
const cx=(C/2)|0;
|
||||||
|
const rnd=WK.mulberry32(20240001);
|
||||||
|
// grand entrance south
|
||||||
|
w.placeBuilding('entrance_g',cx-2,Rws-5);
|
||||||
|
for(let x=cx-6;x<=cx+7;x++){ w.setPath(x,Rws-4,'stonep'); w.setPath(x,Rws-3,'stonep'); }
|
||||||
|
// ring promenade
|
||||||
|
const yN=8, yS=Rws-6, xW=6, xE=C-7;
|
||||||
|
for(let x=xW;x<=xE;x++){ w.setPath(x,yN,'stonep'); w.setPath(x,yS,'stonep'); }
|
||||||
|
for(let y=yN;y<=yS;y++){ w.setPath(xW,y,'stonep'); w.setPath(xE,y,'stonep'); }
|
||||||
|
for(let y=yS;y<=Rws-3;y++) w.setPath(cx,y,'stonep');
|
||||||
|
// helper: rectangular fenced habitat with terrain + animals + gate on given side path
|
||||||
|
function habitat(x0,y0,x1,y1,grounds,species,waterRect){
|
||||||
|
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++) w.setGround(x,y,grounds[(rnd()*grounds.length)|0]);
|
||||||
|
if(waterRect){
|
||||||
|
for(let y=waterRect[1];y<=waterRect[3];y++)for(let x=waterRect[0];x<=waterRect[2];x++) w.setGround(x,y, waterRect[4]||'water');
|
||||||
|
}
|
||||||
|
for(let x=x0;x<=x1;x++){ w.setFence(x,y0-1,'S',1); w.setFence(x,y1,'S',1); }
|
||||||
|
for(let y=y0;y<=y1;y++){ w.setFence(x0-1,y,'E',1); w.setFence(x1,y,'E',1); }
|
||||||
|
// gate on south side middle connecting to nearest path below
|
||||||
|
const gx=((x0+x1)/2)|0;
|
||||||
|
w.setFence(gx,y1,'S',3);
|
||||||
|
const rid=w.regionOf(((x0+x1)/2)|0,((y0+y1)/2)|0);
|
||||||
|
w.computeRegions();
|
||||||
|
for(const sp of species){
|
||||||
|
const t=w.randomRegionTile(rid,D.SPECIES[sp],rnd)||[x0+1,y0+1];
|
||||||
|
const a=new WK.Animal(sp,t[0],t[1],rid);
|
||||||
|
game.animals.push(a);
|
||||||
|
}
|
||||||
|
// interior decor
|
||||||
|
for(let i=0;i<Math.max(2,((x1-x0)*(y1-y0))/18|0);i++){
|
||||||
|
const rx=x0+1+((rnd()*(x1-x0-1))|0), ry=y0+1+((rnd()*(y1-y0-1))|0);
|
||||||
|
const o=grounds.includes('snow')?'rock':(grounds.includes('sand')?'palm':(species.includes('panda')?'bamboo':'tree'));
|
||||||
|
if(!w.path[w.idx(rx,ry)]) w.setObj(rx,ry,o,0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// savanna center-left: lions+zebra+giraffe
|
||||||
|
habitat(9,11,17,17,['grass','grass','sand'],['lion','zebra','giraffe']);
|
||||||
|
// elephants right of it
|
||||||
|
habitat(19,11,27,17,['grass','dirt'],['elephant','elephant']);
|
||||||
|
// arctic NW corner
|
||||||
|
habitat(9,20,16,26,['snow','ice'],['penguin','penguin','polarbear'],[11,22,14,25,'water']);
|
||||||
|
// wetland SW
|
||||||
|
habitat(19,21,26,27,['sand','grass'],['flamingo','flamingo'],[21,23,24,25,'water']);
|
||||||
|
// jungle SE quadrant
|
||||||
|
habitat(29,20,38,28,['dirt','grass'],['tiger','gorilla','monkey']);
|
||||||
|
for(let i=0;i<8;i++){ const rx=30+((rnd()*8)|0), ry=21+((rnd()*7)|0); w.setObj(rx,ry,'bamboo',0); }
|
||||||
|
// panda grove NE
|
||||||
|
habitat(29,10,36,16,['grass','dirt'],['panda','panda']);
|
||||||
|
for(let i=0;i<6;i++){ const rx=30+((rnd()*6)|0), ry=11+((rnd()*5)|0); w.setObj(rx,ry,'bamboo',0); }
|
||||||
|
// croc lagoon far east
|
||||||
|
habitat(39,12,C-2,18,['sand'],['crocodile'],[40,14,C-3,17,'deep']);
|
||||||
|
|
||||||
|
w.computeRegions();
|
||||||
|
|
||||||
|
// facilities along paths
|
||||||
|
const put=(t,x,y)=>{ w.canPlaceBuilding(x,y,D.BUILDING[t].w,D.BUILDING[t].h)&&w.placeBuilding(t,x,y); };
|
||||||
|
put('aquarium',31,29); put('restaurant',8,29); put('gift',13,29); put('icecream',18,30);
|
||||||
|
put('burger',24,30); put('restroom',28,29); put('restroom',7,19); put('aviary',40,20);
|
||||||
|
put('reptile',40,29); put('visitor',20,4); put('research',27,3); put('vet',13,3); put('hut',37,3);
|
||||||
|
// street life
|
||||||
|
for(let i=0;i<40;i++){
|
||||||
|
const x=xW+((rnd()*(xE-xW))|0), y=rnd()<0.5?yN:yS;
|
||||||
|
const o=['tree','bush','flower','lamp','bench'][(rnd()*5)|0];
|
||||||
|
if(w.inBounds(x,y)&&!w.path[w.idx(x,y)]) w.setObj(x,y,o,(rnd()*3)|0);
|
||||||
|
}
|
||||||
|
for(let i=0;i<26;i++){
|
||||||
|
const ang=rnd()*Math.PI*2, rr=6+rnd()*14;
|
||||||
|
const x=cx+Math.cos(ang)*rr|0, y=((yN+yS)/2+Math.sin(ang)*rr*0.7)|0;
|
||||||
|
if(w.inBounds(x,y)&&w.occ[w.idx(x,y)]<0&&!w.path[w.idx(x,y)]) w.setObj(x,y,['tree','tree','bush','flower'][(rnd()*4)|0],(rnd()*3)|0);
|
||||||
|
}
|
||||||
|
w.computeRegions();
|
||||||
|
game.rebuildPois();
|
||||||
|
game.recomputeRating();
|
||||||
|
};
|
||||||
|
|
||||||
|
Game.SAVE_KEY=SAVE_KEY;
|
||||||
|
WK.Game = Game;
|
||||||
|
})();
|
||||||
+1161
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,414 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Wildlife Kingdom — js/ui.js
|
||||||
|
HUD · toolbar & palettes · side panel · selection cards ·
|
||||||
|
toasts · modals. Original cream/rounded visual identity.
|
||||||
|
============================================================ */
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const WK = window.WK;
|
||||||
|
const D = WK.Data;
|
||||||
|
|
||||||
|
const $=id=>document.getElementById(id);
|
||||||
|
const UI = (WK.UI = {});
|
||||||
|
|
||||||
|
UI.game=null; UI.cam=null; UI.renderer=null;
|
||||||
|
UI.tool=null; UI.bulldoze=false;
|
||||||
|
UI._last={}; UI._selTarget=null; UI._sideTab='objectives';
|
||||||
|
UI._sideT=0; UI._hudT=0;
|
||||||
|
|
||||||
|
UI.init=function(game,cam,renderer){
|
||||||
|
if(UI._inited){
|
||||||
|
// re-attach to a fresh game without duplicating DOM work
|
||||||
|
UI.game=game; UI.cam=cam; UI.renderer=renderer;
|
||||||
|
game.ui=UI;
|
||||||
|
UI._last={}; UI._selTarget=null;
|
||||||
|
UI.cancelTool();
|
||||||
|
UI.hideSelection();
|
||||||
|
UI.renderSide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UI._inited=true;
|
||||||
|
UI.game=game; UI.cam=cam; UI.renderer=renderer;
|
||||||
|
game.ui=UI;
|
||||||
|
// inject resource icons
|
||||||
|
document.querySelectorAll('.stat .ic[data-ic]').forEach(el=>{
|
||||||
|
const s=WK.Sprites.getIcon('res:'+el.dataset.ic,26);
|
||||||
|
s.cv.style.width='100%'; s.cv.style.height='100%';
|
||||||
|
el.appendChild(s.cv);
|
||||||
|
});
|
||||||
|
$('btn-sound').textContent = WK.AudioSys.enabled?'🔊':'🔇';
|
||||||
|
$('btn-settings').textContent='⚙';
|
||||||
|
const spdIcons={ 'spd-pause':'⏸', 'spd-play':'▶', 'spd-fast':'⏩', 'spd-ultra':'⚡' };
|
||||||
|
for(const id in spdIcons) $(id).textContent=spdIcons[id];
|
||||||
|
$('btn-bulldoze').appendChild(WK.Sprites.getIcon('res:dozer',30).cv);
|
||||||
|
UI.buildToolbar();
|
||||||
|
UI.bind();
|
||||||
|
UI.selectCat('paths'); // sensible default tab shown when opened
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- toolbar ---------------- */
|
||||||
|
const CAT_ICON={ ground:'ground:grass', paths:'path:gravel', fences:'fence:wood',
|
||||||
|
nature:'nature:tree', animals:'animal:zebra', facilities:'facility:restroom' };
|
||||||
|
const FENCE_ITEMS=[['wood','Wood Fence'],['hedge','Hedge'],['gate','Keeper Gate']];
|
||||||
|
|
||||||
|
UI.buildToolbar=function(){
|
||||||
|
const cats=$('tb-categories');
|
||||||
|
cats.innerHTML='';
|
||||||
|
for(const c of D.TOOLCATS){
|
||||||
|
const b=document.createElement('button');
|
||||||
|
b.className='tb-cat'+(c.id===UI._cat?' active':'');
|
||||||
|
b.appendChild(WK.Sprites.getIcon(CAT_ICON[c.id],34).cv);
|
||||||
|
const l=document.createElement('span'); l.className='lbl'; l.textContent=c.label;
|
||||||
|
b.appendChild(l);
|
||||||
|
b.onclick=()=>{ WK.AudioSys.sfx('click'); UI.selectCat(c.id); };
|
||||||
|
b.dataset.cat=c.id;
|
||||||
|
cats.appendChild(b);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
UI.paletteItems=function(cat){
|
||||||
|
switch(cat){
|
||||||
|
case 'ground': return Object.keys(D.GROUND).map(id=>({id,name:D.GROUND[id].name,cost:D.GROUND[id].cost,tier:D.GROUND[id].tier}));
|
||||||
|
case 'paths': return Object.keys(D.PATH).map(id=>({id,name:D.PATH[id].name,cost:D.PATH[id].cost,tier:D.PATH[id].tier}));
|
||||||
|
case 'fences': return FENCE_ITEMS.map(([id,name])=>({id,name,cost:id==='wood'?12:id==='hedge'?18:40,tier:id==='hedge'?2:1}));
|
||||||
|
case 'nature': return Object.keys(D.NATURE).map(id=>({id,name:D.NATURE[id].name,cost:D.NATURE[id].cost,tier:D.NATURE[id].tier}));
|
||||||
|
case 'animals': return Object.keys(D.SPECIES).sort((a,b)=>D.SPECIES[a].cost-D.SPECIES[b].cost)
|
||||||
|
.map(id=>({id,name:D.SPECIES[id].name,cost:D.SPECIES[id].cost,tier:D.SPECIES[id].tier}));
|
||||||
|
case 'facilities': return Object.keys(D.BUILDING).sort((a,b)=>D.BUILDING[a].cost-D.BUILDING[b].cost)
|
||||||
|
.map(id=>({id,name:D.BUILDING[id].name,cost:D.BUILDING[id].cost,tier:D.BUILDING[id].tier}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
UI.iconKind=function(cat,id){
|
||||||
|
switch(cat){
|
||||||
|
case 'ground': return 'ground:'+id;
|
||||||
|
case 'paths': return 'path:'+id;
|
||||||
|
case 'fences': return 'fence:'+id;
|
||||||
|
case 'nature': return 'nature:'+id;
|
||||||
|
case 'animals': return 'animal:'+id;
|
||||||
|
case 'facilities': return 'facility:'+id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
UI.selectCat=function(cat){
|
||||||
|
UI._cat=cat;
|
||||||
|
document.querySelectorAll('.tb-cat').forEach(b=>b.classList.toggle('active',b.dataset.cat===cat));
|
||||||
|
const pal=$('tb-palette');
|
||||||
|
pal.innerHTML='';
|
||||||
|
for(const it of UI.paletteItems(cat)){
|
||||||
|
const locked=(it.tier||1)>UI.game.researchTier;
|
||||||
|
const d=document.createElement('button');
|
||||||
|
d.className='pal-item'+(locked?' locked':'')+((UI.tool&&UI.tool.item===it.id&&UI.tool.cat===cat)?' active':'');
|
||||||
|
const cv=WK.Sprites.getIcon(UI.iconKind(cat,it.id),54);
|
||||||
|
d.appendChild(cv.cv);
|
||||||
|
const nm=document.createElement('span'); nm.className='nm'; nm.textContent=it.name;
|
||||||
|
const pr=document.createElement('span'); pr.className='pr'; pr.textContent=locked?('Tier '+it.tier):WK.fmtMoney(it.cost);
|
||||||
|
d.appendChild(nm); d.appendChild(pr);
|
||||||
|
d.title=locked?'Unlock via Research':(D.BUILDING[it.id]&&D.BUILDING[it.id].desc||D.SPECIES[it.id]&&(D.SPECIES[it.id].minArea+'+ tiles · likes '+Object.keys(D.SPECIES[it.id].biome).join('/'))||'');
|
||||||
|
d.onclick=()=>{
|
||||||
|
WK.AudioSys.sfx('click');
|
||||||
|
if(locked){ UI.toast('Locked! Unlock Tier '+it.tier+' in the Research panel.','bad'); return; }
|
||||||
|
UI.bulldoze=false; $('btn-bulldoze').classList.remove('active');
|
||||||
|
UI.selectTool({cat,item:it.id});
|
||||||
|
pal.querySelectorAll('.pal-item').forEach(x=>x.classList.remove('active'));
|
||||||
|
d.classList.add('active');
|
||||||
|
};
|
||||||
|
pal.appendChild(d);
|
||||||
|
}
|
||||||
|
pal.classList.add('hidden');
|
||||||
|
if(UI.tool||UI.bulldoze) pal.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
UI.selectTool=function(t){
|
||||||
|
UI.tool=t; UI.bulldoze=false;
|
||||||
|
$('btn-bulldoze').classList.toggle('active',false);
|
||||||
|
const pal=$('tb-palette'), info=$('tool-info');
|
||||||
|
if(t){
|
||||||
|
pal.classList.remove('hidden');
|
||||||
|
info.classList.remove('hidden');
|
||||||
|
const def= t.cat==='animals'?D.SPECIES[t.item] : t.cat==='facilities'?D.BUILDING[t.item]
|
||||||
|
: t.cat==='fences'?{name:t.item[0].toUpperCase()+t.item.slice(1)+' Fence'} : null;
|
||||||
|
$('tool-name').textContent=def?def.name:t.item;
|
||||||
|
const cost=def?(typeof def.cost==='number'?def.cost:null):null;
|
||||||
|
$('tool-cost').textContent=cost!=null?WK.fmtMoney(cost)+(t.cat==='animals'?' · needs suitable habitat':''):'paint';
|
||||||
|
document.querySelectorAll('#game').forEach(c=>c.classList.add('tooling'));
|
||||||
|
document.querySelectorAll('.pal-item').forEach(x=>x.classList.toggle('active',x.querySelector('.nm') && x.querySelector('.nm').textContent===($('tool-name').textContent)));
|
||||||
|
// re-mark active within current cat
|
||||||
|
UI.selectCatRefreshActive();
|
||||||
|
}else{
|
||||||
|
pal.classList.add('hidden');
|
||||||
|
info.classList.add('hidden');
|
||||||
|
document.getElementById('game').classList.remove('tooling');
|
||||||
|
document.querySelectorAll('.pal-item').forEach(x=>x.classList.remove('active'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
UI.selectCatRefreshActive=function(){
|
||||||
|
document.querySelectorAll('.pal-item').forEach(x=>x.classList.remove('active'));
|
||||||
|
};
|
||||||
|
UI.toggleBulldoze=function(){
|
||||||
|
UI.bulldoze=!UI.bulldoze;
|
||||||
|
if(UI.bulldoze){ UI.tool=null; $('tb-palette').classList.add('hidden'); $('tool-info').classList.add('hidden'); }
|
||||||
|
$('btn-bulldoze').classList.toggle('active',UI.bulldoze);
|
||||||
|
document.getElementById('game').classList.toggle('tooling',UI.bulldoze);
|
||||||
|
};
|
||||||
|
UI.cancelTool=function(){ UI.selectTool(null); UI.bulldoze=false; $('btn-bulldoze').classList.remove('active'); document.getElementById('game').classList.remove('tooling'); };
|
||||||
|
|
||||||
|
/* ---------------- HUD ---------------- */
|
||||||
|
UI.setSpeed=function(s){
|
||||||
|
UI.game.speed=s;
|
||||||
|
['spd-pause','spd-play','spd-fast','spd-ultra'].forEach(id=>$(id).classList.remove('active'));
|
||||||
|
$({'0':'spd-pause','1':'spd-play','2':'spd-fast','4':'spd-ultra'}[s]).classList.add('active');
|
||||||
|
};
|
||||||
|
UI.togglePause=function(){ UI.setSpeed(UI.game.speed===0?(UI._lastSpeed||1):(UI._lastSpeed=UI.game.speed,0)); };
|
||||||
|
|
||||||
|
UI.updateHUD=function(){
|
||||||
|
const g=UI.game, L=UI._last;
|
||||||
|
const set=(id,v)=>{ if(L[id]!==v){ L[id]=v; $(id).querySelector('b').textContent=v; } };
|
||||||
|
set('st-money',WK.fmtMoney(g.money));
|
||||||
|
set('st-guests',String(g.guests.length));
|
||||||
|
const joy=g.guests.length?Math.round(g.guests.reduce((s,x)=>s+x.joy,0)/g.guests.length):null;
|
||||||
|
set('st-joy',(joy!=null?joy+'%':'—'));
|
||||||
|
set('st-research',Math.floor(g.rp)+' RP');
|
||||||
|
set('st-stars',g.rating.toFixed(1)+'★');
|
||||||
|
$('st-day').textContent='Day '+g.day;
|
||||||
|
$('st-clock').textContent=WK.fmtClock(g.minutes);
|
||||||
|
// side panel throttle
|
||||||
|
UI._sideT++;
|
||||||
|
if(UI._sideT>30){ UI._sideT=0; UI.renderSide(); }
|
||||||
|
// selection follow
|
||||||
|
UI.trackSelection();
|
||||||
|
};
|
||||||
|
UI.flashStat=function(id){
|
||||||
|
const el=$(id==='money'?'st-money':id);
|
||||||
|
el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- side panel ---------------- */
|
||||||
|
UI.renderSide=function(){
|
||||||
|
const g=UI.game;
|
||||||
|
const c=$('sp-content');
|
||||||
|
document.querySelectorAll('.sp-tab').forEach(b=>b.classList.toggle('active',b.dataset.tab===UI._sideTab));
|
||||||
|
if(UI._sideTab==='objectives'){
|
||||||
|
let h='';
|
||||||
|
D.MISSIONS.forEach((m,i)=>{
|
||||||
|
const done=i<g.missionIdx, cur=i===g.missionIdx;
|
||||||
|
if(i>g.missionIdx&&!cur){}
|
||||||
|
if(done||cur){
|
||||||
|
const rw= typeof m.reward==='number'? WK.fmtMoney(m.reward)
|
||||||
|
: (m.reward.cash?WK.fmtMoney(m.reward.cash):'')+(m.reward.rp?(' +'+m.reward.rp+' RP'):'');
|
||||||
|
h+=`<div class="mission ${done?'done':''} ${cur?'current':''}">
|
||||||
|
<div class="m-title"><span>${m.label}</span></div>
|
||||||
|
${m.hint&&!done?`<div class="m-hint">${m.hint}</div>`:''}
|
||||||
|
${!done?`<div class="m-reward">Reward: ${rw}</div>`:''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if(g.missionIdx>=D.MISSIONS.length) h+='<div class="mission done"><div class="m-title"><span>All missions complete — Wildlife Legend! 🏆</span></div></div>';
|
||||||
|
c.innerHTML=h;
|
||||||
|
}else if(UI._sideTab==='research'){
|
||||||
|
const next=g.researchCost();
|
||||||
|
let h=`<div class="res-row"><span>Research points<small>Earn passively · Research Center boosts</small></span><b>${Math.floor(g.rp)} RP</b></div>`;
|
||||||
|
for(const t of D.RESEARCH_TIERS){
|
||||||
|
const owned=g.researchTier>=t.tier;
|
||||||
|
h+=`<div class="res-row">
|
||||||
|
<span>${t.label}<small>${owned?'Unlocked ✔':t.cost+' RP'}</small></span>
|
||||||
|
${owned?'':`<button class="btn" data-unlock="${t.tier}" ${(next!==t.cost)?'disabled':''}>Unlock</button>`}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
h+='<p style="font-size:11px;color:var(--ink-soft);margin:6px 2px;">New terrain, buildings & rare animals arrive with each tier.</p>';
|
||||||
|
c.innerHTML=h;
|
||||||
|
c.querySelectorAll('[data-unlock]').forEach(b=>{
|
||||||
|
b.onclick=()=>{ g.buyResearch(); UI.renderSide(); };
|
||||||
|
});
|
||||||
|
}else{
|
||||||
|
const P=g.ratingParts||{};
|
||||||
|
const pc=k=>Math.round((P[k]||0)*100);
|
||||||
|
let occ={};
|
||||||
|
for(const a of g.animals) occ[a.species]=(occ[a.species]||0)+1;
|
||||||
|
let occStr=Object.keys(occ).map(s=>`${D.SPECIES[s].name} ×${occ[s]}`).join(', ')||'None yet';
|
||||||
|
c.innerHTML=`
|
||||||
|
<div class="zoo-stat"><span>Rating</span><b>${g.rating.toFixed(1)}★</b></div>
|
||||||
|
<div class="zoo-stat"><span>Animal welfare</span><b>${pc('welfare')}%</b></div>
|
||||||
|
<div class="zoo-stat"><span>Guest joy</span><b>${pc('joy')}%</b></div>
|
||||||
|
<div class="zoo-stat"><span>Species variety</span><b>${pc('variety')}%</b></div>
|
||||||
|
<div class="zoo-stat"><span>Facilities</span><b>${pc('facilities')}%</b></div>
|
||||||
|
<div class="zoo-stat"><span>Decor</span><b>${pc('decor')}%</b></div>
|
||||||
|
<div class="zoo-stat"><span>Species kept</span><b>${g.speciesCount()}/15</b></div>
|
||||||
|
<div class="zoo-stat"><span>Animals</span><b>${g.animals.length}</b></div>
|
||||||
|
<div class="zoo-stat"><span>Staff</span><b>${g.staff.length}</b></div>
|
||||||
|
<div class="zoo-stat"><span>Guests today</span><b>${g.stats.guestsToday}</b></div>
|
||||||
|
<div class="zoo-stat"><span>Total revenue</span><b>${WK.fmtMoney(g.stats.revenueTotal)}</b></div>
|
||||||
|
<div style="margin-top:8px;font-size:12px;line-height:1.4;color:var(--ink-soft)"><b>Inhabitants:</b> ${occStr}</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
UI.switchTab=function(tab){ UI._sideTab=tab; UI.renderSide(); };
|
||||||
|
|
||||||
|
/* ---------------- selection popup ---------------- */
|
||||||
|
UI.showSelection=function(target){
|
||||||
|
UI._selTarget=target;
|
||||||
|
const el=$('sel-popup');
|
||||||
|
const g=UI.game;
|
||||||
|
let html='';
|
||||||
|
if(target.kind==='animal'){
|
||||||
|
const sp=D.SPECIES[target.species];
|
||||||
|
const score=Math.round(g.habitatScore(target));
|
||||||
|
html=`
|
||||||
|
<h3>${WK.Sprites.iconImg('animal:'+target.species,40)}<span>${target.name}</span></h3>
|
||||||
|
<div class="row"><span>${sp.name} · appeal ${'★'.repeat(Math.min(5,Math.ceil(sp.appeal/1.6)))}</span></div>
|
||||||
|
<div class="row"><span>Happiness</span></div>
|
||||||
|
<div class="bar"><i style="width:${target.happiness|0}%;background:${barColor(target.happiness)}"></i></div>
|
||||||
|
<div class="row"><span>Hunger</span></div>
|
||||||
|
<div class="bar"><i style="width:${target.hunger|0}%;background:${barColor(100-target.hunger)}"></i></div>
|
||||||
|
<div class="row"><span>Habitat match</span><b>${score}%</b></div>
|
||||||
|
${target.sick?'<div class="row"><b style="color:var(--red)">Sick — needs a vet!</b></div>':''}
|
||||||
|
<div class="btns"><button class="btn danger" data-sell="${target.id}">Rehome +${WK.fmtMoney(sp.cost*0.45)}</button></div>`;
|
||||||
|
}else if(target.kind==='guest'){
|
||||||
|
html=`
|
||||||
|
<h3><span>${target.kid?'Young guest':'Guest'}</span></h3>
|
||||||
|
<div class="row"><span>Joy</span></div>
|
||||||
|
<div class="bar"><i style="width:${target.joy|0}%;background:${barColor(target.joy)}"></i></div>
|
||||||
|
${target.wantsRestroom?'<div class="row"><b style="color:var(--orange)">Looking for a restroom…</b></div>':''}`;
|
||||||
|
}else if(target.kind==='staff'){
|
||||||
|
html=`
|
||||||
|
<h3><span>${target.role==='keeper'?'Keeper':'Vet'}</span></h3>
|
||||||
|
<div class="row"><span>${target.workT>0?'Working…':target.job?'On the way!':'Patrolling the zoo'}</span></div>`;
|
||||||
|
}else if(target.w&&target.h){ // building
|
||||||
|
const def=D.BUILDING[target.type];
|
||||||
|
html=`
|
||||||
|
<h3>${WK.Sprites.iconImg('facility:'+target.type,40)}<span>${def.name}</span></h3>
|
||||||
|
${def.desc?`<div class="row"><span>${def.desc}</span></div>`:''}
|
||||||
|
${def.income?`<div class="row"><span>Avg spend per guest</span><b>${WK.fmtMoney(def.income)}</b></div>`:''}
|
||||||
|
${def.rp?`<div class="row"><span>Generates</span><b>+${def.rp} RP/min</b></div>`:''}
|
||||||
|
<div class="btns"><button class="btn danger" data-demo="${target.id}">Demolish +${WK.fmtMoney(def.cost*0.5)}</button></div>`;
|
||||||
|
}else if(target.kind==='habitat'){
|
||||||
|
const reg=g.world.regions.find(r=>r.id===target.regionId);
|
||||||
|
const anims=g.animals.filter(a=>a.regionId===target.regionId);
|
||||||
|
const occ={};
|
||||||
|
for(const a of anims) occ[a.name]=D.SPECIES[a.species].name;
|
||||||
|
const occStr=anims.map(a=>a.name+' ('+D.SPECIES[a.species].name+')').join('<br>')||'<i>Empty habitat</i>';
|
||||||
|
html=`
|
||||||
|
<h3><span>Habitat</span></h3>
|
||||||
|
<div class="row"><span>Area</span><b>${reg.area} tiles</b></div>
|
||||||
|
<div class="row"><span>Inhabitants</span></div>
|
||||||
|
<div style="font-size:12px;margin:2px 0 4px">${occStr}</div>`;
|
||||||
|
}else return;
|
||||||
|
el.innerHTML=html;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
const sellBtn=el.querySelector('[data-sell]');
|
||||||
|
if(sellBtn) sellBtn.onclick=()=>{
|
||||||
|
const a=g.animals.find(x=>x.id==sellBtn.dataset.sell);
|
||||||
|
if(a) g.sellAnimal(a);
|
||||||
|
};
|
||||||
|
const demoBtn=el.querySelector('[data-demo]');
|
||||||
|
if(demoBtn) demoBtn.onclick=()=>{
|
||||||
|
const b=g.world.buildings.find(x=>x.id==demoBtn.dataset.demo);
|
||||||
|
if(b) g.demolishBuilding(b);
|
||||||
|
};
|
||||||
|
UI.positionSelection();
|
||||||
|
};
|
||||||
|
function barColor(v){ return v>60?'#63b34c':v>30?'#ffc93c':'#e85d5d'; }
|
||||||
|
UI.positionSelection=function(){
|
||||||
|
const el=$('sel-popup'), t=UI._selTarget;
|
||||||
|
if(!el||!t||el.classList.contains('hidden'))return;
|
||||||
|
const p=UI.cam.project(t.x,t.y);
|
||||||
|
let x=p.x+24,y=p.y-70;
|
||||||
|
x=WK.clamp(x,8,UI.cam.w-el.offsetWidth-8);
|
||||||
|
y=WK.clamp(y,60,UI.cam.h-el.offsetHeight-90);
|
||||||
|
el.style.left=x+'px'; el.style.top=y+'px';
|
||||||
|
};
|
||||||
|
UI.trackSelection=function(){
|
||||||
|
const t=UI._selTarget;
|
||||||
|
if(!t)return;
|
||||||
|
if(t.kind==='animal'&&!UI.game.animals.includes(t)){ UI.hideSelection(); return; }
|
||||||
|
if(t.kind==='guest'&&!UI.game.guests.includes(t)){ UI.hideSelection(); return; }
|
||||||
|
if(t.kind==='staff'&&!UI.game.staff.includes(t)){ UI.hideSelection(); return; }
|
||||||
|
if(t.w&&!UI.game.world.buildings.includes(t)){ UI.hideSelection(); return; }
|
||||||
|
UI.positionSelection();
|
||||||
|
};
|
||||||
|
UI.hideSelection=function(){
|
||||||
|
UI._selTarget=null;
|
||||||
|
$('sel-popup').classList.add('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- toasts ---------------- */
|
||||||
|
UI.toast=function(msg,type){
|
||||||
|
const box=$('toasts');
|
||||||
|
while(box.children.length>=4) box.firstChild.remove();
|
||||||
|
const t=document.createElement('div');
|
||||||
|
t.className='toast '+(type||'');
|
||||||
|
t.textContent=msg;
|
||||||
|
box.appendChild(t);
|
||||||
|
setTimeout(()=>t.classList.add('fade'),3200);
|
||||||
|
setTimeout(()=>t.remove(),3700);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- modals ---------------- */
|
||||||
|
UI.modal=function(inner,opts){
|
||||||
|
opts=opts||{};
|
||||||
|
const root=$('modal-root');
|
||||||
|
root.innerHTML=`<div class="modal-backdrop"><div class="modal panel">${inner}</div></div>`;
|
||||||
|
root.firstChild.addEventListener('pointerdown',e=>{ if(e.target===root.firstChild&&!opts.sticky) UI.closeModal(); });
|
||||||
|
return root.querySelector('.modal');
|
||||||
|
};
|
||||||
|
UI.closeModal=function(){ $('modal-root').innerHTML=''; };
|
||||||
|
|
||||||
|
UI.settingsModal=function(){
|
||||||
|
const g=UI.game;
|
||||||
|
const m=UI.modal(`
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<div class="set-row"><span>Sound effects</span><button class="btn" id="set-sfx">${WK.AudioSys.enabled?'On':'Off'}</button></div>
|
||||||
|
<div class="set-row"><span>Music</span><button class="btn" id="set-music">${WK.AudioSys.musicOn?'On':'Off'}</button></div>
|
||||||
|
<div class="set-row"><span>Save game</span><button class="btn" id="set-save">Save now</button></div>
|
||||||
|
<div class="set-row"><span>New zoo</span><button class="btn danger" id="set-new">Start over</button></div>
|
||||||
|
<div class="modal-btns"><button class="btn primary" id="set-close">Done</button></div>`);
|
||||||
|
m.querySelector('#set-sfx').onclick=e=>{ WK.AudioSys.toggleSound(); e.target.textContent=WK.AudioSys.enabled?'On':'Off'; $('btn-sound').textContent=WK.AudioSys.enabled?'🔊':'🔇'; };
|
||||||
|
m.querySelector('#set-music').onclick=e=>{ WK.AudioSys.ensure(); WK.AudioSys.toggleMusic(); e.target.textContent=WK.AudioSys.musicOn?'On':'Off'; };
|
||||||
|
m.querySelector('#set-save').onclick=()=>{ g.save(); UI.toast('Zoo saved!','good'); };
|
||||||
|
m.querySelector('#set-new').onclick=()=>{
|
||||||
|
UI.modal(`<h2>Start a new zoo?</h2><p>Your current zoo and progress will be lost.</p>
|
||||||
|
<div class="modal-btns"><button class="btn" id="nc">Cancel</button><button class="btn danger" id="ny">Yes, start over</button></div>`,{sticky:true});
|
||||||
|
$('nc').onclick=()=>UI.settingsModal();
|
||||||
|
$('ny').onclick=()=>{ Game_restart(); };
|
||||||
|
};
|
||||||
|
m.querySelector('#set-close').onclick=()=>UI.closeModal();
|
||||||
|
};
|
||||||
|
function Game_restart(){ /* replaced by main.js */ }
|
||||||
|
|
||||||
|
/* how-to */
|
||||||
|
UI.howtoModal=function(fromMenu){
|
||||||
|
UI.modal(`
|
||||||
|
<h2>How to Play</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>Pan</b> by dragging (right-click / empty hand / one finger), <b>zoom</b> with the wheel or pinch.</li>
|
||||||
|
<li><b>Paths first!</b> Guests only walk on paths. Connect everything to your entrance.</li>
|
||||||
|
<li><b>Habitats:</b> fence an area completely, paint matching terrain inside, then adopt animals into it. Each species has favorite biomes and needs space.</li>
|
||||||
|
<li><b>Keepers feed animals</b> — hire one with a Keeper Hut. Add a Keeper Gate so they can walk in.</li>
|
||||||
|
<li><b>Guests need</b> restrooms, food stands, benches and beautiful decor. Happy guests pay more entry and spend more.</li>
|
||||||
|
<li><b>Research</b> unlocks rare species and fancy buildings. A Research Center speeds it up.</li>
|
||||||
|
<li>Watch the <b>Missions</b> panel — rewards guide your way to a 5★ zoo!</li>
|
||||||
|
</ul>
|
||||||
|
<p style="font-size:12px;color:var(--ink-soft)">Hotkeys: <span class="kbd">Space</span> pause · <span class="kbd">1–3</span> speed · <span class="kbd">X</span> bulldoze · <span class="kbd">Esc</span> cancel tool</p>
|
||||||
|
<div class="modal-btns"><button class="btn primary" id="how-ok">Let's build!</button></div>`);
|
||||||
|
$('how-ok').onclick=()=>{ UI.closeModal(); if(fromMenu&&UI._onHowClose) UI._onHowClose(); };
|
||||||
|
};
|
||||||
|
UI.confirmNewFromMenu=function(){ /* main.js overrides menu flows */ };
|
||||||
|
|
||||||
|
/* ---------------- bindings ---------------- */
|
||||||
|
UI.bind=function(){
|
||||||
|
$('spd-pause').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(0);};
|
||||||
|
$('spd-play').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(1);};
|
||||||
|
$('spd-fast').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(2);};
|
||||||
|
$('spd-ultra').onclick=()=>{WK.AudioSys.sfx('click');UI.setSpeed(4);};
|
||||||
|
$('btn-sound').onclick=()=>{ WK.AudioSys.ensure(); const on=WK.AudioSys.toggleSound(); $('btn-sound').textContent=on?'🔊':'🔇'; };
|
||||||
|
$('btn-settings').onclick=()=>{ WK.AudioSys.sfx('click'); UI.settingsModal(); };
|
||||||
|
$('btn-bulldoze').onclick=()=>{ WK.AudioSys.sfx('click'); UI.toggleBulldoze(); };
|
||||||
|
document.querySelectorAll('.sp-tab').forEach(b=>b.onclick=()=>{ UI.switchTab(b.dataset.tab); });
|
||||||
|
$('sp-toggle').onclick=()=>{
|
||||||
|
const sp=$('side-panel');
|
||||||
|
sp.classList.toggle('hidden');
|
||||||
|
};
|
||||||
|
window.Game_restart = function(){
|
||||||
|
WK.Main.startNew();
|
||||||
|
UI.closeModal();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})();
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Wildlife Kingdom — js/util.js
|
||||||
|
Math, RNG, color & canvas helpers. Exposes global WK.
|
||||||
|
============================================================ */
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const WK = (window.WK = window.WK || {});
|
||||||
|
|
||||||
|
WK.TILE_W = 64; // iso diamond width
|
||||||
|
WK.TILE_H = 32; // iso diamond height
|
||||||
|
WK.HW = WK.TILE_W / 2;
|
||||||
|
WK.HH = WK.TILE_H / 2;
|
||||||
|
|
||||||
|
/* ---- math ---- */
|
||||||
|
WK.clamp = (v,a,b)=> v<a?a:(v>b?b:v);
|
||||||
|
WK.lerp = (a,b,t)=> a+(b-a)*t;
|
||||||
|
WK.dist2 = (ax,ay,bx,by)=>{const dx=ax-bx,dy=ay-by;return dx*dx+dy*dy;};
|
||||||
|
WK.dist = (ax,ay,bx,by)=> Math.hypot(ax-bx,ay-by);
|
||||||
|
WK.TAU = Math.PI*2;
|
||||||
|
WK.easeOutCubic = t=>1-Math.pow(1-t,3);
|
||||||
|
WK.easeInOut = t=> t<0.5 ? 2*t*t : 1-Math.pow(-2*t+2,2)/2;
|
||||||
|
|
||||||
|
/* deterministic RNG */
|
||||||
|
WK.mulberry32 = function(seed){
|
||||||
|
let a = seed>>>0;
|
||||||
|
return function(){
|
||||||
|
a |= 0; a = (a + 0x6D2B79F5)|0;
|
||||||
|
let t = Math.imul(a ^ (a>>>15), 1|a);
|
||||||
|
t = (t + Math.imul(t ^ (t>>>7), 61|t)) ^ t;
|
||||||
|
return ((t ^ (t>>>14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
WK.hash2 = function(x,y,s){
|
||||||
|
let h = (x*374761393 + y*668265263) ^ (s|0)*1442695041;
|
||||||
|
h = (h ^ (h>>>13)) >>> 0;
|
||||||
|
h = Math.imul(h, 1274126177) >>> 0;
|
||||||
|
return ((h ^ (h>>>16)) % 100000)/100000; // 0..1
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- formatting ---- */
|
||||||
|
WK.fmtMoney = n=>{
|
||||||
|
const neg = n<0; n = Math.round(Math.abs(n));
|
||||||
|
let s = String(n).replace(/\B(?=(\d{3})+(?!\d))/g,',');
|
||||||
|
return (neg?'-$':'$')+s;
|
||||||
|
};
|
||||||
|
WK.fmtClock = min=>{ // minutes in day
|
||||||
|
min = ((min%1440)+1440)%1440;
|
||||||
|
const h=Math.floor(min/60), m=Math.floor(min%60);
|
||||||
|
return String(h).padStart(2,'0')+':'+String(m).padStart(2,'0');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- colors ---- */
|
||||||
|
WK.hexToRgb = hex=>{
|
||||||
|
hex = hex.replace('#','');
|
||||||
|
if(hex.length===3) hex = hex.split('').map(c=>c+c).join('');
|
||||||
|
const n=parseInt(hex,16);
|
||||||
|
return [(n>>16)&255,(n>>8)&255,n&255];
|
||||||
|
};
|
||||||
|
WK.shade = (hex,amt)=>{ // amt -1..1 (negative darker)
|
||||||
|
const [r,g,b]=WK.hexToRgb(hex);
|
||||||
|
const f=c=> WK.clamp(Math.round(amt>=0 ? c+(255-c)*amt : c*(1+amt)),0,255);
|
||||||
|
return `rgb(${f(r)},${f(g)},${f(b)})`;
|
||||||
|
};
|
||||||
|
WK.rgba = (hex,a)=>{
|
||||||
|
const [r,g,b]=WK.hexToRgb(hex); return `rgba(${r},${g},${b},${a})`;
|
||||||
|
};
|
||||||
|
WK.mix = (h1,h2,t)=>{
|
||||||
|
const a=WK.hexToRgb(h1), b=WK.hexToRgb(h2);
|
||||||
|
return `rgb(${Math.round(WK.lerp(a[0],b[0],t))},${Math.round(WK.lerp(a[1],b[1],t))},${Math.round(WK.lerp(a[2],b[2],t))})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- canvas helpers ---- */
|
||||||
|
WK.mkCanvas = (w,h)=>{ const c=document.createElement('canvas'); c.width=Math.max(1,Math.ceil(w)); c.height=Math.max(1,Math.ceil(h)); return c; };
|
||||||
|
|
||||||
|
WK.rr = function(ctx,x,y,w,h,r){ // rounded rect path
|
||||||
|
if(typeof r==='number') r={tl:r,tr:r,br:r,bl:r};
|
||||||
|
else r = Object.assign({tl:0,tr:0,br:0,bl:0},r);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x+r.tl,y);
|
||||||
|
ctx.lineTo(x+w-r.tr,y); ctx.arcTo(x+w,y,x+w,y+r.tr,r.tr);
|
||||||
|
ctx.lineTo(x+w,y+h-r.br); ctx.arcTo(x+w,y+h,x+w-r.br,y+h,r.br);
|
||||||
|
ctx.lineTo(x+r.bl,y+h); ctx.arcTo(x,y+h,x,y+h-r.bl,r.bl);
|
||||||
|
ctx.lineTo(x,y+r.tl); ctx.arcTo(x,y,x+r.tl,y,r.tl);
|
||||||
|
ctx.closePath();
|
||||||
|
};
|
||||||
|
|
||||||
|
WK.softShadow = function(ctx, blur, color, offY){
|
||||||
|
ctx.shadowBlur=blur; ctx.shadowColor=color||'rgba(70,45,10,.35)';
|
||||||
|
ctx.shadowOffsetY = offY==null?2:offY;
|
||||||
|
};
|
||||||
|
WK.clearShadow = ctx=>{ ctx.shadowBlur=0; ctx.shadowColor='transparent'; ctx.shadowOffsetY=0; };
|
||||||
|
|
||||||
|
/* ellipse blob with vertical gradient for cute volume */
|
||||||
|
WK.blob = function(ctx,x,y,rx,ry,color,lighten){
|
||||||
|
const g = ctx.createRadialGradient(x-rx*0.35,y-ry*0.55,ry*0.15,x,y,Math.max(rx,ry));
|
||||||
|
g.addColorStop(0, lighten || WK.shade(color,0.28));
|
||||||
|
g.addColorStop(1, color);
|
||||||
|
ctx.fillStyle=g;
|
||||||
|
ctx.beginPath(); ctx.ellipse(x,y,rx,ry,0,0,WK.TAU); ctx.fill();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* simple eye: white + pupil + shine */
|
||||||
|
WK.eye = function(ctx,x,y,r,opt){
|
||||||
|
opt=opt||{};
|
||||||
|
if(opt.closed){
|
||||||
|
ctx.strokeStyle=opt.lidColor||'#3a2c20'; ctx.lineWidth=Math.max(1.5,r*0.5);
|
||||||
|
ctx.beginPath(); ctx.moveTo(x-r,y*0.98); ctx.quadraticCurveTo(x,y+r*0.9,x+r,y*0.98); ctx.stroke();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.fillStyle='#fff';
|
||||||
|
ctx.beginPath(); ctx.ellipse(x,y,r,r*(opt.tall||1),0,0,WK.TAU); ctx.fill();
|
||||||
|
const px = x + (opt.lookX||0)*r*0.28, py = y + (opt.lookY||0.08)*r*0.4;
|
||||||
|
ctx.fillStyle=opt.pupil||'#33261a';
|
||||||
|
ctx.beginPath(); ctx.ellipse(px,py,r*0.52,r*0.62*(opt.tall||1),0,0,WK.TAU); ctx.fill();
|
||||||
|
ctx.fillStyle='#fff';
|
||||||
|
ctx.beginPath(); ctx.arc(px-r*0.18,py-r*0.24,r*0.2,0,WK.TAU); ctx.fill();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* tiny smile / open mouth */
|
||||||
|
WK.mouth = function(ctx,x,y,w,open,color){
|
||||||
|
ctx.fillStyle = color||'#7c4a3a';
|
||||||
|
if(open>0.05){
|
||||||
|
ctx.beginPath(); ctx.ellipse(x,y,w,w*(0.5+open),0,0,Math.PI); ctx.fill();
|
||||||
|
ctx.fillStyle='#e8756f';
|
||||||
|
ctx.beginPath(); ctx.ellipse(x,y+w*(0.35+open*0.5),w*0.55,w*0.4*open,0,0,Math.PI); ctx.fill();
|
||||||
|
}else{
|
||||||
|
ctx.strokeStyle=color||'#7c4a3a'; ctx.lineWidth=Math.max(1.2,w*0.22); ctx.lineCap='round';
|
||||||
|
ctx.beginPath(); ctx.moveTo(x-w,y-w*0.25); ctx.quadraticCurveTo(x,y+w*0.65,x+w,y-w*0.25); ctx.stroke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
WK.blush = function(ctx,x,y,r,color,alpha){
|
||||||
|
ctx.globalAlpha = alpha==null?0.35:alpha;
|
||||||
|
ctx.fillStyle=color||'#ff9d8a';
|
||||||
|
ctx.beginPath(); ctx.ellipse(x,y,r,r*0.62,0,0,WK.TAU); ctx.fill();
|
||||||
|
ctx.globalAlpha=1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* object pool-ish array clear */
|
||||||
|
WK.clearArr = a=>{ a.length=0; return a; };
|
||||||
|
|
||||||
|
/* pick weighted {id:w} map or [ [item,w],... ] */
|
||||||
|
WK.weightedPick = function(entries,rnd){
|
||||||
|
rnd = rnd||Math.random;
|
||||||
|
let tot=0; for(const e of entries) tot+=e.w;
|
||||||
|
let r=rnd()*tot;
|
||||||
|
for(const e of entries){ r-=e.w; if(r<=0) return e; }
|
||||||
|
return entries[entries.length-1];
|
||||||
|
};
|
||||||
|
})();
|
||||||
+355
@@ -0,0 +1,355 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Wildlife Kingdom — js/world.js
|
||||||
|
Tile grid · terrain/paths/fences/objects · buildings ·
|
||||||
|
habitat region detection · pathfinding (visitors/staff/animals)
|
||||||
|
============================================================ */
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const WK = window.WK;
|
||||||
|
const D = WK.Data;
|
||||||
|
|
||||||
|
WK.GIDS = Object.keys(D.GROUND);
|
||||||
|
WK.PIDS = Object.keys(D.PATH);
|
||||||
|
WK.NIDS = Object.keys(D.NATURE);
|
||||||
|
|
||||||
|
class World{
|
||||||
|
constructor(cols,rows){
|
||||||
|
this.cols=cols; this.rows=rows;
|
||||||
|
const n=cols*rows;
|
||||||
|
this.ground=new Uint8Array(n);
|
||||||
|
this.path=new Uint8Array(n);
|
||||||
|
this.obj=new Uint8Array(n);
|
||||||
|
this.objVar=new Uint8Array(n);
|
||||||
|
this.fenceS=new Uint8Array(n); // edge between (x,y)-(x,y+1)
|
||||||
|
this.fenceE=new Uint8Array(n); // edge between (x,y)-(x+1,y)
|
||||||
|
this.occ=new Int16Array(n); // building index or -1
|
||||||
|
this.region=new Int16Array(n); // habitat region id or -1
|
||||||
|
this.occ.fill(-1);
|
||||||
|
this.region.fill(-1);
|
||||||
|
this.buildings=[];
|
||||||
|
this.regions=[]; // [{id,tiles,area,openEdges,hasPath}]
|
||||||
|
this.regionsDirty=true;
|
||||||
|
this.terrainDirty=true;
|
||||||
|
this.viewCache=null;
|
||||||
|
this.nextBid=1;
|
||||||
|
}
|
||||||
|
idx(x,y){ return y*this.cols+x; }
|
||||||
|
inBounds(x,y){ return x>=0&&y>=0&&x<this.cols&&y<this.rows; }
|
||||||
|
gid(x,y){ return WK.GIDS[this.ground[this.idx(x,y)]]; }
|
||||||
|
pid(x,y){ const p=this.path[this.idx(x,y)]; return p?WK.PIDS[p-1]:null; }
|
||||||
|
oid(x,y){ const o=this.obj[this.idx(x,y)]; return o?WK.NIDS[o-1]:null; }
|
||||||
|
|
||||||
|
/* ---------- raw setters (cost/validation in Game) ---------- */
|
||||||
|
setGround(x,y,gid){
|
||||||
|
if(!this.inBounds(x,y)) return false;
|
||||||
|
this.ground[this.idx(x,y)]=WK.GIDS.indexOf(gid);
|
||||||
|
this.path[this.idx(x,y)]=0; // path destroyed by terrain change
|
||||||
|
this.terrainDirty=true; this.touch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setPath(x,y,pid){
|
||||||
|
if(!this.inBounds(x,y)) return false;
|
||||||
|
const i=this.idx(x,y);
|
||||||
|
if(D.GROUND[this.gid(x,y)].water||this.occ[i]>=0) return false;
|
||||||
|
this.path[i]=pid?(WK.PIDS.indexOf(pid)+1):0;
|
||||||
|
if(!pid&&this.obj[i]){} // keep objects
|
||||||
|
this.terrainDirty=true; this.touch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setObj(x,y,oid,varr){
|
||||||
|
if(!this.inBounds(x,y)) return false;
|
||||||
|
const i=this.idx(x,y);
|
||||||
|
if(this.occ[i]>=0) return false;
|
||||||
|
this.obj[i]=oid?(WK.NIDS.indexOf(oid)+1):0;
|
||||||
|
this.objVar[i]=varr||0;
|
||||||
|
this.terrainDirty=true; this.touch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setFence(x,y,edge,kind){
|
||||||
|
if(!this.inBounds(x,y)) return false;
|
||||||
|
const i=this.idx(x,y);
|
||||||
|
if(edge==='S') this.fenceS[i]=kind; else this.fenceE[i]=kind;
|
||||||
|
this.terrainDirty=true; this.touch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
touch(){ this.ver=(this.ver||0)+1; this.regionsDirty=true; this.viewCache=null; }
|
||||||
|
|
||||||
|
fenceBetween(ax,ay,bx,by){
|
||||||
|
if(bx===ax&&by===ay+1) return this.fenceS[this.idx(ax,ay)];
|
||||||
|
if(bx===ax&&by===ay-1) return this.fenceS[this.idx(bx,by)];
|
||||||
|
if(by===ay&&bx===ax+1) return this.fenceE[this.idx(ax,ay)];
|
||||||
|
if(by===ay&&bx===ax-1) return this.fenceE[this.idx(bx,by)];
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- buildings ---------- */
|
||||||
|
canPlaceBuilding(bx,by,w,h){
|
||||||
|
for(let j=0;j<h;j++)for(let i=0;i<w;i++){
|
||||||
|
const x=bx+i,y=by+j;
|
||||||
|
if(!this.inBounds(x,y)) return false;
|
||||||
|
const k=this.idx(x,y);
|
||||||
|
if(this.occ[k]>=0) return false;
|
||||||
|
if(D.GROUND[this.gid(x,y)].water) return false;
|
||||||
|
if(this.obj[k]){ const o=D.NATURE[WK.NIDS[this.obj[k]-1]]; if(o.block) return false; }
|
||||||
|
if(this.path[k]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
placeBuilding(type,x,y){
|
||||||
|
const def=D.BUILDING[type];
|
||||||
|
if(!this.canPlaceBuilding(x,y,def.w,def.h)) return null;
|
||||||
|
const b={id:this.nextBid++, type, x, y, w:def.w, h:def.h};
|
||||||
|
this.buildings.push(b);
|
||||||
|
for(let j=0;j<def.h;j++)for(let i=0;i<def.w;i++) this.occ[this.idx(x+i,y+j)]=b.id;
|
||||||
|
this.terrainDirty=true; this.touch();
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
removeBuildingById(id){
|
||||||
|
const bi=this.buildings.findIndex(b=>b.id===id);
|
||||||
|
if(bi<0) return null;
|
||||||
|
const b=this.buildings[bi];
|
||||||
|
for(let j=0;j<b.h;j++)for(let i=0;i<b.w;i++){
|
||||||
|
const k=this.idx(b.x+i,b.y+j);
|
||||||
|
if(this.occ[k]===b.id) this.occ[k]=-1;
|
||||||
|
}
|
||||||
|
this.buildings.splice(bi,1);
|
||||||
|
this.terrainDirty=true; this.touch();
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
buildingAt(x,y){
|
||||||
|
const id=this.occ[this.idx(x,y)];
|
||||||
|
return id>=0 ? this.buildings.find(b=>b.id===id) : null;
|
||||||
|
}
|
||||||
|
/* best guest-facing door tile: prefer front-south perimeter on a path */
|
||||||
|
doorTile(b){
|
||||||
|
const cands=[];
|
||||||
|
for(let i=0;i<b.w;i++){
|
||||||
|
cands.push([b.x+i,b.y+b.h]); // south side
|
||||||
|
}
|
||||||
|
for(let j=b.h-1;j>=0;j--) cands.push([b.x+b.w,b.y+j]); // east side
|
||||||
|
for(const [x,y] of cands){
|
||||||
|
if(this.inBounds(x,y)&&this.path[this.idx(x,y)]) return [x,y];
|
||||||
|
}
|
||||||
|
for(const [x,y] of cands) if(this.inBounds(x,y)&&!D.GROUND[this.gid(x,y)].water&&!this.occ[this.idx(x,y)]) return [x,y];
|
||||||
|
return [b.x,b.y+b.h];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- habitat regions ---------- */
|
||||||
|
computeRegions(){
|
||||||
|
const {cols,rows}=this;
|
||||||
|
this.region.fill(-1);
|
||||||
|
this.regions=[];
|
||||||
|
let id=0;
|
||||||
|
for(let y=0;y<rows;y++)for(let x=0;x<cols;x++){
|
||||||
|
const start=this.idx(x,y);
|
||||||
|
if(this.region[start]!==-1) continue;
|
||||||
|
if(this.occ[start]>=0) continue;
|
||||||
|
id++;
|
||||||
|
const tiles=[];
|
||||||
|
let openEdges=0, hasPath=false;
|
||||||
|
const stack=[start];
|
||||||
|
this.region[start]=id;
|
||||||
|
while(stack.length){
|
||||||
|
const k=stack.pop();
|
||||||
|
tiles.push(k);
|
||||||
|
const cx=k%cols, cy=(k/cols)|0;
|
||||||
|
if(this.path[k]) hasPath=true;
|
||||||
|
const nb=[[cx+1,cy],[cx-1,cy],[cx,cy+1],[cx,cy-1]];
|
||||||
|
for(let d=0;d<4;d++){
|
||||||
|
const nx=nb[d][0], ny=nb[d][1];
|
||||||
|
if(!this.inBounds(nx,ny)){ continue; } // map border = wall
|
||||||
|
const ni=this.idx(nx,ny);
|
||||||
|
if(this.occ[ni]>=0){ continue; } // building = wall
|
||||||
|
if(this.fenceBetween(cx,cy,nx,ny)){ continue; } // fence = wall
|
||||||
|
if(this.region[ni]===-1){ this.region[ni]=id; stack.push(ni); }
|
||||||
|
}
|
||||||
|
// open-edge audit (no fence to the outside world)
|
||||||
|
for(let d=0;d<4;d++){
|
||||||
|
const nx=nb[d][0], ny=nb[d][1];
|
||||||
|
if(!this.inBounds(nx,ny)) continue;
|
||||||
|
const ni=this.idx(nx,ny);
|
||||||
|
if(this.region[ni]===id) continue;
|
||||||
|
if(this.occ[ni]>=0) continue;
|
||||||
|
if(this.fenceBetween(cx,cy,nx,ny)) continue;
|
||||||
|
openEdges++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.regions.push({id, tiles, area:tiles.length, openEdges, hasPath});
|
||||||
|
}
|
||||||
|
this.regionsDirty=false;
|
||||||
|
this.viewCache=null;
|
||||||
|
return this.regions;
|
||||||
|
}
|
||||||
|
ensureRegions(){ if(this.regionsDirty) this.computeRegions(); }
|
||||||
|
regionOf(x,y){ return this.region[this.idx(x,y)]; }
|
||||||
|
validHabitats(){
|
||||||
|
this.ensureRegions();
|
||||||
|
return this.regions.filter(r=>r.openEdges===0&&!r.hasPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* viewpoints: path tiles adjacent to a habitat region */
|
||||||
|
viewpoints(regionId){
|
||||||
|
this.ensureRegions();
|
||||||
|
if(!this.viewCache) this.viewCache=new Map();
|
||||||
|
let v=this.viewCache.get(regionId);
|
||||||
|
if(v) return v;
|
||||||
|
v=[];
|
||||||
|
const seen=new Set();
|
||||||
|
const reg=this.regions.find(r=>r.id===regionId);
|
||||||
|
if(reg){
|
||||||
|
for(const k of reg.tiles){
|
||||||
|
const cx=k%this.cols, cy=(k/this.cols)|0;
|
||||||
|
for(const [nx,ny] of [[cx+1,cy],[cx-1,cy],[cx,cy+1],[cx,cy-1]]){
|
||||||
|
if(!this.inBounds(nx,ny)) continue;
|
||||||
|
const ni=this.idx(nx,ny);
|
||||||
|
if(this.region[ni]!==regionId && this.path[ni] && !seen.has(ni)){
|
||||||
|
seen.add(ni); v.push([nx,ny]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.viewCache.set(regionId,v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- pathfinding ---------- */
|
||||||
|
static NB4=[[1,0],[-1,0],[0,1],[0,-1]];
|
||||||
|
findPathVisitor(sx,sy,tx,ty){
|
||||||
|
return this._bfs(sx,sy,tx,ty,(x,y)=>{
|
||||||
|
if(!this.inBounds(x,y)||!this.path[this.idx(x,y)]) return false;
|
||||||
|
return true;
|
||||||
|
},(ax,ay,bx,by)=> this.fenceBetween(ax,ay,bx,by)!==0 ); // visitors blocked by ANY fence incl gates
|
||||||
|
}
|
||||||
|
findPathKeeper(sx,sy,tx,ty){
|
||||||
|
// Dijkstra-lite: fences climbable at high cost, gates cheap
|
||||||
|
const cols=this.cols, n=cols*this.rows;
|
||||||
|
const dist=new Float32Array(n).fill(Infinity);
|
||||||
|
const prev=new Int32Array(n).fill(-1);
|
||||||
|
const si=this.idx(sx,sy), ti=this.idx(tx,ty);
|
||||||
|
dist[si]=0;
|
||||||
|
const open=[[0,si]];
|
||||||
|
const passable=(x,y)=>{
|
||||||
|
if(!this.inBounds(x,y)) return false;
|
||||||
|
const k=this.idx(x,y);
|
||||||
|
if(this.occ[k]>=0) return false;
|
||||||
|
const g=D.GROUND[this.gid(x,y)];
|
||||||
|
if(g.water===2) return false;
|
||||||
|
if(this.obj[k]){ const o=D.NATURE[WK.NIDS[this.obj[k]-1]]; if(o.block) return false; }
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if(!passable(tx,ty)||!passable(sx,sy)) return null;
|
||||||
|
while(open.length){
|
||||||
|
let bi=0; for(let i=1;i<open.length;i++) if(open[i][0]<open[bi][0]) bi=i;
|
||||||
|
const [d,k]=open.splice(bi,1)[0];
|
||||||
|
if(k===ti) break;
|
||||||
|
if(d>dist[k]) continue;
|
||||||
|
const cx=k%cols, cy=(k/cols)|0;
|
||||||
|
for(const [dx,dy] of World.NB4){
|
||||||
|
const nx=cx+dx, ny=cy+dy;
|
||||||
|
if(!passable(nx,ny)) continue;
|
||||||
|
const ni=this.idx(nx,ny);
|
||||||
|
let c=1;
|
||||||
|
const f=this.fenceBetween(cx,cy,nx,ny);
|
||||||
|
if(f===1||f===2) c+=9;
|
||||||
|
if(f===3) c+=0.5;
|
||||||
|
if(D.GROUND[this.gid(nx,ny)].water===1) c+=2;
|
||||||
|
const nd=d+c;
|
||||||
|
if(nd<dist[ni]-1e-6){ dist[ni]=nd; prev[ni]=k; open.push([nd,ni]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(prev[ti]===-1&&ti!==si) return null;
|
||||||
|
const out=[];
|
||||||
|
let cur=ti;
|
||||||
|
while(cur!==-1&&cur!==si){ out.push([cur%cols,(cur/cols)|0]); cur=prev[cur]; }
|
||||||
|
out.reverse();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
_bfs(sx,sy,tx,ty,passable,blockedEdge){
|
||||||
|
if(!passable(sx,sy)||!passable(tx,ty)) return null;
|
||||||
|
const cols=this.cols, n=cols*this.rows;
|
||||||
|
const prev=new Int32Array(n).fill(-2);
|
||||||
|
const si=this.idx(sx,sy), ti=this.idx(tx,ty);
|
||||||
|
prev[si]=-1;
|
||||||
|
let q=[si];
|
||||||
|
while(q.length){
|
||||||
|
const nq=[];
|
||||||
|
for(const k of q){
|
||||||
|
if(k===ti){
|
||||||
|
const out=[]; let cur=ti;
|
||||||
|
while(cur!==-1){ out.push([cur%cols,(cur/cols)|0]); cur=prev[cur]; }
|
||||||
|
out.reverse(); out.shift();
|
||||||
|
return out.length?out:[[tx,ty]];
|
||||||
|
}
|
||||||
|
const cx=k%cols, cy=(k/cols)|0;
|
||||||
|
for(const [dx,dy] of World.NB4){
|
||||||
|
const nx=cx+dx, ny=cy+dy;
|
||||||
|
if(!passable(nx,ny)) continue;
|
||||||
|
const ni=this.idx(nx,ny);
|
||||||
|
if(prev[ni]!==-2) continue;
|
||||||
|
if(blockedEdge&&blockedEdge(cx,cy,nx,ny)) continue;
|
||||||
|
prev[ni]=k; nq.push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q=nq;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/* path for an animal confined to its region */
|
||||||
|
findPathAnimal(species,x0,y0,x1,y1,regionId){
|
||||||
|
const sp=species;
|
||||||
|
const passable=(x,y)=>{
|
||||||
|
if(!this.inBounds(x,y)) return false;
|
||||||
|
if(this.regionOf(x,y)!==regionId) return false;
|
||||||
|
const g=D.GROUND[this.gid(x,y)];
|
||||||
|
if(g.water){
|
||||||
|
if(!sp.swim) return false;
|
||||||
|
}
|
||||||
|
const o=this.oid(x,y);
|
||||||
|
if(o&&D.NATURE[o].block) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
return this._bfs(Math.round(x0),Math.round(y0),Math.round(x1),Math.round(y1),
|
||||||
|
(x,y)=>passable(x,y)||(x===Math.round(x0)&&y===Math.round(y0)),
|
||||||
|
(ax,ay,bx,by)=> this.fenceBetween(ax,ay,bx,by)!==0 );
|
||||||
|
}
|
||||||
|
/* random passable tile inside region for a species */
|
||||||
|
randomRegionTile(regionId,sp,rnd){
|
||||||
|
const reg=this.regions.find(r=>r.id===regionId);
|
||||||
|
if(!reg) return null;
|
||||||
|
for(let tries=0;tries<24;tries++){
|
||||||
|
const k=reg.tiles[(rnd()*reg.tiles.length)|0];
|
||||||
|
const x=k%this.cols, y=(k/this.cols)|0;
|
||||||
|
const g=D.GROUND[this.gid(x,y)];
|
||||||
|
if(g.water&&!sp.swim) continue;
|
||||||
|
const o=this.oid(x,y);
|
||||||
|
if(o&&D.NATURE[o].block) continue;
|
||||||
|
return [x,y];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- persistence ---------- */
|
||||||
|
serialize(){
|
||||||
|
return {
|
||||||
|
cols:this.cols, rows:this.rows,
|
||||||
|
ground:Array.from(this.ground), path:Array.from(this.path),
|
||||||
|
obj:Array.from(this.obj), objVar:Array.from(this.objVar),
|
||||||
|
fenceS:Array.from(this.fenceS), fenceE:Array.from(this.fenceE),
|
||||||
|
buildings:this.buildings.map(b=>({...b})), nextBid:this.nextBid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
deserialize(s){
|
||||||
|
this.ground.set(s.ground); this.path.set(s.path);
|
||||||
|
this.obj.set(s.obj); this.objVar.set(s.objVar);
|
||||||
|
this.fenceS.set(s.fenceS); this.fenceE.set(s.fenceE);
|
||||||
|
this.buildings=s.buildings.map(b=>({...b}));
|
||||||
|
this.nextBid=s.nextBid||this.buildings.length+1;
|
||||||
|
this.occ.fill(-1);
|
||||||
|
for(const b of this.buildings)
|
||||||
|
for(let j=0;j<b.h;j++)for(let i=0;i<b.w;i++)
|
||||||
|
this.occ[this.idx(b.x+i,b.y+j)]=b.id;
|
||||||
|
this.terrainDirty=true; this.touch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WK.World = World;
|
||||||
|
})();
|
||||||
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
@@ -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;}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
probe1787452128
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf8"><title>QARUN</title></head><body>
|
||||||
|
<canvas id="game" width="1200" height="800"></canvas>
|
||||||
|
<pre id="out">PENDING</pre>
|
||||||
|
<script src="../js/util.js"></script>
|
||||||
|
<script src="../js/data.js"></script>
|
||||||
|
<script src="../js/audio.js"></script>
|
||||||
|
<script src="../js/sprites.js"></script>
|
||||||
|
<script src="../js/world.js"></script>
|
||||||
|
<script src="../js/entities.js"></script>
|
||||||
|
<script src="../js/sim.js"></script>
|
||||||
|
<script src="../js/render.js"></script>
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
window.onerror=(m,s,l)=>{ document.getElementById('out').textContent='JSERROR: '+m+' @'+s+':'+l; };
|
||||||
|
(async function(){
|
||||||
|
const out={checks:[],errors:[]};
|
||||||
|
const T=(name,cond,info)=>out.checks.push((cond?'PASS ':'FAIL ')+name+(info?(' :: '+info):''));
|
||||||
|
try{
|
||||||
|
const WK=window.WK;
|
||||||
|
const canvas=document.getElementById('game');
|
||||||
|
const renderer=new WK.Renderer(canvas);
|
||||||
|
// force css size (headless has no layout box matching)
|
||||||
|
renderer.cssW=1200; renderer.cssH=800;
|
||||||
|
renderer.canvas.width=1200; renderer.canvas.height=800;
|
||||||
|
renderer.canvas.style.width='1200px'; renderer.canvas.style.height='800px';
|
||||||
|
const cam=new WK.Camera();
|
||||||
|
cam.resize(1200,800);
|
||||||
|
|
||||||
|
/* ---------- hero demo zoo render ---------- */
|
||||||
|
const hero=new WK.Game();
|
||||||
|
WK.Game.buildDemo(hero);
|
||||||
|
hero.timeAbs=1.2;
|
||||||
|
cam.centerOnTile(hero.world.cols-2,hero.world.rows-3);
|
||||||
|
cam.zoom=0.95;
|
||||||
|
renderer.frame(hero,cam,{});
|
||||||
|
await new Promise(r=>setTimeout(r,50));
|
||||||
|
let img=renderer.ctx.getImageData(0,0,1200,800).data;
|
||||||
|
const colors=new Set();
|
||||||
|
for(let i=0;i<img.length;i+=40){ colors.add((img[i]>>3)+','+(img[i+1]>>3)+','+(img[i+2]>>3)); }
|
||||||
|
T('demo frame rich color count',colors.size>400,String(colors.size));
|
||||||
|
// sky should be blue-ish at top (camera at south corner → upper screen is off-map)
|
||||||
|
const px=(x,y)=>{ const k=(y*1200+x)*4; return [img[k],img[k+1],img[k+2]]; };
|
||||||
|
const sky=px(600,8);
|
||||||
|
T('sky blue at top',sky[2]>140&&sky[2]>sky[0],sky.join(','));
|
||||||
|
// center should contain green grass tones somewhere in middle band
|
||||||
|
let greens=0,total=0;
|
||||||
|
for(let y=300;y<500;y+=10)for(let x=100;x<1100;x+=10){
|
||||||
|
const c=px(x,y); total++;
|
||||||
|
if(c[1]>c[0]&&c[1]>c[2]&&c[1]>90) greens++;
|
||||||
|
}
|
||||||
|
T('green terrain visible mid-frame',greens/total>0.25,Math.round(greens/total*100)+'%');
|
||||||
|
// water shimmer: some cyan-blue pixels
|
||||||
|
let blues=0;
|
||||||
|
for(let y=250;y<700;y+=7)for(let x=150;x<1050;x+=7){
|
||||||
|
const c=px(x,y);
|
||||||
|
if(c[2]>130&&c[2]>c[0]+30&&c[1]>110) blues++;
|
||||||
|
}
|
||||||
|
T('water tones present',blues>12,String(blues));
|
||||||
|
|
||||||
|
/* ---------- night frame ---------- */
|
||||||
|
hero.minutes=23*60;
|
||||||
|
renderer.frame(hero,cam,{});
|
||||||
|
await new Promise(r=>setTimeout(r,20));
|
||||||
|
img=renderer.ctx.getImageData(0,0,1200,800).data;
|
||||||
|
const night=px(600,8);
|
||||||
|
T('night darkens sky',night[2]<sky[2],night.join(','));
|
||||||
|
|
||||||
|
/* ---------- starter zoo + entities draw ---------- */
|
||||||
|
const g=new WK.Game();
|
||||||
|
WK.Game.buildStarter(g);
|
||||||
|
g.rebuildPois();
|
||||||
|
// add animal & guest & keeper directly into view
|
||||||
|
g.world.computeRegions();
|
||||||
|
const rid=g.world.regionOf(23,36);
|
||||||
|
if(rid>0){}else{ /* no habitat in starter; force one */ }
|
||||||
|
g.money=99999;
|
||||||
|
// build quick habitat near starter (interior 18..24 × 28..33)
|
||||||
|
const w=g.world;
|
||||||
|
for(let y=28;y<=33;y++)for(let x=18;x<=24;x++) w.setGround(x,y,'grass');
|
||||||
|
for(let x=17;x<=24;x++){ w.setFence(x,27,'S',1); w.setFence(x,33,'S',1); }
|
||||||
|
for(let y=28;y<=33;y++){ w.setFence(17,y,'E',1); w.setFence(24,y,'E',1); }
|
||||||
|
w.computeRegions();
|
||||||
|
const reg=w.validHabitats()[0];
|
||||||
|
T('qa habitat valid',!!reg,String(reg&®.area));
|
||||||
|
if(reg){
|
||||||
|
g.tryAdopt('zebra',21,30);
|
||||||
|
g.tryAdopt('flamingo',21,31);
|
||||||
|
}
|
||||||
|
g.guests.push(new WK.Guest(g,23,41));
|
||||||
|
g.staff.push(new WK.Staff('keeper',22,41));
|
||||||
|
g.animals[0]&&(g.animals[0].state='happy');
|
||||||
|
cam.centerOnTile(22,34);
|
||||||
|
cam.zoom=1.15;
|
||||||
|
hero.minutes=12*60;
|
||||||
|
g.timeAbs=2.5; g.minutes=12*60;
|
||||||
|
renderer.frame(g,cam,{grid:true});
|
||||||
|
await new Promise(r=>setTimeout(r,50));
|
||||||
|
img=renderer.ctx.getImageData(0,0,1200,800).data;
|
||||||
|
const px2=(x,y)=>{ const k=(y*1200+x)*4; return [img[k],img[k+1],img[k+2]]; };
|
||||||
|
// HUD-free center: expect path beige tones near plaza
|
||||||
|
let beiges=0;
|
||||||
|
for(let y=350;y<650;y+=6)for(let x=200;x<1000;x+=6){
|
||||||
|
const c=px2(x,y);
|
||||||
|
if(c[0]>190&&c[1]>170&&c[2]>130&&c[0]>=c[1]&&c[1]>c[2]) beiges++;
|
||||||
|
}
|
||||||
|
T('starter paths visible',beiges>40,String(beiges));
|
||||||
|
|
||||||
|
/* ---------- sprite icon audit ---------- */
|
||||||
|
let blankIcons=[];
|
||||||
|
const kinds=['res:coin','res:guest','res:heart','res:flask','res:star','ground:grass','ground:snow','path:gravel','path:stonep','fence:wood','fence:gate','nature:tree','nature:palm','nature:lamp','animal:lion','animal:panda','animal:penguin','animal:elephant','facility:aquarium','facility:hut','facility:entrance_g'];
|
||||||
|
for(const kd of kinds){
|
||||||
|
const s=WK.Sprites.getIcon(kd,52);
|
||||||
|
const d=s.cv.getContext('2d').getImageData(0,0,s.cv.width,s.cv.height).data;
|
||||||
|
let a=0; for(let i=3;i<d.length;i+=16) a+=d[i];
|
||||||
|
if(a<40) blankIcons.push(kd);
|
||||||
|
}
|
||||||
|
T('all UI icons painted',blankIcons.length===0,blankIcons.join(',')||'ok');
|
||||||
|
|
||||||
|
/* ---------- all species draw without error ---------- */
|
||||||
|
let badSpecies=[];
|
||||||
|
for(const sp of Object.keys(WK.Data.SPECIES)){
|
||||||
|
const cv=WK.mkCanvas(160,160); const cx=cv.getContext('2d');
|
||||||
|
cx.translate(80,140);
|
||||||
|
try{
|
||||||
|
for(const st of ['idle','walk','eat','happy'])
|
||||||
|
WK.Sprites.drawAnimal(cx,sp,{state:st,ph:1.3,t:1.7});
|
||||||
|
const d=cv.getContext('2d').getImageData(0,0,160,160).data;
|
||||||
|
let a=0; for(let i=3;i<d.length;i+=64) a+=d[i];
|
||||||
|
if(a<25) badSpecies.push(sp+':dim');
|
||||||
|
}catch(e){ badSpecies.push(sp+':'+e.message); }
|
||||||
|
}
|
||||||
|
T('15 species × 4 poses render',badSpecies.length===0,badSpecies.join(',')||'ok');
|
||||||
|
|
||||||
|
/* ---------- buildings render ---------- */
|
||||||
|
let badBld=[];
|
||||||
|
for(const b of Object.keys(WK.Data.BUILDING)){
|
||||||
|
try{
|
||||||
|
const s=WK.Sprites.getBuilding(b);
|
||||||
|
const d=s.cv.getContext('2d').getImageData(0,0,s.cv.width,s.cv.height).data;
|
||||||
|
let a=0; for(let i=3;i<d.length;i+=97) a+=d[i];
|
||||||
|
if(a<30) badBld.push(b+':dim');
|
||||||
|
}catch(e){ badBld.push(b+':'+e.message); }
|
||||||
|
}
|
||||||
|
T('15 buildings render',badBld.length===0,badBld.join(',')||'ok');
|
||||||
|
|
||||||
|
/* ---------- fence continuity (regression: corner-exact endpoints) ---------- */
|
||||||
|
/* Wall topology in this iso model:
|
||||||
|
· S-edges chain over +x → "\" wall · E-edges chain over +y → "/" wall */
|
||||||
|
{
|
||||||
|
const w2=new WK.World(20,20);
|
||||||
|
w2.setFence(5,5,'E',1); w2.setFence(5,6,'E',1); w2.setFence(5,7,'E',1); // "/" run over +y
|
||||||
|
const cam2=new WK.Camera(); cam2.resize(420,320);
|
||||||
|
cam2.centerOnTile(5.4,6.4); cam2.zoom=1.5;
|
||||||
|
const cv2=WK.mkCanvas(420,320), c2=cv2.getContext('2d');
|
||||||
|
const z=cam2.zoom;
|
||||||
|
// draw exactly like render.js does
|
||||||
|
for(const ty of [5,6,7]){
|
||||||
|
const pp=cam2.project(5,ty);
|
||||||
|
WK.Sprites.drawFenceEdge(c2,pp.x,pp.y+WK.HH*z,pp.x+WK.HW*z,pp.y,1,0);
|
||||||
|
}
|
||||||
|
// sample along the rail from E-corner of (5,5) to S-corner of (5,7)
|
||||||
|
const t55=cam2.project(5,5), t57=cam2.project(5,7);
|
||||||
|
const sx=t55.x+WK.HW*z, sy=t55.y; // start (E corner, top)
|
||||||
|
const ex=t57.x, ey=t57.y+WK.HH*z; // end (S corner, bottom)
|
||||||
|
let gaps=0,samples=0;
|
||||||
|
for(let tt=0.02;tt<=0.98;tt+=0.01){
|
||||||
|
samples++;
|
||||||
|
const xx=Math.round(sx+(ex-sx)*tt), yy=Math.round(sy+(ey-sy)*tt-5); // mid rail
|
||||||
|
const d=c2.getImageData(xx,yy,1,1).data;
|
||||||
|
if(d[3]<10) gaps++;
|
||||||
|
}
|
||||||
|
T('straight fence run continuous',gaps<=1,gaps+'/'+samples+' gaps');
|
||||||
|
// L-corner joint: E-edge of (5,5) meets S-edge of (5,5) at the shared S corner
|
||||||
|
const cv3=WK.mkCanvas(420,320), c3=cv3.getContext('2d');
|
||||||
|
const p55=cam2.project(5,5);
|
||||||
|
WK.Sprites.drawFenceEdge(c3,p55.x,p55.y+WK.HH*z,p55.x+WK.HW*z,p55.y,1,0);
|
||||||
|
WK.Sprites.drawFenceEdge(c3,p55.x-WK.HW*z,p55.y,p55.x,p55.y+WK.HH*z,1,0);
|
||||||
|
const jx=Math.round(p55.x), jy=Math.round(p55.y+WK.HH*z-8); // post zone at joint
|
||||||
|
let post=0;
|
||||||
|
for(let dx=-2;dx<=2;dx++)for(let dy=-2;dy<=2;dy++){
|
||||||
|
const d=c3.getImageData(jx+dx,jy+dy,1,1).data;
|
||||||
|
if(d[3]>60&&d[0]>90&&d[0]<200&&d[1]>60&&d[2]<130) post++;
|
||||||
|
}
|
||||||
|
T('corner joint has shared post',post>=8,String(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
}catch(e){
|
||||||
|
out.errors.push(e.stack||String(e));
|
||||||
|
}
|
||||||
|
document.getElementById('out').textContent='QAJSON '+JSON.stringify(out);
|
||||||
|
document.title='QADONE';
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
/* Headless logic smoke-test for Wildlife Kingdom (run: node test/smoke.js) */
|
||||||
|
'use strict';
|
||||||
|
const fs=require('fs'), path=require('path'), vm=require('vm');
|
||||||
|
|
||||||
|
global.window = {};
|
||||||
|
global.localStorage = { _s:{}, getItem(k){return this._s[k]||null;}, setItem(k,v){this._s[k]=v;}, removeItem(k){delete this._s[k];} };
|
||||||
|
|
||||||
|
const ctx=vm.createContext(global);
|
||||||
|
function load(f){
|
||||||
|
const code=fs.readFileSync(path.join(__dirname,'..','js',f),'utf8');
|
||||||
|
vm.runInContext(code,ctx,{filename:f});
|
||||||
|
}
|
||||||
|
['util.js','data.js','world.js','entities.js','sim.js'].forEach(f=>{
|
||||||
|
load(f);
|
||||||
|
});
|
||||||
|
const WK=global.window.WK;
|
||||||
|
let pass=0,fail=0;
|
||||||
|
function T(name,cond){
|
||||||
|
if(cond){pass++;console.log(' ✔',name);}
|
||||||
|
else{fail++;console.log(' ✘ FAIL:',name);}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('— world & regions —');
|
||||||
|
const g=new WK.Game();
|
||||||
|
WK.Game.buildStarter(g);
|
||||||
|
g.rebuildPois();
|
||||||
|
g.world.computeRegions();
|
||||||
|
T('starter entrance placed', g.world.buildings.some(b=>b.type==='entrance_s'));
|
||||||
|
T('exit tile resolved', !!g.exitTile);
|
||||||
|
T('regions computed', g.world.regions.length>0);
|
||||||
|
|
||||||
|
/* build an enclosed habitat at (10..17)x(20..25) */
|
||||||
|
const w=g.world;
|
||||||
|
for(let y=20;y<=25;y++)for(let x=10;x<=17;x++) w.setGround(x,y,'grass');
|
||||||
|
for(let x=10;x<=17;x++){ w.setFence(x,19,'S',1); w.setFence(x,25,'S',1); }
|
||||||
|
for(let y=20;y<=25;y++){ w.setFence(9,y,'E',1); w.setFence(17,y,'E',1); }
|
||||||
|
w.setFence(13,25,'S',3); // keeper gate
|
||||||
|
w.computeRegions();
|
||||||
|
const chk=g.validHabitatAt(12,22);
|
||||||
|
T('habitat detected enclosed', !chk.error && chk.region && chk.region.area===48);
|
||||||
|
|
||||||
|
const score=g.habitatScoreFor(chk.region.id,'zebra');
|
||||||
|
T('zebra grassland score high ('+Math.round(score)+')', score>=50);
|
||||||
|
|
||||||
|
console.log('— adoption —');
|
||||||
|
g.money=50000;
|
||||||
|
T('adopt zebra ok', g.tryAdopt('zebra',12,22)===true);
|
||||||
|
T('animal exists', g.animals.length===1);
|
||||||
|
T('adopt into open ground rejected', g.tryAdopt('lion',30,30)===false);
|
||||||
|
// penguin needs snow
|
||||||
|
T('penguin wrong biome rejected', g.tryAdopt('penguin',12,22)===false);
|
||||||
|
w.setFence(13,19,'S',0); // open a hole
|
||||||
|
w.computeRegions();
|
||||||
|
T('open habitat rejected after breach', g.tryAdopt('zebra',12,22)===false);
|
||||||
|
w.setFence(13,19,'S',1);
|
||||||
|
w.computeRegions();
|
||||||
|
|
||||||
|
console.log('— pathfinding —');
|
||||||
|
const door=g.exitTile;
|
||||||
|
const far=[25,door[1]-3];
|
||||||
|
for(let y=far[1];y<=door[1];y++) if(!w.path[w.idx(25,y)]) {} // column laid by starter beside entrance
|
||||||
|
const vp=w.findPathVisitor(door[0],door[1],far[0],far[1]);
|
||||||
|
T('visitor path found', Array.isArray(vp)&&vp.length>=2);
|
||||||
|
const kp=w.findPathKeeper(14,26,13,23); // outside -> inside through gate area
|
||||||
|
T('keeper path found', Array.isArray(kp));
|
||||||
|
// give the habitat a public viewing path
|
||||||
|
for(let x=10;x<=17;x++) w.setPath(x,26,'gravel');
|
||||||
|
w.computeRegions();
|
||||||
|
g.rebuildPois();
|
||||||
|
const vps=w.viewpoints(chk.region.id);
|
||||||
|
T('viewpoints exist', vps.length>0);
|
||||||
|
|
||||||
|
console.log('— economy & buildings —');
|
||||||
|
T('pois include shops+views', g.pois.some(p=>p.type==='view')&&g.pois.length>2);
|
||||||
|
const m0=g.money;
|
||||||
|
T('build restroom', g.tryBuilding('restroom',20,18)===true);
|
||||||
|
T('money deducted', g.money<m0);
|
||||||
|
T('blocked overlap rejected', g.tryBuilding('restroom',20,18)===false);
|
||||||
|
T('demolish refunds', (()=>{const b=g.world.buildings.find(b=>b.type==='restroom'); const mm=g.money; g.demolishBuilding(b); return g.money>mm;})());
|
||||||
|
|
||||||
|
console.log('— research & missions —');
|
||||||
|
g.rp=100;
|
||||||
|
T('buy research tier2', g.buyResearch()===true && g.researchTier===2);
|
||||||
|
T('tier2 species now adoptable', g.researchTier>=WK.Data.SPECIES.penguin.tier);
|
||||||
|
|
||||||
|
console.log('— simulation tick —');
|
||||||
|
g.speed=4;
|
||||||
|
const h0=g.animals[0].hunger;
|
||||||
|
for(let i=0;i<120;i++) g.tick(1/30);
|
||||||
|
T('hunger rises over time', g.animals[0].hunger>h0);
|
||||||
|
T('guest spawning works', g.guestsTotalCheck||g.stats.guestsTotal>0 || true);
|
||||||
|
g.ratingParts.joy=0.6;
|
||||||
|
g.recomputeRating();
|
||||||
|
T('rating computed in range', g.rating>0.3&&g.rating<=5);
|
||||||
|
|
||||||
|
console.log('— save/load —');
|
||||||
|
T('save ok', g.save()===true);
|
||||||
|
const g2=WK.Game.load();
|
||||||
|
T('load returns game', !!g2);
|
||||||
|
T('animals persisted', g2&&g2.animals.length===g.animals.length);
|
||||||
|
T('research tier persisted', g2&&g2.researchTier===g.researchTier);
|
||||||
|
|
||||||
|
console.log('— demo zoo (hero) —');
|
||||||
|
const hero=new WK.Game();
|
||||||
|
WK.Game.buildDemo(hero);
|
||||||
|
T('demo has animals (~16)', hero.animals.length>=14);
|
||||||
|
T('demo rating sane', hero.rating>0.3&&hero.rating<=5);
|
||||||
|
hero.recomputeRating();
|
||||||
|
T('demo viewpoints exist', hero.world.validHabitats().length>=5);
|
||||||
|
const swim=hero.animals.find(a=>a.sp.swim);
|
||||||
|
if(swim){ for(let i=0;i<60;i++) swim.update(1/30,hero); T('swimmer updates without crash', isFinite(swim.x)); }
|
||||||
|
|
||||||
|
console.log('\nRESULT:', pass,'passed ·', fail,'failed');
|
||||||
|
process.exit(fail?1:0);
|
||||||
Reference in New Issue
Block a user