/* ========================================================= * 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; })();