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:
2026-08-23 07:00:23 +00:00
commit 8fbe70d0b0
21 changed files with 6583 additions and 0 deletions
+97
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1725
View File
File diff suppressed because it is too large Load Diff
+526
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
})();