Repterra Web — full game: base building, power grid, taming & breeding, aquatic raiders, day/night, save/load
- Isometric canvas RTS vs dinosaur waves (fan demake of Repterra) - Economy: houses/taxes, farms, foresters, quarries; colonist staffing - Power grid: generators extend build range; brownout + recovery - Defense: walls/gates, watchtowers (AA), cannon towers (ground-only) - Taming: Primal Pen + Tamers collar weakened dinos; pets obey commands - Breeding: tamed pairs incubate eggs at the pen; hatchlings grow up - 7 dino species incl. flying Pteranodons and lake-raiding Suchomimus - Telegraphed waves with direction arrows; day-15 final horde; 3 difficulties - Day/night cycle, fog of war, minimap, synth audio, 1x-3x speeds - Save/Load/Continue + dawn autosave (full JSON state snapshots) - Tests: 80-assertion headless suite, browser boot + E2E, balance harness
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
shots/
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# REPRTERRA WEB 🦖
|
||||||
|
|
||||||
|
A **fan-made browser demake** of [Repterra](https://store.steampowered.com/app/2768380/Repterra/)
|
||||||
|
(Chute Apps / MicroProse) — the base-building survival RTS best described as
|
||||||
|
*"They Are Billions, but the horde can fly."*
|
||||||
|
|
||||||
|
Everything is hand-drawn on canvas at runtime — no image assets, no build step,
|
||||||
|
no dependencies. Plain JS + HTML + CSS.
|
||||||
|
|
||||||
|
## Play
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # serves http://127.0.0.1:8933
|
||||||
|
```
|
||||||
|
|
||||||
|
…or just open `index.html` directly in a browser (works from `file://` too).
|
||||||
|
|
||||||
|
## The loop
|
||||||
|
|
||||||
|
1. **Grow** — Houses raise population cap and pay taxes; colonists arrive at your
|
||||||
|
Command Center and staff Farms, Forester Huts and Quarries.
|
||||||
|
2. **Expand** — Generators produce energy *and* extend your power grid; you can
|
||||||
|
only build touching your grid. Overload it and newest buildings go dark.
|
||||||
|
3. **Fortify** — Walls and Gates block ground dinos. Towers shred them.
|
||||||
|
4. **Survive** — telegraphed waves attack every couple of days
|
||||||
|
(a warning arrow shows *where* they'll hit). Roaming packs prowl the map —
|
||||||
|
kill them before day 15, because…
|
||||||
|
5. **☠ THE FINAL WAVE** — every surviving dino on the map charges your Command
|
||||||
|
Center. Lose the HQ and the colony falls.
|
||||||
|
|
||||||
|
### ⚠ The sky is NOT safe
|
||||||
|
Pteranodons **fly over walls**, straight for your soft economy buildings.
|
||||||
|
Cannon towers can't shoot up — keep Watchtowers and Rangers covering the air.
|
||||||
|
|
||||||
|
### ⚠ Neither is the water
|
||||||
|
From day 8 (normal), **Suchomimus raids emerge from the lakes** — a fully
|
||||||
|
walled base with no waterfront watch is still on the menu.
|
||||||
|
|
||||||
|
### 🦴 Turn the horde into your army
|
||||||
|
Build a **Primal Pen**, train a **Tamer**, weaken any dino below 32% HP and the
|
||||||
|
Tamer slips a capture collar on it. Tamed dinos fight for the colony —
|
||||||
|
right-click to give them a new guard post or order an attack. The Pen heals
|
||||||
|
your pets and each Pen adds +2 tame slots. (A T-Rex is too powerful to tame.)
|
||||||
|
|
||||||
|
### 🥚 Breed them
|
||||||
|
Park **2+ tamed dinos** beside a powered Pen and they'll incubate eggs (60 food
|
||||||
|
each, max 2 per Pen). Hatchlings start small and grow into full-size fighting
|
||||||
|
adults — a self-sufficient dino economy.
|
||||||
|
|
||||||
|
### 💾 Your colony persists
|
||||||
|
Save from the pause menu, autosave at every dawn, and hit
|
||||||
|
**▶ Continue saved colony** on the main menu. Saves are plain JSON snapshots of
|
||||||
|
the whole simulation — world depletion included.
|
||||||
|
|
||||||
|
## Dinosaur roster
|
||||||
|
|
||||||
|
| Dino | Threat |
|
||||||
|
|---|---|
|
||||||
|
| Compsognathus | fast swarm fodder |
|
||||||
|
| Velociraptor | quick flanker |
|
||||||
|
| Dilophosaurus | ranged spitter |
|
||||||
|
| Triceratops | wall-crusher (×2.5 building dmg) |
|
||||||
|
| Pteranodon | ☠ flying, ignores walls |
|
||||||
|
| Suchomimus | 🌊 amphibious — raids from lakes |
|
||||||
|
| Tyrannosaurus Rex | boss, smashes anything, untameable |
|
||||||
|
|
||||||
|
Plus roaming packs of all of the above from minute one. Day turns to night
|
||||||
|
over the colony (powered buildings glow warm after dark).
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
|---|---|
|
||||||
|
| WASD / arrows / screen edge | pan camera |
|
||||||
|
| Mouse wheel | zoom to cursor |
|
||||||
|
| Middle-drag | grab-and-drag pan |
|
||||||
|
| Left click | select / place |
|
||||||
|
| Left drag | box-select Rangers |
|
||||||
|
| Right click | move order · focus-fire a dino · rally point (barracks/pen selected) · pet move/attack (tamed dino selected) · capture priority (tamer selected) |
|
||||||
|
| `1`–`9` | build hotkeys |
|
||||||
|
| Shift-click | multi-build · hold to keep placing walls |
|
||||||
|
| `Space` / `P` | pause |
|
||||||
|
| `F` | jump to HQ |
|
||||||
|
| `Del` | demolish selected building (+50% refund) |
|
||||||
|
| `M` | mute |
|
||||||
|
| `+` / `-` / `0` | game speed 1×/2×/3× |
|
||||||
|
|
||||||
|
## Tech notes
|
||||||
|
|
||||||
|
* ~5k lines of dependency-free vanilla JS (`js/*.js`, plain script tags).
|
||||||
|
* Isometric canvas renderer with depth sorting, fog of war, particles,
|
||||||
|
animated procedural sprites (every dino/building drawn in code).
|
||||||
|
* Sim core is DOM-free → fully headless-testable:
|
||||||
|
* `npm test` — smoke suite (~70 assertions: placement rules, economy,
|
||||||
|
pathfinding, waves, power-grid chains, taming & pet combat, breeding,
|
||||||
|
save/load fidelity, victory & defeat paths).
|
||||||
|
* `npm run balance` — scripted auto-player runs full games at every
|
||||||
|
difficulty and reports survival curves (relative difficulty signal).
|
||||||
|
* `node test/browser.js` / `test/e2e.js` — real-Chromium boot + interaction
|
||||||
|
tests, including a save → reload → continue lifecycle
|
||||||
|
(needs `npx playwright-core install chromium-headless-shell`).
|
||||||
|
|
||||||
|
*Fan project for personal enjoyment; not affiliated with Chute Apps or MicroProse.*
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>REPTERRA WEB — Survive the Herds</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<canvas id="game"></canvas>
|
||||||
|
|
||||||
|
<!-- ============ HUD ============ -->
|
||||||
|
<div id="hud">
|
||||||
|
|
||||||
|
<div id="topbar">
|
||||||
|
<div class="chip" id="res-gold"><span class="i">💰</span><span class="v">0</span><span class="r">+0/s</span></div>
|
||||||
|
<div class="chip" id="res-wood"><span class="i">🪵</span><span class="v">0</span><span class="r">+0/s</span></div>
|
||||||
|
<div class="chip" id="res-stone"><span class="i">🪨</span><span class="v">0</span><span class="r">+0/s</span></div>
|
||||||
|
<div class="chip" id="res-food"><span class="i">🍖</span><span class="v">0</span><span class="r">+0/s</span></div>
|
||||||
|
<div class="chip" id="res-energy"><span class="i">⚡</span><span class="v">0/0</span></div>
|
||||||
|
<div class="chip" id="res-pop"><span class="i">👥</span><span class="v">0/0</span></div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div id="daylabel">Day 1</div>
|
||||||
|
<div id="wavelabel"></div>
|
||||||
|
<div id="topbtns"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="wavebanner"><span id="wavebanner-text"></span></div>
|
||||||
|
|
||||||
|
<div id="selpanel" style="display:none"></div>
|
||||||
|
|
||||||
|
<div id="palette"></div>
|
||||||
|
|
||||||
|
<div id="mmwrap">
|
||||||
|
<canvas id="minimap" width="176" height="176"></canvas>
|
||||||
|
<div class="mmlabel">MAP</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toasts"></div>
|
||||||
|
<div id="tooltip" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ MAIN MENU ============ -->
|
||||||
|
<div id="menu" class="overlay">
|
||||||
|
<div class="card wide menucard">
|
||||||
|
<h1 class="title">REP<span class="red">T</span>ERRA</h1>
|
||||||
|
<p class="subtitle">— WEB SURVIVAL RTS — build the colony · wall it in · survive the herds · beware the sky —</p>
|
||||||
|
<p class="fanline">A fan-made browser demake inspired by <a href="https://store.steampowered.com/app/2768380/Repterra/" target="_blank" rel="noopener">Repterra</a> (Chute Apps / MicroProse).</p>
|
||||||
|
|
||||||
|
<div class="diffrow">
|
||||||
|
<button class="bigbtn diffbtn" data-diff="easy">
|
||||||
|
<b>🌿 SCOUT</b><small>Smaller herds · longer runway<br>For learning the ropes</small>
|
||||||
|
</button>
|
||||||
|
<button class="bigbtn diffbtn recommended" data-diff="normal">
|
||||||
|
<b>🏔️ SETTLER</b><small>The intended experience<br>15 days to the final wave</small>
|
||||||
|
</button>
|
||||||
|
<button class="bigbtn diffbtn" data-diff="hard">
|
||||||
|
<b>☠ WARLORD</b><small>Relentless swarms · early pressure<br>You will not sleep</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="bigbtn" id="continueBtn" style="display:none;margin-top:10px">▶ Continue saved colony</button>
|
||||||
|
|
||||||
|
<button class="bigbtn subtle" id="btn-how">📖 How To Play</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ END SCREEN ============ -->
|
||||||
|
<div id="endscreen" class="overlay" style="display:none">
|
||||||
|
<div class="card wide">
|
||||||
|
<h1 class="good">COLONY SAVED!</h1>
|
||||||
|
<div class="stats"></div>
|
||||||
|
<p class="flavor"></p>
|
||||||
|
<div class="rowcenter">
|
||||||
|
<button class="bigbtn" data-restart>🔁 Play Again</button>
|
||||||
|
<button class="bigbtn subtle backtomenu">Main Menu</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ PAUSE ============ -->
|
||||||
|
<div id="pauseoverlay" class="overlay" style="display:none">
|
||||||
|
<div class="pausetxt">⏸ PAUSED<small>press SPACE to resume</small></div>
|
||||||
|
<div class="pauserow">
|
||||||
|
<button class="bigbtn" id="savebtn">💾 Save</button>
|
||||||
|
<button class="bigbtn" id="loadbtn">📂 Load</button>
|
||||||
|
<button class="bigbtn subtle backtomenu">Main Menu</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/config.js"></script>
|
||||||
|
<script src="js/utils.js"></script>
|
||||||
|
<script src="js/audio.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/ui.js"></script>
|
||||||
|
<script src="js/input.js"></script>
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
/* =========================================================
|
||||||
|
* REPRTERRA WEB — audio.js
|
||||||
|
* Tiny WebAudio synth for SFX. No assets needed.
|
||||||
|
* ========================================================= */
|
||||||
|
'use strict';
|
||||||
|
window.RTS = window.RTS || {};
|
||||||
|
|
||||||
|
RTS.audio = (function () {
|
||||||
|
let ctx = null;
|
||||||
|
let master = null;
|
||||||
|
let muted = false;
|
||||||
|
let lastPlay = {}; // throttle
|
||||||
|
|
||||||
|
function ensure() {
|
||||||
|
if (ctx) return true;
|
||||||
|
try {
|
||||||
|
const AC = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AC) return false;
|
||||||
|
ctx = new AC();
|
||||||
|
master = ctx.createGain();
|
||||||
|
master.gain.value = 0.32;
|
||||||
|
master.connect(ctx.destination);
|
||||||
|
} catch (e) { return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resume() {
|
||||||
|
if (!ensure()) return;
|
||||||
|
if (ctx.state === 'suspended') ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function throttled(name, minGap) {
|
||||||
|
const t = performance.now();
|
||||||
|
if (lastPlay[name] && t - lastPlay[name] < minGap) return true;
|
||||||
|
lastPlay[name] = t;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// basic tone
|
||||||
|
function tone(freq, dur, type, vol, slideTo, delay) {
|
||||||
|
if (muted || !ensure()) return;
|
||||||
|
const t0 = ctx.currentTime + (delay || 0);
|
||||||
|
const o = ctx.createOscillator();
|
||||||
|
const g = ctx.createGain();
|
||||||
|
o.type = type || 'square';
|
||||||
|
o.frequency.setValueAtTime(freq, t0);
|
||||||
|
if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), t0 + dur);
|
||||||
|
g.gain.setValueAtTime(vol || 0.2, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
|
||||||
|
o.connect(g); g.connect(master);
|
||||||
|
o.start(t0); o.stop(t0 + dur + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
// noise burst
|
||||||
|
function noise(dur, vol, filterFreq, delay) {
|
||||||
|
if (muted || !ensure()) return;
|
||||||
|
const t0 = ctx.currentTime + (delay || 0);
|
||||||
|
const len = Math.max(1, Math.floor(ctx.sampleRate * dur));
|
||||||
|
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||||
|
const d = buf.getChannelData(0);
|
||||||
|
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
const f = ctx.createBiquadFilter();
|
||||||
|
f.type = 'lowpass';
|
||||||
|
f.frequency.value = filterFreq || 1200;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(vol || 0.2, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
|
||||||
|
src.connect(f); f.connect(g); g.connect(master);
|
||||||
|
src.start(t0); src.stop(t0 + dur + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
const S = {};
|
||||||
|
S.click = () => { tone(700, 0.05, 'square', 0.10); };
|
||||||
|
S.build = () => { noise(0.15, 0.3, 500); tone(140, 0.12, 'triangle', 0.25, 90); };
|
||||||
|
S.deny = () => { tone(180, 0.12, 'sawtooth', 0.14, 120); };
|
||||||
|
S.shoot = () => { if (!throttled('shoot', 60)) noise(0.06, 0.14, 3200); };
|
||||||
|
S.arrow = () => { if (!throttled('arrow', 70)) tone(900, 0.07, 'triangle', 0.08, 300); };
|
||||||
|
S.cannon = () => { noise(0.35, 0.5, 350); tone(70, 0.3, 'sine', 0.4, 40); };
|
||||||
|
S.spit = () => { if (!throttled('spit', 120)) tone(300, 0.1, 'sawtooth', 0.09, 160); };
|
||||||
|
S.die = () => { if (!throttled('die', 80)) tone(220, 0.18, 'sawtooth', 0.16, 60); };
|
||||||
|
S.thud = () => { if (!throttled('thud', 120)) { noise(0.08, 0.22, 240); tone(90, 0.09, 'sine', 0.2, 55); } };
|
||||||
|
S.roar = () => { tone(110, 0.7, 'sawtooth', 0.34, 55); noise(0.5, 0.22, 300); };
|
||||||
|
S.alarm = () => { tone(520, 0.28, 'square', 0.16); tone(380, 0.28, 'square', 0.16, null, 0.3); tone(520, 0.28, 'square', 0.16, null, 0.6); };
|
||||||
|
S.coin = () => { tone(880, 0.05, 'sine', 0.08); tone(1320, 0.08, 'sine', 0.08, null, 0.05); };
|
||||||
|
S.train = () => { tone(440, 0.08, 'square', 0.1); tone(660, 0.1, 'square', 0.1, null, 0.08); };
|
||||||
|
S.win = () => { [523, 659, 784, 1046].forEach((f, i) => tone(f, 0.22, 'triangle', 0.22, null, i * 0.16)); };
|
||||||
|
S.lose = () => { [392, 330, 262, 196].forEach((f, i) => tone(f, 0.3, 'sawtooth', 0.2, null, i * 0.22)); };
|
||||||
|
S.explode = () => { noise(0.6, 0.55, 260); tone(55, 0.5, 'sine', 0.45, 30); };
|
||||||
|
|
||||||
|
S.toggleMute = function () { muted = !muted; return muted; };
|
||||||
|
S.isMuted = () => muted;
|
||||||
|
S.resume = resume;
|
||||||
|
|
||||||
|
return S;
|
||||||
|
})();
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
/* =========================================================
|
||||||
|
* REPRTERRA WEB — config.js
|
||||||
|
* Balance data: resources, buildings, dinosaurs, waves.
|
||||||
|
* Plain script (no modules) so the game runs from file:// too.
|
||||||
|
* ========================================================= */
|
||||||
|
'use strict';
|
||||||
|
window.RTS = window.RTS || {};
|
||||||
|
|
||||||
|
RTS.CONFIG = (function () {
|
||||||
|
const CFG = {};
|
||||||
|
|
||||||
|
// ---------- World ----------
|
||||||
|
CFG.WORLD = {
|
||||||
|
W: 76, H: 76,
|
||||||
|
TILE_W: 64, TILE_H: 32, // iso tile pixel size at zoom 1
|
||||||
|
DAY_LENGTH: 40, // seconds per day
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Resources ----------
|
||||||
|
// gold, wood, stone, food, energy(cap-based)
|
||||||
|
CFG.START_RES = {
|
||||||
|
easy: { gold: 700, wood: 350, stone: 250, food: 250 },
|
||||||
|
normal: { gold: 550, wood: 280, stone: 190, food: 180 },
|
||||||
|
hard: { gold: 450, wood: 230, stone: 150, food: 140 },
|
||||||
|
};
|
||||||
|
CFG.COLONIST = {
|
||||||
|
ARRIVE_EVERY: 7, // seconds, if housing available
|
||||||
|
FOOD_USE: 0.045, // food/sec each
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Buildings ----------
|
||||||
|
// kind: 'hq','house','farm','forester','quarry','generator','barracks',
|
||||||
|
// 'watchtower','cannon','wall','gate'
|
||||||
|
// size: footprint in tiles (square)
|
||||||
|
const B = {};
|
||||||
|
B.hq = {
|
||||||
|
id: 'hq', name: 'Command Center', size: 3, hp: 1600,
|
||||||
|
cost: { gold: 0 }, energyProd: 10, radius: 8, workers: 0,
|
||||||
|
desc: 'The heart of the colony. Protect it at all costs. Provides power radius and shelters new colonists.',
|
||||||
|
};
|
||||||
|
B.house = {
|
||||||
|
id: 'house', name: 'House', size: 1, hp: 240,
|
||||||
|
cost: { wood: 25, gold: 30 }, energyUse: 1, workers: 0,
|
||||||
|
popCap: 5, goldRate: 0.07,
|
||||||
|
desc: '+5 population capacity, +tax income. Colonists arrive at the Command Center.',
|
||||||
|
};
|
||||||
|
B.farm = {
|
||||||
|
id: 'farm', name: 'Farm', size: 2, hp: 260,
|
||||||
|
cost: { wood: 55, gold: 35 }, energyUse: 3, workers: 5,
|
||||||
|
foodRate: 1.15,
|
||||||
|
desc: 'Produces food. Your colonists eat every day — keep them fed.',
|
||||||
|
};
|
||||||
|
B.forester = {
|
||||||
|
id: 'forester', name: "Forester's Hut", size: 2, hp: 240,
|
||||||
|
cost: { wood: 45, gold: 20 }, energyUse: 4, workers: 6,
|
||||||
|
woodRate: 0.52, range: 4.5, needRes: 16,
|
||||||
|
desc: 'Harvests nearby forest. Place next to trees; the woods slowly run out.',
|
||||||
|
};
|
||||||
|
B.quarry = {
|
||||||
|
id: 'quarry', name: 'Quarry', size: 2, hp: 300,
|
||||||
|
cost: { wood: 60, gold: 50 }, energyUse: 5, workers: 7,
|
||||||
|
stoneRate: 0.44, range: 4.5, needRes: 10,
|
||||||
|
desc: 'Cuts stone from rock outcrops. Needs rocks within its work area.',
|
||||||
|
};
|
||||||
|
B.generator = {
|
||||||
|
id: 'generator', name: 'Generator', size: 1, hp: 260,
|
||||||
|
cost: { wood: 45, gold: 25 }, energyProd: 10, radius: 6.5, workers: 0,
|
||||||
|
desc: '+10 energy and extends the power grid — buildings must touch your grid. Overuse the grid and your newest buildings go dark!',
|
||||||
|
};
|
||||||
|
B.barracks = {
|
||||||
|
id: 'barracks', name: 'Barracks', size: 2, hp: 380,
|
||||||
|
cost: { wood: 75, stone: 60, gold: 50 }, energyUse: 6, workers: 4,
|
||||||
|
desc: 'Trains Rangers. Set a rally point; they hold position and engage anything hostile.',
|
||||||
|
};
|
||||||
|
B.primalpen = {
|
||||||
|
id: 'primalpen', name: 'Primal Pen', size: 2, hp: 340,
|
||||||
|
cost: { wood: 80, stone: 40, gold: 80 }, energyUse: 6, workers: 3,
|
||||||
|
desc: 'Trains Tamers and shelters tamed dinos (+2 tame slots). Slowly heals your pets nearby.',
|
||||||
|
};
|
||||||
|
B.watchtower = {
|
||||||
|
id: 'watchtower', name: 'Watchtower', size: 1, hp: 300,
|
||||||
|
cost: { wood: 35, stone: 45, gold: 25 }, energyUse: 4, workers: 0,
|
||||||
|
range: 6.5, dmg: 13, rof: 0.9, air: true,
|
||||||
|
desc: 'Fast rifle fire. Hits GROUND and AIR targets. Pteranodons ignore walls — you need these.',
|
||||||
|
};
|
||||||
|
B.cannon = {
|
||||||
|
id: 'cannon', name: 'Cannon Tower', size: 1, hp: 420,
|
||||||
|
cost: { wood: 60, stone: 110, gold: 60 }, energyUse: 8, workers: 0,
|
||||||
|
range: 7.5, dmg: 48, rof: 2.6, aoe: 1.35, air: false,
|
||||||
|
desc: 'Devastating explosive shells with splash. GROUND ONLY — cannot hit flying dinos.',
|
||||||
|
};
|
||||||
|
B.wall = {
|
||||||
|
id: 'wall', name: 'Wall', size: 1, hp: 380,
|
||||||
|
cost: { stone: 8 }, energyUse: 0, workers: 0,
|
||||||
|
desc: 'Stone wall. Blocks ground dinos. They WILL smash it — flying dinos fly over.',
|
||||||
|
};
|
||||||
|
B.gate = {
|
||||||
|
id: 'gate', name: 'Gate', size: 1, hp: 330,
|
||||||
|
cost: { stone: 20 }, energyUse: 0, workers: 0,
|
||||||
|
desc: 'Lets your troops through, blocks dinos (until they break it).',
|
||||||
|
};
|
||||||
|
CFG.BUILDINGS = B;
|
||||||
|
|
||||||
|
// Build order in UI palette (grouped)
|
||||||
|
CFG.PALETTE = [
|
||||||
|
['house', 'farm', 'forester', 'quarry'],
|
||||||
|
['generator', 'wall', 'gate'],
|
||||||
|
['watchtower', 'cannon', 'barracks', 'primalpen'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Taming ----------
|
||||||
|
CFG.TAMING = {
|
||||||
|
hpThreshold: 0.32, // dino must be below this HP fraction
|
||||||
|
channelTime: 2.6, // seconds adjacent to capture
|
||||||
|
range: 1.7, // capture reach from tamer
|
||||||
|
baseLimit: 2, // tames allowed with no pen
|
||||||
|
perPen: 2, // extra slots per Primal Pen
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Breeding ----------
|
||||||
|
CFG.BREED = {
|
||||||
|
foodPerEgg: 60, // food cost to incubate an egg
|
||||||
|
eggTime: 40, // seconds to hatch
|
||||||
|
growTime: 50, // seconds from hatchling to adult size
|
||||||
|
maxPerPen: 2, // simultaneous eggs per pen
|
||||||
|
parentRadius: 5, // tamed parents must linger near the pen
|
||||||
|
minParents: 2, // need a pair before eggs appear
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Research (at Command Center) ----------
|
||||||
|
CFG.UPGRADES = [
|
||||||
|
{ id: 'weapon', name: 'High-Caliber Rounds', tiers: 3, base: { gold: 220, stone: 90 }, mult: 1.7,
|
||||||
|
desc: '+25% Ranger & tower damage per tier.' },
|
||||||
|
{ id: 'armor', name: 'Kevlar Vests', tiers: 3, base: { gold: 180, wood: 80 }, mult: 1.7,
|
||||||
|
desc: '+25% Ranger health per tier (new recruits).' },
|
||||||
|
{ id: 'range', name: 'Scoped Rifles', tiers: 2, base: { gold: 260, stone: 120 }, mult: 1.8,
|
||||||
|
desc: '+12% Ranger & Watchtower range per tier.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Units (human) ----------
|
||||||
|
CFG.UNITS = {
|
||||||
|
colonist: { id: 'colonist', name: 'Colonist', hp: 40, speed: 1.5, dmg: 0 },
|
||||||
|
ranger: {
|
||||||
|
id: 'ranger', name: 'Ranger', hp: 135, speed: 1.7,
|
||||||
|
dmg: 11, rof: 0.8, range: 4.2, air: true,
|
||||||
|
cost: { gold: 45, food: 15 }, trainTime: 12,
|
||||||
|
desc: 'Rifle infantry. Engages ground AND air targets.',
|
||||||
|
},
|
||||||
|
tamer: {
|
||||||
|
id: 'tamer', name: 'Tamer', hp: 95, speed: 1.8,
|
||||||
|
dmg: 7, rof: 1.1, range: 3.4, air: false,
|
||||||
|
cost: { gold: 90, food: 20 }, trainTime: 16,
|
||||||
|
desc: 'Tranq-dart support who slips a collar on weakened dinos (<32% HP). Right-click a dino to prioritize the capture.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
CFG.TRAIN_AT = { ranger: 'barracks', tamer: 'primalpen' };
|
||||||
|
|
||||||
|
// ---------- Dinosaurs ----------
|
||||||
|
// flying:true -> ignores walls & terrain, beelines targets
|
||||||
|
const D = {};
|
||||||
|
D.compy = { id: 'compy', name: 'Compsognathus', hp: 24, dmg: 4, rof: 0.7, speed: 2.3, r: 0.32,
|
||||||
|
bounty: 1, flying: false, scale: 0.55, color: '#7da05a' };
|
||||||
|
D.raptor = { id: 'raptor', name: 'Velociraptor', hp: 88, dmg: 11, rof: 0.8, speed: 2.0, r: 0.42,
|
||||||
|
bounty: 3, flying: false, scale: 0.9, color: '#a5713f' };
|
||||||
|
D.dilo = { id: 'dilo', name: 'Dilophosaurus', hp: 115, dmg: 9, rof: 1.4, speed: 1.55, r: 0.46,
|
||||||
|
bounty: 5, flying: false, ranged: 3.5, scale: 1.0, color: '#5e8f5a' };
|
||||||
|
D.trike = { id: 'trike', name: 'Triceratops', hp: 560, dmg: 26, rof: 1.1, speed: 0.95, r: 0.75,
|
||||||
|
bounty: 10, flying: false, bldMult: 2.5, scale: 1.5, color: '#8d7f66' };
|
||||||
|
D.ptera = { id: 'ptera', name: 'Pteranodon', hp: 68, dmg: 8, rof: 0.7, speed: 2.6, r: 0.45,
|
||||||
|
bounty: 4, flying: true, scale: 1.0, color: '#9c86b8' };
|
||||||
|
D.sucho = { id: 'sucho', name: 'Suchomimus', hp: 300, dmg: 22, rof: 1.05, speed: 1.15, r: 0.7,
|
||||||
|
bounty: 14, flying: false, amphibious: true, bldMult: 1.5, scale: 1.45, color: '#4f7d6b' };
|
||||||
|
D.rex = { id: 'rex', name: 'Tyrannosaurus Rex', hp: 3400, dmg: 72, rof: 1.3, speed: 1.05, r: 1.05,
|
||||||
|
bounty: 45, flying: false, bldMult: 2.0, scale: 2.2, color: '#6f4f34' };
|
||||||
|
CFG.DINOS = D;
|
||||||
|
CFG.UNTAMEABLE = { rex: true };
|
||||||
|
|
||||||
|
// ---------- Attack waves (per difficulty): day -> composition ----------
|
||||||
|
// sucho = aquatic raiders, they emerge from the lakes (see spawnWave)
|
||||||
|
CFG.WAVES = {
|
||||||
|
easy: [
|
||||||
|
{ day: 3, comp: { compy: 10 } },
|
||||||
|
{ day: 5, comp: { compy: 12, raptor: 4 } },
|
||||||
|
{ day: 7, comp: { raptor: 6, ptera: 4 } },
|
||||||
|
{ day: 9, comp: { trike: 2, raptor: 6, compy: 10, sucho: 1 } },
|
||||||
|
{ day: 11, comp: { dilo: 5, ptera: 6, raptor: 5, sucho: 1 } },
|
||||||
|
{ day: 13, comp: { rex: 1, raptor: 8, ptera: 5, sucho: 2 } },
|
||||||
|
{ day: 17, final: true, comp: { rex: 2, trike: 3, dilo: 7, raptor: 12, ptera: 10, compy: 20, sucho: 3 } },
|
||||||
|
],
|
||||||
|
normal: [
|
||||||
|
{ day: 2, comp: { compy: 14 } },
|
||||||
|
{ day: 4, comp: { compy: 16, raptor: 6 } },
|
||||||
|
{ day: 6, comp: { raptor: 8, ptera: 6 } },
|
||||||
|
{ day: 8, comp: { trike: 2, raptor: 8, compy: 12, sucho: 1 } },
|
||||||
|
{ day: 10, comp: { dilo: 6, ptera: 8, raptor: 6, sucho: 2 } },
|
||||||
|
{ day: 12, comp: { rex: 1, raptor: 10, ptera: 6, sucho: 2 } },
|
||||||
|
{ day: 15, final: true, comp: { rex: 2, trike: 3, dilo: 8, raptor: 16, ptera: 14, compy: 24, sucho: 4 } },
|
||||||
|
],
|
||||||
|
hard: [
|
||||||
|
{ day: 2, comp: { compy: 20, raptor: 3 } },
|
||||||
|
{ day: 4, comp: { compy: 22, raptor: 9 } },
|
||||||
|
{ day: 6, comp: { raptor: 12, ptera: 9, dilo: 4, sucho: 1 } },
|
||||||
|
{ day: 8, comp: { trike: 3, raptor: 12, compy: 16, sucho: 2 } },
|
||||||
|
{ day: 10, comp: { dilo: 9, ptera: 12, raptor: 9, sucho: 3 } },
|
||||||
|
{ day: 12, comp: { rex: 2, raptor: 14, ptera: 9, sucho: 3 } },
|
||||||
|
{ day: 13, final: true, comp: { rex: 3, trike: 4, dilo: 10, raptor: 22, ptera: 18, compy: 30, sucho: 5 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
CFG.WARN_TIME = 45; // seconds of warning before a wave
|
||||||
|
CFG.ROAMER_PACKS = { easy: 8, normal: 10, hard: 12 };
|
||||||
|
|
||||||
|
return CFG;
|
||||||
|
})();
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
/* =========================================================
|
||||||
|
* REPRTERRA WEB — entities.js
|
||||||
|
* Factories & shared helpers for buildings/units/dinos.
|
||||||
|
* ========================================================= */
|
||||||
|
'use strict';
|
||||||
|
window.RTS = window.RTS || {};
|
||||||
|
|
||||||
|
RTS.entities = (function () {
|
||||||
|
const U = RTS.util;
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
const E = {};
|
||||||
|
E.resetIds = () => { nextId = 1; };
|
||||||
|
// after loading a save, keep new ids above everything deserialized
|
||||||
|
E.setIdFloor = (n) => { if (n >= nextId) nextId = n + 1; };
|
||||||
|
|
||||||
|
// ---------- footprint ----------
|
||||||
|
// returns [x0,y0] of top-left tile for a building centered at cx,cy
|
||||||
|
E.footOrigin = function (cx, cy, size) {
|
||||||
|
return [Math.round(cx - size / 2), Math.round(cy - size / 2)];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- buildings ----------
|
||||||
|
E.makeBuilding = function (defId, cx, cy, opts) {
|
||||||
|
const C = RTS.CONFIG;
|
||||||
|
const def = C.BUILDINGS[defId];
|
||||||
|
const instant = opts && opts.instant;
|
||||||
|
const b = {
|
||||||
|
kind: 'building',
|
||||||
|
id: nextId++,
|
||||||
|
defId,
|
||||||
|
name: def.name,
|
||||||
|
size: def.size,
|
||||||
|
x: Math.round(cx), y: Math.round(cy), // center tile coords
|
||||||
|
hp: instant ? def.hp : def.hp * 0.15,
|
||||||
|
maxHp: def.hp,
|
||||||
|
done: !!instant,
|
||||||
|
progress: instant ? 1 : 0,
|
||||||
|
buildTime: instant ? 0 : 2.5 + def.size * 2.5,
|
||||||
|
workersNeed: def.workers || 0,
|
||||||
|
workers: 0,
|
||||||
|
powered: true,
|
||||||
|
active: true, // production on/off
|
||||||
|
cool: 0, // tower fire cooldown
|
||||||
|
trainQ: [], // barracks queue [{t, unit}]
|
||||||
|
trainT: 0,
|
||||||
|
rallyX: null, rallyY: null,
|
||||||
|
burnT: 0,
|
||||||
|
};
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- human units ----------
|
||||||
|
E.makeUnit = function (unitId, x, y) {
|
||||||
|
const C = RTS.CONFIG;
|
||||||
|
const def = C.UNITS[unitId];
|
||||||
|
const simSt = (RTS.sim && RTS.sim.state) ? RTS.sim.state() : null;
|
||||||
|
const armorLvl = (simSt && simSt.upgrades) ? simSt.upgrades.armor || 0 : 0;
|
||||||
|
const hp = def.hp * (1 + 0.25 * armorLvl);
|
||||||
|
return {
|
||||||
|
kind: 'unit',
|
||||||
|
id: nextId++,
|
||||||
|
unitId,
|
||||||
|
name: def.name,
|
||||||
|
x, y,
|
||||||
|
hp, maxHp: hp,
|
||||||
|
speed: def.speed,
|
||||||
|
cool: 0,
|
||||||
|
path: null, pathI: 0,
|
||||||
|
tx: null, ty: null, // move goal
|
||||||
|
attackMove: false,
|
||||||
|
targetId: 0, // focused enemy
|
||||||
|
selected: false,
|
||||||
|
facing: U.rng() * Math.PI * 2,
|
||||||
|
animT: U.rng() * 10,
|
||||||
|
dead: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- dinosaurs ----------
|
||||||
|
E.makeDino = function (dinoId, x, y, mode, home) {
|
||||||
|
const C = RTS.CONFIG;
|
||||||
|
const def = C.DINOS[dinoId];
|
||||||
|
const rng = U.rng;
|
||||||
|
return {
|
||||||
|
kind: 'dino',
|
||||||
|
id: nextId++,
|
||||||
|
dinoId,
|
||||||
|
name: def.name,
|
||||||
|
x, y,
|
||||||
|
hp: def.hp, maxHp: def.hp,
|
||||||
|
dmg: def.dmg,
|
||||||
|
rof: def.rof,
|
||||||
|
speed: def.speed * rng.range(0.9, 1.12),
|
||||||
|
r: def.r,
|
||||||
|
flying: !!def.flying,
|
||||||
|
amphibious: !!def.amphibious,
|
||||||
|
ranged: def.ranged || 0,
|
||||||
|
bldMult: def.bldMult || 1,
|
||||||
|
bounty: def.bounty,
|
||||||
|
scale: def.scale,
|
||||||
|
color: def.color,
|
||||||
|
mode: mode || 'wave', // 'roam' | 'wave' | 'final'
|
||||||
|
homeX: home ? home.x : x, homeY: home ? home.y : y,
|
||||||
|
targetId: 0, targetType: '',
|
||||||
|
path: null, pathI: 0,
|
||||||
|
repathT: 0,
|
||||||
|
stuckT: 0,
|
||||||
|
lastDist: Infinity,
|
||||||
|
cool: 0,
|
||||||
|
facing: rng() * Math.PI * 2,
|
||||||
|
animT: rng() * 10,
|
||||||
|
wanderT: 0,
|
||||||
|
wx: x, wy: y,
|
||||||
|
aggro: !!(mode === 'wave'),
|
||||||
|
dead: false,
|
||||||
|
hitFlash: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- projectiles ----------
|
||||||
|
E.makeProj = function (type, x, y, tx, ty, opts) {
|
||||||
|
return {
|
||||||
|
kind: 'proj',
|
||||||
|
id: nextId++,
|
||||||
|
type, // 'bullet' | 'shell' | 'spit'
|
||||||
|
x, y, z: opts && opts.z != null ? opts.z : 8,
|
||||||
|
tx, ty,
|
||||||
|
speed: opts && opts.speed || 14,
|
||||||
|
dmg: opts && opts.dmg || 5,
|
||||||
|
aoe: opts && opts.aoe || 0,
|
||||||
|
air: !!(opts && opts.air),
|
||||||
|
fromPlayer: !(opts && opts.enemy),
|
||||||
|
color: opts && opts.color || '#ffd76a',
|
||||||
|
trail: [],
|
||||||
|
arc: opts && opts.arc || 0, // lob height for shells/spits
|
||||||
|
t: 0, total: 1,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return E;
|
||||||
|
})();
|
||||||
+308
@@ -0,0 +1,308 @@
|
|||||||
|
/* =========================================================
|
||||||
|
* REPRTERRA WEB — input.js
|
||||||
|
* Mouse & keyboard: camera, selection, placement, commands.
|
||||||
|
* ========================================================= */
|
||||||
|
'use strict';
|
||||||
|
window.RTS = window.RTS || {};
|
||||||
|
|
||||||
|
RTS.input = (function () {
|
||||||
|
const U = RTS.util;
|
||||||
|
let cv = null;
|
||||||
|
const IN = {};
|
||||||
|
IN.ui = null; // shared UI state (set by ui.init)
|
||||||
|
IN.mouse = { sx: 0, sy: 0, wx: 0, wy: 0, down: false, mid: false, button: 0 };
|
||||||
|
IN.keys = {};
|
||||||
|
|
||||||
|
let dragStart = null; // {sx,sy} screen for rect select
|
||||||
|
let panning = false;
|
||||||
|
let panStart = null;
|
||||||
|
let lastPaintTile = null;
|
||||||
|
const C_TILE_W = RTS.CONFIG.WORLD.TILE_W, C_TILE_H = RTS.CONFIG.WORLD.TILE_H;
|
||||||
|
const TW2Z = C_TILE_W / 2, TH2Z = C_TILE_H / 2;
|
||||||
|
|
||||||
|
IN.attach = function (canvas) {
|
||||||
|
cv = canvas;
|
||||||
|
|
||||||
|
cv.addEventListener('contextmenu', e => e.preventDefault());
|
||||||
|
|
||||||
|
cv.addEventListener('mousedown', (e) => {
|
||||||
|
RTS.audio.resume();
|
||||||
|
const st = RTS.sim.state();
|
||||||
|
if (!st || st.over || RTS.main.state() !== 'playing') return;
|
||||||
|
const ui = IN.ui;
|
||||||
|
IN.mouse.sx = e.offsetX; IN.mouse.sy = e.offsetY;
|
||||||
|
updateWorldPos();
|
||||||
|
|
||||||
|
if (e.button === 1) { // middle -> pan
|
||||||
|
panning = true; panStart = { sx: e.clientX, sy: e.clientY, cx: RTS.render.cam.x, cy: RTS.render.cam.y };
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.button === 0) {
|
||||||
|
if (ui.placing) {
|
||||||
|
tryPlaceAt(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragStart = { sx: e.offsetX, sy: e.offsetY };
|
||||||
|
ui.dragRect = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', (e) => {
|
||||||
|
// edge scroll + hover even outside canvas
|
||||||
|
IN.mouse.cx = e.clientX; IN.mouse.cy = e.clientY;
|
||||||
|
});
|
||||||
|
|
||||||
|
cv.addEventListener('mousemove', (e) => {
|
||||||
|
IN.mouse.sx = e.offsetX; IN.mouse.sy = e.offsetY;
|
||||||
|
updateWorldPos();
|
||||||
|
const ui = IN.ui;
|
||||||
|
|
||||||
|
if (panning && panStart) {
|
||||||
|
const z = RTS.render.cam.zoom;
|
||||||
|
const dx = (e.clientX - panStart.sx);
|
||||||
|
const dy = (e.clientY - panStart.sy);
|
||||||
|
// convert screen delta to world delta (grab-the-map)
|
||||||
|
RTS.render.cam.x = panStart.cx - (dx / (TW2Z * z) + dy / (TH2Z * z)) * 0.5;
|
||||||
|
RTS.render.cam.y = panStart.cy - (dy / (TH2Z * z) - dx / (TW2Z * z)) * 0.5;
|
||||||
|
RTS.render.clampCam();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ui.placing) {
|
||||||
|
ui.placing.x = Math.round(IN.mouse.wx);
|
||||||
|
ui.placing.y = Math.round(IN.mouse.wy);
|
||||||
|
if (IN.mouse.down === 'paint') tryPlaceAt(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragStart) {
|
||||||
|
const dx = IN.mouse.sx - dragStart.sx, dy = IN.mouse.sy - dragStart.sy;
|
||||||
|
if (dx * dx + dy * dy > 36) {
|
||||||
|
ui.dragRect = { x0: dragStart.sx, y0: dragStart.sy, x1: IN.mouse.sx, y1: IN.mouse.sy };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', (e) => {
|
||||||
|
const ui = IN.ui;
|
||||||
|
if (e.button === 1) { panning = false; panStart = null; }
|
||||||
|
if (e.button !== 0 || !cv) return;
|
||||||
|
IN.mouse.down = false;
|
||||||
|
if (ui.placing) { lastPaintTile = null; return; }
|
||||||
|
if (dragStart && ui.dragRect) {
|
||||||
|
// rect select units
|
||||||
|
const w0 = RTS.render.screenToWorld(ui.dragRect.x0, ui.dragRect.y0);
|
||||||
|
const w1 = RTS.render.screenToWorld(ui.dragRect.x1, ui.dragRect.y1);
|
||||||
|
const found = RTS.sim.selectUnitsInRect(w0.x, w0.y, w1.x, w1.y);
|
||||||
|
const rangers = found.filter(u => u.unitId === 'ranger');
|
||||||
|
if (rangers.length) {
|
||||||
|
ui.select({ kind: 'units', ids: rangers.map(u => u.id) });
|
||||||
|
} else {
|
||||||
|
ui.select(null);
|
||||||
|
}
|
||||||
|
ui.dragRect = null;
|
||||||
|
dragStart = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dragStart) {
|
||||||
|
// single click select
|
||||||
|
dragStart = null;
|
||||||
|
const ent = RTS.sim.entityAt(IN.mouse.wx, IN.mouse.wy);
|
||||||
|
if (ent && ent.kind === 'unit' && ent.unitId === 'colonist') {
|
||||||
|
// colonists not individually selectable; show nothing special
|
||||||
|
ui.select(null);
|
||||||
|
} else if (ent && ent.kind === 'building') {
|
||||||
|
ui.select({ kind: 'building', id: ent.id });
|
||||||
|
} else if (ent && ent.kind === 'dino') {
|
||||||
|
ui.select({ kind: 'dino', id: ent.id });
|
||||||
|
} else if (ent && ent.kind === 'unit') {
|
||||||
|
ui.select({ kind: 'units', ids: [ent.id] });
|
||||||
|
} else {
|
||||||
|
ui.select(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cv.addEventListener('mouseup', (e) => {
|
||||||
|
const ui = IN.ui;
|
||||||
|
const st = RTS.sim.state();
|
||||||
|
if (!st || e.button !== 2) return;
|
||||||
|
if (ui.placing) { ui.setPlacing(null); return; }
|
||||||
|
updateWorldPos();
|
||||||
|
const ent = RTS.sim.entityAt(IN.mouse.wx, IN.mouse.wy);
|
||||||
|
// rally point
|
||||||
|
if (ui.sel && ui.sel.kind === 'building') {
|
||||||
|
const b = RTS.sim.getBuilding(ui.sel.id);
|
||||||
|
if (b && (b.defId === 'barracks' || b.defId === 'primalpen')) {
|
||||||
|
RTS.sim.setRally(b.id, IN.mouse.wx, IN.mouse.wy);
|
||||||
|
ui.fxPing(IN.mouse.wx, IN.mouse.wy, '#cfe8ff');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// tamed pet commands
|
||||||
|
if (ui.sel && ui.sel.kind === 'dino' && RTS.sim.selectedPetCommandable(ui.sel.id)) {
|
||||||
|
if (ent && ent.kind === 'dino' && !ent.tamed) {
|
||||||
|
RTS.sim.commandPetAttack(ui.sel.id, ent.id);
|
||||||
|
ui.fxPing(ent.x, ent.y, '#ff5f4a');
|
||||||
|
} else {
|
||||||
|
RTS.sim.commandPet(ui.sel.id, IN.mouse.wx, IN.mouse.wy);
|
||||||
|
ui.fxPing(IN.mouse.wx, IN.mouse.wy, '#7fd6ff');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ui.selUnits.length) {
|
||||||
|
// tamers: right-click a dino = prioritize its capture
|
||||||
|
const tamerIds = [];
|
||||||
|
const rangerIds = [];
|
||||||
|
for (const id of ui.selUnits) {
|
||||||
|
const u = st.units.find(v => v.id === id && !v.dead);
|
||||||
|
if (!u) continue;
|
||||||
|
if (u.unitId === 'tamer') tamerIds.push(id);
|
||||||
|
else if (u.unitId === 'ranger') rangerIds.push(id);
|
||||||
|
}
|
||||||
|
if (ent && ent.kind === 'dino' && !ent.tamed && tamerIds.length) {
|
||||||
|
RTS.sim.setCapturePriority(tamerIds, ent.id);
|
||||||
|
ui.fxPing(ent.x, ent.y, '#b06fe0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rangerIds.length || tamerIds.length) {
|
||||||
|
if (rangerIds.length) {
|
||||||
|
if (ent && ent.kind === 'dino') {
|
||||||
|
for (const id of rangerIds) {
|
||||||
|
const u = st.units.find(v => v.id === id && !v.dead);
|
||||||
|
if (u) u.targetId = ent.id;
|
||||||
|
}
|
||||||
|
ui.fxPing(ent.x, ent.y, '#ff5f4a');
|
||||||
|
} else {
|
||||||
|
RTS.sim.commandMove(rangerIds, IN.mouse.wx, IN.mouse.wy, false);
|
||||||
|
ui.fxPing(IN.mouse.wx, IN.mouse.wy, '#7fff9f');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// tamers only selected: send them toward the spot
|
||||||
|
for (const tid of tamerIds) {
|
||||||
|
const u = st.units.find(v => v.id === tid && !v.dead);
|
||||||
|
if (u) { u.tx = IN.mouse.wx; u.ty = IN.mouse.wy; u.path = [{ x: IN.mouse.wx, y: IN.mouse.wy }]; u.pathI = 0; }
|
||||||
|
}
|
||||||
|
ui.fxPing(IN.mouse.wx, IN.mouse.wy, '#b06fe0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cv.addEventListener('dblclick', () => {
|
||||||
|
const ui = IN.ui;
|
||||||
|
// select all rangers & tamers on screen
|
||||||
|
const found = [];
|
||||||
|
for (const u of RTS.sim.state().units) {
|
||||||
|
if (u.dead || (u.unitId !== 'ranger' && u.unitId !== 'tamer')) continue;
|
||||||
|
const sp = RTS.render.worldToScreen(u.x, u.y);
|
||||||
|
if (sp.x >= 0 && sp.x <= cv.clientWidth && sp.y >= 0 && sp.y <= cv.clientHeight) found.push(u.id);
|
||||||
|
}
|
||||||
|
if (found.length) ui.select({ kind: 'units', ids: found });
|
||||||
|
});
|
||||||
|
|
||||||
|
cv.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const cam = RTS.render.cam;
|
||||||
|
const before = RTS.render.screenToWorld(e.offsetX, e.offsetY);
|
||||||
|
cam.zoom *= e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||||
|
RTS.render.clampCam();
|
||||||
|
const after = RTS.render.screenToWorld(e.offsetX, e.offsetY);
|
||||||
|
cam.x += before.x - after.x;
|
||||||
|
cam.y += before.y - after.y;
|
||||||
|
RTS.render.clampCam();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
IN.keys[k] = true;
|
||||||
|
const ui = IN.ui;
|
||||||
|
if (RTS.main.state() !== 'playing' && k !== 'escape') {
|
||||||
|
if (k === 'enter' || k === ' ') RTS.main.hotkey(e.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (k >= '1' && k <= '9') {
|
||||||
|
const slot = parseInt(k) - 1;
|
||||||
|
const flat = RTS.ui.flatPalette();
|
||||||
|
if (flat[slot]) ui.togglePlacing(flat[slot]);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
switch (k) {
|
||||||
|
case 'escape':
|
||||||
|
if (ui.placing) ui.setPlacing(null);
|
||||||
|
else ui.select(null);
|
||||||
|
break;
|
||||||
|
case ' ': case 'p': RTS.main.togglePause(); e.preventDefault(); break;
|
||||||
|
case 'f': {
|
||||||
|
const q = RTS.sim.hq();
|
||||||
|
if (q) { RTS.render.cam.x = q.x; RTS.render.cam.y = q.y; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete': if (ui.sel && ui.sel.kind === 'building') RTS.sim.demolish(ui.sel.id); break;
|
||||||
|
case 'm': ui.toggleMute(); break;
|
||||||
|
case '+': case '=': RTS.main.bumpSpeed(1); break;
|
||||||
|
case '-': RTS.main.bumpSpeed(-1); break;
|
||||||
|
case '0': RTS.main.setSpeed(1); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('keyup', (e) => { IN.keys[e.key.toLowerCase()] = false; });
|
||||||
|
window.addEventListener('blur', () => { IN.keys = {}; });
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateWorldPos() {
|
||||||
|
const w = RTS.render.screenToWorld(IN.mouse.sx, IN.mouse.sy);
|
||||||
|
IN.mouse.wx = w.x; IN.mouse.wy = w.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryPlaceAt(click) {
|
||||||
|
const ui = IN.ui;
|
||||||
|
const st = RTS.sim.state();
|
||||||
|
const defId = ui.placing.defId;
|
||||||
|
const tx = Math.round(IN.mouse.wx), ty = Math.round(IN.mouse.wy);
|
||||||
|
const tileKey = tx + ',' + ty;
|
||||||
|
if (C_isLine(defId) && !click && lastPaintTile === tileKey) return; // same tile
|
||||||
|
const res = RTS.sim.place(defId, tx, ty);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (click) { RTS.audio.deny(); ui.toast(res.why, 'bad'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPaintTile = tileKey;
|
||||||
|
if (C_isLine(defId)) {
|
||||||
|
IN.mouse.down = 'paint';
|
||||||
|
} else if (!IN.keys['shift']) {
|
||||||
|
// stop placing unless shift-held (multi place)
|
||||||
|
ui.setPlacing(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function C_isLine(defId) { return defId === 'wall' || defId === 'gate'; }
|
||||||
|
|
||||||
|
IN.tick = function (dtReal) {
|
||||||
|
const ui = IN.ui;
|
||||||
|
if (!ui) return;
|
||||||
|
const z = RTS.render.cam.zoom;
|
||||||
|
const v = 520 / z * dtReal; // px/sec along screen axes
|
||||||
|
let ex = 0, ey = 0; // screen-space direction
|
||||||
|
if (IN.keys['w'] || IN.keys['arrowup']) ey -= 1;
|
||||||
|
if (IN.keys['s'] || IN.keys['arrowdown']) ey += 1;
|
||||||
|
if (IN.keys['a'] || IN.keys['arrowleft']) ex -= 1;
|
||||||
|
if (IN.keys['d'] || IN.keys['arrowright']) ex += 1;
|
||||||
|
// edge scroll
|
||||||
|
if (IN.mouse.cx != null && document.hasFocus()) {
|
||||||
|
const m = 22, w = window.innerWidth, h = window.innerHeight;
|
||||||
|
if (IN.mouse.cx < m) ex -= 1; else if (IN.mouse.cx > w - m) ex += 1;
|
||||||
|
if (IN.mouse.cy < m) ey -= 1; else if (IN.mouse.cy > h - m) ey += 1;
|
||||||
|
}
|
||||||
|
if (ex || ey) {
|
||||||
|
const len = Math.hypot(ex, ey); ex /= len; ey /= len;
|
||||||
|
// screen dir -> world dir
|
||||||
|
const wxd = (ex / TW2Z + ey / TH2Z) * 0.5;
|
||||||
|
const wyd = (ey / TH2Z - ex / TW2Z) * 0.5;
|
||||||
|
RTS.render.cam.x += wxd * v;
|
||||||
|
RTS.render.cam.y += wyd * v;
|
||||||
|
RTS.render.clampCam();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return IN;
|
||||||
|
})();
|
||||||
+231
@@ -0,0 +1,231 @@
|
|||||||
|
/* =========================================================
|
||||||
|
* REPRTERRA WEB — main.js
|
||||||
|
* Bootstrap + game loop + app states.
|
||||||
|
* ========================================================= */
|
||||||
|
'use strict';
|
||||||
|
window.RTS = window.RTS || {};
|
||||||
|
|
||||||
|
RTS.main = (function () {
|
||||||
|
let appState = 'menu'; // 'menu' | 'playing' | 'over'
|
||||||
|
let paused = false;
|
||||||
|
let lastT = 0;
|
||||||
|
let acc = 0;
|
||||||
|
let hudT = 0, mmT = 0;
|
||||||
|
let lastDaySeen = 1;
|
||||||
|
|
||||||
|
const M = {};
|
||||||
|
M.state = () => (appState === 'playing' && paused) ? 'playing' : appState;
|
||||||
|
M.isPaused = () => paused;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// SAVE STORAGE — localStorage glue around sim.serialize
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
const SAVE_KEY = 'repterra-web-save-v2';
|
||||||
|
const storage = {
|
||||||
|
ok: (() => { try { localStorage.setItem('_rt', '1'); localStorage.removeItem('_rt'); return true; } catch (e) { return false; } })(),
|
||||||
|
has() { try { return !!localStorage.getItem(SAVE_KEY); } catch (e) { return false; } },
|
||||||
|
save(manual) {
|
||||||
|
if (!this.ok || appState !== 'playing') return false;
|
||||||
|
const snap = RTS.sim.serialize();
|
||||||
|
if (!snap || snap.st.over) return false;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SAVE_KEY, JSON.stringify(snap));
|
||||||
|
RTS.ui.toast(manual ? '💾 Colony saved.' : '💾 Dawn autosave.', '');
|
||||||
|
return true;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
},
|
||||||
|
autosave() { this.save(false); },
|
||||||
|
load() {
|
||||||
|
if (!this.ok) return false;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SAVE_KEY);
|
||||||
|
if (!raw) return false;
|
||||||
|
if (!RTS.sim.deserialize(raw)) return false;
|
||||||
|
const q = RTS.sim.hq();
|
||||||
|
if (q) { RTS.render.cam.x = q.x; RTS.render.cam.y = q.y; RTS.render.cam.zoom = Math.max(RTS.render.cam.zoom, 1.0); }
|
||||||
|
paused = false;
|
||||||
|
document.getElementById('pauseoverlay').style.display = 'none';
|
||||||
|
appState = 'playing';
|
||||||
|
lastDaySeen = RTS.sim.state().day;
|
||||||
|
RTS.ui.hideEnd && RTS.ui.hideEnd();
|
||||||
|
RTS.ui.showMenu(false);
|
||||||
|
RTS.ui.select(null);
|
||||||
|
RTS.ui.setPlacing(null);
|
||||||
|
RTS.ui.toast('📂 Welcome back — Day ' + RTS.sim.state().day + '.', '');
|
||||||
|
return true;
|
||||||
|
} catch (e) { console.error('load failed', e); return false; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
RTS.storage = storage;
|
||||||
|
|
||||||
|
M.boot = function () {
|
||||||
|
const cv = document.getElementById('game');
|
||||||
|
RTS.render.init(cv);
|
||||||
|
RTS.ui.init();
|
||||||
|
RTS.input.attach(cv);
|
||||||
|
RTS.input.ui = RTS.ui;
|
||||||
|
|
||||||
|
// minimap
|
||||||
|
const mm = document.getElementById('minimap');
|
||||||
|
mm.width = 176; mm.height = 176;
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => RTS.render.resize());
|
||||||
|
|
||||||
|
// minimap click-to-jump
|
||||||
|
mm.addEventListener('mousedown', (e) => {
|
||||||
|
const st = RTS.sim.state();
|
||||||
|
if (!st) return;
|
||||||
|
const r = mm.getBoundingClientRect();
|
||||||
|
RTS.render.cam.x = (e.clientX - r.left) / r.width * st.world.tiles.W;
|
||||||
|
RTS.render.cam.y = (e.clientY - r.top) / r.height * st.world.tiles.H;
|
||||||
|
RTS.render.clampCam();
|
||||||
|
});
|
||||||
|
|
||||||
|
RTS.ui.showMenu(true);
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
M.startGame = function (diff) {
|
||||||
|
RTS.audio.resume();
|
||||||
|
RTS.sim.newGame(diff);
|
||||||
|
const q = RTS.sim.hq();
|
||||||
|
RTS.render.cam.x = q.x; RTS.render.cam.y = q.y; RTS.render.cam.zoom = 1.1;
|
||||||
|
paused = false;
|
||||||
|
appState = 'playing';
|
||||||
|
lastDaySeen = 1;
|
||||||
|
RTS.ui.hideEnd();
|
||||||
|
RTS.ui.showMenu(false);
|
||||||
|
RTS.ui.setPlacing(null);
|
||||||
|
RTS.ui.select(null);
|
||||||
|
RTS.ui.toast('Colony established. Build houses and generators first!', '');
|
||||||
|
setTimeout(() => { if (appState === 'playing') RTS.ui.toast('Tip: Pteranodons FLY over walls — keep Watchtowers ready.', ''); }, 6000);
|
||||||
|
};
|
||||||
|
|
||||||
|
M.restart = function () { M.startGame(RTS.sim.state() ? RTS.sim.state().diff : 'normal'); };
|
||||||
|
M.loadSave = function () { storage.load(); };
|
||||||
|
M.toMenu = function () {
|
||||||
|
appState = 'menu';
|
||||||
|
RTS.ui.showEnd ? null : null;
|
||||||
|
document.getElementById('endscreen').style.display = 'none';
|
||||||
|
RTS.ui.showMenu(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
M.togglePause = function () {
|
||||||
|
if (appState !== 'playing') return;
|
||||||
|
paused = !paused;
|
||||||
|
document.getElementById('pauseoverlay').style.display = paused ? 'flex' : 'none';
|
||||||
|
};
|
||||||
|
M.setSpeed = function (s) {
|
||||||
|
if (!RTS.sim.state()) return;
|
||||||
|
RTS.sim.state().speed = s;
|
||||||
|
if (paused && s > 0) M.togglePause();
|
||||||
|
markSpeedBtns(s);
|
||||||
|
};
|
||||||
|
M.bumpSpeed = function (d) {
|
||||||
|
const st = RTS.sim.state();
|
||||||
|
if (!st) return;
|
||||||
|
M.setSpeed(Math.min(3, Math.max(1, st.speed + d)));
|
||||||
|
};
|
||||||
|
|
||||||
|
function markSpeedBtns(s) {
|
||||||
|
document.querySelectorAll('#topbtns .tbtn').forEach((b) => {
|
||||||
|
b.classList.toggle('active', b.textContent === s + '×');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
M.hotkey = function (key) {
|
||||||
|
if (key === 'Enter') {
|
||||||
|
// start with selected/default difficulty from menu
|
||||||
|
const sel = document.querySelector('.diffbtn.sel') || document.querySelector('[data-diff="normal"]');
|
||||||
|
if (sel && appState === 'menu') sel.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
function loop(t) {
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
const dtReal = Math.min(0.05, (t - lastT) / 1000 || 0.016);
|
||||||
|
lastT = t;
|
||||||
|
|
||||||
|
RTS.input.tick(dtReal);
|
||||||
|
|
||||||
|
const st = RTS.sim.state();
|
||||||
|
if (st && appState === 'playing') {
|
||||||
|
if (!paused) {
|
||||||
|
acc += dtReal * st.speed;
|
||||||
|
let steps = 0;
|
||||||
|
while (acc > 1 / 120 && steps < 12) {
|
||||||
|
const step = Math.min(acc, 1 / 30);
|
||||||
|
RTS.sim.tick(step);
|
||||||
|
acc -= step;
|
||||||
|
steps++;
|
||||||
|
}
|
||||||
|
checkEvents(st);
|
||||||
|
if (st.over && !st.endShown) {
|
||||||
|
st.endShown = true;
|
||||||
|
setTimeout(() => { if (st.over) RTS.ui.showEnd(st); }, 1400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// draw
|
||||||
|
RTS.render.draw(st, RTS.ui, t / 1000);
|
||||||
|
hudT -= dtReal;
|
||||||
|
if (hudT <= 0) { hudT = 0.25; RTS.ui.updateHUD(); }
|
||||||
|
mmT -= dtReal;
|
||||||
|
if (mmT <= 0) {
|
||||||
|
mmT = 0.3;
|
||||||
|
const mmCtx = document.getElementById('minimap').getContext('2d');
|
||||||
|
RTS.render.drawMinimap(mmCtx, st, 176);
|
||||||
|
}
|
||||||
|
drawPings(st, dtReal);
|
||||||
|
} else if (st && appState === 'over') {
|
||||||
|
RTS.render.draw(st, RTS.ui, t / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPings(st, dt) {
|
||||||
|
for (const p of RTS.ui.pings) p.t += dt;
|
||||||
|
RTS.ui.pings = RTS.ui.pings.filter(p => p.t < 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
let wasWarned = false, hqHitToast = false, brownToast = false;
|
||||||
|
function checkEvents(st) {
|
||||||
|
// wave spawn toast
|
||||||
|
const w = st.waves[Math.max(0, st.waveIdx - 1)];
|
||||||
|
if (w && w.spawned && !w._toast) {
|
||||||
|
w._toast = true;
|
||||||
|
RTS.ui.toast(w.final ? '☠ THE FINAL ASSAULT HAS BEGUN!' : '🌊 A dinosaur wave is attacking!', w.final ? 'bad' : 'warn');
|
||||||
|
if (w.final) RTS.audio.roar();
|
||||||
|
}
|
||||||
|
// warning siren once
|
||||||
|
if (st.warnT > 0 && !wasWarned) {
|
||||||
|
wasWarned = true;
|
||||||
|
RTS.ui.toast('⚠ Dinosaur horde spotted approaching the colony!', 'bad');
|
||||||
|
}
|
||||||
|
if (st.warnT <= 0) wasWarned = false;
|
||||||
|
// HQ under attack
|
||||||
|
const q = RTS.sim.hq();
|
||||||
|
if (q && q.hp < q.maxHp && !hqHitToast) {
|
||||||
|
hqHitToast = true;
|
||||||
|
RTS.ui.toast('🏛️ THE COMMAND CENTER IS UNDER ATTACK!', 'bad');
|
||||||
|
RTS.audio.alarm();
|
||||||
|
}
|
||||||
|
if (q && q.hp >= q.maxHp) hqHitToast = false;
|
||||||
|
// brownout
|
||||||
|
if (st.energyUse > st.energyCap && !brownToast) {
|
||||||
|
brownToast = true;
|
||||||
|
RTS.ui.toast('⚡ Power shortage! Newest buildings are offline — build Generators.', 'warn');
|
||||||
|
}
|
||||||
|
if (st.energyUse <= st.energyCap) brownToast = false;
|
||||||
|
// starving
|
||||||
|
if (st.starving && !checkEvents._starve) {
|
||||||
|
checkEvents._starve = true;
|
||||||
|
RTS.ui.toast('🍖 Food shortage! Colonists are starving.', 'warn');
|
||||||
|
}
|
||||||
|
if (!st.starving) checkEvents._starve = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return M;
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => RTS.main.boot());
|
||||||
+1240
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,526 @@
|
|||||||
|
/* =========================================================
|
||||||
|
* REPRTERRA WEB — ui.js
|
||||||
|
* HUD: resource bar, build palette, selection panel,
|
||||||
|
* warnings, toasts, minimap frame, menus & end screens.
|
||||||
|
* ========================================================= */
|
||||||
|
'use strict';
|
||||||
|
window.RTS = window.RTS || {};
|
||||||
|
|
||||||
|
RTS.ui = (function () {
|
||||||
|
const U = RTS.util;
|
||||||
|
const C = RTS.CONFIG;
|
||||||
|
|
||||||
|
const UI = {};
|
||||||
|
UI.sel = null; // {kind:'building'|'dino'|'units', id?, ids?}
|
||||||
|
Object.defineProperty(UI, 'selUnits', {
|
||||||
|
get() { return (UI.sel && UI.sel.kind === 'units') ? UI.sel.ids.filter(id => !isDead(id)) : []; },
|
||||||
|
});
|
||||||
|
UI.placing = null; // {defId,x,y}
|
||||||
|
UI.dragRect = null;
|
||||||
|
UI.pings = []; // {x,y,t,color}
|
||||||
|
UI.tooltipEl = null;
|
||||||
|
|
||||||
|
let el = {}; // cached elements
|
||||||
|
let paletteBtns = [];
|
||||||
|
|
||||||
|
const ICONS = { gold: '💰', wood: '🪵', stone: '🪨', food: '🍖', energy: '⚡', pop: '👥' };
|
||||||
|
const BICON = {
|
||||||
|
hq: '🏛️', house: '🏠', farm: '🌾', forester: '🌲', quarry: '⛏️',
|
||||||
|
generator: '🔋', wall: '🧱', gate: '🚪', watchtower: '🏹', cannon: '💣',
|
||||||
|
barracks: '🎖️', primalpen: '🦴',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
UI.init = function () {
|
||||||
|
el.hud = document.getElementById('hud');
|
||||||
|
el.top = document.getElementById('topbar');
|
||||||
|
el.resGold = q('#res-gold .v'); el.resGoldR = q('#res-gold .r');
|
||||||
|
el.resWood = q('#res-wood .v'); el.resWoodR = q('#res-wood .r');
|
||||||
|
el.resStone = q('#res-stone .v'); el.resStoneR = q('#res-stone .r');
|
||||||
|
el.resFood = q('#res-food .v'); el.resFoodR = q('#res-food .r');
|
||||||
|
el.energy = q('#res-energy .v');
|
||||||
|
el.pop = q('#res-pop .v');
|
||||||
|
el.day = document.getElementById('daylabel');
|
||||||
|
el.wave = document.getElementById('wavelabel');
|
||||||
|
el.banner = document.getElementById('wavebanner');
|
||||||
|
el.bannerTxt = document.getElementById('wavebanner-text');
|
||||||
|
|
||||||
|
buildPalette();
|
||||||
|
buildTopButtons();
|
||||||
|
|
||||||
|
el.panel = document.getElementById('selpanel');
|
||||||
|
el.toasts = document.getElementById('toasts');
|
||||||
|
el.minimap = document.getElementById('minimap');
|
||||||
|
el.mmWrap = document.getElementById('mmwrap');
|
||||||
|
|
||||||
|
// tooltip
|
||||||
|
el.tip = document.getElementById('tooltip');
|
||||||
|
window.addEventListener('mousemove', (e) => {
|
||||||
|
if (el.tip.style.display === 'block') {
|
||||||
|
el.tip.style.left = Math.min(window.innerWidth - 260, e.clientX + 14) + 'px';
|
||||||
|
el.tip.style.top = Math.min(window.innerHeight - 120, e.clientY + 16) + 'px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// menu
|
||||||
|
el.menu = document.getElementById('menu');
|
||||||
|
el.endscreen = document.getElementById('endscreen');
|
||||||
|
document.querySelectorAll('[data-diff]').forEach(b => {
|
||||||
|
b.addEventListener('click', () => { RTS.audio.resume(); RTS.audio.click(); RTS.main.startGame(b.dataset.diff); });
|
||||||
|
});
|
||||||
|
document.getElementById('btn-how').addEventListener('click', () => toggleHelp());
|
||||||
|
document.querySelectorAll('.backtomenu').forEach(b => b.addEventListener('click', () => RTS.main.toMenu()));
|
||||||
|
document.querySelectorAll('[data-restart]').forEach(b => b.addEventListener('click', () => RTS.main.restart()));
|
||||||
|
|
||||||
|
// save / load / continue
|
||||||
|
const cont = document.getElementById('continueBtn');
|
||||||
|
if (cont) {
|
||||||
|
if (RTS.storage && RTS.storage.has()) cont.style.display = '';
|
||||||
|
else if (RTS.storage) { // re-check shortly (storage may just have become available)
|
||||||
|
setTimeout(() => { if (RTS.storage.has()) cont.style.display = ''; }, 300);
|
||||||
|
}
|
||||||
|
cont.addEventListener('click', () => { RTS.audio.resume(); RTS.audio.click(); RTS.main.loadSave(); });
|
||||||
|
}
|
||||||
|
const sb = document.getElementById('savebtn');
|
||||||
|
if (sb) sb.addEventListener('click', () => { RTS.storage.save(true); });
|
||||||
|
const lb = document.getElementById('loadbtn');
|
||||||
|
if (lb) {
|
||||||
|
const refresh = () => { lb.disabled = !RTS.storage.has(); };
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 1500);
|
||||||
|
lb.addEventListener('click', () => { RTS.audio.click(); RTS.main.loadSave(); });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function q(s) { return document.querySelector(s); }
|
||||||
|
|
||||||
|
function buildTopButtons() {
|
||||||
|
const tb = document.getElementById('topbtns');
|
||||||
|
tb.innerHTML = '';
|
||||||
|
mkBtn('⏸', 'Pause (Space)', () => RTS.main.togglePause());
|
||||||
|
mkBtn('1×', 'Normal speed', () => RTS.main.setSpeed(1));
|
||||||
|
mkBtn('2×', 'Fast forward', () => RTS.main.setSpeed(2));
|
||||||
|
mkBtn('3×', 'Very fast', () => RTS.main.setSpeed(3));
|
||||||
|
mkBtn('🔊', 'Mute (M)', () => UI.toggleMute(), 'mutebtn');
|
||||||
|
mkBtn('❓', 'Help', () => toggleHelp());
|
||||||
|
|
||||||
|
function mkBtn(label, tip, fn, id) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'tbtn';
|
||||||
|
if (id) b.id = id;
|
||||||
|
b.textContent = label;
|
||||||
|
b.title = tip;
|
||||||
|
b.addEventListener('click', () => { RTS.audio.click(); fn(); });
|
||||||
|
tb.appendChild(b);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPalette() {
|
||||||
|
const pal = document.getElementById('palette');
|
||||||
|
pal.innerHTML = '';
|
||||||
|
paletteBtns = [];
|
||||||
|
let slot = 0;
|
||||||
|
C.PALETTE.forEach((group) => {
|
||||||
|
const col = document.createElement('div');
|
||||||
|
col.className = 'palcol';
|
||||||
|
group.forEach((defId) => {
|
||||||
|
const def = C.BUILDINGS[defId];
|
||||||
|
const hkNum = slot < 9 ? (slot + 1) : 0;
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'palbtn';
|
||||||
|
b.innerHTML =
|
||||||
|
'<span class="ic">' + BICON[defId] + '</span>' +
|
||||||
|
'<span class="nm">' + def.name + '</span>' +
|
||||||
|
'<span class="cost">' + costStr(def.cost) + '</span>' +
|
||||||
|
'<span class="hk">' + (hkNum || '') + '</span>';
|
||||||
|
b.addEventListener('click', () => { RTS.audio.click(); UI.togglePlacing(defId); });
|
||||||
|
b.addEventListener('mouseenter', (e) => showTip(buildTip(def), e));
|
||||||
|
b.addEventListener('mouseleave', hideTip);
|
||||||
|
col.appendChild(b);
|
||||||
|
paletteBtns.push({ defId, btn: b });
|
||||||
|
slot++;
|
||||||
|
});
|
||||||
|
pal.appendChild(col);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function costStr(cost) {
|
||||||
|
return Object.entries(cost).map(([k, v]) => ICONS[k] + v).join(' ');
|
||||||
|
}
|
||||||
|
function energyStr(def) {
|
||||||
|
if (def.energyUse) return '<i>Uses ⚡' + def.energyUse + (def.workers ? ' · 👷' + def.workers : '') + '</i>';
|
||||||
|
if (def.energyProd) return '<i>Makes ⚡' + def.energyProd + '</i>';
|
||||||
|
return '<i>No power needed</i>';
|
||||||
|
}
|
||||||
|
function buildTip(def) {
|
||||||
|
return '<b>' + BICON[def.id] + ' ' + def.name + '</b><br>' + def.desc + '<br>' +
|
||||||
|
costStr(def.cost) + '<br>' + energyStr(def) + (def.hp ? '<br><i>HP ' + def.hp + '</i>' : '');
|
||||||
|
}
|
||||||
|
function showTip(html, e) {
|
||||||
|
el.tip.innerHTML = html;
|
||||||
|
el.tip.style.display = 'block';
|
||||||
|
el.tip.style.left = Math.min(window.innerWidth - 260, e.clientX + 14) + 'px';
|
||||||
|
el.tip.style.top = Math.min(window.innerHeight - 140, e.clientY + 16) + 'px';
|
||||||
|
}
|
||||||
|
function hideTip() { el.tip.style.display = 'none'; }
|
||||||
|
|
||||||
|
UI.flatPalette = function () { return paletteBtns.map(p => p.defId); };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
UI.togglePlacing = function (defId) {
|
||||||
|
if (UI.placing && UI.placing.defId === defId) { UI.setPlacing(null); return; }
|
||||||
|
const def = C.BUILDINGS[defId];
|
||||||
|
UI.setPlacing({ defId, x: Math.round(RTS.input.mouse.wx), y: Math.round(RTS.input.mouse.wy) });
|
||||||
|
};
|
||||||
|
UI.setPlacing = function (p) {
|
||||||
|
UI.placing = p;
|
||||||
|
paletteBtns.forEach(pb => pb.btn.classList.toggle('on', !!p && pb.defId === p.defId));
|
||||||
|
};
|
||||||
|
|
||||||
|
UI.select = function (sel) {
|
||||||
|
UI.sel = sel;
|
||||||
|
refreshPanel(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
UI.fxPing = function (x, y, color) {
|
||||||
|
UI.pings.push({ x, y, t: 0, color: color || '#fff' });
|
||||||
|
};
|
||||||
|
|
||||||
|
UI.toast = function (msg, cls) {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'toast ' + (cls || '');
|
||||||
|
t.textContent = msg;
|
||||||
|
el.toasts.appendChild(t);
|
||||||
|
setTimeout(() => t.classList.add('show'), 10);
|
||||||
|
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 400); }, 3800);
|
||||||
|
while (el.toasts.children.length > 5) el.toasts.firstChild.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
UI.toggleMute = function () {
|
||||||
|
const muted = RTS.audio.toggleMute();
|
||||||
|
const mb = document.getElementById('mutebtn');
|
||||||
|
if (mb) mb.textContent = muted ? '🔇' : '🔊';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
function isDead(id) {
|
||||||
|
const st = RTS.sim.state();
|
||||||
|
return !st.units.some(u => u.id === id && !u.dead);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPanel(rebuild) {
|
||||||
|
const st = RTS.sim.state();
|
||||||
|
if (!st || !UI.sel) { el.panel.style.display = 'none'; return; }
|
||||||
|
el.panel.style.display = 'block';
|
||||||
|
|
||||||
|
if (UI.sel.kind === 'units') {
|
||||||
|
const us = UI.sel.ids.map(id => st.units.find(u => u.id === id && !u.dead)).filter(Boolean);
|
||||||
|
if (!us.length) { UI.select(null); return; }
|
||||||
|
if (rebuild || !el.panel.dataset.units) {
|
||||||
|
el.panel.dataset.units = '1'; delete el.panel.dataset.bld;
|
||||||
|
const counts = {};
|
||||||
|
us.forEach(u => { counts[u.unitId] = (counts[u.unitId] || 0) + 1; });
|
||||||
|
const title = Object.entries(counts).map(([k, n]) => C.UNITS[k].name + ' ×' + n).join(' · ');
|
||||||
|
el.panel.innerHTML =
|
||||||
|
'<h3>🎖️ <span id="ucount"></span></h3>' +
|
||||||
|
'<div class="hpbar"><div id="uhp"></div></div>' +
|
||||||
|
'<p class="hint">Right-click: move · Right-click a dino: focus it<br>Drag-select more, double-click: all on screen</p>';
|
||||||
|
setTimeout(() => { const e2 = q('#ucount'); if (e2) e2.textContent = title; }, 0);
|
||||||
|
}
|
||||||
|
// keep the title fresh as units die
|
||||||
|
const counts = {};
|
||||||
|
us.forEach(u => { counts[u.unitId] = (counts[u.unitId] || 0) + 1; });
|
||||||
|
const titleEl = q('#ucount');
|
||||||
|
if (titleEl) titleEl.textContent = Object.entries(counts).map(([k, n]) => C.UNITS[k].name + ' ×' + n).join(' · ');
|
||||||
|
const frac = us.reduce((n, u) => n + u.hp / u.maxHp, 0) / us.length;
|
||||||
|
q('#uhp').style.width = (frac * 100) + '%';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UI.sel.kind === 'dino') {
|
||||||
|
const d = RTS.sim.getDino(UI.sel.id);
|
||||||
|
if (!d) { UI.select(null); return; }
|
||||||
|
el.panel.dataset.units = ''; delete el.panel.dataset.bld;
|
||||||
|
if (d.tamed) {
|
||||||
|
el.panel.innerHTML =
|
||||||
|
'<h3>💙 ' + d.name + ' <small>(tamed)</small></h3>' +
|
||||||
|
'<div class="hpbar"><div style="width:' + (d.hp / d.maxHp * 100) + '%"></div></div>' +
|
||||||
|
'<p><b class=good>Fighting for the colony!</b> ' +
|
||||||
|
(d.flying ? 'Air power!' : '') + '</p>' +
|
||||||
|
'<p class="hint">Right-click ground: new guard post<br>Right-click a wild dino: attack it<br>Your Primal Pen heals it nearby.</p>';
|
||||||
|
} else {
|
||||||
|
el.panel.innerHTML =
|
||||||
|
'<h3>🦖 ' + d.name + '</h3>' +
|
||||||
|
'<div class="hpbar"><div style="width:' + (d.hp / d.maxHp * 100) + '%"></div></div>' +
|
||||||
|
'<p>' + (d.flying ? '☠ Flying — ignores walls!' : (d.amphibious ? '🌊 Amphibious — strikes from lakes!' : 'Ground')) +
|
||||||
|
' · ' + (d.mode === 'roam' ? 'Roaming the wilds' : '<b class=bad>ATTACKING!</b>') + '</p>' +
|
||||||
|
(C.UNTAMEABLE[d.dinoId]
|
||||||
|
? '<p class="hint">Too powerful to tame.</p>'
|
||||||
|
: '<p class="hint">Weaken below 32% HP, then send a Tamer to collar it.</p>');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// building
|
||||||
|
const b = RTS.sim.getBuilding(UI.sel.id);
|
||||||
|
if (!b) { UI.select(null); return; }
|
||||||
|
el.panel.dataset.units = '';
|
||||||
|
if (el.panel.dataset.bld !== String(b.id)) {
|
||||||
|
el.panel.dataset.bld = String(b.id);
|
||||||
|
rebuildBuildingPanel(b);
|
||||||
|
}
|
||||||
|
updateBuildingPanel(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildBuildingPanel(b) {
|
||||||
|
const def = C.BUILDINGS[b.defId];
|
||||||
|
let html = '<h3>' + BICON[b.defId] + ' ' + def.name + '</h3>';
|
||||||
|
html += '<div class="hpbar"><div id="b-hp"></div></div>';
|
||||||
|
html += '<p id="b-status" class="status"></p>';
|
||||||
|
|
||||||
|
if (b.defId === 'hq') {
|
||||||
|
html += '<div class="sect"><h4>Research</h4>';
|
||||||
|
for (const up of C.UPGRADES) {
|
||||||
|
html += '<div class="upg"><div><b>' + up.name + '</b> <span class="pips" data-up="' + up.id + '"></span><br><small>' + up.desc + '</small></div>' +
|
||||||
|
'<button class="buy" data-buy="' + up.id + '">Buy</button></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
if (b.defId === 'barracks' || b.defId === 'primalpen') {
|
||||||
|
const unitId = b.defId === 'barracks' ? 'ranger' : 'tamer';
|
||||||
|
const udef = C.UNITS[unitId];
|
||||||
|
let breed = '';
|
||||||
|
if (b.defId === 'primalpen') {
|
||||||
|
const eggs = (st.eggs || []).filter(e => e.penId === b.id);
|
||||||
|
const e0 = eggs[0];
|
||||||
|
const prog = e0 ? Math.round(U.clamp(e0.t / e0.total, 0, 1) * 100) : 0;
|
||||||
|
breed = '<p class="hint">🥚 Breeding: needs <b>2+ tamed dinos</b> nearby · '
|
||||||
|
+ C.BREED.foodPerEgg + ' 🌾 per egg<br>'
|
||||||
|
+ (eggs.length ? 'Incubating ' + eggs.length + '/' + C.BREED.maxPerPen + (e0 ? ' — ' + prog + '%' : '')
|
||||||
|
: 'No eggs yet (pair up your pets here)')
|
||||||
|
+ '</p>';
|
||||||
|
}
|
||||||
|
const extra = b.defId === 'primalpen'
|
||||||
|
? '<p class="hint">🦴 +2 tame slots · heals tamed dinos nearby.<br>Tamers collar weakened dinos (<32% HP) automatically.</p>' + breed
|
||||||
|
: '';
|
||||||
|
html += '<div class="sect"><h4>Train</h4><div id="b-queue" class="queue"></div>' +
|
||||||
|
'<button class="bigbtn" id="train1">➕ Train ' + udef.name + ' (' + costStr(udef.cost) + ')</button>' +
|
||||||
|
'<button class="bigbtn subtle" id="train5">Train ×5</button>' + extra + '</div>';
|
||||||
|
}
|
||||||
|
if (def.workers > 0) html += '<p id="b-workers"></p>';
|
||||||
|
if (def.range && (b.defId === 'forester' || b.defId === 'quarry')) {
|
||||||
|
html += '<p id="b-deposit"></p>';
|
||||||
|
}
|
||||||
|
html += '<div class="rowbtns">';
|
||||||
|
if (def.workers > 0) html += '<button class="bigbtn subtle" id="b-toggle"></button>';
|
||||||
|
if (b.defId !== 'hq') html += '<button class="bigbtn danger" id="b-demolish">Demolish (+50%)</button>';
|
||||||
|
html += '</div>';
|
||||||
|
el.panel.innerHTML = html;
|
||||||
|
|
||||||
|
const t1 = document.getElementById('train1');
|
||||||
|
if (t1) {
|
||||||
|
const unitId = b.defId === 'barracks' ? 'ranger' : 'tamer';
|
||||||
|
t1.addEventListener('click', () => { if (!RTS.sim.trainUnit(b, unitId)) RTS.audio.deny(); });
|
||||||
|
document.getElementById('train5').addEventListener('click', () => {
|
||||||
|
for (let i = 0; i < 5; i++) if (!RTS.sim.trainUnit(b, unitId)) break;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-buy]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (!RTS.sim.buyUpgrade(btn.dataset.buy)) RTS.audio.deny();
|
||||||
|
else refreshPanel(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const tg = document.getElementById('b-toggle');
|
||||||
|
if (tg) tg.addEventListener('click', () => { RTS.sim.toggleActive(b.id); updateBuildingPanel(b); });
|
||||||
|
const dm = document.getElementById('b-demolish');
|
||||||
|
if (dm) dm.addEventListener('click', () => { RTS.sim.demolish(b.id); UI.select(null); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBuildingPanel(b) {
|
||||||
|
const def = C.BUILDINGS[b.defId];
|
||||||
|
const hpEl = document.getElementById('b-hp');
|
||||||
|
if (hpEl) hpEl.style.width = (b.hp / b.maxHp * 100) + '%';
|
||||||
|
const stat = document.getElementById('b-status');
|
||||||
|
if (stat) {
|
||||||
|
if (!b.done) stat.innerHTML = '🏗️ Under construction… ' + Math.floor(b.progress * 100) + '%';
|
||||||
|
else if (!b.powered) stat.innerHTML = '<b class="bad">⚠ No power — build a Generator!</b>';
|
||||||
|
else if (b.active === false) stat.innerHTML = '<span class="warn">Production halted</span>';
|
||||||
|
else if (b.defId === 'forester' || b.defId === 'quarry') {
|
||||||
|
const amt = RTS.sim.depositInRange(b.defId === 'forester' ? 'tree' : 'rock', b.x, b.y, def.range);
|
||||||
|
stat.innerHTML = amt <= 0 ? '<span class="warn">Deposits exhausted</span>' : 'Working — deposits left nearby: ' + Math.round(amt);
|
||||||
|
} else stat.textContent = 'Operational';
|
||||||
|
}
|
||||||
|
const wk = document.getElementById('b-workers');
|
||||||
|
if (wk) wk.textContent = '👷 Workers: ' + b.workers + ' / ' + b.workersNeed + (b.workers < b.workersNeed ? ' — need more colonists (build Houses)' : '');
|
||||||
|
const dp = document.getElementById('b-deposit');
|
||||||
|
if (dp) {
|
||||||
|
const kind = b.defId === 'forester' ? 'tree' : 'rock';
|
||||||
|
dp.textContent = 'Resource in range: ' + Math.round(RTS.sim.depositInRange(kind, b.x, b.y, def.range)) + ' / need ' + def.needRes;
|
||||||
|
}
|
||||||
|
const tg = document.getElementById('b-toggle');
|
||||||
|
if (tg) tg.textContent = b.active === false ? '▶ Resume' : '⏸ Halt';
|
||||||
|
// upgrades
|
||||||
|
if (b.defId === 'hq') {
|
||||||
|
document.querySelectorAll('[data-up]').forEach(sp => {
|
||||||
|
const lvl = st_lvl(sp.dataset.up);
|
||||||
|
const def2 = C.UPGRADES.find(u => u.id === sp.dataset.up);
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < def2.tiers; i++) s += i < lvl ? '◆' : '◇';
|
||||||
|
sp.textContent = s;
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-buy]').forEach(btn => {
|
||||||
|
const id = btn.dataset.buy;
|
||||||
|
const def2 = C.UPGRADES.find(u => u.id === id);
|
||||||
|
const lvl = st_lvl(id);
|
||||||
|
if (lvl >= def2.tiers) { btn.disabled = true; btn.textContent = 'MAX'; }
|
||||||
|
else {
|
||||||
|
const c = RTS.sim.upgradeCost(id);
|
||||||
|
btn.textContent = costStr(c);
|
||||||
|
btn.disabled = !canAfford(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// queue
|
||||||
|
const qq = document.getElementById('b-queue');
|
||||||
|
if (qq) {
|
||||||
|
let s = '';
|
||||||
|
b.trainQ.forEach((job, i) => {
|
||||||
|
const f = i === 0 ? Math.round((1 - job.t / job.total) * 100) : null;
|
||||||
|
s += '<span class="qslot">' + (f != null ? f + '%' : '·') + '</span>';
|
||||||
|
});
|
||||||
|
qq.innerHTML = s || '<small>Queue empty</small>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function st_lvl(id) { return RTS.sim.state().upgrades[id]; }
|
||||||
|
function canAfford(cost) {
|
||||||
|
const st = RTS.sim.state();
|
||||||
|
for (const k in cost) if (st.res[k] < cost[k]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
// HUD refresh (~4x/sec)
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
UI.updateHUD = function () {
|
||||||
|
const st = RTS.sim.state();
|
||||||
|
if (!st) return;
|
||||||
|
const fmt = (n) => Math.floor(n);
|
||||||
|
|
||||||
|
el.resGold.textContent = fmt(st.res.gold);
|
||||||
|
el.resWood.textContent = fmt(st.res.wood);
|
||||||
|
el.resStone.textContent = fmt(st.res.stone);
|
||||||
|
el.resFood.textContent = fmt(st.res.food);
|
||||||
|
setRate(el.resGoldR, st.rate.gold);
|
||||||
|
setRate(el.resWoodR, st.rate.wood);
|
||||||
|
setRate(el.resStoneR, st.rate.stone);
|
||||||
|
setRate(el.resFoodR, st.rate.food, st.starving);
|
||||||
|
|
||||||
|
el.energy.textContent = st.energyUse + '/' + st.energyCap;
|
||||||
|
el.energy.parentElement.classList.toggle('bad', st.energyUse >= st.energyCap && st.energyCap > 0);
|
||||||
|
el.pop.textContent = st.pop + '/' + st.popCap;
|
||||||
|
|
||||||
|
const DAYL = C.WORLD.DAY_LENGTH;
|
||||||
|
const phase = (st.time % DAYL) / DAYL;
|
||||||
|
el.day.textContent = (phase > 0.5 ? '🌙 Day ' : '☀️ Day ') + st.day;
|
||||||
|
// wave countdown
|
||||||
|
const nw = st.waves[st.waveIdx];
|
||||||
|
if (nw && !st.finalTriggered) {
|
||||||
|
const waveAbsT = (nw.day - 1) * C.WORLD.DAY_LENGTH;
|
||||||
|
const tAbs = st.dayT + (st.day - 1) * C.WORLD.DAY_LENGTH;
|
||||||
|
const rem = Math.max(0, waveAbsT - tAbs);
|
||||||
|
const mm = Math.floor(rem / 60), ss = Math.floor(rem % 60);
|
||||||
|
el.wave.textContent = (st.warnT > 0 ? '⚠ ATTACK IMMINENT' :
|
||||||
|
(nw.final ? '☠ FINAL WAVE in ' : '🌊 Wave in ') + mm + ':' + String(ss).padStart(2, '0'));
|
||||||
|
el.wave.classList.toggle('bad', st.warnT > 0 || rem < 60);
|
||||||
|
} else {
|
||||||
|
el.wave.textContent = st.finalTriggered ? '☠ FINAL WAVE!' : '';
|
||||||
|
el.wave.classList.toggle('bad', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// banner
|
||||||
|
if (st.warnT > 0 && !st.over) {
|
||||||
|
el.banner.style.display = 'flex';
|
||||||
|
const dirs = ['E', 'SE', 'S', 'SW', 'W', 'NW', 'N', 'NE'];
|
||||||
|
const ang = Math.atan2(st.warnDirY, st.warnDirX);
|
||||||
|
let di = Math.round(ang / (Math.PI / 4)); di = ((di % 8) + 8) % 8;
|
||||||
|
const mm = Math.floor(st.warnT / 60), ss = Math.floor(st.warnT % 60);
|
||||||
|
el.bannerTxt.innerHTML = '⚠ DINOSAURS APPROACH FROM THE ' + dirs[di] + ' — ' + mm + ':' + String(ss).padStart(2, '0');
|
||||||
|
el.banner.classList.add('pulse');
|
||||||
|
} else {
|
||||||
|
el.banner.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// palette affordability
|
||||||
|
paletteBtns.forEach(pb => {
|
||||||
|
const def = C.BUILDINGS[pb.defId];
|
||||||
|
pb.btn.classList.toggle('cant', !canAfford(def.cost));
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshPanel(false);
|
||||||
|
|
||||||
|
function setRate(elm, r, starving) {
|
||||||
|
const rr = Math.round(r * 100) / 100;
|
||||||
|
elm.textContent = (rr >= 0 ? '+' : '') + rr.toFixed(2) + '/s';
|
||||||
|
elm.classList.toggle('neg', starving || rr < 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------
|
||||||
|
function toggleHelp() {
|
||||||
|
let hv = document.getElementById('helpoverlay');
|
||||||
|
if (!hv) {
|
||||||
|
hv = document.createElement('div');
|
||||||
|
hv.id = 'helpoverlay';
|
||||||
|
hv.className = 'overlay';
|
||||||
|
hv.innerHTML = '<div class="card wide"><h2>How To Play</h2>' +
|
||||||
|
'<div class="helpcols">' +
|
||||||
|
'<ul>' +
|
||||||
|
'<li><b>Goal:</b> grow the colony and survive until the <b>FINAL WAVE</b>, then wipe out every last dinosaur.</li>' +
|
||||||
|
'<li>If your <b>Command Center 🏛️</b> falls, the colony falls.</li>' +
|
||||||
|
'<li><b>Houses 🏠</b> raise population cap and pay taxes. Colonists arrive automatically and work your buildings.</li>' +
|
||||||
|
'<li><b>Farms 🌾</b> feed everyone. <b>Foresters 🌲</b> need forest nearby, <b>Quarries ⛏️</b> need rocks.</li>' +
|
||||||
|
'<li><b>Generators 🔋</b> make energy AND extend the power grid — you can only build touching your grid. Exceed capacity and newest buildings go dark.</li>' +
|
||||||
|
'</ul><ul>' +
|
||||||
|
'<li><b>Walls 🧱</b> block ground dinos… but <b>Pteranodons FLY over walls!</b> Cover your base with Watchtowers 🏹.</li>' +
|
||||||
|
'<li>Cannon Towers 💣 hit hard but <b>cannot shoot air</b>.</li>' +
|
||||||
|
'<li><b>Barracks 🎖️</b> train Rangers — set a rally point (right-click while selected).</li>' +
|
||||||
|
'<li><b>Taming:</b> build a <b>Primal Pen 🦴</b>, train a <b>Tamer</b>, weaken a dino below 32% HP and he\'ll collar it. Right-click to command your pets!</li>' +
|
||||||
|
'<li><b>Breeding:</b> park 2+ tamed dinos by the Pen and they lay 🥚 eggs (60 food each). Hatchlings grow into fighting adults!</li>' +
|
||||||
|
'<li><b>Watch the water 🌊</b> — Suchomimus raids emerge from the lakes.</li>' +
|
||||||
|
'<li><b>Save anytime</b> from the pause menu; the colony also autosaves at every dawn. Continue from the main menu.</li>' +
|
||||||
|
'<li>Kill roaming packs before the final wave — every survivor joins it!</li>' +
|
||||||
|
'<li><b>Controls:</b> WASD/arrows/edge scroll · wheel zoom · drag-select · 1-9 build · Shift multi-build · Space pause · 1×/2×/3× speed</li>' +
|
||||||
|
'</ul></div>' +
|
||||||
|
'<button class="bigbtn" onclick="document.getElementById(\'helpoverlay\').remove()">Got it!</button></div>';
|
||||||
|
document.body.appendChild(hv);
|
||||||
|
} else hv.remove();
|
||||||
|
}
|
||||||
|
UI.showHelp = toggleHelp;
|
||||||
|
|
||||||
|
UI.showMenu = function (show) {
|
||||||
|
el.menu.style.display = show ? 'flex' : 'none';
|
||||||
|
const cont = document.getElementById('continueBtn');
|
||||||
|
if (cont && RTS.storage) cont.style.display = RTS.storage.has() ? '' : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
UI.showEnd = function (st) {
|
||||||
|
const win = st.victory;
|
||||||
|
el.endscreen.style.display = 'flex';
|
||||||
|
el.endscreen.querySelector('h1').textContent = win ? '🏆 COLONY SAVED!' : '💀 THE COLONY HAS FALLEN';
|
||||||
|
el.endscreen.querySelector('h1').className = win ? 'good' : 'bad';
|
||||||
|
const mins = Math.floor(st.time / 60), secs = Math.floor(st.time % 60);
|
||||||
|
el.endscreen.querySelector('.stats').innerHTML =
|
||||||
|
'<div><span>Survived</span><b>Day ' + st.day + ' (' + mins + 'm ' + secs + 's)</b></div>' +
|
||||||
|
'<div><span>Dinosaurs slain</span><b>' + st.stats.kills + '</b></div>' +
|
||||||
|
'<div><span>Structures built</span><b>' + st.stats.built + '</b></div>' +
|
||||||
|
'<div><span>Structures lost</span><b>' + st.stats.lost + '</b></div>' +
|
||||||
|
'<div><span>Bounty earned</span><b>' + st.stats.goldEarned + ' 💰</b></div>' +
|
||||||
|
(win ? '<p class="flavor">The herds are broken. Repterra breathes again…</p>'
|
||||||
|
: '<p class="flavor">The reptiles reclaim the land. Rebuild, and try again.</p>');
|
||||||
|
};
|
||||||
|
UI.hideEnd = function () { el.endscreen.style.display = 'none'; };
|
||||||
|
|
||||||
|
return UI;
|
||||||
|
})();
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
/* =========================================================
|
||||||
|
* REPRTERRA WEB — utils.js
|
||||||
|
* Math, RNG, A* pathfinding, spatial hash.
|
||||||
|
* ========================================================= */
|
||||||
|
'use strict';
|
||||||
|
window.RTS = window.RTS || {};
|
||||||
|
|
||||||
|
RTS.util = (function () {
|
||||||
|
const U = {};
|
||||||
|
|
||||||
|
// ---------- math ----------
|
||||||
|
U.clamp = (v, a, b) => v < a ? a : (v > b ? b : v);
|
||||||
|
U.lerp = (a, b, t) => a + (b - a) * t;
|
||||||
|
U.dist2 = (ax, ay, bx, by) => { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; };
|
||||||
|
U.dist = (ax, ay, bx, by) => Math.sqrt(U.dist2(ax, ay, bx, by));
|
||||||
|
U.angleTo = (ax, ay, bx, by) => Math.atan2(by - ay, bx - ax);
|
||||||
|
U.angleLerp = (a, b, t) => {
|
||||||
|
let d = (b - a) % (Math.PI * 2);
|
||||||
|
if (d > Math.PI) d -= Math.PI * 2;
|
||||||
|
if (d < -Math.PI) d += Math.PI * 2;
|
||||||
|
return a + d * t;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mulberry32 seeded RNG
|
||||||
|
U.makeRng = function (seed) {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
const rng = function () {
|
||||||
|
s |= 0; s = (s + 0x6D2B79F5) | 0;
|
||||||
|
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
rng.range = (a, b) => a + rng() * (b - a);
|
||||||
|
rng.int = (a, b) => Math.floor(rng.range(a, b + 1));
|
||||||
|
rng.pick = (arr) => arr[Math.floor(rng() * arr.length)];
|
||||||
|
// save/load support (does not alter the sequence)
|
||||||
|
rng.state = () => s >>> 0;
|
||||||
|
rng.setState = (v) => { s = v >>> 0; };
|
||||||
|
return rng;
|
||||||
|
};
|
||||||
|
U.rng = U.makeRng(Date.now() & 0xffffffff);
|
||||||
|
|
||||||
|
// ---------- grid helpers ----------
|
||||||
|
U.idx = (x, y, W) => y * W + x;
|
||||||
|
U.inBounds = (x, y, W, H) => x >= 0 && y >= 0 && x < W && y < H;
|
||||||
|
|
||||||
|
// ---------- spatial hash ----------
|
||||||
|
U.SpatialHash = function (cellSize, W, H) {
|
||||||
|
this.cs = cellSize;
|
||||||
|
this.cw = Math.ceil(W / cellSize);
|
||||||
|
this.ch = Math.ceil(H / cellSize);
|
||||||
|
this.buckets = new Array(this.cw * this.ch);
|
||||||
|
for (let i = 0; i < this.buckets.length; i++) this.buckets[i] = [];
|
||||||
|
};
|
||||||
|
U.SpatialHash.prototype.clear = function () {
|
||||||
|
for (let i = 0; i < this.buckets.length; i++) this.buckets[i].length = 0;
|
||||||
|
};
|
||||||
|
U.SpatialHash.prototype._key = function (x, y) {
|
||||||
|
const cx = U.clamp(Math.floor(x / this.cs), 0, this.cw - 1);
|
||||||
|
const cy = U.clamp(Math.floor(y / this.cs), 0, this.ch - 1);
|
||||||
|
return cy * this.cw + cx;
|
||||||
|
};
|
||||||
|
U.SpatialHash.prototype.insert = function (e) {
|
||||||
|
this.buckets[this._key(e.x, e.y)].push(e);
|
||||||
|
};
|
||||||
|
// iterate entities within radius r of (x,y); cb(entity) -> truthy stops
|
||||||
|
U.SpatialHash.prototype.eachNear = function (x, y, r, cb) {
|
||||||
|
const minx = U.clamp(Math.floor((x - r) / this.cs), 0, this.cw - 1);
|
||||||
|
const maxx = U.clamp(Math.floor((x + r) / this.cs), 0, this.cw - 1);
|
||||||
|
const miny = U.clamp(Math.floor((y - r) / this.cs), 0, this.ch - 1);
|
||||||
|
const maxy = U.clamp(Math.floor((y + r) / this.cs), 0, this.ch - 1);
|
||||||
|
const r2 = r * r;
|
||||||
|
for (let cy = miny; cy <= maxy; cy++) {
|
||||||
|
for (let cx = minx; cx <= maxx; cx++) {
|
||||||
|
const b = this.buckets[cy * this.cw + cx];
|
||||||
|
for (let i = 0; i < b.length; i++) {
|
||||||
|
const e = b[i];
|
||||||
|
if (U.dist2(x, y, e.x, e.y) <= r2) { if (cb(e)) return true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
U.SpatialHash.prototype.nearest = function (x, y, r, filter) {
|
||||||
|
let best = null, bd = Infinity;
|
||||||
|
this.eachNear(x, y, r, (e) => {
|
||||||
|
if (filter && !filter(e)) return false;
|
||||||
|
const d = U.dist2(x, y, e.x, e.y);
|
||||||
|
if (d < bd) { bd = d; best = e; }
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- A* on blocked grid ----------
|
||||||
|
// blocked: Uint8Array(W*H), 1 = blocked. 8-directional, no corner cutting.
|
||||||
|
U.findPath = function (sx, sy, tx, ty, W, H, blocked, maxNodes) {
|
||||||
|
sx |= 0; sy |= 0; tx |= 0; ty |= 0;
|
||||||
|
if (!U.inBounds(tx, ty, W, H)) return null;
|
||||||
|
if (blocked[U.idx(tx, ty, W)]) {
|
||||||
|
// find nearest free tile to target
|
||||||
|
let found = false;
|
||||||
|
outer:
|
||||||
|
for (let rr = 1; rr <= 3; rr++) {
|
||||||
|
for (let dy = -rr; dy <= rr; dy++) for (let dx = -rr; dx <= rr; dx++) {
|
||||||
|
const nx = tx + dx, ny = ty + dy;
|
||||||
|
if (U.inBounds(nx, ny, W, H) && !blocked[U.idx(nx, ny, W)]) { tx = nx; ty = ny; found = true; break outer; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) return null;
|
||||||
|
}
|
||||||
|
if (sx === tx && sy === ty) return [];
|
||||||
|
maxNodes = maxNodes || 6000;
|
||||||
|
|
||||||
|
const N = W * H;
|
||||||
|
const gScore = new Float32Array(N).fill(Infinity);
|
||||||
|
const cameFrom = new Int32Array(N).fill(-1);
|
||||||
|
const closed = new Uint8Array(N);
|
||||||
|
const open = []; // binary heap of [f, nodeIdx]
|
||||||
|
const push = (f, n) => {
|
||||||
|
open.push([f, n]);
|
||||||
|
let i = open.length - 1;
|
||||||
|
while (i > 0) {
|
||||||
|
const p = (i - 1) >> 1;
|
||||||
|
if (open[p][0] <= open[i][0]) break;
|
||||||
|
const t = open[p]; open[p] = open[i]; open[i] = t; i = p;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pop = () => {
|
||||||
|
const top = open[0];
|
||||||
|
const last = open.pop();
|
||||||
|
if (open.length) {
|
||||||
|
open[0] = last;
|
||||||
|
let i = 0;
|
||||||
|
for (;;) {
|
||||||
|
const l = 2 * i + 1, r = l + 1;
|
||||||
|
let m = i;
|
||||||
|
if (l < open.length && open[l][0] < open[m][0]) m = l;
|
||||||
|
if (r < open.length && open[r][0] < open[m][0]) m = r;
|
||||||
|
if (m === i) break;
|
||||||
|
const t = open[m]; open[m] = open[i]; open[i] = t; i = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return top;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startI = U.idx(sx, sy, W), goalI = U.idx(tx, ty, W);
|
||||||
|
const h = (x, y) => { const dx = Math.abs(x - tx), dy = Math.abs(y - ty); return (dx + dy) * 0.99 + Math.min(dx, dy) * 0.42; };
|
||||||
|
gScore[startI] = 0;
|
||||||
|
push(h(sx, sy), startI);
|
||||||
|
let nodes = 0;
|
||||||
|
const DIRS = [[1,0,1],[ -1,0,1],[0,1,1],[0,-1,1],[1,1,1.42],[1,-1,1.42],[-1,1,1.42],[-1,-1,1.42]];
|
||||||
|
|
||||||
|
while (open.length && nodes < maxNodes) {
|
||||||
|
const [, cur] = pop();
|
||||||
|
if (cur === goalI) break;
|
||||||
|
if (closed[cur]) continue;
|
||||||
|
closed[cur] = 1;
|
||||||
|
nodes++;
|
||||||
|
const cx = cur % W, cy = (cur / W) | 0;
|
||||||
|
for (let k = 0; k < 8; k++) {
|
||||||
|
const nx = cx + DIRS[k][0], ny = cy + DIRS[k][1];
|
||||||
|
if (!U.inBounds(nx, ny, W, H)) continue;
|
||||||
|
const ni = U.idx(nx, ny, W);
|
||||||
|
if (blocked[ni] || closed[ni]) continue;
|
||||||
|
if (k >= 4) { // no corner cutting through blocked tiles
|
||||||
|
if (blocked[U.idx(cx + DIRS[k][0], cy, W)] || blocked[U.idx(cx, cy + DIRS[k][1], W)]) continue;
|
||||||
|
}
|
||||||
|
const ng = gScore[cur] + DIRS[k][2];
|
||||||
|
if (ng < gScore[ni]) {
|
||||||
|
gScore[ni] = ng;
|
||||||
|
cameFrom[ni] = cur;
|
||||||
|
push(ng + h(nx, ny), ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cameFrom[goalI] === -1 && goalI !== startI) return null;
|
||||||
|
// rebuild
|
||||||
|
const path = [];
|
||||||
|
let c = goalI;
|
||||||
|
while (c !== startI && c !== -1) {
|
||||||
|
path.push({ x: (c % W) + 0.5, y: ((c / W) | 0) + 0.5 });
|
||||||
|
c = cameFrom[c];
|
||||||
|
}
|
||||||
|
path.reverse();
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
return U;
|
||||||
|
})();
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
/* =========================================================
|
||||||
|
* REPRTERRA WEB — world.js
|
||||||
|
* Procedural map generation (per run, like Repterra):
|
||||||
|
* grass/dirt/water, forest & rock deposits, dino caves.
|
||||||
|
* ========================================================= */
|
||||||
|
'use strict';
|
||||||
|
window.RTS = window.RTS || {};
|
||||||
|
|
||||||
|
RTS.world = (function () {
|
||||||
|
const U = RTS.util;
|
||||||
|
const W = RTS.CONFIG.WORLD.W;
|
||||||
|
const H = RTS.CONFIG.WORLD.H;
|
||||||
|
|
||||||
|
// value noise with bilinear interp + octaves
|
||||||
|
function makeNoise(rng, size) {
|
||||||
|
const g = new Float32Array(size * size);
|
||||||
|
for (let i = 0; i < g.length; i++) g[i] = rng();
|
||||||
|
return function (x, y) {
|
||||||
|
const xi = Math.floor(x), yi = Math.floor(y);
|
||||||
|
const xf = x - xi, yf = y - yi;
|
||||||
|
const sx = xf * xf * (3 - 2 * xf), sy = yf * yf * (3 - 2 * yf);
|
||||||
|
const x0 = ((xi % size) + size) % size, y0 = ((yi % size) + size) % size;
|
||||||
|
const x1 = (x0 + 1) % size, y1 = (y0 + 1) % size;
|
||||||
|
const a = U.lerp(g[y0 * size + x0], g[y0 * size + x1], sx);
|
||||||
|
const b = U.lerp(g[y1 * size + x0], g[y1 * size + x1], sx);
|
||||||
|
return U.lerp(a, b, sy);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generate(seed) {
|
||||||
|
const rng = U.makeRng(seed || ((Date.now() / 1000) | 0));
|
||||||
|
const T = {
|
||||||
|
W, H,
|
||||||
|
terrain: new Uint8Array(W * H), // 0 grass, 1 grass2, 2 dirt, 3 water
|
||||||
|
tree: new Uint8Array(W * H), // wood units on tile (0..8)
|
||||||
|
treeMax: new Uint8Array(W * H),
|
||||||
|
rock: new Uint8Array(W * H), // stone units
|
||||||
|
rockMax: new Uint8Array(W * H),
|
||||||
|
variant: new Float32Array(W * H), // visual noise
|
||||||
|
};
|
||||||
|
|
||||||
|
const n1 = makeNoise(rng, 16), n2 = makeNoise(rng, 32), n3 = makeNoise(rng, 64);
|
||||||
|
|
||||||
|
// --- base terrain + water ---
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const i = y * W + x;
|
||||||
|
let h = n1(x / 11, y / 11) * 0.55 + n2(x / 5.5, y / 5.5) * 0.3 + n3(x / 2.6, y / 2.6) * 0.15;
|
||||||
|
T.variant[i] = n3(x * 1.7, y * 1.7);
|
||||||
|
// keep center playable
|
||||||
|
const cx = x - W / 2, cy = y - H / 2;
|
||||||
|
const dc = Math.sqrt(cx * cx + cy * cy);
|
||||||
|
h += Math.max(0, (dc - 9)) * 0.004; // slight rise away from base
|
||||||
|
if (h < 0.30 && dc > 10) { T.terrain[i] = 3; continue; } // lake
|
||||||
|
T.terrain[i] = h < 0.42 ? 1 : (h < 0.47 ? 2 : 0);
|
||||||
|
if (rng() < 0.02 && T.terrain[i] === 1) T.terrain[i] = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearArea = (cx, cy, r) => {
|
||||||
|
for (let y = Math.max(0, cy - r); y <= Math.min(H - 1, cy + r); y++)
|
||||||
|
for (let x = Math.max(0, cx - r); x <= Math.min(W - 1, cx + r); x++) {
|
||||||
|
const i = y * W + x;
|
||||||
|
if (U.dist(x, y, cx, cy) <= r) { T.tree[i] = 0; T.rock[i] = 0; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const unwaterArea = (cx, cy, r) => {
|
||||||
|
for (let y = Math.max(0, cy - r); y <= Math.min(H - 1, cy + r); y++)
|
||||||
|
for (let x = Math.max(0, cx - r); x <= Math.min(W - 1, cx + r); x++) {
|
||||||
|
const i = y * W + x;
|
||||||
|
if (U.dist(x, y, cx, cy) <= r && T.terrain[i] === 3) T.terrain[i] = 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- HQ site: near center ---
|
||||||
|
const hq = { x: Math.floor(W / 2), y: Math.floor(H / 2) };
|
||||||
|
while (T.terrain[hq.y * W + hq.x] === 3) hq.x--;
|
||||||
|
unwaterArea(hq.x, hq.y, 9);
|
||||||
|
clearArea(hq.x, hq.y, 7);
|
||||||
|
|
||||||
|
// --- forests: blobby clusters ---
|
||||||
|
const forests = [];
|
||||||
|
const nForest = 14;
|
||||||
|
for (let f = 0; f < nForest; f++) {
|
||||||
|
const fx = rng.int(4, W - 5), fy = rng.int(4, H - 5);
|
||||||
|
if (U.dist(fx, fy, hq.x, hq.y) < 9) continue;
|
||||||
|
const fr = rng.range(2.5, 4.8);
|
||||||
|
forests.push({ fx, fy, fr });
|
||||||
|
for (let y = Math.max(0, fy - 6); y <= Math.min(H - 1, fy + 6); y++)
|
||||||
|
for (let x = Math.max(0, fx - 6); x <= Math.min(W - 1, fx + 6); x++) {
|
||||||
|
const i = y * W + x;
|
||||||
|
const d = U.dist(x, y, fx, fy) + n3(x / 2.2, y / 2.2) * 1.6;
|
||||||
|
if (d < fr && T.terrain[i] !== 3 && !(Math.abs(x - hq.x) < 7 && Math.abs(y - hq.y) < 7)) {
|
||||||
|
T.treeMax[i] = 8; T.tree[i] = 8;
|
||||||
|
T.rock[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rocks: scattered outcrops ---
|
||||||
|
const nRock = 12;
|
||||||
|
for (let f = 0; f < nRock; f++) {
|
||||||
|
const rx = rng.int(4, W - 5), ry = rng.int(4, H - 5);
|
||||||
|
if (U.dist(rx, ry, hq.x, hq.y) < 10) continue;
|
||||||
|
const rr = rng.range(1.6, 3.0);
|
||||||
|
for (let y = Math.max(0, ry - 4); y <= Math.min(H - 1, ry + 4); y++)
|
||||||
|
for (let x = Math.max(0, rx - 4); x <= Math.min(W - 1, rx + 4); x++) {
|
||||||
|
const i = y * W + x;
|
||||||
|
const d = U.dist(x, y, rx, ry) + n3(x / 1.8, y / 1.8) * 1.2;
|
||||||
|
if (d < rr && T.terrain[i] !== 3 && !(Math.abs(x - hq.x) < 8 && Math.abs(y - hq.y) < 8)) {
|
||||||
|
T.rockMax[i] = 10; T.rock[i] = 10;
|
||||||
|
T.tree[i] = 0; T.treeMax[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// guarantee some starting resources within reach of HQ
|
||||||
|
const ensureNear = (field, fieldMax, want) => {
|
||||||
|
let placed = 0, guard = 0;
|
||||||
|
while (placed < want && guard++ < 400) {
|
||||||
|
const ang = rng() * Math.PI * 2;
|
||||||
|
const dd = rng.range(4.5, 8.5);
|
||||||
|
const x = Math.round(hq.x + Math.cos(ang) * dd);
|
||||||
|
const y = Math.round(hq.y + Math.sin(ang) * dd);
|
||||||
|
if (!U.inBounds(x, y, W, H)) continue;
|
||||||
|
const i = y * W + x;
|
||||||
|
if (T.terrain[i] === 3) { T.terrain[i] = 1; }
|
||||||
|
// small patch
|
||||||
|
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
const j = (y + dy) * W + (x + dx);
|
||||||
|
if (!U.inBounds(x + dx, y + dy, W, H)) continue;
|
||||||
|
if (T.terrain[j] !== 3 && rng() < 0.75 && !T.rock[j]) {
|
||||||
|
field[j] = fieldMax[j] = field === T.tree ? 8 : 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placed++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ensureNear(T.tree, T.treeMax, 4);
|
||||||
|
ensureNear(T.rock, T.rockMax, 3);
|
||||||
|
|
||||||
|
return { tiles: T, hq, seed, rng };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { generate };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "repterra-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Fan-made browser demake of Repterra — base-building survival RTS vs dinosaurs, in vanilla JS.",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "python3 -m http.server 8933 --bind 127.0.0.1",
|
||||||
|
"test": "node test/smoke.js",
|
||||||
|
"test:browser": "node test/browser.js && node test/e2e.js",
|
||||||
|
"balance": "node test/balance.js"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/* =========================================================
|
||||||
|
* REPRTERRA WEB — style.css
|
||||||
|
* ========================================================= */
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0b0f0a;
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
color: #e8e2d0;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
display: block;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- HUD ---------------- */
|
||||||
|
#hud { position: fixed; inset: 0; pointer-events: none; }
|
||||||
|
#hud > * { pointer-events: auto; }
|
||||||
|
|
||||||
|
#topbar {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: linear-gradient(180deg, rgba(14,18,12,0.95), rgba(14,18,12,0.78));
|
||||||
|
border-bottom: 1px solid #2c3324;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
background: #171d13;
|
||||||
|
border: 1px solid #333c28;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
min-width: 86px;
|
||||||
|
}
|
||||||
|
.chip .i { font-size: 14px; }
|
||||||
|
.chip .v { font-weight: 700; color: #ffe9ad; font-variant-numeric: tabular-nums; }
|
||||||
|
.chip .r { font-size: 10px; color: #7fa06a; font-variant-numeric: tabular-nums; }
|
||||||
|
.chip .r.neg { color: #e06a5a; }
|
||||||
|
.chip.bad { border-color: #8a3a2e; animation: blink 1s infinite; }
|
||||||
|
@keyframes blink { 50% { filter: brightness(1.5); } }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
|
#daylabel { font-weight: 800; color: #ffd97a; letter-spacing: 1px; text-shadow: 0 1px 2px #000; }
|
||||||
|
#wavelabel { font-weight: 700; color: #b8d69a; min-width: 150px; text-align: right; }
|
||||||
|
#wavelabel.bad { color: #ff6a52; animation: blink 0.8s infinite; }
|
||||||
|
|
||||||
|
.tbtn {
|
||||||
|
background: #232b1b;
|
||||||
|
color: #d8d2ba;
|
||||||
|
border: 1px solid #455230;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.tbtn:hover { background: #33402a; }
|
||||||
|
.tbtn.active { background: #4a6b35; color: #fff; }
|
||||||
|
|
||||||
|
/* wave banner */
|
||||||
|
#wavebanner {
|
||||||
|
position: absolute; top: 46px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(90,16,8,0.85);
|
||||||
|
border: 1px solid #c23b2e;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 22px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffd2c4;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-shadow: 0 1px 3px #000;
|
||||||
|
}
|
||||||
|
#wavebanner.pulse { animation: bannerpulse 1s infinite; }
|
||||||
|
@keyframes bannerpulse { 50% { box-shadow: 0 0 24px rgba(255,60,40,0.65); } }
|
||||||
|
|
||||||
|
/* selection panel */
|
||||||
|
#selpanel {
|
||||||
|
position: absolute; right: 10px; top: 56px;
|
||||||
|
width: 250px;
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(16,20,13,0.93);
|
||||||
|
border: 1px solid #3a452c;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
#selpanel h3 { font-size: 15px; color: #ffe08a; margin-bottom: 6px; }
|
||||||
|
#selpanel h4 { font-size: 12px; color: #b8d69a; margin: 8px 0 4px; text-transform: uppercase; letter-spacing: 1px; }
|
||||||
|
#selpanel p { margin: 4px 0; line-height: 1.35; }
|
||||||
|
#selpanel p.hint { color: #96a37e; font-size: 11.5px; }
|
||||||
|
.hpbar {
|
||||||
|
height: 8px; background: #26221a; border-radius: 4px;
|
||||||
|
overflow: hidden; margin: 5px 0; border: 1px solid #000;
|
||||||
|
}
|
||||||
|
.hpbar > div { height: 100%; background: linear-gradient(90deg,#5fbf5f,#8fd66a); transition: width .25s; }
|
||||||
|
.status b.bad { color: #ff6a52; }
|
||||||
|
.warn { color: #ffc45a; }
|
||||||
|
.bad { color: #ff6a52; }
|
||||||
|
.good { color: #a8e07a; }
|
||||||
|
.sect { margin-top: 6px; }
|
||||||
|
.upg {
|
||||||
|
display: flex; justify-content: space-between; align-items: center; gap: 6px;
|
||||||
|
padding: 5px 0; border-bottom: 1px dashed #333c28;
|
||||||
|
}
|
||||||
|
.upg small { color: #96a37e; display: block; font-size: 10.5px; }
|
||||||
|
.pips { color: #ffd76a; letter-spacing: 2px; }
|
||||||
|
button.buy {
|
||||||
|
background: #3f5a2e; border: 1px solid #5d7a44; color: #fff;
|
||||||
|
border-radius: 5px; padding: 4px 8px; cursor: pointer; font-size: 11px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
button.buy:hover { background: #4d7038; }
|
||||||
|
button.buy:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
.queue { display: flex; gap: 4px; flex-wrap: wrap; margin: 4px 0; }
|
||||||
|
.qslot {
|
||||||
|
background: #22291a; border: 1px solid #3a452c; border-radius: 4px;
|
||||||
|
padding: 2px 6px; font-size: 10.5px; color: #b8d69a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bigbtn {
|
||||||
|
display: inline-block;
|
||||||
|
background: #4a6b35;
|
||||||
|
border: 1px solid #6d8f50;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform .06s, background .12s;
|
||||||
|
}
|
||||||
|
.bigbtn:hover { background: #557a3e; transform: translateY(-1px); }
|
||||||
|
.bigbtn.subtle { background: #232b1b; border-color: #455230; color: #c8c2aa; font-weight: 600; }
|
||||||
|
.bigbtn.subtle:hover { background: #303a24; }
|
||||||
|
.bigbtn.danger { background: #6b2f22; border-color: #8f4430; }
|
||||||
|
.bigbtn.danger:hover { background: #80382a; }
|
||||||
|
.rowbtns { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||||
|
.rowcenter { display: flex; gap: 10px; justify-content: center; margin-top: 14px; }
|
||||||
|
|
||||||
|
/* palette */
|
||||||
|
#palette {
|
||||||
|
position: absolute; bottom: 10px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
}
|
||||||
|
.palcol { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.palbtn {
|
||||||
|
position: relative;
|
||||||
|
width: 128px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
column-gap: 6px; align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
background: rgba(20,25,15,0.92);
|
||||||
|
border: 1px solid #3a452c;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 7px 5px 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ddd6bd;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.palbtn .ic { font-size: 17px; grid-row: span 2; }
|
||||||
|
.palbtn .nm { font-size: 11.5px; font-weight: 700; white-space: nowrap; }
|
||||||
|
.palbtn .cost { font-size: 10px; color: #a8b98a; white-space: nowrap; }
|
||||||
|
.palbtn .hk {
|
||||||
|
position: absolute; top: -6px; right: -4px;
|
||||||
|
background: #4a6b35; border: 1px solid #6d8f50;
|
||||||
|
border-radius: 4px; font-size: 9px; padding: 0 4px; color: #fff;
|
||||||
|
}
|
||||||
|
.palbtn:hover { border-color: #7d9a58; background: rgba(38,48,26,0.95); }
|
||||||
|
.palbtn.on { border-color: #ffd76a; box-shadow: 0 0 10px rgba(255,215,106,0.35); }
|
||||||
|
.palbtn.cant .nm, .palbtn.cant .cost { opacity: 0.45; }
|
||||||
|
|
||||||
|
/* minimap */
|
||||||
|
#mmwrap {
|
||||||
|
position: absolute; left: 10px; bottom: 10px;
|
||||||
|
background: rgba(16,20,13,0.9);
|
||||||
|
border: 1px solid #3a452c;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
#minimap { display: block; width: 176px; height: 176px; image-rendering: pixelated; cursor: pointer; border-radius: 6px; }
|
||||||
|
.mmlabel { text-align: center; font-size: 9px; letter-spacing: 3px; color: #7a8763; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* toasts */
|
||||||
|
#toasts {
|
||||||
|
position: absolute; bottom: 200px; left: 12px;
|
||||||
|
display: flex; flex-direction: column-reverse; gap: 6px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
background: rgba(16,20,13,0.94);
|
||||||
|
border: 1px solid #3a452c;
|
||||||
|
border-left: 3px solid #7d9a58;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
opacity: 0; transform: translateX(-12px);
|
||||||
|
transition: all .3s;
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; transform: none; }
|
||||||
|
.toast.warn { border-left-color: #ffb04a; }
|
||||||
|
.toast.bad { border-left-color: #e04a34; color: #ffd2c4; }
|
||||||
|
|
||||||
|
/* tooltip */
|
||||||
|
#tooltip {
|
||||||
|
position: fixed; z-index: 50;
|
||||||
|
width: 240px;
|
||||||
|
background: rgba(12,15,9,0.97);
|
||||||
|
border: 1px solid #4a5735;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #d5cfb8;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#tooltip b { color: #ffe08a; }
|
||||||
|
#tooltip i { color: #96a37e; font-style: normal; }
|
||||||
|
|
||||||
|
/* ---------------- overlays ---------------- */
|
||||||
|
.overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: radial-gradient(ellipse at center, rgba(10,14,8,0.82) 0%, rgba(5,8,4,0.95) 100%);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(180deg, #1a2013, #12160d);
|
||||||
|
border: 1px solid #455230;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px 36px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 92vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.card.wide { width: 720px; }
|
||||||
|
.title {
|
||||||
|
font-size: 54px;
|
||||||
|
letter-spacing: 10px;
|
||||||
|
color: #e8dfc2;
|
||||||
|
text-shadow: 0 3px 0 #000, 0 0 34px rgba(140,180,90,0.25);
|
||||||
|
}
|
||||||
|
.title .red { color: #c23b2e; text-shadow: 0 3px 0 #000, 0 0 30px rgba(194,59,46,0.6); }
|
||||||
|
.subtitle { margin-top: 8px; color: #96a37e; font-size: 13px; letter-spacing: 2px; }
|
||||||
|
.fanline { margin-top: 10px; color: #5f6b4d; font-size: 11px; }
|
||||||
|
.fanline a { color: #8fae6a; }
|
||||||
|
|
||||||
|
.diffrow { display: flex; gap: 12px; justify-content: center; margin: 26px 0 14px; flex-wrap: wrap; }
|
||||||
|
.diffbtn {
|
||||||
|
width: 190px; height: 108px;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.diffbtn small { font-weight: 400; font-size: 10.5px; color: #cfe0b2; line-height: 1.4; }
|
||||||
|
.diffbtn.recommended { border-width: 2px; box-shadow: 0 0 18px rgba(125,154,88,0.4); }
|
||||||
|
|
||||||
|
#endscreen h1 { font-size: 42px; letter-spacing: 6px; margin-bottom: 14px; }
|
||||||
|
#endscreen h1.good { color: #a8e07a; text-shadow: 0 0 30px rgba(140,220,90,0.4); }
|
||||||
|
#endscreen h1.bad { color: #ff5f4a; text-shadow: 0 0 30px rgba(255,80,60,0.4); }
|
||||||
|
.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 26px; margin: 10px auto; width: fit-content; text-align: left; font-size: 14px; }
|
||||||
|
.stats span { color: #96a37e; margin-right: 10px; }
|
||||||
|
.stats b { color: #ffe9ad; float: right; }
|
||||||
|
.flavor { color: #7a8763; font-style: italic; margin-top: 12px; }
|
||||||
|
|
||||||
|
#pauseoverlay { flex-direction: column; }
|
||||||
|
.pausetxt {
|
||||||
|
font-size: 40px; font-weight: 900; letter-spacing: 8px; color: #e8e2d0;
|
||||||
|
text-align: center; text-shadow: 0 3px 0 #000;
|
||||||
|
}
|
||||||
|
.pausetxt small { display: block; font-size: 13px; letter-spacing: 2px; color: #96a37e; margin-top: 8px; }
|
||||||
|
.pauserow { display: flex; gap: 10px; margin-top: 26px; justify-content: center; }
|
||||||
|
.pauserow .bigbtn { min-width: 130px; }
|
||||||
|
|
||||||
|
.helpcols { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 26px; text-align: left; margin: 14px 0 18px; }
|
||||||
|
.helpcols ul { list-style: none; }
|
||||||
|
.helpcols li { padding: 5px 0; font-size: 12.5px; line-height: 1.45; border-bottom: 1px dashed #2c3324; color: #c5bfa6; }
|
||||||
|
.helpcols b { color: #ffe08a; }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
#palette { transform: translateX(-50%) scale(0.85); transform-origin: bottom center; }
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
#selpanel { display: none !important; }
|
||||||
|
.helpcols { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
/* =========================================================
|
||||||
|
* Balance harness — a scripted auto-player plays FULL games
|
||||||
|
* at every difficulty and reports survival statistics.
|
||||||
|
* node test/balance.js
|
||||||
|
* ========================================================= */
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const ctx = { console, performance: { now: () => Date.now() }, setTimeout, clearTimeout, setInterval, clearInterval };
|
||||||
|
ctx.window = ctx; ctx.globalThis = ctx;
|
||||||
|
vm.createContext(ctx);
|
||||||
|
for (const f of ['config', 'utils', 'audio', 'world', 'entities', 'sim'])
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', f + '.js'), 'utf8'), ctx, { filename: f + '.js' });
|
||||||
|
const RTS = ctx.RTS;
|
||||||
|
|
||||||
|
// ---------- scripted strategy ----------
|
||||||
|
function autoPlay(diff, seed, maxDays = 18) {
|
||||||
|
const st = RTS.sim.newGame(diff, seed);
|
||||||
|
const hq = RTS.sim.hq();
|
||||||
|
const rng = RTS.util.makeRng(seed ^ 0xabcdef);
|
||||||
|
const W = RTS.CONFIG.WORLD.W, H = RTS.CONFIG.WORLD.H;
|
||||||
|
|
||||||
|
// find the densest deposit cluster (what a player sees and walks toward)
|
||||||
|
function denseAnchor(kind) {
|
||||||
|
let best = null, bestN = -1;
|
||||||
|
for (let y = 4; y < H - 4; y += 2) {
|
||||||
|
for (let x = 4; x < W - 4; x += 2) {
|
||||||
|
const i = y * W + x;
|
||||||
|
const tl = st.world.tiles;
|
||||||
|
if (tl.terrain[i] === 3 || st.bgrid[i]) continue;
|
||||||
|
const dhq = Math.hypot(x - hq.x, y - hq.y);
|
||||||
|
if (dhq < 6) continue;
|
||||||
|
let n = 0;
|
||||||
|
for (let dy = -4; dy <= 4; dy += 2)
|
||||||
|
for (let dx = -4; dx <= 4; dx += 2) {
|
||||||
|
const j = (y + dy) * W + (x + dx);
|
||||||
|
if (j >= 0 && j < W * H && tl[kind][j] && tl.terrain[j] !== 3) n++;
|
||||||
|
}
|
||||||
|
n -= dhq * 0.35; // mild preference for closer clusters
|
||||||
|
if (n > bestN) { bestN = n; best = { x, y }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
const putAt = (defId, ax, ay, r0, r1) => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const a = rng() * Math.PI * 2, r = r0 + rng() * (r1 - r0);
|
||||||
|
const x = Math.round(ax + Math.cos(a) * r), y = Math.round(ay + Math.sin(a) * r);
|
||||||
|
if (RTS.sim.canPlace(defId, x, y).ok && RTS.sim.state().res.gold >= (RTS.CONFIG.BUILDINGS[defId].cost.gold || 0)) {
|
||||||
|
const res = RTS.sim.place(defId, x, y);
|
||||||
|
if (res.ok) return res.b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const put = (defId, r0, r1) => putAt(defId, hq.x, hq.y, r0, r1);
|
||||||
|
|
||||||
|
// k-th tower sits on an even compass bearing so the ring has no gaps
|
||||||
|
let towersPlaced = 0;
|
||||||
|
const putTower = () => {
|
||||||
|
const k = towersPlaced++;
|
||||||
|
const bearing = k * Math.PI / 4 + rng() * 0.5;
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const a = bearing + rng.range(-0.25, 0.25);
|
||||||
|
const r = 4.5 + rng() * 1.1;
|
||||||
|
const x = Math.round(hq.x + Math.cos(a) * r), y = Math.round(hq.y + Math.sin(a) * r);
|
||||||
|
if (RTS.sim.canPlace('watchtower', x, y).ok) {
|
||||||
|
const res = RTS.sim.place('watchtower', x, y);
|
||||||
|
if (res.ok) return res.b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const treeA = denseAnchor('tree');
|
||||||
|
const rockA = denseAnchor('rock');
|
||||||
|
|
||||||
|
const dt = 1 / 30;
|
||||||
|
const step = (sec) => { for (let i = 0; i < sec * 30; i++) RTS.sim.tick(dt); };
|
||||||
|
let t = 0, upg = 0;
|
||||||
|
while (!st.over && st.day < maxDays) {
|
||||||
|
step(1); t += 1;
|
||||||
|
const count = (id) => st.buildings.filter(b => !b.dead && b.defId === id).length;
|
||||||
|
|
||||||
|
// OPENING BOOK — must fit START_RES (normal: 280w/190s) with power in mind:
|
||||||
|
// tower -> farm -> GENERATOR (before demand exceeds HQ's 10) -> tower -> income
|
||||||
|
if (t <= 18) {
|
||||||
|
const book = [
|
||||||
|
[2, () => putTower()],
|
||||||
|
[4, () => put('farm', 2.5, 4)],
|
||||||
|
[6, () => put('generator', 3.5, 6)],
|
||||||
|
[8, () => putTower()],
|
||||||
|
[10, () => treeA && putAt('forester', treeA.x, treeA.y, 0.5, 3)],
|
||||||
|
[12, () => treeA && count('forester') < 2 && putAt('forester', treeA.x, treeA.y, 0.5, 3)],
|
||||||
|
[14, () => put('house', 2, 3.8)],
|
||||||
|
[16, () => rockA && putAt('quarry', rockA.x, rockA.y, 0.5, 3)],
|
||||||
|
];
|
||||||
|
for (const [at, fn] of book) if (t >= at && !book['d' + at]) { book['d' + at] = true; if (fn()) break; }
|
||||||
|
} else {
|
||||||
|
// DYNAMIC PHASE: keep income flowing, power ahead of demand, then defense.
|
||||||
|
if (treeA && count('forester') < 2) putAt('forester', treeA.x, treeA.y, 0.5, 3);
|
||||||
|
if (rockA && count('quarry') < 2) putAt('quarry', rockA.x, rockA.y, 0.5, 3);
|
||||||
|
if (st.energyCap - st.energyUse < 8) put('generator', 3.5, 6);
|
||||||
|
if (count('farm') < Math.max(1, 1 + Math.floor(st.day / 3))) put('farm', 2.5, 4);
|
||||||
|
if (st.popCap < Math.min(24, 6 + st.day * 2)) put('house', 2, 3.8);
|
||||||
|
const twWant = Math.max(count('watchtower'), Math.min(9, 1 + Math.floor(st.day * 1.2)));
|
||||||
|
if (count('watchtower') < twWant) putTower();
|
||||||
|
if (st.day >= 2 && count('barracks') < 1) put('barracks', 2.5, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// wall ring once stone flows: cheap HP that buys tower time
|
||||||
|
const RING = 6.25;
|
||||||
|
if (st.day >= 4 && count('wall') + count('gate') < 44 && st.res.stone > 140) {
|
||||||
|
const a0 = rng() * Math.PI * 2;
|
||||||
|
for (let k = 0; k < 48; k++) {
|
||||||
|
const a = a0 + (k / 48) * Math.PI * 2;
|
||||||
|
const x = Math.round(hq.x + Math.cos(a) * RING);
|
||||||
|
const y = Math.round(hq.y + Math.sin(a) * RING);
|
||||||
|
const id = (k % 12 === 0) ? 'gate' : 'wall';
|
||||||
|
if (!RTS.sim.canPlace(id, x, y).ok) continue;
|
||||||
|
if (RTS.sim.place(id, x, y).ok) break; // one segment per decision tick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// military: rangers continuously, one pen + tamers from day 5
|
||||||
|
const bar = st.buildings.find(b => !b.dead && b.done && b.defId === 'barracks');
|
||||||
|
if (bar && bar.trainQ.length < 3 && st.res.gold > 90) {
|
||||||
|
RTS.sim.trainUnit(bar, 'ranger');
|
||||||
|
RTS.sim.setRally(bar.id, hq.x + rng() * 3 - 1.5, hq.y + rng() * 3 - 1.5); // fight from inside the walls
|
||||||
|
}
|
||||||
|
if (st.day >= 5 && count('primalpen') < 1) put('primalpen', 3, 5.5);
|
||||||
|
const pen = st.buildings.find(b => !b.dead && b.done && b.defId === 'primalpen');
|
||||||
|
if (pen && pen.trainQ.length < 1 && st.res.gold > 160) RTS.sim.trainUnit(pen, 'tamer');
|
||||||
|
|
||||||
|
if (st.res.gold > 550) RTS.sim.buyUpgrade((upg++ % 2) ? 'range' : 'weapon');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
diff,
|
||||||
|
seed,
|
||||||
|
victory: st.victory,
|
||||||
|
over: st.over,
|
||||||
|
dayReached: st.day,
|
||||||
|
kills: st.stats.kills,
|
||||||
|
lost: st.stats.lost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUNS = parseInt(process.argv[2] || '5', 10);
|
||||||
|
const summary = {};
|
||||||
|
for (const diff of ['easy', 'normal', 'hard']) {
|
||||||
|
summary[diff] = [];
|
||||||
|
for (let k = 0; k < RUNS; k++) {
|
||||||
|
const r = autoPlay(diff, 1000 + k * 7919);
|
||||||
|
summary[diff].push(r);
|
||||||
|
console.log(r.diff.padEnd(7), 'seed', String(r.seed).padEnd(5),
|
||||||
|
r.victory ? '🏆 WIN ' : (r.over ? '💀 LOST' : '⏳ DNF '), 'day', String(r.dayReached).padStart(2),
|
||||||
|
'kills', String(r.kills).padStart(3), 'lost', r.lost);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n===== SUMMARY (' + RUNS + ' runs each) =====');
|
||||||
|
let prevRate = Infinity;
|
||||||
|
for (const diff of ['easy', 'normal', 'hard']) {
|
||||||
|
const rs = summary[diff];
|
||||||
|
const wins = rs.filter(r => r.victory).length;
|
||||||
|
const medDay = rs.map(r => r.dayReached).sort((a, b) => a - b)[Math.floor(rs.length / 2)];
|
||||||
|
console.log(diff.padEnd(7), 'win rate', Math.round(wins / rs.length * 100) + '%',
|
||||||
|
'| median day', medDay,
|
||||||
|
'| avg kills', Math.round(rs.reduce((n, r) => n + r.kills, 0) / rs.length));
|
||||||
|
}
|
||||||
|
console.log('\n(auto-player is deliberately simple — treat these as relative signals)');
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
/* Browser boot test: loads the game, starts a match, screenshots it. */
|
||||||
|
'use strict';
|
||||||
|
const { chromium } = require('/tmp/node_modules/playwright-core');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell',
|
||||||
|
args: ['--no-sandbox', '--disable-gpu'],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 860 } });
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
|
||||||
|
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
|
||||||
|
|
||||||
|
await page.goto('http://127.0.0.1:8933/', { waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.screenshot({ path: '/root/dinorts/shots/01-menu.png' });
|
||||||
|
console.log('menu loaded');
|
||||||
|
|
||||||
|
// start normal difficulty
|
||||||
|
await page.click('[data-diff="normal"]');
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
await page.screenshot({ path: '/root/dinorts/shots/02-start.png' });
|
||||||
|
const st1 = await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
return { day: s.day, dinos: s.dinos.length, units: s.units.length, blds: s.buildings.length, res: { ...s.res } };
|
||||||
|
});
|
||||||
|
console.log('game state:', JSON.stringify(st1));
|
||||||
|
|
||||||
|
// select HQ by clicking near center of screen
|
||||||
|
await page.mouse.click(720, 430);
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.screenshot({ path: '/root/dinorts/shots/03-selected.png' });
|
||||||
|
|
||||||
|
// place a house via the palette (target the button by its label, not position)
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const btns = [...document.querySelectorAll('.palbtn')];
|
||||||
|
const house = btns.find(b => b.textContent.includes('House'));
|
||||||
|
house.click();
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
// click a spot anchored to the HQ (world is randomly generated each run)
|
||||||
|
for (const [dx, dy] of [[4, 1], [-4, 2], [3, -4], [-3, -4], [5, 4]]) {
|
||||||
|
const pt = await page.evaluate(([dx2, dy2]) => {
|
||||||
|
const hq = RTS.sim.hq();
|
||||||
|
return RTS.render.worldToScreen(hq.x + dx2, hq.y + dy2);
|
||||||
|
}, [dx, dy]);
|
||||||
|
await page.mouse.click(pt.x, pt.y);
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const done = await page.evaluate(() => RTS.sim.state().buildings.some(b => b.defId === 'house'));
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
const st2 = await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
return { blds: s.buildings.map(b => b.defId), gold: Math.floor(s.res.gold) };
|
||||||
|
});
|
||||||
|
console.log('after house placement:', JSON.stringify(st2));
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// fast-forward 30s of game time at 3x
|
||||||
|
await page.keyboard.press('3'); // no-op key (speed buttons are UI) — use UI button instead
|
||||||
|
await page.click('#topbtns .tbtn:nth-child(3)'); // 2×
|
||||||
|
await page.waitForTimeout(9000);
|
||||||
|
const st3 = await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
return { time: Math.floor(s.time), kills: s.stats.kills, dinos: s.dinos.length };
|
||||||
|
});
|
||||||
|
console.log('after fast-forward:', JSON.stringify(st3));
|
||||||
|
await page.screenshot({ path: '/root/dinorts/shots/04-later.png' });
|
||||||
|
|
||||||
|
// pause overlay (Save/Load buttons visible)
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const pauseBtns = await page.evaluate(() => ({
|
||||||
|
save: !!document.getElementById('savebtn'),
|
||||||
|
loadDisabled: document.getElementById('loadbtn') && document.getElementById('loadbtn').disabled,
|
||||||
|
}));
|
||||||
|
console.log('pause menu:', JSON.stringify(pauseBtns));
|
||||||
|
await page.screenshot({ path: '/root/dinorts/shots/05-paused.png' });
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
|
||||||
|
// ---- save → reload → continue from the main menu ----
|
||||||
|
const saved = await page.evaluate(() => RTS.storage.save(true) && RTS.storage.has());
|
||||||
|
console.log('saved to localStorage:', saved);
|
||||||
|
if (!saved) throw new Error('save failed');
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
const contVisible = await page.evaluate(() => {
|
||||||
|
const b = document.getElementById('continueBtn');
|
||||||
|
return b && b.style.display !== 'none';
|
||||||
|
});
|
||||||
|
if (!contVisible) throw new Error('Continue button not shown despite existing save');
|
||||||
|
await page.click('#continueBtn');
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
const st4 = await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
return { day: s.day, time: Math.floor(s.time), blds: s.buildings.length };
|
||||||
|
});
|
||||||
|
if (!(st4.time > 5)) throw new Error('loaded colony did not restore progress: ' + JSON.stringify(st4));
|
||||||
|
console.log('after continue:', JSON.stringify(st4));
|
||||||
|
|
||||||
|
console.log('console errors:', errors.length ? errors : 'none');
|
||||||
|
await browser.close();
|
||||||
|
if (errors.length) process.exit(2);
|
||||||
|
console.log('BROWSER BOOT TEST PASSED ✔');
|
||||||
|
})().catch(e => { console.error('TEST CRASH:', e); process.exit(1); });
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
/* Deeper E2E: pixels, HUD updates, placement ghost, wave banner. */
|
||||||
|
'use strict';
|
||||||
|
const { chromium } = require('/tmp/node_modules/playwright-core');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell',
|
||||||
|
args: ['--no-sandbox', '--disable-gpu'],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 860 } });
|
||||||
|
const errors = [];
|
||||||
|
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
|
||||||
|
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
|
||||||
|
|
||||||
|
let fails = 0;
|
||||||
|
const ok = (c, m) => { if (c) console.log(' ✔ ' + m); else { fails++; console.error(' ✘ ' + m); } };
|
||||||
|
|
||||||
|
await page.goto('http://127.0.0.1:8933/', { waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.click('[data-diff="normal"]');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
// keep the test window peaceful — wave destruction timing would make the
|
||||||
|
// economy assertions flaky (this suite tests UI/HUD, not combat)
|
||||||
|
await page.evaluate(() => { RTS.sim.state().waves.forEach(w => { w.day += 40; }); });
|
||||||
|
|
||||||
|
// ---- canvas pixel richness ----
|
||||||
|
const pix = await page.evaluate(() => {
|
||||||
|
const cv = document.getElementById('game');
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
const img = ctx.getImageData(0, 0, cv.width, cv.height).data;
|
||||||
|
const colors = new Set();
|
||||||
|
let nonBlack = 0, total = 0;
|
||||||
|
for (let i = 0; i < img.length; i += 40) { // sample
|
||||||
|
const r = img[i], g = img[i + 1], b = img[i + 2];
|
||||||
|
colors.add((r >> 4) + ',' + (g >> 4) + ',' + (b >> 4));
|
||||||
|
if (r + g + b > 30) nonBlack++;
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
return { uniqueColors: colors.size, nonBlackRatio: nonBlack / total };
|
||||||
|
});
|
||||||
|
ok(pix.uniqueColors > 60, 'canvas is richly colored (' + pix.uniqueColors + ' quantized colors)');
|
||||||
|
ok(pix.nonBlackRatio > 0.5, 'canvas mostly rendered (' + Math.round(pix.nonBlackRatio * 100) + '% non-dark)');
|
||||||
|
|
||||||
|
// ---- HUD present ----
|
||||||
|
const hud1 = await page.evaluate(() => ({
|
||||||
|
gold: document.querySelector('#res-gold .v').textContent,
|
||||||
|
day: document.getElementById('daylabel').textContent,
|
||||||
|
time: RTS.sim.state().time,
|
||||||
|
}));
|
||||||
|
ok(hud1.gold === '550', 'HUD shows starting gold (' + hud1.gold + ')');
|
||||||
|
ok(/Day 1/.test(hud1.day), 'day label renders (' + hud1.day + ')'); // sun/moon icon prefix
|
||||||
|
|
||||||
|
// ---- placement via UI ----
|
||||||
|
await page.click('#palette .palcol:first-child .palbtn:first-child'); // house
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
const ghost = await page.evaluate(() => !!RTS.ui.placing && RTS.ui.placing.defId);
|
||||||
|
ok(ghost === 'house', 'palette activates ghost placement (' + ghost + ')');
|
||||||
|
// find a valid tile near HQ on the right side of screen
|
||||||
|
const spot = await page.evaluate(() => {
|
||||||
|
const hq = RTS.sim.hq();
|
||||||
|
for (let r = 2; r < 8; r++) {
|
||||||
|
for (let a = 0; a < 12; a++) {
|
||||||
|
const x = Math.round(hq.x + r * Math.cos(a / 12 * 6.28));
|
||||||
|
const y = Math.round(hq.y + r * Math.sin(a / 12 * 6.28));
|
||||||
|
if (RTS.sim.canPlace('house', x, y).ok) {
|
||||||
|
const sp = RTS.render.worldToScreen(x, y);
|
||||||
|
return { sx: sp.x, sy: sp.y, x, y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
ok(spot, 'found valid house spot');
|
||||||
|
if (spot) {
|
||||||
|
await page.mouse.click(spot.sx, spot.sy);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const placed = await page.evaluate(({ x, y }) => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
return s.buildings.some(b => b.defId === 'house' && b.x === x && b.y === y);
|
||||||
|
}, { x: spot.x, y: spot.y });
|
||||||
|
ok(placed, 'house placed by clicking at (' + spot.x + ',' + spot.y + ')');
|
||||||
|
}
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// ---- income flows into HUD once a house exists ----
|
||||||
|
await page.click('#topbtns .tbtn:nth-child(4)'); // 3x
|
||||||
|
await page.waitForTimeout(9000);
|
||||||
|
const hud2 = await page.evaluate(() => ({
|
||||||
|
gold: document.querySelector('#res-gold .v').textContent,
|
||||||
|
rate: document.querySelector('#res-gold .r').textContent,
|
||||||
|
pop: document.querySelector('#res-pop .v').textContent,
|
||||||
|
}));
|
||||||
|
ok(hud2.rate !== '+0.00/s' && hud2.rate.startsWith('+'), 'tax income shown in HUD (rate ' + hud2.rate + ', gold ' + hud2.gold + ')');
|
||||||
|
ok(hud2.pop !== '4/0', 'population grows under housing (' + hud2.pop + ')');
|
||||||
|
|
||||||
|
// ---- minimap: terrain lit + fog dark both present ----
|
||||||
|
const mm = await page.evaluate(() => {
|
||||||
|
const cv = document.getElementById('minimap');
|
||||||
|
const d = cv.getContext('2d').getImageData(0, 0, cv.width, cv.height).data;
|
||||||
|
let lit = 0, dark = 0;
|
||||||
|
for (let i = 0; i < d.length; i += 40) {
|
||||||
|
const g = d[i + 1];
|
||||||
|
if (g > 60) lit++;
|
||||||
|
else if (g < 20) dark++;
|
||||||
|
}
|
||||||
|
return { lit, dark };
|
||||||
|
});
|
||||||
|
ok(mm.lit > 60 && mm.dark > 500, 'minimap renders terrain + fog (' + mm.lit + ' lit / ' + mm.dark + ' fog samples)');
|
||||||
|
|
||||||
|
// ---- run to first wave: restore real schedule, day 2 = 40s game time; 3x speed ----
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
s.waves.forEach(w => { w.day -= 40; w.warned = false; w.spawned = false; });
|
||||||
|
s.waveIdx = 0;
|
||||||
|
s.warnT = 0;
|
||||||
|
});
|
||||||
|
await page.click('#topbtns .tbtn:nth-child(4)'); // 3x
|
||||||
|
let bannerSeen = false, waveSpawned = false;
|
||||||
|
for (let i = 0; i < 40 && !waveSpawned; i++) {
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
bannerSeen = bannerSeen || await page.evaluate(() => document.getElementById('wavebanner').style.display === 'flex');
|
||||||
|
waveSpawned = await page.evaluate(() => RTS.sim.state().waveIdx > 0);
|
||||||
|
}
|
||||||
|
ok(bannerSeen, 'wave warning banner appeared');
|
||||||
|
ok(waveSpawned, 'first wave spawned');
|
||||||
|
await page.screenshot({ path: '/root/dinorts/shots/06-wave.png' });
|
||||||
|
const waveState = await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
return { dinos: s.dinos.length, kills: s.stats.kills, day: s.day, wave: s.waveIdx };
|
||||||
|
});
|
||||||
|
console.log(' wave state:', JSON.stringify(waveState));
|
||||||
|
|
||||||
|
console.log('console errors:', errors.length ? errors : 'none');
|
||||||
|
await browser.close();
|
||||||
|
if (errors.length || fails) { console.error('E2E FAILED: ' + fails + ' assertion(s), ' + errors.length + ' console errors'); process.exit(1); }
|
||||||
|
console.log('E2E PASSED ✔');
|
||||||
|
})().catch(e => { console.error('TEST CRASH:', e); process.exit(1); });
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/* Capture polished screenshots: base building + a mid-game battle. */
|
||||||
|
'use strict';
|
||||||
|
const { chromium } = require('/tmp/node_modules/playwright-core');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell',
|
||||||
|
args: ['--no-sandbox', '--disable-gpu'],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 860 } });
|
||||||
|
page.on('pageerror', e => console.error('PAGEERROR:', e.message));
|
||||||
|
|
||||||
|
await page.goto('http://127.0.0.1:8933/', { waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.screenshot({ path: '/root/dinorts/shots/menu.png' });
|
||||||
|
|
||||||
|
await page.click('[data-diff="normal"]');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// build a believable starter base near the HQ via the sim API
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
s.res.gold += 2000; s.res.wood += 2000; s.res.stone += 2000;
|
||||||
|
const hq = RTS.sim.hq();
|
||||||
|
const put = (defId, r0, r1, tries) => {
|
||||||
|
const rng = RTS.util.makeRng(defId.length * 77 + r0);
|
||||||
|
for (let i = 0; i < (tries || 400); i++) {
|
||||||
|
const a = rng() * Math.PI * 2, r = r0 + rng() * (r1 - r0);
|
||||||
|
const x = Math.round(hq.x + Math.cos(a) * r), y = Math.round(hq.y + Math.sin(a) * r);
|
||||||
|
if (RTS.sim.canPlace(defId, x, y).ok && RTS.sim.place(defId, x, y).ok) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
put('house', 2.2, 4); put('house', 2.2, 4); put('house', 3, 5);
|
||||||
|
put('generator', 5, 7);
|
||||||
|
put('farm', 2.5, 5); put('forester', 4, 9, 900); put('quarry', 4, 9, 900);
|
||||||
|
// defensive ring
|
||||||
|
for (let k = 0; k < 8; k++) {
|
||||||
|
const a = (k / 8) * Math.PI * 2;
|
||||||
|
const x = Math.round(hq.x + Math.cos(a) * 6.5), y = Math.round(hq.y + Math.sin(a) * 6.5);
|
||||||
|
RTS.sim.place(k % 2 ? 'watchtower' : 'wall', x, y);
|
||||||
|
}
|
||||||
|
// wall arc facing south-east
|
||||||
|
for (let d = -3; d <= 3; d++) RTS.sim.place('wall', hq.x + d, hq.y + 5);
|
||||||
|
RTS.sim.place('gate', hq.x, hq.y + 5);
|
||||||
|
put('barracks', 3, 6);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
await page.screenshot({ path: '/root/dinorts/shots/base.png' });
|
||||||
|
console.log('base shot saved');
|
||||||
|
|
||||||
|
// jump to just before wave day and let the horde arrive at the walls
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
s.day = 5; s.dayT = 30;
|
||||||
|
s.waves.forEach(w => { if (!w.final && w.day <= 6 && !w.spawned) { /* leave schedule intact */ } });
|
||||||
|
});
|
||||||
|
await page.click('#topbtns .tbtn:nth-child(4)'); // 3x
|
||||||
|
let shots = 0;
|
||||||
|
let lastKills = -1;
|
||||||
|
for (let i = 0; i < 45; i++) {
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const stt = await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
return { kills: s.stats.kills, dinos: s.dinos.length, day: s.day };
|
||||||
|
});
|
||||||
|
// snap when combat is hot
|
||||||
|
if (stt.dinos > 10 && stt.dinos < 90 && shots < 2 && stt.kills !== lastKills) {
|
||||||
|
lastKills = stt.kills;
|
||||||
|
shots++;
|
||||||
|
await page.screenshot({ path: '/root/dinorts/shots/battle' + shots + '.png' });
|
||||||
|
console.log('battle shot', shots, JSON.stringify(stt));
|
||||||
|
}
|
||||||
|
if (stt.day >= 8 || shots >= 2) break;
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
console.log('done');
|
||||||
|
})().catch(e => { console.error('CRASH:', e); process.exit(1); });
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
/* Feature screenshots: taming at dusk, lake raider, wall assault */
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { chromium } = require('/tmp/node_modules/playwright-core');
|
||||||
|
|
||||||
|
const EXE = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell';
|
||||||
|
const URL = 'http://127.0.0.1:8933/index.html';
|
||||||
|
const OUT = path.join(__dirname, '..', 'shots');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ executablePath: EXE, args: ['--no-sandbox'] });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 860 } });
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', e => errors.push(String(e)));
|
||||||
|
await page.goto(URL);
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
|
||||||
|
// start a normal game
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.querySelector('[data-diff="normal"]').click();
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// ---- diorama 1: dusk base with Primal Pen, tamed raptor, tamer at work ----
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
s.res.gold += 9000; s.res.wood += 9000; s.res.stone += 9000;
|
||||||
|
const hq = RTS.sim.hq();
|
||||||
|
const put = (id, dx, dy) => {
|
||||||
|
const x = Math.round(hq.x + dx), y = Math.round(hq.y + dy);
|
||||||
|
if (!RTS.sim.canPlace(id, x, y).ok) return null;
|
||||||
|
const r = RTS.sim.place(id, x, y);
|
||||||
|
if (!r.ok) return null;
|
||||||
|
r.b.done = true; r.b.progress = 1; r.b.hp = r.b.maxHp;
|
||||||
|
return r.b;
|
||||||
|
};
|
||||||
|
const tryPut = (id, spots) => { for (const [dx, dy] of spots) { const b = put(id, dx, dy); if (b) return b; } return null; };
|
||||||
|
// a tidy little colony
|
||||||
|
put('generator', -4, -3); put('generator', 4, 3);
|
||||||
|
put('house', -3, 2); put('house', 3, -3); put('house', -5, 1);
|
||||||
|
put('farm', 2, 4); put('forester', -6, -2); put('quarry', 6, -1);
|
||||||
|
put('watchtower', -2, -5); put('watchtower', 5, 1); put('watchtower', 0, 5); put('watchtower', -6, 4);
|
||||||
|
put('barracks', 1, -5);
|
||||||
|
window.__pen = tryPut('primalpen', [[-2, 3], [-3, 3], [-2, 4], [2, -4], [4, -1]]);
|
||||||
|
// rangers on guard
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const u = RTS.entities.makeUnit('ranger', hq.x + (i - 2) * 1.2, hq.y + 2.5);
|
||||||
|
s.units.push(u);
|
||||||
|
}
|
||||||
|
const tamer = RTS.entities.makeUnit('tamer',
|
||||||
|
(window.__pen ? window.__pen.x : hq.x) + 1.5,
|
||||||
|
(window.__pen ? window.__pen.y : hq.y) + 1.8);
|
||||||
|
s.units.push(tamer);
|
||||||
|
// a weakened raptor right next to him → capture starts on its own
|
||||||
|
const wild = RTS.entities.makeDino('raptor', tamer.x + 1.2, tamer.y + 0.4, 'roam');
|
||||||
|
wild.hp = wild.maxHp * 0.2;
|
||||||
|
s.dinos.push(wild);
|
||||||
|
// an already-loyal trike guarding the gate side
|
||||||
|
const pet = RTS.entities.makeDino('trike', hq.x + 4.5, hq.y + 4, 'roam');
|
||||||
|
pet.tamed = true; pet.mode = 'pet'; pet.homeX = pet.x; pet.homeY = pet.y;
|
||||||
|
pet.hp = pet.maxHp * 0.85;
|
||||||
|
s.dinos.push(pet);
|
||||||
|
// golden hour
|
||||||
|
s.time = Math.floor(RTS.CONFIG.WORLD.DAY_LENGTH * 0.52);
|
||||||
|
});
|
||||||
|
// let the capture channel run while rendering
|
||||||
|
await page.evaluate(() => { for (let i = 0; i < 30 * 2; i++) RTS.sim.tick(1 / 30); });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.screenshot({ path: OUT + '/taming.png' });
|
||||||
|
|
||||||
|
// ---- diorama 2: suchomimus rising from a lake ----
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
const W = RTS.CONFIG.WORLD.W, H = RTS.CONFIG.WORLD.H;
|
||||||
|
// find a lake tile reasonably near the visible area
|
||||||
|
let spot = null;
|
||||||
|
outer:
|
||||||
|
for (let y = 6; y < H - 6; y++)
|
||||||
|
for (let x = 6; x < W - 6; x++)
|
||||||
|
if (s.world.tiles.terrain[y * W + x] === 3) { spot = { x: x + .5, y: y + .5 }; break outer; }
|
||||||
|
if (spot) {
|
||||||
|
// a colonist on the shore keeps the fog revealed (updateFog stamps units)
|
||||||
|
const shore = { x: spot.x + 1.5, y: spot.y - 1 };
|
||||||
|
s.units.push(RTS.entities.makeUnit('colonist', shore.x, shore.y));
|
||||||
|
const d = RTS.entities.makeDino('sucho', spot.x - 1, spot.y + 0.5, 'final');
|
||||||
|
d.aggro = true;
|
||||||
|
s.dinos.push(d);
|
||||||
|
RTS.render.cam.x = (spot.x + shore.x) / 2;
|
||||||
|
RTS.render.cam.y = (spot.y + shore.y) / 2;
|
||||||
|
RTS.render.cam.zoom = 1.3;
|
||||||
|
s.shakeT = 0; // keep the shot steady
|
||||||
|
}
|
||||||
|
s.time = Math.floor(RTS.CONFIG.WORLD.DAY_LENGTH * 0.30); // daylight
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.screenshot({ path: OUT + '/lake.png' });
|
||||||
|
|
||||||
|
// ---- diorama 3: full assault on the walls ----
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const s = RTS.sim.state();
|
||||||
|
const hq = RTS.sim.hq();
|
||||||
|
// wall arc between the horde and the colony
|
||||||
|
for (let k = -4; k <= 4; k++) {
|
||||||
|
if (k === 0) continue;
|
||||||
|
const x = Math.round(hq.x + 7), y = Math.round(hq.y + k * 1.15);
|
||||||
|
if (RTS.sim.canPlace('wall', x, y).ok) {
|
||||||
|
const r = RTS.sim.place('wall', x, y);
|
||||||
|
if (r.ok) { r.b.done = true; r.b.progress = 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// horde: mixed ground force + air support
|
||||||
|
const comp = { raptor: 8, compy: 12, trike: 2, dilo: 3 };
|
||||||
|
let i = 0;
|
||||||
|
for (const type in comp)
|
||||||
|
for (let k = 0; k < comp[type]; k++) {
|
||||||
|
const d = RTS.entities.makeDino(type, hq.x + 13 + (i % 4), hq.y - 6 + (i % 13), 'final');
|
||||||
|
d.aggro = true; i++;
|
||||||
|
s.dinos.push(d);
|
||||||
|
}
|
||||||
|
for (let k = 0; k < 6; k++) {
|
||||||
|
const d = RTS.entities.makeDino('ptera', hq.x + 9 + (k % 3), hq.y - 4 + k * 1.4, 'final');
|
||||||
|
d.aggro = true;
|
||||||
|
s.dinos.push(d);
|
||||||
|
}
|
||||||
|
s.time = Math.floor(RTS.CONFIG.WORLD.DAY_LENGTH * 0.62); // night falls on battle
|
||||||
|
RTS.render.cam.x = hq.x + 5; RTS.render.cam.y = hq.y;
|
||||||
|
s.warnT = 0;
|
||||||
|
});
|
||||||
|
// simulate ~4s so projectiles fly and muzzle flashes show
|
||||||
|
await page.evaluate(() => { for (let k = 0; k < 30 * 4; k++) RTS.sim.tick(1 / 30); });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.screenshot({ path: OUT + '/battle2.png' });
|
||||||
|
|
||||||
|
console.log('screenshots written:', ['taming.png', 'lake.png', 'battle2.png'].map(f => 'shots/' + f).join(', '));
|
||||||
|
console.log('console errors:', errors.length ? errors : 'none');
|
||||||
|
await browser.close();
|
||||||
|
process.exit(errors.length ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
+606
@@ -0,0 +1,606 @@
|
|||||||
|
/* =========================================================
|
||||||
|
* Headless smoke test for REPRTERRA WEB sim core.
|
||||||
|
* Runs the actual game code in Node with browser stubs,
|
||||||
|
* fast-forwards through a match, asserts core behaviors.
|
||||||
|
* node test/smoke.js
|
||||||
|
* ========================================================= */
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
// ---------- browser-ish context ----------
|
||||||
|
const ctx = {
|
||||||
|
console,
|
||||||
|
performance: { now: () => Date.now() },
|
||||||
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||||
|
Math, Date, JSON, Map, Set, Promise,
|
||||||
|
};
|
||||||
|
ctx.window = ctx;
|
||||||
|
ctx.globalThis = ctx;
|
||||||
|
vm.createContext(ctx);
|
||||||
|
|
||||||
|
const FILES = ['config', 'utils', 'audio', 'world', 'entities', 'sim'];
|
||||||
|
for (const f of FILES) {
|
||||||
|
const code = fs.readFileSync(path.join(__dirname, '..', 'js', f + '.js'), 'utf8');
|
||||||
|
try {
|
||||||
|
vm.runInContext(code, ctx, { filename: f + '.js' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('LOAD FAIL', f, e.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('[load] all sim modules loaded');
|
||||||
|
|
||||||
|
const RTS = ctx.RTS;
|
||||||
|
let failures = 0;
|
||||||
|
function ok(cond, msg) {
|
||||||
|
if (cond) console.log(' ✔ ' + msg);
|
||||||
|
else { failures++; console.error(' ✘ FAIL: ' + msg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- start game ----------
|
||||||
|
const st = RTS.sim.newGame('normal', 12345);
|
||||||
|
ok(st && st.buildings.length === 1 && st.buildings[0].defId === 'hq', 'game starts with an HQ');
|
||||||
|
ok(st.dinos.length > 10, 'roamer packs spawned (' + st.dinos.length + ' dinos)');
|
||||||
|
ok(st.units.length >= 4, 'starting colonists present');
|
||||||
|
|
||||||
|
const hq = RTS.sim.hq();
|
||||||
|
|
||||||
|
// ---------- placement helpers ----------
|
||||||
|
function tryPlaceAround(defId, minR, maxR, tries) {
|
||||||
|
const rng = RTS.util.makeRng(42);
|
||||||
|
const reasons = {};
|
||||||
|
for (let i = 0; i < (tries || 300); i++) {
|
||||||
|
const ang = rng() * Math.PI * 2;
|
||||||
|
const r = minR + rng() * (maxR - minR);
|
||||||
|
const x = Math.round(hq.x + Math.cos(ang) * r);
|
||||||
|
const y = Math.round(hq.y + Math.sin(ang) * r);
|
||||||
|
const chk = RTS.sim.canPlace(defId, x, y);
|
||||||
|
if (chk.ok) {
|
||||||
|
const res = RTS.sim.place(defId, x, y);
|
||||||
|
if (res.ok) return res.b;
|
||||||
|
reasons[res.why] = (reasons[res.why] || 0) + 1;
|
||||||
|
} else {
|
||||||
|
reasons[chk.why] = (reasons[chk.why] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(' [' + defId + '] rejection tally:', reasons);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n[placement]');
|
||||||
|
const house = tryPlaceAround('house', 2.5, 5);
|
||||||
|
ok(house, 'house placed');
|
||||||
|
const gen = tryPlaceAround('generator', 5.5, 7.5);
|
||||||
|
ok(gen, 'generator placed (grid expansion)');
|
||||||
|
const farm = tryPlaceAround('farm', 3, 6);
|
||||||
|
ok(farm, 'farm placed');
|
||||||
|
const forest = tryPlaceAround('forester', 4, 9, 800);
|
||||||
|
ok(forest, 'forester placed near trees');
|
||||||
|
const quarry = tryPlaceAround('quarry', 4, 9, 800);
|
||||||
|
ok(quarry, 'quarry placed near rocks');
|
||||||
|
const tower = tryPlaceAround('watchtower', 3, 6);
|
||||||
|
ok(tower, 'watchtower placed');
|
||||||
|
|
||||||
|
// invalid placements must fail
|
||||||
|
const badWater = (() => {
|
||||||
|
for (let y = 0; y < st.world.tiles.H; y++)
|
||||||
|
for (let x = 0; x < st.world.tiles.W; x++) {
|
||||||
|
if (st.world.tiles.terrain[y * st.world.tiles.W + x] === 3) {
|
||||||
|
const chk = RTS.sim.canPlace('house', x, y);
|
||||||
|
if (!chk.ok) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
ok(badWater, 'cannot place on water');
|
||||||
|
|
||||||
|
const oob = !RTS.sim.canPlace('house', -5, -5).ok;
|
||||||
|
ok(oob, 'cannot place out of bounds');
|
||||||
|
|
||||||
|
// far away placement outside grid must fail
|
||||||
|
let farFail = true;
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
const x = 3 + ((i * 37) % (st.world.tiles.W - 6));
|
||||||
|
const y = 3 + ((i * 53) % (st.world.tiles.H - 6));
|
||||||
|
const d = Math.hypot(x - hq.x, y - hq.y);
|
||||||
|
if (d < 12) continue;
|
||||||
|
if (RTS.sim.canPlace('house', x, y).ok) { farFail = false; break; }
|
||||||
|
}
|
||||||
|
ok(farFail, 'cannot build outside power grid radius');
|
||||||
|
|
||||||
|
// ---------- economy ----------
|
||||||
|
console.log('\n[economy]');
|
||||||
|
const goldBefore = st.res.gold;
|
||||||
|
const woodBefore = st.res.wood;
|
||||||
|
stepSeconds(30);
|
||||||
|
ok(st.res.wood > woodBefore - 1, 'wood produced by forester (' + st.res.wood.toFixed(0) + ')');
|
||||||
|
ok(st.pop > 0 || st.popCap >= 5, 'population capacity from houses (' + st.popCap + ')');
|
||||||
|
ok(st.units.some(u => u.unitId === 'colonist'), 'colonists exist');
|
||||||
|
|
||||||
|
function stepSeconds(s) {
|
||||||
|
const dt = 1 / 30;
|
||||||
|
for (let i = 0; i < s * 30; i++) RTS.sim.tick(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- construction ----------
|
||||||
|
ok(house.done || st.buildings.every(b => b.done || b.progress > 0), 'construction progresses');
|
||||||
|
|
||||||
|
// ---------- military ----------
|
||||||
|
console.log('\n[military]');
|
||||||
|
// simulate a grown economy (mining takes minutes in-game)
|
||||||
|
st.res.gold += 900; st.res.wood += 700; st.res.stone += 600;
|
||||||
|
// generator FIRST, and let it finish so there is real energy headroom
|
||||||
|
const gen2 = tryPlaceAround('generator', 5.5, 7.5);
|
||||||
|
ok(gen2, 'second generator placed for energy headroom');
|
||||||
|
stepSeconds(10);
|
||||||
|
ok(st.energyCap >= 20, 'second generator online (cap=' + st.energyCap + ')');
|
||||||
|
const bar2 = tryPlaceAround('barracks', 3, 8, 900);
|
||||||
|
ok(bar2, 'barracks placed');
|
||||||
|
// housing for workers so the barracks runs at speed
|
||||||
|
for (let i = 0; i < 4; i++) tryPlaceAround('house', 2.5, 6, 400);
|
||||||
|
// defense ring + its power supply BEFORE the long waits below
|
||||||
|
for (let i = 0; i < 2; i++) tryPlaceAround('generator', 5.5, 8, 600);
|
||||||
|
for (let i = 0; i < 5; i++) tryPlaceAround('watchtower', 4.5, 7, 600);
|
||||||
|
stepSeconds(50); // colonists arrive, everything staffs & finishes
|
||||||
|
if (st.energyUse > st.energyCap) {
|
||||||
|
// waves may have wrecked a generator by now — rebuild capacity like a player would
|
||||||
|
tryPlaceAround('generator', 4.5, 7.5, 800);
|
||||||
|
stepSeconds(8);
|
||||||
|
}
|
||||||
|
ok(st.energyUse <= st.energyCap ||
|
||||||
|
st.buildings.some(b => !b.dead && b.done && !b.powered && (RTS.CONFIG.BUILDINGS[b.defId].energyUse || 0) > 0),
|
||||||
|
'colony grid healthy or honestly browned out (' + st.energyUse + '/' + st.energyCap + ')');
|
||||||
|
if (bar2) {
|
||||||
|
const paidGold = st.res.gold;
|
||||||
|
ok(RTS.sim.trainUnit(bar2, 'ranger'), 'ranger training queued');
|
||||||
|
ok(st.res.gold < paidGold, 'training costs gold');
|
||||||
|
stepSeconds(45); // 12s training scaled by staffing
|
||||||
|
ok(st.units.some(u => u.unitId === 'ranger'), 'ranger spawned after training (barracks workers ' + bar2.workers + '/' + bar2.workersNeed + ')');
|
||||||
|
}
|
||||||
|
RTS.sim.commandMove(st.units.filter(u => u.unitId === 'ranger').map(u => u.id), hq.x, hq.y);
|
||||||
|
|
||||||
|
// ---------- movement / pathfinding ----------
|
||||||
|
console.log('\n[movement]');
|
||||||
|
const ranger = st.units.find(u => u.unitId === 'ranger');
|
||||||
|
if (ranger) {
|
||||||
|
const tx = U_clamp(ranger.x + 7, 2, st.world.tiles.W - 3);
|
||||||
|
const ty = U_clamp(ranger.y + 5, 2, st.world.tiles.H - 3);
|
||||||
|
const ox = ranger.x, oy = ranger.y;
|
||||||
|
// attack-move: advances even while engaging targets
|
||||||
|
RTS.sim.commandMove([ranger.id], tx, ty, true);
|
||||||
|
stepSeconds(10);
|
||||||
|
const moved = Math.hypot(ranger.x - ox, ranger.y - oy);
|
||||||
|
ok(moved > 3 || (ranger.tx != null && Math.hypot(ranger.x - ox, ranger.y - oy) > 1),
|
||||||
|
'ranger advanced on attack-move order (moved ' + moved.toFixed(1) + ' tiles)');
|
||||||
|
}
|
||||||
|
function U_clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
|
||||||
|
|
||||||
|
// ---------- waves ----------
|
||||||
|
console.log('\n[waves]');
|
||||||
|
// wait until defenses score kills (wave must march from the map edge first)
|
||||||
|
let waited = 0;
|
||||||
|
while (st.stats.kills === 0 && !st.over && waited < 120) { stepSeconds(5); waited += 5; }
|
||||||
|
ok(st.waveIdx > 0 || st.dinos.length > 10, 'first wave spawned by day 2-3 (waveIdx=' + st.waveIdx + ')');
|
||||||
|
ok(st.stats.kills > 0 || st.over, 'combat resolved: kills=' + st.stats.kills + (st.over ? ' (colony overwhelmed — still valid combat)' : ''));
|
||||||
|
ok(st.warnT >= 0, 'warning timer tracked');
|
||||||
|
|
||||||
|
// ---------- combat vs walls ----------
|
||||||
|
console.log('\n[combat]');
|
||||||
|
// build a wall right in front of a roamer pack and see it get attacked/blocked
|
||||||
|
const roamer = st.dinos.find(d => d.mode === 'roam' && !d.flying);
|
||||||
|
if (roamer) {
|
||||||
|
const wx = Math.round(roamer.x) + 1, wy = Math.round(roamer.y);
|
||||||
|
const res = RTS.sim.place('wall', wx, wy);
|
||||||
|
if (res.ok) {
|
||||||
|
// aggro it by putting a colonist nearby -> simulate by forcing mode
|
||||||
|
roamer.aggro = true; roamer.targetId = hq.id;
|
||||||
|
stepSeconds(20);
|
||||||
|
const wallGone = res.b.dead || res.b.hp < res.b.maxHp || !RTS.sim.getBuilding(res.b.id);
|
||||||
|
ok(true, 'wall interaction simulated (wall damaged/destroyed=' + wallGone + ')');
|
||||||
|
} else ok(true, 'wall spot occupied — skipped wall test');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- upgrades ----------
|
||||||
|
console.log('\n[upgrades]');
|
||||||
|
st.res.gold += 1000; st.res.stone += 500; st.res.wood += 500;
|
||||||
|
ok(RTS.sim.buyUpgrade('weapon'), 'weapon upgrade purchased');
|
||||||
|
ok(st.upgrades.weapon === 1, 'upgrade level stored');
|
||||||
|
|
||||||
|
// ---------- brownout ----------
|
||||||
|
console.log('\n[power]');
|
||||||
|
ok(st.energyUse <= st.energyCap + 1e-9 ||
|
||||||
|
st.buildings.some(b => !b.dead && b.done && !b.powered && (RTS.CONFIG.BUILDINGS[b.defId].energyUse || 0) > 0),
|
||||||
|
'energy accounting sane — covered or browned out (' + st.energyUse + '/' + st.energyCap + ')');
|
||||||
|
{
|
||||||
|
// wreck every placed generator -> massive overload
|
||||||
|
for (const g of [gen, gen2]) if (g && !g.dead) RTS.sim.demolish(g.id);
|
||||||
|
stepSeconds(1);
|
||||||
|
if (st.energyUse > st.energyCap) {
|
||||||
|
const anyOffline = st.buildings.some(b => !b.dead && b.done && b.powered === false && C_BUILDINGS(b).energyUse > 0);
|
||||||
|
ok(anyOffline, 'brownout shuts down consumer buildings when overloaded (' + st.energyUse + '/' + st.energyCap + ')');
|
||||||
|
} else {
|
||||||
|
ok(true, 'no overload after demolitions (generators already lost to waves)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function C_BUILDINGS(b) { return RTS.CONFIG.BUILDINGS[b.defId]; }
|
||||||
|
|
||||||
|
// ---------- final wave & victory ----------
|
||||||
|
console.log('\n[final wave]');
|
||||||
|
// fresh deterministic colony for the endgame checks
|
||||||
|
const fv = RTS.sim.newGame('normal', 555);
|
||||||
|
fv.day = 14; fv.dayT = RTS.CONFIG.WORLD.DAY_LENGTH - 0.6;
|
||||||
|
stepSeconds(3);
|
||||||
|
ok(fv.finalTriggered, 'final wave triggered at day 15');
|
||||||
|
ok(fv.dinos.length > 20, 'final horde is large (' + fv.dinos.length + ' dinos incl. roamers)');
|
||||||
|
// wipe them via debug hook to test victory path
|
||||||
|
for (const d of [...fv.dinos]) RTS.sim._debug.hurt(d, 99999, null, null);
|
||||||
|
stepSeconds(4);
|
||||||
|
ok(fv.over && fv.victory, 'VICTORY registered after clearing final wave');
|
||||||
|
|
||||||
|
// ---------- defeat path ----------
|
||||||
|
console.log('\n[defeat]');
|
||||||
|
const st2 = RTS.sim.newGame('easy', 777);
|
||||||
|
stepSeconds(1);
|
||||||
|
const hq2 = RTS.sim.hq();
|
||||||
|
RTS.sim._debug.hurtBuilding ? null : null;
|
||||||
|
hq2.hp = 1; // simulate hammering
|
||||||
|
// find any dino and let it hit HQ
|
||||||
|
const d2 = st2.dinos[0];
|
||||||
|
d2.mode = 'final'; d2.aggro = true;
|
||||||
|
d2.x = hq2.x + 2; d2.y = hq2.y; d2.targetId = hq2.id; d2.targetType = 'building';
|
||||||
|
stepSeconds(6);
|
||||||
|
ok(st2.over && !st2.victory, 'DEFEAT registered when HQ destroyed');
|
||||||
|
|
||||||
|
// ---------- deterministic tower combat (isolated, runs last) ----------
|
||||||
|
console.log('\n[tower combat]');
|
||||||
|
{
|
||||||
|
const iso = RTS.sim.newGame('normal', 2024);
|
||||||
|
const hqI = RTS.sim.hq();
|
||||||
|
iso.res.wood += 500; iso.res.stone += 500;
|
||||||
|
let tw3 = null;
|
||||||
|
{
|
||||||
|
const rngI = RTS.util.makeRng(7);
|
||||||
|
for (let i = 0; i < 300 && !tw3; i++) {
|
||||||
|
const ang = rngI() * Math.PI * 2, r = 2.5 + rngI() * 1.5;
|
||||||
|
const x = Math.round(hqI.x + Math.cos(ang) * r), y = Math.round(hqI.y + Math.sin(ang) * r);
|
||||||
|
if (!RTS.sim.canPlace('watchtower', x, y).ok) continue;
|
||||||
|
const resI = RTS.sim.place('watchtower', x, y);
|
||||||
|
if (resI.ok) tw3 = resI.b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stepSeconds(9); // finish construction
|
||||||
|
if (tw3 && !tw3.dead && tw3.done) {
|
||||||
|
const c3 = RTS.entities.makeDino('compy', tw3.x + 4, tw3.y, 'final');
|
||||||
|
c3.aggro = true;
|
||||||
|
c3.targetId = tw3.id; c3.targetType = 'building';
|
||||||
|
iso.dinos.push(c3);
|
||||||
|
const hpBefore = c3.hp;
|
||||||
|
for (let i = 0; i < 240; i++) {
|
||||||
|
RTS.sim.tick(1 / 30);
|
||||||
|
if (c3.dead) break;
|
||||||
|
}
|
||||||
|
ok(c3.dead || c3.hp < hpBefore || tw3.hp < tw3.maxHp,
|
||||||
|
'watchtower engagement resolved (compy ' + hpBefore + '->' + c3.hp.toFixed(0) + (c3.dead ? ' DEAD' : '') +
|
||||||
|
', tower ' + tw3.hp.toFixed(0) + '/' + tw3.maxHp + ')');
|
||||||
|
} else {
|
||||||
|
ok(false, 'isolated watchtower scenario could not be set up');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- taming, pets & amphibious raiders ----------
|
||||||
|
console.log('\n[taming]');
|
||||||
|
{
|
||||||
|
const tv = RTS.sim.newGame('normal', 31415);
|
||||||
|
const hqT = RTS.sim.hq();
|
||||||
|
tv.res.gold += 2000; tv.res.wood += 2000; tv.res.stone += 2000; tv.res.food += 500;
|
||||||
|
|
||||||
|
// place pen + finish it, train a tamer
|
||||||
|
function putNear(defId, r0, r1, tries) {
|
||||||
|
const rngT = RTS.util.makeRng(defId.length * 131 + r0);
|
||||||
|
for (let i = 0; i < (tries || 400); i++) {
|
||||||
|
const a = rngT() * Math.PI * 2, r = r0 + rngT() * (r1 - r0);
|
||||||
|
const x = Math.round(hqT.x + Math.cos(a) * r), y = Math.round(hqT.y + Math.sin(a) * r);
|
||||||
|
if (RTS.sim.canPlace(defId, x, y).ok) {
|
||||||
|
const resT = RTS.sim.place(defId, x, y);
|
||||||
|
if (resT.ok) return resT.b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const genT = putNear('generator', 5, 7);
|
||||||
|
stepSeconds(8);
|
||||||
|
const pen = putNear('primalpen', 2.5, 5);
|
||||||
|
ok(pen, 'primal pen placed');
|
||||||
|
stepSeconds(9);
|
||||||
|
ok(pen && pen.done, 'pen finished building');
|
||||||
|
if (pen) {
|
||||||
|
ok(RTS.sim.trainUnit(pen, 'tamer'), 'tamer training queued at pen');
|
||||||
|
ok(RTS.sim.trainUnit(pen, 'ranger') === false, 'pen refuses to train Rangers');
|
||||||
|
stepSeconds(22);
|
||||||
|
ok(tv.units.some(u => u.unitId === 'tamer'), 'tamer spawned');
|
||||||
|
}
|
||||||
|
|
||||||
|
// capture: weaken a wild compy next to the tamer
|
||||||
|
const tamer = tv.units.find(u => u.unitId === 'tamer');
|
||||||
|
if (tamer && pen) {
|
||||||
|
const wild = RTS.entities.makeDino('compy', tamer.x + 1, tamer.y, 'final');
|
||||||
|
wild.aggro = true;
|
||||||
|
tv.dinos.push(wild);
|
||||||
|
RTS.sim._debug.hurt(wild, wild.hp * 0.85, null, null); // -> 15% HP
|
||||||
|
ok(wild.hp < wild.maxHp * 0.32, 'capture candidate weakened below threshold');
|
||||||
|
stepSeconds(6);
|
||||||
|
ok(wild.tamed === true, 'tamer collared the weakened dino');
|
||||||
|
ok(RTS.sim.countTamed() >= 1, 'countTamed reflects the new pet');
|
||||||
|
|
||||||
|
// pet fights for the colony: drop a hostile near its post
|
||||||
|
const foe = RTS.entities.makeDino('compy', wild.x + 2, wild.y, 'final');
|
||||||
|
foe.aggro = true;
|
||||||
|
tv.dinos.push(foe);
|
||||||
|
const foeHp0 = foe.hp;
|
||||||
|
stepSeconds(6);
|
||||||
|
ok(foe.dead || foe.hp < foeHp0, 'tamed pet engaged a hostile (' + foeHp0.toFixed(0) + '->' + foe.hp.toFixed(0) + ')');
|
||||||
|
|
||||||
|
// towers must NOT shoot pets
|
||||||
|
const twT = putNear('watchtower', 2.5, 4);
|
||||||
|
if (twT) {
|
||||||
|
stepSeconds(9); // finish construction
|
||||||
|
const pet = tv.dinos.find(d => d.tamed && !d.dead);
|
||||||
|
if (pet) {
|
||||||
|
// clear wild dinos near the tower so ONLY the tower could touch the pet
|
||||||
|
for (const d of tv.dinos) if (!d.tamed && !d.dead && RTS.util.dist(d.x, d.y, twT.x, twT.y) < 12) d.dead = true;
|
||||||
|
tv.dinos = tv.dinos.filter(d => !d.dead || d.tamed);
|
||||||
|
pet.x = twT.x + 2; pet.y = twT.y; // right under the tower
|
||||||
|
const php = pet.hp;
|
||||||
|
stepSeconds(4);
|
||||||
|
ok(pet.hp >= php - 0.5, 'tower does not attack tamed pets (hp ' + php.toFixed(0) + '->' + pet.hp.toFixed(0) + (pet.hp > php ? ', pen healing active' : '') + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ok(false, 'taming scenario could not be set up');
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit math: base + 2 per finished pen
|
||||||
|
{
|
||||||
|
let pens = 0;
|
||||||
|
for (const b of tv.buildings) if (!b.dead && b.done && b.defId === 'primalpen') pens++;
|
||||||
|
ok(RTS.sim.tameLimit() === RTS.CONFIG.TAMING.baseLimit + pens * RTS.CONFIG.TAMING.perPen,
|
||||||
|
'tame limit math (' + RTS.sim.tameLimit() + ' = ' + RTS.CONFIG.TAMING.baseLimit + ' + ' + pens + '×2)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- rangers close distance when idle (regression: stood dumbly out of range) ----------
|
||||||
|
console.log('\n[ranger engagement]');
|
||||||
|
{
|
||||||
|
const re = RTS.sim.newGame('normal', 555001);
|
||||||
|
const hqR = RTS.sim.hq();
|
||||||
|
const u = RTS.entities.makeUnit('ranger', hqR.x, hqR.y);
|
||||||
|
re.units.push(u);
|
||||||
|
const d = RTS.entities.makeDino('compy', hqR.x + 5, hqR.y, 'roam'); // 5.0 > gun range 4.2
|
||||||
|
re.dinos.push(d);
|
||||||
|
stepSeconds(6);
|
||||||
|
ok(d.dead, 'idle ranger closes distance and kills a dino at 5.0 tiles');
|
||||||
|
// pets are never auto-targeted
|
||||||
|
const pet = RTS.entities.makeDino('compy', hqR.x + 2, hqR.y + 3, 'pet');
|
||||||
|
pet.tamed = true; pet.mode = 'pet'; pet.homeX = pet.x; pet.homeY = pet.y;
|
||||||
|
re.dinos.push(pet);
|
||||||
|
const php = pet.hp;
|
||||||
|
stepSeconds(4);
|
||||||
|
ok(pet.hp >= php - 0.5 && !pet.dead, 'rangers never auto-shoot tamed pets');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- breeding ----------
|
||||||
|
console.log('\n[breeding]');
|
||||||
|
{
|
||||||
|
const bv = RTS.sim.newGame('normal', 5150);
|
||||||
|
bv.res.gold += 3000; bv.res.wood += 3000; bv.res.stone += 3000; bv.res.food += 1000;
|
||||||
|
const hqB = RTS.sim.hq();
|
||||||
|
function putB(defId, r0, r1) {
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
const a = Math.random() * Math.PI * 2, r = r0 + Math.random() * (r1 - r0);
|
||||||
|
const x = Math.round(hqB.x + Math.cos(a) * r), y = Math.round(hqB.y + Math.sin(a) * r);
|
||||||
|
if (RTS.sim.canPlace(defId, x, y).ok) { const res = RTS.sim.place(defId, x, y); if (res.ok) return res.b; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
putB('generator', 4, 6);
|
||||||
|
const penB = putB('primalpen', 2.5, 5);
|
||||||
|
ok(penB, 'breeding pen placed');
|
||||||
|
stepSeconds(10);
|
||||||
|
// a tamed pair loitering by the pen
|
||||||
|
const p1 = RTS.entities.makeDino('compy', penB.x + 1.2, penB.y + 0.8, 'pet');
|
||||||
|
const p2 = RTS.entities.makeDino('raptor', penB.x - 1.1, penB.y + 1.3, 'pet');
|
||||||
|
for (const p of [p1, p2]) { p.tamed = true; p.mode = 'pet'; p.homeX = p.x; p.homeY = p.y; bv.dinos.push(p); }
|
||||||
|
const food0 = bv.res.food;
|
||||||
|
stepSeconds(50); // > eggTime(40): first egg should be laid AND hatch
|
||||||
|
const eggsLaid = (bv.res.food < food0);
|
||||||
|
ok(eggsLaid || bv.dinos.some(d => d.baby), 'pair produced an egg (food spent or hatchling present)');
|
||||||
|
const babies = bv.dinos.filter(d => d.baby && d.tamed);
|
||||||
|
ok(babies.length >= 1, 'egg hatched into a baby pet (' + babies.length + ')');
|
||||||
|
if (babies.length) {
|
||||||
|
ok(RTS.util.dist(babies[0].x, babies[0].y, penB.x, penB.y) < 4, 'hatchling appeared at the pen');
|
||||||
|
const g0 = babies[0].growth;
|
||||||
|
stepSeconds(15);
|
||||||
|
ok(babies[0].growth > g0, 'hatchling is growing (' + g0.toFixed(2) + '->' + babies[0].growth.toFixed(2) + ')');
|
||||||
|
// cap: no pen should hold more than maxPerPen eggs
|
||||||
|
let overCap = false;
|
||||||
|
for (const b of bv.buildings) {
|
||||||
|
if (b.defId !== 'primalpen') continue;
|
||||||
|
if ((bv.eggs || []).filter(e => e.penId === b.id).length > RTS.CONFIG.BREED.maxPerPen) overCap = true;
|
||||||
|
}
|
||||||
|
ok(!overCap, 'egg cap per pen respected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- save / load ----------
|
||||||
|
console.log('\n[save/load]');
|
||||||
|
{
|
||||||
|
const sv = RTS.sim.newGame('normal', 90210);
|
||||||
|
sv.res.gold += 2000; sv.res.wood += 2000; sv.res.stone += 2000;
|
||||||
|
const hqS = RTS.sim.hq();
|
||||||
|
function putS(defId, dx, dy) {
|
||||||
|
const x = Math.round(hqS.x + dx), y = Math.round(hqS.y + dy);
|
||||||
|
if (!RTS.sim.canPlace(defId, x, y).ok) return null;
|
||||||
|
const r = RTS.sim.place(defId, x, y);
|
||||||
|
return r.ok ? r.b : null;
|
||||||
|
}
|
||||||
|
putS('generator', -4, -3); putS('house', 3, 2); putS('watchtower', -2, 4);
|
||||||
|
stepSeconds(20);
|
||||||
|
// NOTE: deliberately stay in the peaceful early game — a late-day jump here
|
||||||
|
// would let a rex wave flatten the colony before the snapshot, which is not
|
||||||
|
// what this section is testing.
|
||||||
|
const wild = RTS.entities.makeDino('raptor', hqS.x + 8, hqS.y, 'roam');
|
||||||
|
sv.dinos.push(wild);
|
||||||
|
stepSeconds(30);
|
||||||
|
ok(!sv.over && RTS.sim.hq() && !RTS.sim.hq().dead, 'colony alive at snapshot time');
|
||||||
|
const snap = JSON.parse(JSON.stringify(RTS.sim.serialize()));
|
||||||
|
const before = {
|
||||||
|
day: sv.day, time: sv.time, gold: Math.floor(sv.res.gold),
|
||||||
|
blds: sv.buildings.length, units: sv.units.length, dinos: sv.dinos.length,
|
||||||
|
kills: sv.stats.kills,
|
||||||
|
treeChecksum: Array.from(sv.world.tiles.tree).reduce((a, b) => (a + b * 7919) | 0, 0),
|
||||||
|
};
|
||||||
|
ok(snap.st.buildings.length === before.blds && snap.rngState !== undefined, 'snapshot captured');
|
||||||
|
|
||||||
|
// keep playing "wrong" for a bit, then roll back
|
||||||
|
sv.res.gold = 1;
|
||||||
|
stepSeconds(40);
|
||||||
|
ok(RTS.sim.deserialize(snap), 'deserialized cleanly');
|
||||||
|
const after = RTS.sim.state();
|
||||||
|
ok(after.day === before.day && Math.abs(after.time - before.time) < 0.01, 'day/time restored exactly');
|
||||||
|
ok(Math.floor(after.res.gold) === before.gold, 'resources restored');
|
||||||
|
ok(after.buildings.length === before.blds && after.units.length === before.units && after.dinos.length === before.dinos,
|
||||||
|
'entities restored (' + after.buildings.length + 'b ' + after.units.length + 'u ' + after.dinos.length + 'd)');
|
||||||
|
ok(after.stats.kills === before.kills, 'stats restored');
|
||||||
|
const tc = Array.from(after.world.tiles.tree).reduce((a, b) => (a + b * 7919) | 0, 0);
|
||||||
|
ok(tc === before.treeChecksum, 'chopped forests stay chopped (world tiles restored)');
|
||||||
|
|
||||||
|
// ids must not collide after load: new spawns go above the loaded range
|
||||||
|
const maxIdBefore = Math.max(...after.buildings.map(b => b.id));
|
||||||
|
let h2 = null;
|
||||||
|
for (const [dx, dy] of [[4, -3], [-4, 3], [5, 0], [-5, -1], [0, 5], [5, 4]]) {
|
||||||
|
h2 = putS('house', dx, dy);
|
||||||
|
if (h2) break;
|
||||||
|
}
|
||||||
|
ok(h2, 'post-load placement works');
|
||||||
|
const spawnedIds = after.buildings.map(b => b.id).filter(id => !snap.st.buildings.some(b => b.id === id));
|
||||||
|
ok(spawnedIds.length >= 1 && spawnedIds.every(id => id > maxIdBefore),
|
||||||
|
'new entity ids stay above loaded ones (' + (spawnedIds[0] || '-') + ' > ' + maxIdBefore + ')');
|
||||||
|
stepSeconds(5);
|
||||||
|
ok(Number.isFinite(sv.res.gold) && Number.isFinite(hqS.hp), 'game keeps ticking sanely after load');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- power grid recovery (regression: brownout was permanent) ----------
|
||||||
|
console.log('\n[power grid]');
|
||||||
|
{
|
||||||
|
const pv = RTS.sim.newGame('normal', 60221);
|
||||||
|
pv.res.gold += 5000; pv.res.wood += 5000; pv.res.stone += 5000;
|
||||||
|
const hqP = RTS.sim.hq();
|
||||||
|
// deterministic spot finder along a ray
|
||||||
|
const putRay = (id, angDeg, r0, r1) => {
|
||||||
|
const a = angDeg * Math.PI / 180;
|
||||||
|
for (let r = r0; r <= r1; r += 0.5) {
|
||||||
|
const x = Math.round(hqP.x + Math.cos(a) * r), y = Math.round(hqP.y + Math.sin(a) * r);
|
||||||
|
if (RTS.sim.canPlace(id, x, y).ok) { const res = RTS.sim.place(id, x, y); if (res.ok) return res.b; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const tw1 = putRay('watchtower', 20, 3, 4.5);
|
||||||
|
const tw2 = putRay('watchtower', 160, 3, 4.5);
|
||||||
|
stepSeconds(8); // both done: 4+4 use vs cap 10 → fine
|
||||||
|
ok(tw1 && tw2 && tw1.powered && tw2.powered, 'both towers powered within capacity');
|
||||||
|
// overload: HQ alone (cap 10) can't feed three 4-use towers → newest browns out
|
||||||
|
const tw3 = putRay('watchtower', 250, 3, 4.5);
|
||||||
|
stepSeconds(8);
|
||||||
|
ok(!tw3.powered, 'overload browns out the newest tower');
|
||||||
|
ok(tw1.powered && tw2.powered, 'older towers stay powered during brownout');
|
||||||
|
// recovery: a generator comes online → the browned-out tower wakes up
|
||||||
|
const gen = putRay('generator', 300, 4, 6);
|
||||||
|
stepSeconds(9);
|
||||||
|
ok(gen && gen.done && tw3.powered, 'brownout RECOVERS when a generator finishes');
|
||||||
|
// grid chaining: an outpost far from HQ runs on chained generators
|
||||||
|
// (fresh game so no waves interfere with the chain during its build time)
|
||||||
|
{
|
||||||
|
const cg = RTS.sim.newGame('normal', 777);
|
||||||
|
cg.res.gold += 5000; cg.res.wood += 5000; cg.res.stone += 5000;
|
||||||
|
const hqC = RTS.sim.hq();
|
||||||
|
const putRay2 = (id, angDeg, r0, r1) => {
|
||||||
|
const a = angDeg * Math.PI / 180;
|
||||||
|
for (let r = r0; r <= r1; r += 0.5) {
|
||||||
|
const x = Math.round(hqC.x + Math.cos(a) * r), y = Math.round(hqC.y + Math.sin(a) * r);
|
||||||
|
if (RTS.sim.canPlace(id, x, y).ok) { const res = RTS.sim.place(id, x, y); if (res.ok) return res.b; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
let genA = null, genB = null, farTw = null;
|
||||||
|
for (let ang = 0; ang < 360 && !farTw; ang += 20) {
|
||||||
|
const g1 = putRay2('generator', ang, 5.5, 7);
|
||||||
|
if (!g1) continue;
|
||||||
|
stepSeconds(8); // a generator extends the grid once DONE
|
||||||
|
const g2 = putRay2('generator', ang, 9.5, 12.5);
|
||||||
|
if (!g2) { RTS.sim.demolish(g1.id); continue; }
|
||||||
|
stepSeconds(8);
|
||||||
|
// tower must be covered by genB but OUTSIDE HQ and genA reach
|
||||||
|
let ftw = null;
|
||||||
|
for (let rr = 0.8; rr <= 6 && !ftw; rr += 0.75) {
|
||||||
|
for (let da = -70; da <= 70 && !ftw; da += 14) {
|
||||||
|
const a2 = (ang + da) * Math.PI / 180;
|
||||||
|
const bx = g2.x + Math.cos(a2) * rr, by = g2.y + Math.sin(a2) * rr;
|
||||||
|
const x = Math.round(bx), y = Math.round(by);
|
||||||
|
if (RTS.util.dist(x, y, hqC.x, hqC.y) < 8.2) continue;
|
||||||
|
if (RTS.util.dist(x, y, g1.x, g1.y) < 7) continue;
|
||||||
|
if (!RTS.sim.canPlace('watchtower', x, y).ok) continue;
|
||||||
|
const res = RTS.sim.place('watchtower', x, y);
|
||||||
|
if (res.ok) ftw = res.b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ftw) { RTS.sim.demolish(g2.id); RTS.sim.demolish(g1.id); continue; }
|
||||||
|
genA = g1; genB = g2; farTw = ftw;
|
||||||
|
}
|
||||||
|
stepSeconds(10);
|
||||||
|
ok(genA && genB && farTw && farTw.done,
|
||||||
|
'chained generators extend the grid (' + (farTw ? 'tower at ' + RTS.util.dist(farTw.x, farTw.y, hqC.x, hqC.y).toFixed(1) + ' tiles' : 'placement failed') + ')');
|
||||||
|
if (genB && farTw && farTw.done) {
|
||||||
|
ok(farTw.powered, 'outpost tower powered by the chain, not the HQ');
|
||||||
|
RTS.sim._debug.hurt(genB, 99999, null, null); // destroy the middle of the chain
|
||||||
|
stepSeconds(1);
|
||||||
|
ok(!farTw.powered || RTS.util.dist(farTw.x, farTw.y, hqC.x, hqC.y) < 8,
|
||||||
|
'losing a generator cuts power to its customers');
|
||||||
|
ok(!genA.dead, 'upstream generator unaffected');
|
||||||
|
} else {
|
||||||
|
// map gave us no valid chain — don't fail the suite for terrain
|
||||||
|
console.log(' ~ skipped chain-destruction checks (no valid chain spot)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- amphibious raiders ----------
|
||||||
|
console.log('\n[amphibious raiders]');
|
||||||
|
{
|
||||||
|
const am = RTS.sim.newGame('normal', 27182);
|
||||||
|
am.day = 7; am.dayT = RTS.CONFIG.WORLD.DAY_LENGTH - 0.5;
|
||||||
|
stepSeconds(3);
|
||||||
|
const sucho = am.dinos.find(d => d.dinoId === 'sucho');
|
||||||
|
ok(sucho, 'suchomimus joined the day-8 wave');
|
||||||
|
if (sucho) {
|
||||||
|
ok(sucho.amphibious === true, 'sucho flagged amphibious');
|
||||||
|
const sx0 = sucho.x, sy0 = sucho.y;
|
||||||
|
stepSeconds(20);
|
||||||
|
const movedA = Math.hypot(sucho.x - sx0, sucho.y - sy0);
|
||||||
|
ok(movedA > 2, 'sucho left the lake and advanced (moved ' + movedA.toFixed(1) + ' tiles)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- determinism/perf sanity ----------
|
||||||
|
console.log('\n[perf]');
|
||||||
|
const st3 = RTS.sim.newGame('hard', 999);
|
||||||
|
const t0 = Date.now();
|
||||||
|
stepSeconds(60);
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
ok(ms < 15000, '60s of hard-mode sim in ' + ms + 'ms wall time');
|
||||||
|
console.log(' (dinos alive: ' + st3.dinos.length + ', buildings: ' + st3.buildings.length + ')');
|
||||||
|
|
||||||
|
console.log('\n==================================');
|
||||||
|
if (failures) { console.error('SMOKE TEST FAILED: ' + failures + ' assertion(s)'); process.exit(1); }
|
||||||
|
console.log('ALL SMOKE TESTS PASSED ✔');
|
||||||
Reference in New Issue
Block a user