Files
deepseek 8fbe70d0b0 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
2026-08-23 07:00:23 +00:00

1726 lines
61 KiB
JavaScript

/* =========================================================
* REPRTERRA WEB — sim.js
* The simulation: state, economy, power grid, workers,
* construction, combat, dinosaur AI, waves, win/lose.
* ========================================================= */
'use strict';
window.RTS = window.RTS || {};
RTS.sim = (function () {
const U = RTS.util;
const C = RTS.CONFIG;
const W = C.WORLD.W, H = C.WORLD.H;
const DIFF = {
easy: { hpMult: 0.85, spdMult: 0.95 },
normal: { hpMult: 1.00, spdMult: 1.00 },
hard: { hpMult: 1.18, spdMult: 1.06 },
};
let st = null;
// =========================================================
// NEW GAME
// =========================================================
function newGame(diff, seed) {
RTS.entities.resetIds();
const world = RTS.world.generate(seed);
const T = world.tiles;
st = {
diff: diff || 'normal',
seed: world.seed,
time: 0,
day: 1,
dayT: 0,
speed: 1,
res: Object.assign({}, C.START_RES[diff || 'normal']),
rate: { gold: 0, wood: 0, stone: 0, food: 0 }, // EMA for HUD
energyCap: 0, energyUse: 0,
pop: 0, popCap: 0, colonistT: 3,
starving: false,
buildings: [], units: [], dinos: [], projs: [],
parts: [], decals: [], texts: [],
upgrades: { weapon: 0, armor: 0, range: 0 },
waves: C.WAVES[diff || 'normal'].map(w => Object.assign({ spawned: false, warned: false }, w)),
waveIdx: 0,
warnT: -1, warnDirX: 0, warnDirY: 0, warnedDay: -1,
finalTriggered: false, finalClearedT: -1,
over: false, victory: false, overT: 0,
stats: { kills: 0, built: 0, lost: 0, goldEarned: 0 },
world, hqId: 0,
blockedEnemy: new Uint8Array(W * H),
blockedFriend: new Uint8Array(W * H),
blockedAmph: new Uint8Array(W * H), // water passable (Suchomimus)
bgrid: new Int32Array(W * H), // building id per tile
vis: new Uint8Array(W * H), // 0 dark, 1 explored, 2 visible
fogT: 0,
hashD: new U.SpatialHash(3, W, H), // dinos
hashP: new U.SpatialHash(4, W, H), // player units+dinos? -> player units only
hashB: new U.SpatialHash(4, W, H), // buildings (by center)
pathCalls: 0,
ambientRoarT: 6,
shakeT: 0, shakeMag: 0,
eggs: [], // breeding (see breedingTick)
_eggSeq: 0,
};
// --- HQ ---
const hq = RTS.entities.makeBuilding('hq', world.hq.x, world.hq.y, { instant: true });
st.buildings.push(hq);
st.hqId = hq.id;
st.bmap = new Map([[hq.id, hq]]);
// --- starting colonists ---
for (let i = 0; i < 4; i++) spawnColonist();
// --- roamer packs ---
const rng = U.makeRng(world.seed ^ 0x9e3779b9);
const packs = C.ROAMER_PACKS[st.diff];
for (let p = 0; p < packs; p++) {
const kind = rng.pick(['raptor', 'raptor', 'ptera', 'compy', 'trike', 'dilo']);
let x, y, tries = 0;
do {
x = rng.int(3, W - 4); y = rng.int(3, H - 4);
tries++;
} while ((U.dist(x, y, world.hq.x, world.hq.y) < 16 || !passableTile(x, y)) && tries < 60);
if (tries >= 60) continue;
const counts = { raptor: rng.int(3, 5), ptera: rng.int(2, 4), compy: rng.int(8, 13), trike: rng.int(1, 2), dilo: rng.int(2, 3) };
const n = counts[kind];
for (let i = 0; i < n; i++) {
const dx = rng.range(-2, 2), dy = rng.range(-2, 2);
const px = U.clamp(x + dx, 1, W - 2), py = U.clamp(y + dy, 1, H - 2);
const d = RTS.entities.makeDino(kind, px, py, 'roam', { x, y });
applyDiff(d);
st.dinos.push(d);
}
}
rebuildGrids();
recomputeEnergy();
updateHashes();
updateFog(true);
return st;
}
function applyDiff(d) {
const m = DIFF[st.diff];
d.hp = d.maxHp = Math.round(d.maxHp * m.hpMult);
d.speed *= m.spdMult;
}
function passableTile(x, y) {
const T = st.world.tiles;
const i = y * W + x;
return T.terrain[i] !== 3 && !st.bgrid[i] && !T.tree[i] && !T.rock[i];
}
// =========================================================
// GRIDS / BLOCKING
// =========================================================
function rebuildGrids() {
const T = st.world.tiles;
st.bgrid.fill(0);
for (const b of st.buildings) {
if (b.dead) continue;
stampFoot(b, (x, y) => { st.bgrid[y * W + x] = b.id; });
}
for (let i = 0; i < W * H; i++) {
const solid = T.terrain[i] === 3 || st.bgrid[i] !== 0;
const building = st.bgrid[i] !== 0;
st.blockedEnemy[i] = solid ? 1 : 0;
st.blockedAmph[i] = building ? 1 : 0; // swimmers cross water
const bid = st.bgrid[i];
const friendlyPass = bid && isGate(bid);
st.blockedFriend[i] = (solid && !friendlyPass) ? 1 : 0;
}
}
function isGate(bid) {
const b = getB(bid);
return b && b.defId === 'gate';
}
function stampFoot(b, cb) {
const [x0, y0] = RTS.entities.footOrigin(b.x, b.y, b.size);
for (let y = y0; y < y0 + b.size; y++)
for (let x = x0; x < x0 + b.size; x++)
if (U.inBounds(x, y, W, H)) if (cb(x, y)) return;
}
function buildingAt(x, y) {
x |= 0; y |= 0;
if (!U.inBounds(x, y, W, H)) return null;
return getB(st.bgrid[y * W + x]);
}
function getB(id) {
if (!id || !st.bmap) return null;
return st.bmap.get(id) || null;
}
function getDino(id) {
if (!id) return null;
for (const d of st.dinos) if (d.id === id && !d.dead) return d;
return null;
}
// =========================================================
// POWER / PLACEMENT
// =========================================================
function recomputeEnergy() {
let cap = 0, use = 0;
for (const b of st.buildings) {
if (!b.done || b.dead) continue;
const def = C.BUILDINGS[b.defId];
cap += def.energyProd || 0;
use += def.energyUse || 0;
}
st.energyCap = cap; st.energyUse = use;
}
function poweredAt(cx, cy) {
// within radius of HQ or a finished, still-standing generator
for (const b of st.buildings) {
if (!b.done || b.dead) continue;
const def = C.BUILDINGS[b.defId];
if (!def.radius) continue;
if (U.dist(cx, cy, b.x, b.y) <= def.radius + 0.2) return true;
}
return false;
}
function depositInRange(kind, cx, cy, range) {
const T = st.world.tiles;
let total = 0;
const [x0, y0] = [Math.floor(cx - range), Math.floor(cy - range)];
for (let y = Math.max(0, y0); y <= Math.min(H - 1, Math.ceil(cy + range)); y++)
for (let x = Math.max(0, x0); x <= Math.min(W - 1, Math.ceil(cx + range)); x++) {
if (U.dist(x + 0.5, y + 0.5, cx, cy) > range) continue;
const i = y * W + x;
total += kind === 'tree' ? T.tree[i] : T.rock[i];
}
return total;
}
// can we place def with CENTER at (cx,cy)?
function canPlace(defId, cx, cy) {
const def = C.BUILDINGS[defId];
const T = st.world.tiles;
const [x0, y0] = RTS.entities.footOrigin(cx, cy, def.size);
if (x0 < 0 || y0 < 0 || x0 + def.size > W || y0 + def.size > H) return { ok: false, why: 'Out of bounds' };
for (let y = y0; y < y0 + def.size; y++) {
for (let x = x0; x < x0 + def.size; x++) {
const i = y * W + x;
if (T.terrain[i] === 3) return { ok: false, why: 'Cannot build on water' };
if (st.bgrid[i]) return { ok: false, why: 'Blocked by another building' };
if (T.tree[i] || T.rock[i]) return { ok: false, why: 'Terrain occupied — clear it or build elsewhere' };
}
}
if (!poweredAt(cx, cy)) return { ok: false, why: 'Outside your power grid — build a Generator closer' };
if (defId === 'forester') {
const amt = depositInRange('tree', cx, cy, def.range);
if (amt < def.needRes) return { ok: false, why: 'Needs at least ' + def.needRes + ' wood in the surrounding forest' };
}
if (defId === 'quarry') {
const amt = depositInRange('rock', cx, cy, def.range);
if (amt < def.needRes) return { ok: false, why: 'Needs rock outcrops nearby (' + def.needRes + ' stone)' };
}
// NOTE: exceeding energy capacity is allowed at placement time;
// brownout shuts the newest buildings down until more Generators are built.
return { ok: true };
}
function afford(cost, mult) {
for (const k in cost) if (st.res[k] < cost[k] * (mult || 1)) return false;
return true;
}
function pay(cost, mult) {
for (const k in cost) st.res[k] -= cost[k] * (mult || 1);
}
function place(defId, cx, cy) {
const chk = canPlace(defId, cx, cy);
if (!chk.ok) return chk;
const def = C.BUILDINGS[defId];
if (!afford(def.cost)) return { ok: false, why: 'Not enough resources' };
pay(def.cost);
const b = RTS.entities.makeBuilding(defId, cx, cy);
st.buildings.push(b);
st.stats.built++;
rebuildGrids();
recomputeEnergy();
RTS.audio.build();
return { ok: true, b };
}
function demolish(id) {
const b = getB(id);
if (!b || b.defId === 'hq') return false;
const def = C.BUILDINGS[b.defId];
for (const k in def.cost) st.res[k] += Math.floor(def.cost[k] * 0.5);
killBuilding(b, true);
return true;
}
function trainUnit(b, unitId) {
const def = C.UNITS[unitId];
if (!def || !def.cost) return false;
// each building trains its own troop type
if (C.TRAIN_AT[unitId] !== b.defId) return false;
if (b.trainQ.length >= 5) return false;
if (!afford(def.cost)) return false;
pay(def.cost);
b.trainQ.push({ unit: unitId, t: def.trainTime, total: def.trainTime });
RTS.audio.click();
return true;
}
function upgradeCost(up) {
const def = C.UPGRADES.find(u => u.id === up);
const lvl = st.upgrades[up];
const m = Math.pow(def.mult, lvl);
const c = {};
for (const k in def.base) c[k] = Math.round(def.base[k] * m);
return c;
}
function buyUpgrade(up) {
const def = C.UPGRADES.find(u => u.id === up);
const lvl = st.upgrades[up];
if (lvl >= def.tiers) return false;
const c = upgradeCost(up);
if (!afford(c)) return false;
pay(c);
st.upgrades[up]++;
RTS.audio.coin();
addText(hq().x, hq().y - 2, def.name + ' ' + romanize(st.upgrades[up]), '#8fd6ff');
return true;
}
function romanize(n) { return ['I', 'II', 'III'][n - 1] || n; }
function hq() { return getB(st.hqId); }
// =========================================================
// SPAWNING
// =========================================================
function spawnColonist() {
const q = hq();
if (!q) return;
const ang = U.rng() * Math.PI * 2;
const u = RTS.entities.makeUnit('colonist',
U.clamp(q.x + Math.cos(ang) * 2.2, 1, W - 2),
U.clamp(q.y + Math.sin(ang) * 2.2, 1, H - 2));
st.units.push(u);
}
function spawnRanger(b) { spawnTrained(b, 'ranger'); }
function spawnTrained(b, unitId) {
const u = RTS.entities.makeUnit(unitId, b.x + U.rng.range(-1, 1), b.y + b.size / 2 + 0.8);
st.units.push(u);
if (b.rallyX != null) {
S.commandMove([u.id], b.rallyX + U.rng.range(-0.8, 0.8), b.rallyY + U.rng.range(-0.8, 0.8));
}
RTS.audio.train();
}
function edgeSpawnPoint(dirAng) {
// point far from HQ along direction, clamped inside map
const q = hq(); if (!q) return { x: 2, y: 2 };
let x = q.x, y = q.y;
const dx = Math.cos(dirAng), dy = Math.sin(dirAng);
for (let s = 0; s < 200; s++) {
const nx = x + dx, ny = y + dy;
if (nx < 2 || ny < 2 || nx > W - 3 || ny > H - 3) break;
x = nx; y = ny;
}
return { x, y };
}
function spawnWave(w) {
// use the direction chosen at warning time so the arrow tells the truth
const ang = st.pendingAng != null ? st.pendingAng : U.rng() * Math.PI * 2;
const pt = edgeSpawnPoint(ang);
const lakePt = findLakePoint();
st.warnDirX = Math.cos(ang); st.warnDirY = Math.sin(ang);
for (const type in w.comp) {
// aquatic raiders emerge from a random lake, not the map edge
const base = type === 'sucho' ? (lakePt || pt) : pt;
for (let i = 0; i < w.comp[type]; i++) {
const off = type === 'sucho' ? 2.5 : 5.5;
const px = U.clamp(base.x + U.rng.range(-off, off), 2, W - 3);
const py = U.clamp(base.y + U.rng.range(-off, off), 2, H - 3);
const d = RTS.entities.makeDino(type, px, py, w.final ? 'final' : 'wave');
applyDiff(d);
d.aggro = true;
st.dinos.push(d);
}
if (type === 'sucho' && lakePt) {
RTS.ui && RTS.ui.toast && !w._suchoToast && (w._suchoToast = true) &&
RTS.ui.toast('🌊 Something is moving in the water…', 'warn');
}
}
RTS.audio.roar();
st.shakeT = 0.8; st.shakeMag = 4;
}
function findLakePoint() {
for (let i = 0; i < 300; i++) {
const x = U.rng.int(2, W - 3), y = U.rng.int(2, H - 3);
if (st.world.tiles.terrain[y * W + x] !== 3) continue;
if (U.dist(x, y, hq().x, hq().y) < 10) continue;
return { x, y };
}
return null;
}
// =========================================================
// EFFECTS
// =========================================================
function addPart(p) {
if (st.parts.length > 600) st.parts.shift();
st.parts.push(Object.assign({ z: 6, vz: 0, vx: 0, vy: 0, grav: 22, life: 0.5, maxLife: 0.5, size: 2, color: '#a33', type: 'blood' }, p));
}
function fxBlood(x, y, n, big) {
for (let i = 0; i < n; i++) {
const a = U.rng() * Math.PI * 2, sp = U.rng.range(1, 4) * (big ? 1.6 : 1);
addPart({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp * 0.6, z: 5 + U.rng() * 5, vz: U.rng.range(2, 9), color: U.rng.pick(['#8c1f1f', '#a32c2c', '#701616']), size: U.rng.range(1.5, 3.5) * (big ? 1.5 : 1), life: U.rng.range(0.3, 0.7) });
}
if (st.decals.length < 220 && U.rng() < (big ? 1 : 0.45)) {
st.decals.push({ x, y, r: (big ? U.rng.range(0.5, 0.95) : U.rng.range(0.2, 0.42)), alpha: 0.55, color: '#5e1414', t: 0 });
}
}
function fxSmoke(x, y, n, col) {
for (let i = 0; i < n; i++) {
addPart({ x: x + U.rng.range(-0.3, 0.3), y: y + U.rng.range(-0.3, 0.3), vx: U.rng.range(-0.5, 0.5), vy: U.rng.range(-0.7, -0.2), z: 10 + U.rng() * 8, vz: U.rng.range(4, 9), grav: -2, color: col || U.rng.pick(['#555', '#666', '#484848']), size: U.rng.range(3, 6), life: U.rng.range(0.6, 1.3), type: 'smoke' });
}
}
function fxSpark(x, y) {
for (let i = 0; i < 3; i++) {
addPart({ x, y, vx: U.rng.range(-2, 2), vy: U.rng.range(-2, 2), z: 8, vz: U.rng.range(1, 5), color: '#ffd76a', size: 1.5, life: 0.2 });
}
}
function fxBoom(x, y, r) {
for (let i = 0; i < 14; i++) {
const a = U.rng() * Math.PI * 2, sp = U.rng.range(2, 7);
addPart({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp * 0.5, z: 6, vz: U.rng.range(4, 12), color: U.rng.pick(['#ffcf5a', '#ff8c3a', '#c96a2a', '#777']), size: U.rng.range(2, 5), life: U.rng.range(0.3, 0.8) });
}
st.decals.push({ x, y, r: r * 0.7, alpha: 0.4, color: '#26221c', t: 0 });
st.shakeT = 0.25; st.shakeMag = 3;
}
function addText(x, y, str, color) {
if (st.texts.length > 40) st.texts.shift();
st.texts.push({ x, y, str, color: color || '#ffe08a', life: 1.6, maxLife: 1.6 });
}
// =========================================================
// DAMAGE
// =========================================================
function playerDmg(base) { return base * (1 + 0.25 * st.upgrades.weapon); }
function playerRange(base) { return base * (1 + 0.12 * st.upgrades.range); }
function hurtDino(d, dmg, srcX, srcY, silentBlood) {
if (d.dead) return;
d.hp -= dmg;
d.hitFlash = 0.12;
if (!silentBlood) fxBlood(d.x, d.y, d.scale > 1.4 ? 6 : 3, d.scale > 1.4);
// tamed pets: no bounty/kill stats, no AI retaliation — the player commands them
if (d.tamed) {
if (d.hp <= 0) {
d.dead = true;
fxBlood(d.x, d.y, d.scale > 1.4 ? 26 : 10, d.scale > 1.4);
st.decals.push({ x: d.x, y: d.y, r: d.r * 1.6, alpha: 0.5, color: '#4a1010', t: 0 });
RTS.audio.die();
RTS.ui && RTS.ui.toast && RTS.ui.toast('💔 Your ' + d.name + ' has fallen.', 'bad');
}
return;
}
// retaliate chance: switch target to attacker
if (srcX != null && d.mode === 'roam') d.aggro = true;
if (srcX != null && !d.flying && U.rng() < 0.35) { d.targetId = 0; d.repathT = 0; d.forcedX = srcX; d.forcedY = srcY; d.forcedT = 2.5; }
if (d.hp <= 0) {
d.dead = true;
st.stats.kills++;
st.res.gold += d.bounty;
st.stats.goldEarned += d.bounty;
addText(d.x, d.y - 1, '+' + d.bounty, '#ffd76a');
fxBlood(d.x, d.y, d.scale > 1.4 ? 26 : 10, d.scale > 1.4);
st.decals.push({ x: d.x, y: d.y, r: d.r * 1.6, alpha: 0.5, color: '#4a1010', t: 0 });
if (d.dinoId === 'rex') { RTS.audio.explode(); st.shakeT = 0.6; st.shakeMag = 5; fxSmoke(d.x, d.y, 10); }
else RTS.audio.die();
}
}
function hurtPlayerEntity(e, dmg, byDino) {
e.hp -= dmg;
e.hitFlash = 0.15;
if (e.hp <= 0 && !e.dead) {
if (e.kind === 'building') killBuilding(e, false);
else if (e.kind === 'dino') {
// a tamed pet fighting for the colony
e.dead = true;
fxBlood(e.x, e.y, 14, e.scale > 1.4);
st.decals.push({ x: e.x, y: e.y, r: e.r * 1.5, alpha: 0.5, color: '#4a1010', t: 0 });
RTS.audio.die();
RTS.ui && RTS.ui.toast && RTS.ui.toast('💔 Your ' + e.name + ' has fallen.', 'bad');
} else {
e.dead = true;
fxBlood(e.x, e.y, 6, false);
RTS.audio.die();
}
}
}
function killBuilding(b, demo) {
b.dead = true;
const wasHQ = b.id === st.hqId;
// free tiles
stampFoot(b, () => true);
rebuildGrids();
recomputeEnergy();
if (!demo) {
st.stats.lost++;
fxBoom(b.x, b.y, b.size);
fxSmoke(b.x, b.y, 14);
st.decals.push({ x: b.x, y: b.y, r: b.size * 0.8, alpha: 0.6, color: '#211d18', t: 0, rubble: true, size: b.size });
RTS.audio.explode();
if (wasHQ) endGame(false);
} else {
fxSmoke(b.x, b.y, 6);
}
}
function endGame(victory) {
if (st.over) return;
st.over = true;
st.victory = victory;
st.overT = 0;
RTS.audio.stopAlarm?.();
if (victory) RTS.audio.win(); else RTS.audio.lose();
}
// =========================================================
// MAIN TICK
// =========================================================
function tick(dt) {
if (st.over) { st.overT += dt; updateFx(dt); return; }
st.time += dt;
const prevDay = st.day;
st.dayT += dt;
if (st.dayT >= C.WORLD.DAY_LENGTH) { st.dayT -= C.WORLD.DAY_LENGTH; st.day++; onNewDay(); }
st.pathCalls = 0;
updateHashes();
economyTick(dt);
constructionTick(dt);
barracksTick(dt);
towersTick(dt);
rangersTick(dt);
tamersTick(dt);
colonistsTick(dt);
penHealTick(dt);
breedingTick(dt);
eggsTick(dt);
dinosTick(dt);
projectilesTick(dt);
updateFx(dt);
st.fogT -= dt;
if (st.fogT <= 0) { st.fogT = 0.4; updateFog(false); }
wavesTick(dt);
// ambient roar
st.ambientRoarT -= dt;
if (st.ambientRoarT <= 0) {
st.ambientRoarT = U.rng.range(14, 30);
if (st.dinos.length > 6) RTS.audio.roar();
}
if (st.shakeT > 0) st.shakeT -= dt;
}
function onNewDay() {
// dawn autosave (storage glue lives in main; no-op headless)
if (RTS.storage && RTS.storage.autosave) RTS.storage.autosave();
}
function updateHashes() {
st.hashD.clear();
for (const d of st.dinos) if (!d.dead) st.hashD.insert(d);
st.hashP.clear();
for (const u of st.units) if (!u.dead) st.hashP.insert(u);
st.hashB.clear();
for (const b of st.buildings) if (!b.dead) st.hashB.insert(b);
// id -> building map
if (!st.bmap) st.bmap = new Map();
else st.bmap.clear();
for (const b of st.buildings) st.bmap.set(b.id, b);
}
// ---------------------------------------------------------
// ECONOMY
// ---------------------------------------------------------
function economyTick(dt) {
const T = st.world.tiles;
// reset worker assignment
let workersAvail = 0;
for (const u of st.units) if (!u.dead && u.unitId === 'colonist') workersAvail++;
st.pop = workersAvail;
let popCap = 0, goldInc = 0;
let foodProd = 0, woodProd = 0, stoneProd = 0;
// collect candidate jobs
const jobs = [];
for (const b of st.buildings) {
if (b.dead || !b.done) continue;
const def = C.BUILDINGS[b.defId];
popCap += def.popCap || 0;
if (def.goldRate) goldInc += def.goldRate * (b.powered ? 1 : 0.4);
if (def.workers > 0) jobs.push(b);
}
st.popCap = popCap;
// brownout: if use > cap, newest buildings go offline
// POWER GRID: recomputed fresh every tick so that
// - brownouts RECOVER once new Generators come online
// - losing a Generator actually cuts power to its customers
for (const b of st.buildings) {
if (b.dead || !b.done || !(C.BUILDINGS[b.defId].energyUse > 0)) continue;
b.powered = poweredAt(b.x, b.y);
}
if (st.energyUse > st.energyCap) {
let excess = st.energyUse - st.energyCap;
const sorted = st.buildings.filter(b => !b.dead && b.done).sort((a, b) => b.id - a.id);
for (const b of sorted) {
if (excess <= 0) break;
const def = C.BUILDINGS[b.defId];
const use = def.energyUse || 0;
if (use > 0 && b.powered) { b.powered = false; excess -= use; }
}
}
// assign workers fairly: always top up the least-staffed building first,
// so a late-built barracks isn't starved by earlier farms
const needy = jobs.filter(b => b.active !== false);
for (const b of needy) b.workers = 0;
while (workersAvail > 0) {
let best = null, bestRatio = Infinity;
for (const b of needy) {
const r = b.workers / b.workersNeed;
if (r < bestRatio - 1e-9 && r < 1) { bestRatio = r; best = b; }
}
if (!best) break;
best.workers++;
workersAvail--;
}
// production
for (const b of st.buildings) {
if (b.dead || !b.done) continue;
const def = C.BUILDINGS[b.defId];
const on = b.powered && b.active !== false;
let eff = on ? (b.workersNeed ? b.workers / b.workersNeed : 1) : 0;
if (b.defId === 'forester' && eff > 0) {
const f = harvestAround(b, 'tree', def.woodRate * dt * eff, T.tree, T.treeMax);
woodProd += def.woodRate * eff * f;
} else if (b.defId === 'quarry' && eff > 0) {
const f = harvestAround(b, 'rock', def.stoneRate * dt * eff, T.rock, T.rockMax);
stoneProd += def.stoneRate * eff * f;
} else if (b.defId === 'farm') {
foodProd += def.foodRate * eff;
}
if (b.burnT > 0) { b.burnT -= dt; if (U.rng() < dt * 8) fxSmoke(b.x + U.rng.range(-0.5, 0.5), b.y + U.rng.range(-0.5, 0.5), 1, '#333'); }
}
// food consumption
const soldiers = st.units.reduce((n, u) => n + (!u.dead && u.unitId === 'ranger' ? 1 : 0), 0);
const eat = st.pop * C.COLONIST.FOOD_USE + soldiers * 0.02;
st.res.food += (foodProd - eat) * dt;
st.starving = st.res.food <= 0.01;
if (st.res.food < 0) st.res.food = 0;
st.res.gold += goldInc * dt * (st.starving ? 0.5 : 1);
st.res.wood += woodProd;
st.res.stone += stoneProd;
// smooth rates for HUD (production values are already per-second)
const inst = { gold: goldInc * (st.starving ? 0.5 : 1), wood: woodProd, stone: stoneProd, food: foodProd - eat };
for (const k in st.rate) st.rate[k] = U.lerp(st.rate[k], inst[k] || 0, 0.08);
// colonist arrivals
st.colonistT -= dt;
if (st.colonistT <= 0) {
st.colonistT = C.COLONIST.ARRIVE_EVERY;
if (st.pop < st.popCap && !st.starving) { spawnColonist(); }
}
}
// drains deposit tiles near b; returns efficiency 0..1 based on remaining density
function harvestAround(b, kind, amount, field, fieldMax) {
const def = C.BUILDINGS[b.defId];
const range = def.range;
const avail = depositInRange(kind, b.x, b.y, range);
if (avail <= 0) return 0.12; // exhausted: tiny trickle
let left = amount * 3.2; // conversion: money-rate <-> tile-units
const [x0, y0] = [Math.floor(b.x - range), Math.floor(b.y - range)];
outer:
for (let y = Math.max(0, y0); y <= Math.min(H - 1, Math.ceil(b.y + range)); y++) {
for (let x = Math.max(0, x0); x <= Math.min(W - 1, Math.ceil(b.x + range)); x++) {
if (U.dist(x + 0.5, y + 0.5, b.x, b.y) > range) continue;
const i = y * W + x;
if (field[i] <= 0) continue;
const take = Math.min(field[i], left);
field[i] -= take; left -= take;
if (left <= 0) break outer;
}
}
return Math.min(1, avail / (def.needRes * 1.5));
}
// ---------------------------------------------------------
// CONSTRUCTION
// ---------------------------------------------------------
function constructionTick(dt) {
for (const b of st.buildings) {
if (b.dead || b.done) continue;
b.progress += dt / b.buildTime;
b.hp = Math.min(b.maxHp, b.maxHp * (0.15 + 0.85 * b.progress));
if (U.rng() < dt * 3) fxSmoke(b.x + U.rng.range(-0.5, 0.5) * b.size, b.y + U.rng.range(-0.3, 0.3) * b.size, 1, '#997');
if (b.progress >= 1) {
b.done = true; b.progress = 1; b.hp = b.maxHp;
recomputeEnergy();
RTS.audio.build();
}
}
}
// ---------------------------------------------------------
// BARRACKS
// ---------------------------------------------------------
function barracksTick(dt) {
for (const b of st.buildings) {
if (b.dead || !b.done) continue;
const trains = b.defId === 'barracks' || b.defId === 'primalpen';
if (!trains || !b.trainQ.length) continue;
const job = b.trainQ[0];
const poweredOk = b.powered;
if (poweredOk) job.t -= dt * (b.workers > 0 ? 1 : 0.4);
if (job.t <= 0) {
b.trainQ.shift();
spawnTrained(b, job.unit);
}
}
}
// ---------------------------------------------------------
// TOWERS
// ---------------------------------------------------------
function towersTick(dt) {
for (const b of st.buildings) {
if (b.dead || !b.done) continue;
const def = C.BUILDINGS[b.defId];
if (!def.dmg) continue;
b.cool -= dt;
if (!b.powered || b.cool > 0) continue;
const range = playerRange(def.range);
const tgt = st.hashD.nearest(b.x, b.y, range, (d) => (!d.dead && !d.tamed && (!d.flying || def.air)));
if (!tgt) continue;
b.cool = def.rof;
// lead the target
const flight = U.dist(b.x, b.y, tgt.x, tgt.y) / (def.aoe ? 9 : 20);
const ax = tgt.x + (tgt.vx || 0) * flight, ay = tgt.y + (tgt.vy || 0) * flight;
if (def.aoe) {
const p = RTS.entities.makeProj('shell', b.x, b.y, ax, ay, { dmg: playerDmg(def.dmg), aoe: def.aoe, speed: 9, arc: 2.2, color: '#222' });
p.total = U.dist(b.x, b.y, ax, ay) / p.speed;
st.projs.push(p);
RTS.audio.cannon();
fxSmoke(b.x, b.y, 3, '#ccc');
} else {
const p = RTS.entities.makeProj('bullet', b.x, b.y, ax, ay, { dmg: playerDmg(def.dmg), speed: 20, air: true, color: '#ffe08a' });
p.targetId = tgt.id;
p.total = U.dist(b.x, b.y, ax, ay) / p.speed;
st.projs.push(p);
RTS.audio.shoot();
fxSpark(b.x, b.y);
}
}
}
// ---------------------------------------------------------
// RANGERS
// ---------------------------------------------------------
function rangersTick(dt) {
for (const u of st.units) {
if (u.dead || u.unitId !== 'ranger') continue;
const def = C.UNITS.ranger;
u.cool -= dt;
u.animT += dt;
let target = getDino(u.targetId);
// validate focused target
if (target && (U.dist(u.x, u.y, target.x, target.y) > playerRange(def.range) * 2.6)) target = null;
// acquire (never auto-target the player's own pets)
if (!target) {
const acqR = (u.tx != null ? playerRange(def.range) : playerRange(def.range) + 1.2);
target = st.hashD.nearest(u.x, u.y, acqR, d => !d.dead && !d.tamed);
u.targetId = target ? target.id : 0;
}
const rng = playerRange(def.range);
// movement along path
let moving = false;
if (u.tx != null) {
if (target && U.dist(u.x, u.y, target.x, target.y) <= rng && !u.attackMoveHold) {
// stop to fight unless explicit move ordered far
if (u.attackMove) { /* keep advancing */ }
else moving = false;
}
if (!target || u.attackMove) {
moving = followPath(u, dt);
if (!moving) { u.tx = null; u.path = null; }
}
} else if (target) {
// IDLE rangers close the gap themselves: acquire radius is wider than
// gun range, so without this they'd stand dumbly just out of range
const dd = U.dist(u.x, u.y, target.x, target.y);
if (dd > rng * 0.9) {
moveToFreeUnit(u, target.x, target.y, dt);
moving = true;
u.facing = U.angleTo(u.x, u.y, target.x, target.y);
}
}
// shooting
if (target && u.cool <= 0 && U.dist(u.x, u.y, target.x, target.y) <= rng) {
u.cool = def.rof;
u.facing = U.angleTo(u.x, u.y, target.x, target.y);
const p = RTS.entities.makeProj('bullet', u.x, u.y, target.x, target.y, { dmg: playerDmg(def.dmg), speed: 20, air: true });
p.targetId = target.id;
p.total = U.dist(u.x, u.y, target.x, target.y) / p.speed;
st.projs.push(p);
RTS.audio.shoot();
}
}
// purge dead humans (colonists handled here too)
st.units = st.units.filter(u => !u.dead);
}
function followPath(e, dt) {
if (!e.path || e.pathI >= e.path.length) return false;
const wp = e.path[e.pathI];
const dx = wp.x - e.x, dy = wp.y - e.y;
const dd = Math.hypot(dx, dy);
if (dd < 0.18) { e.pathI++; return e.pathI < e.path.length; }
const sp = e.speed * dt;
const nx = e.x + dx / dd * sp, ny = e.y + dy / dd * sp;
// don't wade into water or buildings
const T = st.world.tiles;
const ti = (ny | 0) * W + (nx | 0);
if (U.inBounds(nx | 0, ny | 0, W, H)) {
if (T.terrain[ti] === 3 || st.bgrid[ti]) { e.pathI++; return true; }
}
e.x = U.clamp(nx, 0.5, W - 0.5); e.y = U.clamp(ny, 0.5, H - 0.5);
e.vx = dx / dd * e.speed; e.vy = dy / dd * e.speed;
e.facing = U.angleLerp(e.facing, Math.atan2(dy, dx), 0.25);
e.animT += dt;
return true;
}
// ---------------------------------------------------------
// COLONISTS (civilians)
// ---------------------------------------------------------
function colonistsTick(dt) {
const q = hq();
for (const u of st.units) {
if (u.dead || u.unitId !== 'colonist') continue;
u.animT += dt;
// flee from nearby dinos
const threat = st.hashD.nearest(u.x, u.y, 3.5, d => !d.dead);
if (threat && q) {
const a = U.angleTo(threat.x, threat.y, u.x, u.y);
u.x = U.clamp(u.x + Math.cos(a) * u.speed * dt, 1, W - 2);
u.y = U.clamp(u.y + Math.sin(a) * u.speed * dt, 1, H - 2);
u.flee = 1.2;
} else if (u.flee > 0) {
u.flee -= dt;
const a = u.facing;
u.x = U.clamp(u.x + Math.cos(a) * u.speed * 0.7 * dt, 1, W - 2);
u.y = U.clamp(u.y + Math.sin(a) * u.speed * 0.7 * dt, 1, H - 2);
} else if (!u.wanderT || u.wanderT <= 0) {
u.wanderT = U.rng.range(2, 6);
if (q) {
const a = U.rng() * Math.PI * 2, r = U.rng.range(1.5, 4);
u.goalX = U.clamp(q.x + Math.cos(a) * r, 1, W - 2);
u.goalY = U.clamp(q.y + Math.sin(a) * r, 1, H - 2);
}
} else {
u.wanderT -= dt;
if (u.goalX != null) {
const dx = u.goalX - u.x, dy = u.goalY - u.y;
const dd = Math.hypot(dx, dy);
if (dd > 0.2) {
u.x += dx / dd * u.speed * 0.45 * dt;
u.y += dy / dd * u.speed * 0.45 * dt;
u.facing = Math.atan2(dy, dx);
u.animT += dt * 0.6;
} else u.goalX = null;
}
}
}
st.units = st.units.filter(u => !u.dead);
}
// ---------------------------------------------------------
// DINOSAURS
// ---------------------------------------------------------
function dinosTick(dt) {
const q = hq();
for (const d of st.dinos) {
if (d.dead) continue;
d.animT += dt;
d.cool -= dt;
if (d.hitFlash > 0) d.hitFlash -= dt;
d.repathT -= dt;
d.vx = 0; d.vy = 0;
// ---- player-owned pets fight under your command ----
if (d.tamed) {
if (d.baby) {
d.growth = Math.min(1, (d.growth || 0) + dt / C.BREED.growTime);
if (d.growth >= 1) {
d.baby = false;
addText(d.x, d.y - 2, 'Grown up!', '#a8e07a');
}
}
petTick(d, dt);
continue;
}
// ---- target selection ----
let target = currentTarget(d, dt);
if (!target || (target.kind === 'building' && target.dead)) target = null;
if (!target && (d.mode !== 'roam' || d.aggro)) {
target = pickTarget(d);
if (target) { d.targetId = target.id; d.targetType = target.kind; }
}
if (d.mode === 'roam' && !d.aggro) {
// lazy wandering
d.wanderT -= dt;
if (d.wanderT <= 0) {
d.wanderT = U.rng.range(2, 5);
const a = U.rng() * Math.PI * 2, r = U.rng.range(1, 4);
d.wx = U.clamp(d.homeX + Math.cos(a) * r, 1, W - 2);
d.wy = U.clamp(d.homeY + Math.sin(a) * r, 1, H - 2);
}
moveTo(d, d.wx, d.wy, dt, true);
// aggro check
const near = nearestPlayerThing(d.x, d.y, 7);
if (near) d.aggro = true;
continue;
}
if (!target) {
// final mode but base gone etc.
if (q) moveTo(d, q.x, q.y, dt, false);
continue;
}
const tp = { x: target.x, y: target.y };
const reach = d.ranged ? d.ranged : (d.r + (target.size ? target.size * 0.62 : (target.r || 0.3)) + 0.28);
const dd = U.dist(d.x, d.y, tp.x, tp.y);
if (dd <= reach) {
// attack!
d.facing = U.angleLerp(d.facing, U.angleTo(d.x, d.y, tp.x, tp.y), 0.3);
if (d.cool <= 0) {
d.cool = C.DINOS[d.dinoId].rof;
if (d.ranged) {
const p = RTS.entities.makeProj('spit', d.x, d.y, tp.x, tp.y, { dmg: d.dmg, speed: 8, arc: 1.6, enemy: true, color: '#7fe07f' });
p.targetId = target.id; p.total = dd / p.speed;
st.projs.push(p);
RTS.audio.spit();
} else {
let dmg = d.dmg;
if (target.kind === 'building') dmg *= d.bldMult;
if (target.kind === 'dino') hurtDino(target, dmg, d.x, d.y);
else hurtPlayerEntity(target, dmg, d);
fxBlood(tp.x, tp.y, 2);
if (d.dinoId === 'rex') { st.shakeT = 0.3; st.shakeMag = 3; }
RTS.audio.thud();
}
}
} else {
// approach
if (d.flying) {
moveFly(d, tp, dt);
} else {
const arrived = moveTo(d, tp.x, tp.y, dt, false);
if (!arrived && d.stuckT > 0.9) {
// try repath once more, then smash whatever blocks
ensurePath(d, tp.x, tp.y);
}
}
}
}
// purge dead
st.dinos = st.dinos.filter(d => !d.dead);
}
function currentTarget(d, dt) {
if (d.forcedT > 0) {
d.forcedT -= dt;
const t = nearestPlayerThing(d.forcedX, d.forcedY, 2.5);
if (t) return t;
}
if (!d.targetId) return null;
// search both lists
if (d.targetType === 'building') { const b = getB(d.targetId); return (b && !b.dead) ? b : null; }
for (const u of st.units) if (u.id === d.targetId && !u.dead) return u;
return null;
}
function pickTarget(d) {
// nearest player thing with slight preference for buildings over distant units
let best = null, bestScore = Infinity;
for (const b of st.buildings) {
if (b.dead) continue;
const dd = U.dist(d.x, d.y, b.x, b.y) - b.size * 0.3;
if (dd < bestScore) { bestScore = dd; best = b; }
}
const u = st.hashP.nearest(d.x, d.y, 6.5, x => !x.dead);
if (u) {
const du = U.dist(d.x, d.y, u.x, u.y);
if (du < bestScore * 0.8) { best = u; bestScore = du; }
}
// traitor pets are legitimate targets too
for (const p of st.dinos) {
if (!p.tamed || p.dead) continue;
const dp = U.dist(d.x, d.y, p.x, p.y);
if (dp < Math.min(bestScore * 0.8, 7)) { best = p; bestScore = dp; }
}
return best;
}
function nearestPlayerThing(x, y, r) {
let best = null, bd = Infinity;
for (const b of st.buildings) {
if (b.dead) continue;
const dd = U.dist(x, y, b.x, b.y);
if (dd < bd && dd < r + b.size) { bd = dd; best = b; }
}
const u = st.hashP.nearest(x, y, r, e => !e.dead);
if (u) { const dd = U.dist(x, y, u.x, u.y); if (dd < bd) { bd = dd; best = u; } }
for (const p of st.dinos) {
if (!p.tamed || p.dead) continue;
const dd = U.dist(x, y, p.x, p.y);
if (dd < bd && dd < r + p.r) { bd = dd; best = p; }
}
return best;
}
// ---------------------------------------------------------
// TAMED PETS — guard their post, obey move/attack orders
// ---------------------------------------------------------
function petTick(d, dt) {
let target = currentTarget(d, dt);
// focused hostile must be a wild dino
if (target && target.kind !== 'dino') target = null;
if (target && target.tamed) target = null;
if (!target && d.petAttackId) {
const foe = getDino(d.petAttackId);
if (foe && !foe.dead && !foe.tamed) target = foe;
else d.petAttackId = 0;
}
// auto-guard: engage hostiles near the pet's post
if (!target) {
const homeX = d.homeX != null ? d.homeX : d.x, homeY = d.homeY != null ? d.homeY : d.y;
let bd = Infinity, bf = null;
st.hashD.eachNear(homeX, homeY, 6, o => {
if (!o.dead && !o.tamed) { const dd = U.dist2(homeX, homeY, o.x, o.y); if (dd < bd) { bd = dd; bf = o; } }
return false;
});
target = bf;
if (bf) d.petAttackId = bf.id;
}
if (target) {
const reach = d.r + (target.r || 0.3) + 0.3;
const dd = U.dist(d.x, d.y, target.x, target.y);
d.facing = U.angleLerp(d.facing, U.angleTo(d.x, d.y, target.x, target.y), 0.25);
if (dd <= reach) {
if (d.cool <= 0) {
d.cool = C.DINOS[d.dinoId].rof;
hurtDino(target, d.dmg, d.x, d.y);
fxBlood(target.x, target.y, 2);
RTS.audio.thud();
if (d.dinoId === 'rex' || d.dinoId === 'sucho') { st.shakeT = 0.25; st.shakeMag = 2.5; }
}
} else {
moveToFree(d, target.x, target.y, dt);
}
return;
}
// no fight: hold position or walk back to the order point
if (d.petGoal) {
const dg = U.dist(d.x, d.y, d.petGoal.x, d.petGoal.y);
if (dg < 0.6) d.petGoal = null;
else { moveToFree(d, d.petGoal.x, d.petGoal.y, dt); return; }
}
if (d.homeX != null && U.dist(d.x, d.y, d.homeX, d.homeY) > 1.5) {
moveToFree(d, d.homeX, d.homeY, dt);
}
}
// free steering for pets: ignores walls (they're dinos), avoids buildings
function moveToFree(d, gx, gy, dt) {
const dx = gx - d.x, dy = gy - d.y;
const dd = Math.hypot(dx, dy) || 1;
const sp = d.speed * dt;
const nx = d.x + dx / dd * sp, ny = d.y + dy / dd * sp;
const ti = (ny | 0) * W + (nx | 0);
if (U.inBounds(nx | 0, ny | 0, W, H) && st.bgrid[ti]) return false; // don't walk through buildings
d.x = U.clamp(nx, 0.5, W - 0.5);
d.y = U.clamp(ny, 0.5, H - 0.5);
d.facing = U.angleLerp(d.facing, Math.atan2(dy, dx), 0.25);
separate(d, dt);
return true;
}
// ground steering with path following + blocker smashing
function moveTo(d, gx, gy, dt, wander) {
const bx = d.x, by = d.y;
// path management
if (!d.path || d.repathT <= 0) {
ensurePath(d, gx, gy);
d.repathT = 2.5 + U.rng();
}
// follow
let moved = false;
if (d.path && d.pathI < d.path.length) {
const wp = d.path[d.pathI];
const dx = wp.x - d.x, dy = wp.y - d.y;
const dd = Math.hypot(dx, dy);
if (dd < 0.25) { d.pathI++; }
else {
const sp = d.speed * dt;
const nx = d.x + dx / dd * sp, ny = d.y + dy / dd * sp;
if (tryMove(d, nx, ny)) {
moved = true;
d.facing = U.angleLerp(d.facing, Math.atan2(dy, dx), 0.3);
}
}
}
if (!moved) {
// direct steering (also handles bumping into walls)
const dx = gx - d.x, dy = gy - d.y;
const dd = Math.hypot(dx, dy) || 1;
const sp = d.speed * dt;
const nx = d.x + dx / dd * sp, ny = d.y + dy / dd * sp;
if (tryMove(d, nx, ny)) {
moved = true;
d.facing = U.angleLerp(d.facing, Math.atan2(dy, dx), 0.3);
}
}
// stuck detection
const prog = U.dist(bx, by, gx, gy);
if (prog > d.lastDist - 0.001) d.stuckT += dt; else d.stuckT = Math.max(0, d.stuckT - dt * 2);
d.lastDist = prog;
if (moved && wander === undefined) separate(d, dt);
return moved;
}
function tryMove(d, nx, ny) {
// building collision -> register blocker & stop
const gx = nx | 0, gy = ny | 0;
if (U.inBounds(gx, gy, W, H) && st.bgrid[gy * W + gx]) {
const b = getB(st.bgrid[gy * W + gx]);
if (b && !b.dead) {
const close = U.dist(d.x, d.y, b.x, b.y) < b.size * 0.75 + d.r + 0.55;
if (close) {
d.blockerId = b.id;
const cur = currentTargetById(d.targetId);
if (!cur || U.dist(d.x, d.y, b.x, b.y) < U.dist(d.x, d.y, cur.x, cur.y)) {
d.targetId = b.id; d.targetType = 'building';
}
}
return false;
}
}
d.x = U.clamp(nx, 0.5, W - 0.5);
d.y = U.clamp(ny, 0.5, H - 0.5);
return true;
}
function separate(d, dt) {
let px = 0, py = 0, n = 0;
st.hashD.eachNear(d.x, d.y, d.r + 0.55, (o) => {
if (o === d || o.dead || o.flying !== d.flying) return false;
const dx = d.x - o.x, dy = d.y - o.y;
const dd2 = dx * dx + dy * dy;
const md = d.r + o.r + 0.12;
if (dd2 < md * md && dd2 > 1e-6) {
const dd = Math.sqrt(dd2);
px += dx / dd * (md - dd); py += dy / dd * (md - dd); n++;
}
return false;
});
if (n) {
d.x = U.clamp(d.x + px * 0.45, 0.5, W - 0.5);
d.y = U.clamp(d.y + py * 0.45, 0.5, H - 0.5);
}
}
function moveFly(d, tp, dt) {
const dx = tp.x - d.x, dy = tp.y - d.y;
const dd = Math.hypot(dx, dy) || 1;
const sp = d.speed * dt;
d.x += dx / dd * sp; d.y += dy / dd * sp;
d.zoff = Math.sin(st.time * 3 + d.animT) * 0.35 + 2.2;
d.facing = U.angleLerp(d.facing, Math.atan2(dy, dx), 0.2);
separate(d, dt);
}
function ensurePath(d, gx, gy) {
if (st.pathCalls >= 8) return; // budget
st.pathCalls++;
const blocked = d.amphibious ? st.blockedAmph : st.blockedEnemy;
const path = U.findPath(d.x | 0, d.y | 0, gx | 0, gy | 0, W, H, blocked, 3500);
if (path && path.length) {
// smooth: skip first node if very close
d.path = path; d.pathI = 0;
if (path.length && U.dist(d.x, d.y, path[0].x, path[0].y) < 0.4) d.pathI = 1;
} else {
d.path = null; // straight steer -> will bump & smash
}
}
// ---------------------------------------------------------
// TAMERS — tranq support & dino capture
// ---------------------------------------------------------
function countTamed() {
let n = 0;
for (const d of st.dinos) if (d.tamed && !d.dead) n++;
return n;
}
function tameLimit() {
let pens = 0;
for (const b of st.buildings) if (!b.dead && b.done && b.defId === 'primalpen') pens++;
return C.TAMING.baseLimit + pens * C.TAMING.perPen;
}
function tamersTick(dt) {
for (const u of st.units) {
if (u.dead || u.unitId !== 'tamer') continue;
const def = C.UNITS.tamer;
u.cool -= dt;
u.animT += dt;
// NOTE: capture channel persists through nibbles — the danger is in
// weakening a dino without killing it, not in standing next to it.
// ordered movement has priority
let moving = false;
if (u.tx != null) {
moving = followPath(u, dt);
if (!moving) { u.tx = null; u.path = null; }
}
// capture priority target (right-clicked by the player)
let prio = u.capturePriorityId ? getDino(u.capturePriorityId) : null;
if (prio && (prio.dead || prio.tamed || prio.hp > prio.maxHp * C.TAMING.hpThreshold)) { prio = null; u.capturePriorityId = 0; }
if (prio && u.tx == null && U.dist(u.x, u.y, prio.x, prio.y) > C.TAMING.range) {
moveToFreeUnit(u, prio.x, prio.y, dt);
moving = true;
}
// tranq dart at wild dinos — but never at a capturable one (don't kill your future pet!)
if (u.cool <= 0) {
const tameable = d => !d.dead && !d.tamed &&
!(d.hp <= d.maxHp * C.TAMING.hpThreshold && !C.UNTAMEABLE[d.dinoId]);
const foe = st.hashD.nearest(u.x, u.y, def.range, tameable);
if (foe) {
u.cool = def.rof;
u.facing = U.angleTo(u.x, u.y, foe.x, foe.y);
const p = RTS.entities.makeProj('bullet', u.x, u.y, foe.x, foe.y, { dmg: playerDmg(def.dmg), speed: 14, air: false, color: '#b06fe0' });
p.targetId = foe.id;
p.total = U.dist(u.x, u.y, foe.x, foe.y) / p.speed;
st.projs.push(p);
RTS.audio.arrow();
}
}
// capture channel
let cand = prio;
if (!cand && u.tx == null) {
let bd = Infinity;
st.hashD.eachNear(u.x, u.y, C.TAMING.range + 0.4, d => {
if (d.dead || d.tamed || d.flying) return false;
if (C.UNTAMEABLE[d.dinoId]) return false;
if (d.hp > d.maxHp * C.TAMING.hpThreshold) return false;
const dd = U.dist2(u.x, u.y, d.x, d.y);
if (dd < bd) { bd = dd; cand = d; }
return false;
});
}
if (cand && U.dist(u.x, u.y, cand.x, cand.y) <= C.TAMING.range + 0.3) {
if (u.channelId !== cand.id) { u.channelId = cand.id; u.channelT = 0; }
u.channelT += dt;
cand.beingTamed = U.clamp(u.channelT / C.TAMING.channelTime, 0, 1);
if (u.channelT >= C.TAMING.channelTime) {
u.channelId = 0; u.channelT = 0;
cand.beingTamed = 0;
if (prio) u.capturePriorityId = 0;
tameDino(cand);
}
} else {
if (u.channelId) { const old = getDino(u.channelId); if (old) old.beingTamed = 0; }
u.channelId = 0; u.channelT = 0;
}
}
}
function moveToFreeUnit(u, gx, gy, dt) {
const dx = gx - u.x, dy = gy - u.y;
const dd = Math.hypot(dx, dy) || 1;
const sp = u.speed * dt;
const nx = u.x + dx / dd * sp, ny = u.y + dy / dd * sp;
const ti = (ny | 0) * W + (nx | 0);
if (U.inBounds(nx | 0, ny | 0, W, H) && st.blockedFriend[ti]) return false;
u.x = U.clamp(nx, 0.5, W - 0.5);
u.y = U.clamp(ny, 0.5, H - 0.5);
u.facing = Math.atan2(dy, dx);
return true;
}
function tameDino(d) {
if (d.dead || d.tamed) return;
if (countTamed() >= tameLimit()) {
addText(d.x, d.y - 1.5, 'No pen space!', '#ff8f6a');
RTS.ui && RTS.ui.toast && RTS.ui.toast('🦴 Tame limit reached — build a Primal Pen (+2 slots).', 'warn');
return;
}
d.tamed = true;
d.mode = 'pet';
d.aggro = false;
d.targetId = 0; d.targetType = '';
d.path = null; d.petAttackId = 0; d.petGoal = null;
d.homeX = d.x; d.homeY = d.y;
d.hp = Math.max(d.hp, d.maxHp * 0.4); // collar stabilizes the beast
addText(d.x, d.y - 1.5, 'TAMED!', '#7fd6ff');
RTS.audio.coin();
RTS.ui && RTS.ui.toast && RTS.ui.toast('🦖 ' + d.name + ' tamed! Right-click to command it.', '');
}
// ---------------------------------------------------------
// PRIMAL PEN — heals nearby tamed dinos
// ---------------------------------------------------------
function penHealTick(dt) {
for (const b of st.buildings) {
if (b.dead || !b.done || b.defId !== 'primalpen' || !b.powered) continue;
st.hashD.eachNear(b.x, b.y, 4.5, d => {
if (!d.dead && d.tamed && d.hp < d.maxHp) {
d.hp = Math.min(d.maxHp, d.hp + 2.5 * dt);
}
return false;
});
}
}
// ---------------------------------------------------------
// BREEDING — tamed pairs incubate eggs at a Primal Pen
// ---------------------------------------------------------
function breedingTick(dt) {
st.breedT = (st.breedT || 0) - dt;
if (st.breedT > 0) return;
st.breedT = 1; // check once per second
for (const b of st.buildings) {
if (b.dead || !b.done || b.defId !== 'primalpen' || !b.powered) continue;
const myEggs = st.eggs.filter(e => e.penId === b.id);
if (myEggs.length >= C.BREED.maxPerPen) continue;
// gather the parent flock
const parents = [];
st.hashD.eachNear(b.x, b.y, C.BREED.parentRadius, d => {
if (d.tamed && !d.dead && !d.baby && !C.UNTAMEABLE[d.dinoId]) parents.push(d);
return false;
});
if (parents.length < C.BREED.minParents) continue;
if (!afford({ food: C.BREED.foodPerEgg })) continue;
pay({ food: C.BREED.foodPerEgg });
const species = U.rng.pick(parents).dinoId;
const a = U.rng() * Math.PI * 2;
st.eggs.push({
id: ++st._eggSeq,
penId: b.id,
x: b.x + Math.cos(a) * 0.9,
y: b.y + Math.sin(a) * 0.9,
t: 0,
total: C.BREED.eggTime,
species,
});
}
}
function eggsTick(dt) {
for (const e of st.eggs) {
e.t += dt;
if (e.t >= e.total) {
e.hatched = true;
const d = RTS.entities.makeDino(e.species, e.x + U.rng.range(-0.3, 0.3), e.y + U.rng.range(-0.3, 0.3), 'pet');
applyDiff(d);
d.tamed = true;
d.mode = 'pet';
d.baby = true;
d.growth = 0;
d.homeX = e.x; d.homeY = e.y;
st.dinos.push(d);
addText(e.x, e.y - 1, '🐣 Hatched!', '#ffe9a8');
RTS.ui && RTS.ui.toast && RTS.ui.toast('🐣 A baby ' + d.name + ' hatched!', '');
}
}
st.eggs = st.eggs.filter(e => !e.hatched);
}
// ---------------------------------------------------------
// PROJECTILES
// ---------------------------------------------------------
function projectilesTick(dt) {
for (const p of st.projs) {
p.t += dt;
const f = U.clamp(p.t / p.total, 0, 1);
p.px = p.x; p.py = p.y;
p.x = U.lerp(p.sx != null ? p.sx : (p.sx = p.x), p.tx, f);
p.y = U.lerp(p.sy != null ? p.sy : (p.sy = p.y), p.ty, f);
p.zf = f;
if (p.arc) p.zh = Math.sin(f * Math.PI) * p.arc * 2.2;
if (p.type === 'bullet') {
// homing-ish: update aim to live target
const t = getDino(p.targetId);
if (t && !t.dead) {
p.tx = t.x; p.ty = t.y;
p.total = Math.max(p.total, p.t + 0.02);
}
}
if (f >= 1) {
p.done = true;
impact(p);
}
}
st.projs = st.projs.filter(p => !p.done);
}
function impact(p) {
if (p.type === 'shell') {
fxBoom(p.tx, p.ty, p.aoe);
RTS.audio.explode();
st.hashD.eachNear(p.tx, p.ty, p.aoe, (d) => {
if (!d.dead && !d.flying && !d.tamed) hurtDino(d, p.dmg * (1 - U.dist(d.x, d.y, p.tx, p.ty) / p.aoe * 0.5), null, null);
return false;
});
} else if (p.type === 'spit') {
const t = p.targetId && (currentTargetById(p.targetId));
fxSpark(p.tx, p.ty);
if (t && U.dist(t.x, t.y, p.tx, p.ty) < 1.0) hurtPlayerEntity(t, p.dmg, null);
} else {
// bullet
const t = getDino(p.targetId);
fxSpark(p.tx, p.ty);
if (t && !t.dead && U.dist(t.x, t.y, p.tx, p.ty) < 0.9) hurtDino(t, p.dmg, p.sx, p.sy);
}
}
function currentTargetById(id) {
for (const u of st.units) if (u.id === id && !u.dead) return u;
for (const b of st.buildings) if (b.id === id && !b.dead) return b;
return null;
}
// ---------------------------------------------------------
// FX UPDATE
// ---------------------------------------------------------
function updateFx(dt) {
for (const p of st.parts) {
p.life -= dt;
p.x += p.vx * dt; p.y += p.vy * dt;
p.z += p.vz * dt; p.vz -= p.grav * dt;
if (p.z < 0) { p.z = 0; p.vz *= -0.3; p.vx *= 0.6; p.vy *= 0.6; }
}
st.parts = st.parts.filter(p => p.life > 0);
for (const dcl of st.decals) dcl.t += dt;
if (st.decals.length > 240) st.decals.splice(0, st.decals.length - 240);
for (const t of st.texts) { t.life -= dt; t.y -= dt * 0.8; }
st.texts = st.texts.filter(t => t.life > 0);
}
// ---------------------------------------------------------
// WAVES
// ---------------------------------------------------------
function wavesTick(dt) {
// victory: final wave triggered and every WILD dino is dead (pets don't count!)
if (st.finalTriggered && !st.over) {
let wild = 0;
for (const d of st.dinos) if (!d.dead && !d.tamed) wild++;
if (wild === 0) {
st.finalClearedT = st.finalClearedT < 0 ? 2.0 : st.finalClearedT - dt;
if (st.finalClearedT <= 0) endGame(true);
}
}
// time until next wave event
const w = st.waves[st.waveIdx];
if (!w) return;
const waveAbsT = (w.day - 1) * C.WORLD.DAY_LENGTH; // absolute seconds
const tAbs = st.dayT + (st.day - 1) * C.WORLD.DAY_LENGTH;
if (!w.warned && tAbs >= waveAbsT - C.WARN_TIME) {
w.warned = true;
const ang = U.rng() * Math.PI * 2;
st.pendingAng = ang; // spawnWave() will use this
st.warnDirX = Math.cos(ang); st.warnDirY = Math.sin(ang);
st.warnT = waveAbsT - tAbs;
st.warnedDay = w.day;
RTS.audio.alarm();
}
if (st.warnT > 0) st.warnT = Math.max(0, waveAbsT - tAbs);
if (!w.spawned && tAbs >= waveAbsT) {
w.spawned = true;
spawnWave(w);
if (w.final) {
st.finalTriggered = true;
// every surviving roamer joins the assault
for (const d of st.dinos) {
if (d.mode === 'roam') { d.mode = 'final'; d.aggro = true; d.targetId = 0; d.repathT = 0; }
}
}
st.waveIdx++;
}
}
// ---------------------------------------------------------
// FOG
// ---------------------------------------------------------
function updateFog(force) {
const vis = st.vis;
for (let i = 0; i < vis.length; i++) if (vis[i] === 2) vis[i] = 1;
const stamp = (cx, cy, r) => {
const x0 = Math.max(0, Math.floor(cx - r)), x1 = Math.min(W - 1, Math.ceil(cx + r));
const y0 = Math.max(0, Math.floor(cy - r)), y1 = Math.min(H - 1, Math.ceil(cy + r));
for (let y = y0; y <= y1; y++)
for (let x = x0; x <= x1; x++)
if (U.dist(x + 0.5, y + 0.5, cx, cy) <= r) vis[y * W + x] = 2;
};
for (const b of st.buildings) if (!b.dead && b.done) stamp(b.x, b.y, 4.5 + b.size * 0.8);
for (const b of st.buildings) if (!b.dead && !b.done) stamp(b.x, b.y, 2.5);
for (const u of st.units) if (!u.dead) stamp(u.x, u.y, 4);
}
// =========================================================
// PUBLIC API (input/UI)
// =========================================================
const S = {};
S.newGame = newGame;
S.tick = tick;
S.state = () => st;
S.canPlace = canPlace;
S.place = place;
S.demolish = demolish;
S.trainUnit = trainUnit;
S.buyUpgrade = buyUpgrade;
S.upgradeCost = upgradeCost;
S.buildingAt = buildingAt;
S.getBuilding = getB;
S.getDino = getDino;
S.depositInRange = depositInRange;
S.poweredAt = poweredAt;
S.hq = hq;
S.entityAt = function (x, y) {
const b = buildingAt(x, y);
if (b) return b;
let best = null, bd = Infinity;
st.hashP.eachNear(x, y, 0.8, (u) => {
const dd = U.dist2(x, y, u.x, u.y);
if (dd < bd) { bd = dd; best = u; }
return false;
});
if (best) return best;
st.hashD.eachNear(x, y, 0.9, (d) => {
const dd = U.dist2(x, y, d.x, d.y);
if (dd < bd && dd < (d.r + 0.5) * (d.r + 0.5)) { bd = dd; best = d; }
return false;
});
return best;
};
S.selectUnitsInRect = function (x0, y0, x1, y1) {
const ax = Math.min(x0, x1), bx2 = Math.max(x0, x1);
const ay = Math.min(y0, y1), by2 = Math.max(y0, y1);
const found = [];
for (const u of st.units) {
if (u.dead) continue;
if (u.x >= ax && u.x <= bx2 && u.y >= ay && u.y <= by2) { u.selected = true; found.push(u); }
else u.selected = false;
}
return found;
};
S.commandMove = function (ids, x, y, attackMove) {
const n = ids.length;
const cols = Math.ceil(Math.sqrt(n));
ids.forEach((id, i) => {
const u = st.units.find(v => v.id === id && v.unitId === 'ranger' && !v.dead);
if (!u) return;
const ox = (i % cols) * 0.9 - cols * 0.45;
const oy = Math.floor(i / cols) * 0.9 - cols * 0.45;
const gx = U.clamp(x + ox, 1, W - 2), gy = U.clamp(y + oy, 1, H - 2);
u.tx = gx; u.ty = gy;
u.attackMove = !!attackMove;
u.targetId = 0;
if (st.pathCalls < 24) {
st.pathCalls++;
const path = U.findPath(u.x | 0, u.y | 0, gx | 0, gy | 0, W, H, st.blockedFriend, 3000);
u.path = path || null; u.pathI = 0;
if (!path) { u.path = [{ x: gx, y: gy }]; u.pathI = 0; }
}
});
};
S.setRally = function (bid, x, y) {
const b = getB(bid);
if (!b || (b.defId !== 'barracks' && b.defId !== 'primalpen')) return;
b.rallyX = x; b.rallyY = y;
};
// ---- pets & taming ----
S.countTamed = countTamed;
S.tameLimit = tameLimit;
S.commandPet = function (id, x, y) {
const d = getDino(id);
if (!d || !d.tamed || d.dead) return false;
d.petGoal = { x: U.clamp(x, 1, W - 2), y: U.clamp(y, 1, H - 2) };
d.homeX = d.petGoal.x; d.homeY = d.petGoal.y; // new guard post
d.petAttackId = 0;
return true;
};
S.commandPetAttack = function (id, foeId) {
const d = getDino(id);
const foe = getDino(foeId);
if (!d || !d.tamed || d.dead || !foe || foe.dead || foe.tamed) return false;
d.petAttackId = foeId;
d.petGoal = null;
return true;
};
S.setCapturePriority = function (tamerIds, foeId) {
let any = false;
for (const tid of tamerIds) {
const u = st.units.find(v => v.id === tid && v.unitId === 'tamer' && !v.dead);
if (!u) continue;
const foe = getDino(foeId);
if (!foe || foe.dead || foe.tamed || C.UNTAMEABLE[foe.dinoId]) continue;
u.capturePriorityId = foeId;
u.tx = null; u.path = null; // override move orders
any = true;
}
return any;
};
S.selectedPetCommandable = function (id) {
const d = getDino(id);
return !!(d && d.tamed && !d.dead);
};
S.toggleActive = function (bid) {
const b = getB(bid);
if (b) b.active = b.active === false ? true : false;
};
S.roarShake = function () { st.shakeT = 0.4; st.shakeMag = 3; };
// ---------------------------------------------------------
// SAVE / LOAD — full-state snapshots (plain JSON)
// Derived containers (hashes, grids, bmap) are rebuilt on load;
// the world regenerates from its seed, then resource tiles are
// restored so chopped forests stay chopped.
// ---------------------------------------------------------
const SKIP_KEYS = { hashD: 1, hashP: 1, hashB: 1, bmap: 1, world: 1,
blockedEnemy: 1, blockedFriend: 1, blockedAmph: 1,
bgrid: 1, pathCalls: 1 };
function taToArr(ta) { return Array.from(ta); }
function arrToTa(a, Ctor) { const t = new Ctor(a.length); for (let i = 0; i < a.length; i++) t[i] = a[i]; return t; }
S.serialize = function () {
if (!st) return null;
const out = { v: 2, rngState: U.rng.state(), st: {} };
for (const k in st) {
const v = st[k];
if (v && v.tiles) {
// world: regenerate from seed, but keep depleted resources
out.st[k] = {
seed: v.seed, hq: v.hq, W: v.W, H: v.H,
terrain: taToArr(v.tiles.terrain),
tree: taToArr(v.tiles.tree),
rock: taToArr(v.tiles.rock),
treeMax: taToArr(v.tiles.treeMax),
rockMax: taToArr(v.tiles.rockMax),
};
continue;
}
if (SKIP_KEYS[k]) continue;
if (v === undefined) continue;
if (v instanceof Uint8Array || v instanceof Int32Array) {
out.st[k] = { __ta: v.constructor.name, data: taToArr(v) };
} else if (v instanceof Map) {
continue; // derived
} else if (typeof v !== 'function') {
out.st[k] = JSON.parse(JSON.stringify(v)); // plain deep copy
}
}
return out;
};
S.deserialize = function (snap) {
// deep-clone the incoming snapshot: restored state must NEVER alias the
// caller's object (mutations would corrupt e.g. a cached save)
const o = typeof snap === 'string'
? JSON.parse(snap)
: JSON.parse(JSON.stringify(snap));
if (!o || !o.st) return false;
// pristine skeleton + deterministic world + id sequence reset
newGame(o.st.diff || 'normal', o.st.seed);
let maxId = 0;
for (const list of [o.st.buildings || [], o.st.units || [], o.st.dinos || []])
for (const e of list) if (e.id > maxId) maxId = e.id;
RTS.entities.setIdFloor(maxId);
for (const k in o.st) {
if (k === 'world') continue;
const v = o.st[k];
if (v && v.__ta === 'Uint8Array') st[k] = arrToTa(v.data, Uint8Array);
else if (v && v.__ta === 'Int32Array') st[k] = arrToTa(v.data, Int32Array);
else st[k] = v;
}
// restore the played-on world state
if (o.st.world) {
const T = st.world.tiles;
T.terrain.set(arrToTa(o.st.world.terrain, Uint8Array));
T.tree.set(arrToTa(o.st.world.tree, Uint8Array));
T.rock.set(arrToTa(o.st.world.rock, Uint8Array));
T.treeMax.set(arrToTa(o.st.world.treeMax, Uint8Array));
T.rockMax.set(arrToTa(o.st.world.rockMax, Uint8Array));
}
U.rng.setState(o.rngState >>> 0);
rebuildGrids();
updateHashes();
recomputeEnergy();
updateFog(true);
st.over = !!o.st.over; // keep terminal states terminal
return true;
};
// test hooks (harmless in production)
S._debug = {
hurt: hurtDino,
};
return S;
})();