SpaceCities / engine /bomb.js
Claude
Tier 2a: collapse the cross-module duplicates, pin the tie-breaks
e847942 unverified
Raw History Blame
16.2 kB
/* ============================================================
The Helium Bomb (engine/entities.js UNITS.heliumbomb): a mobile doomsday
device, built unarmed β€” free to move without risk. Once the player ARMS it
(a direct field flip, `bomb.armed = true`, the same pattern hudSelection.js
already uses for the Mender's auto-repair toggle), one of three triggers
sets it off:
- it takes a hit at all (combat.js's performAttack calls detonateIfAttacked
before applying normal damage) β€” an impact fuze, instantaneous,
- a live enemy unit or building comes within BOMB_DETECT_RANGE (checked
once per tick from engine/sim.js via updateBombFuse) β€” lights the fuse,
- the player manually triggers it ("Detonate Now" in hudSelection.js,
which calls lightFuse) β€” also lights the fuse, not an instant blast.
The two fuse-based triggers don't detonate on the spot: lightFuse just
timestamps when the blast is due (BOMB_FUSE_DELAY sim-seconds later), and
updateBombFuse (engine/sim.js, every tick) is what actually calls
detonateBomb() once that time comes β€” a real window for the enemy that
tripped it, or anything standing in the blast, to react. Being attacked
stays instantaneous by design: it's a defensive reflex, not a timed device.
Every path that DOES fire the actual blast funnels through detonateBomb()
so they can never disagree about what it does. An UNARMED bomb ignores all
three β€” it can be shot, stood next to, or driven right up to a base with no
effect, exactly like any other unit β€” so arming is a deliberate, reversible
(see disarm in hudSelection.js, which also cuts a lit fuse) commitment, not
an inherent property of the unit.
The blast radius is deliberately tied to POWER_TIERS[0] (engine/industry.js)
β€” the same "on-grid" ring every Reactor/Generator projects β€” rather than an
independent magic number: the bomb's damage reaches exactly as far as a
power station's innermost efficiency zone, so the number on the tooltip can
never silently drift from what the power-grid UI already shows the player.
DAMAGE FALLOFF: the blast isn't a flat "erase everything inside the
radius" cutoff β€” HP loss decays with the square of distance from ground
zero (bombDamageAt below), same shape as a real blast's inverse-square
falloff. Distance is measured to each caught entity's RIM, not its center
(rimDistance below) β€” so a big building's own footprint doesn't stand
between it and the peak band: at physical contact, a Gate or Command
Center's hull IS inside BOMB_CORE_RADIUS, not 36-38 units short of it the
way raw center-to-center distance would read it. The curve itself is still
referenced against BOMB_CORE_RADIUS (1.5x the bomb's own footprint) rather
than the true center, since 1/d^2 diverges as d->0; inside that "unitary
distance" everything takes the same peak hit (BOMB_MAX_DAMAGE, enough to
one-shot even the toughest building in the game), and it tapers off from
there out to zero at BOMB_BLAST_RADIUS. So ground zero is still an
indiscriminate kill, but a lightly-built unit caught out near the edge may
only take a scratch instead of vanishing outright.
TERRAFORMING: a detonation doesn't just erase β€” it also schedules a crater
(state.craters) at the blast center. Once CRATER_SPAWN_DELAY of sim time
passes, updateCraters (called every tick from engine/sim.js) turns it into
a real, mineable ResourceNode, exactly like any node engine/map.js
generates β€” gather.js, the fog/discovery rules, and the rendering all need
zero special-casing for it, because it's a plain node once it exists. The
commodity is chosen deterministically (hashStr off the crater's own id, the
sim's only sanctioned source of pseudo-randomness besides the seeded RNG β€”
see engine/rng.js) from every RAW-tier commodity, not just the ones this
planet's deposit table naturally rolled β€” the blast synthesizes a deposit
that wouldn't otherwise be here, which is the whole point of paying to
"terraform" a spot rather than just scouting for one.
WRECKAGE: separately from the crater, every unit/building the blast actually
kills also deposits its own battle wreckage (engine/wreckage.js), exactly
like an ordinary combat kill β€” the two stack, so a detonation leaves both
the bomb's synthesized crater AND ordinary wreckage from everything caught
in it.
============================================================ */
"use strict";
import { radiusOf } from "./colliders.js";
import { UNITS } from "./entities.js";
import { POWER_TIERS } from "./industry.js";
import { removeEntity } from "./state.js";
import { hashStr } from "./rng.js";
import { depositWreckage } from "./wreckage.js";
import { COM } from "../data.js";
// The power station's "first circle": POWER_TIERS[0] is the innermost,
// on-grid efficiency band every Reactor/Generator projects. Nothing this far
// out (or beyond) takes any blast damage at all.
export const BOMB_BLAST_RADIUS = POWER_TIERS[0].max;
// The falloff curve's "unitary distance": 1.5x the bomb unit's own radius
// (engine/entities.js). Derived, not a separate magic number, so it can
// never drift out of proportion to the unit it's describing. Everything at
// or inside this distance takes the full BOMB_MAX_DAMAGE hit β€” an inverse-
// square curve diverges as distance approaches zero, so it has to be capped
// somewhere, and this is the natural "ground zero" radius to cap it at.
export const BOMB_CORE_RADIUS = UNITS.heliumbomb.radius * 1.5;
// How close a live enemy has to come before an ARMED bomb lights its fuse on
// its own β€” the same 1.5x-radius "point-blank" distance the damage falloff
// itself is anchored to (BOMB_CORE_RADIUS), not a separate magic number: if
// you're already standing close enough to take full damage, you're close
// enough to have set it off.
export const BOMB_DETECT_RANGE = BOMB_CORE_RADIUS;
// Peak HP loss at/inside BOMB_CORE_RADIUS β€” comfortably past the toughest
// building in the game (the Antimatter Gate, 1200hp) so ground zero still
// reads as an outright kill, same as the old flat-erasure blast.
export const BOMB_MAX_DAMAGE = 3000;
// HP lost to the blast at `dist` from ground zero: flat BOMB_MAX_DAMAGE out
// to BOMB_CORE_RADIUS, then falling off with the square of distance the rest
// of the way to BOMB_BLAST_RADIUS (0 beyond it). A pre-baked lookup table
// isn't worth it here β€” this only ever runs once per detonation, over the
// handful of entities caught in range, never per-frame β€” so the curve is
// just computed directly rather than approximated.
export function bombDamageAt(dist) {
if (dist > BOMB_BLAST_RADIUS) return 0;
const d = Math.max(dist, BOMB_CORE_RADIUS);
return BOMB_MAX_DAMAGE * (BOMB_CORE_RADIUS / d) ** 2;
}
// How long (sim seconds β€” state.time, not wall clock) an armed bomb spends
// "cooking" after its fuse is lit β€” by an enemy coming within
// BOMB_DETECT_RANGE, or the player's own "Detonate Now" command β€” before the
// actual blast (detonateBomb) goes off. A real window to react instead of the
// old instant, unavoidable detonation. Being attacked is a separate trigger
// (detonateIfAttacked below) and stays instantaneous, unaffected by this.
export const BOMB_FUSE_DELAY = 4;
// How long (sim seconds β€” state.time, not wall clock) after a detonation the
// crater takes to mature into a mineable deposit.
export const CRATER_SPAWN_DELAY = 60;
// The fresh deposit's size β€” comparable to a real surface cluster (see
// engine/map.js's deposit amounts), a solid payoff for the strategic goods
// and risk the detonation itself cost.
export const CRATER_NODE_AMOUNT = 400;
// Every commodity a crater can turn up β€” ALL Raw-tier goods (data.js COM),
// not just what this planet's own deposit table naturally rolled.
export const CRATER_COMMODITIES = Object.keys(COM).filter(id => COM[id].tier === "Raw");
function isBomb(e) {
return !!e && e.kind === "unit" && UNITS[e.type]?.role === "bomb";
}
// `e`'s own radius β€” a building carries it directly on the instance (state.js's makeBuilding
// Distance from the bomb's center to `e`'s RIM, not its center β€” max(0, ...) so an entity whose
// footprint already overlaps the bomb (or contains it) reads as point-blank, never negative. This
// is the fix behind the header's one-shot claim: raw center-to-center distance used to count a big
// building's own radius AGAINST it, putting even physical wall contact outside BOMB_CORE_RADIUS.
function rimDistance(e, bomb) {
return Math.max(0, Math.hypot(e.x - bomb.x, e.y - bomb.y) - radiusOf(e));
}
// Detonate `bomb` right now, unconditionally β€” the ONE place the blast itself
// happens, so every trigger produces the exact same result. Every unit and
// building within BOMB_BLAST_RADIUS of the bomb's center (measured to each
// one's own RIM, not its center β€” rimDistance above), ANY owner included (the
// bomb's own side's units and buildings, and the bomb itself, are not
// exempt), takes bombDamageAt(dist) HP loss and dies (outright removed, and β€”
// same as an ordinary combat kill β€” deposits its own wreckage,
// engine/wreckage.js) if that drops it to 0 or below; anything too far out to
// be reduced to 0 just survives, scarred. Snapshots
// the caught set with its distances before removing anything, so the sweep
// can't be thrown off by entities disappearing mid-scan. One bombDetonated
// event drives the explosion VFX + sound (boot.js/effects.js/sound.js); each
// killed entity still gets its own entityKilled event so the ordinary
// death-flash plays across the whole blast.
export function detonateBomb(state, bomb) {
if (!bomb || bomb.hp <= 0) return; // already gone β€” e.g. two triggers fired the same tick
const caught = [];
for (const u of state.units.values()) {
const dist = rimDistance(u, bomb);
if (dist <= BOMB_BLAST_RADIUS) caught.push({ e: u, dist });
}
for (const b of state.buildings.values()) {
const dist = rimDistance(b, bomb);
if (dist <= BOMB_BLAST_RADIUS) caught.push({ e: b, dist });
}
for (const { e, dist } of caught) {
e.hp -= bombDamageAt(dist);
if (e.hp <= 0) {
depositWreckage(state, e);
removeEntity(state, e.id);
// unitType/kind (docs/improvement-proposals.md "Tiered destruction"): same additive fields
// engine/combat.js's own entityKilled pushes carry, so a blast's casualties get the same
// size-scaled death ring/sound as an ordinary kill, not the flat default.
state.events.push({ type: "entityKilled", x: e.x, y: e.y, owner: e.owner, unitType: e.type, kind: e.kind });
}
}
state.events.push({
type: "bombDetonated", x: bomb.x, y: bomb.y,
radius: BOMB_BLAST_RADIUS, coreRadius: BOMB_CORE_RADIUS, owner: bomb.owner,
});
// Schedule the crater. Named off the bomb's own (globally-unique, module-global-minted)
// id rather than a fresh counter, so it can never collide with a map-generated node's
// "n<N>" id scheme β€” the two id spaces are namespaced apart by construction.
(state.craters || (state.craters = [])).push({
id: `crater-${bomb.id}`, x: bomb.x, y: bomb.y, owner: bomb.owner,
spawnAt: state.time + CRATER_SPAWN_DELAY,
});
}
// Per-tick check (engine/sim.js, once per tick β€” not per-unit): matures every pending
// crater whose timer has come due this tick (there can be more than one; nothing staggers
// them). Mutates state.craters/state.map in place; nothing to return.
export function updateCraters(state) {
if (!state.craters || !state.craters.length) return;
const ready = state.craters.filter(c => state.time >= c.spawnAt);
if (!ready.length) return;
state.craters = state.craters.filter(c => state.time < c.spawnAt);
for (const crater of ready) spawnCraterNode(state, crater);
}
// Turn one matured crater into a real, plain ResourceNode (engine/types.js) β€” indistinguishable
// to gather.js/rendering/fog from anything engine/map.js generated, except for the `crater: true`
// tag persist.js uses to know it needs full serialization (a map-generated node only ever needs
// its `amount` saved; this one doesn't exist in the seed-regenerated map at all, so it needs its
// whole shape saved and re-added on load β€” see persist.js's rehydratePlanet). The commodity is
// picked deterministically off the crater's own id: same crater, same roll, every time, on every
// machine β€” no engine randomness involved (engine-purity.test.js).
function spawnCraterNode(state, crater) {
const com = CRATER_COMMODITIES[hashStr(crater.id) % CRATER_COMMODITIES.length];
const node = {
id: crater.id, com, amount: CRATER_NODE_AMOUNT, max: CRATER_NODE_AMOUNT,
x: crater.x, y: crater.y, crater: true,
};
state.map.nodes.push(node);
if (state.map.nodesById) state.map.nodesById.set(node.id, node);
state.events.push({ type: "craterMatured", x: crater.x, y: crater.y, com, owner: crater.owner });
}
// If `target` is an ARMED Helium Bomb, being hit AT ALL β€” regardless of how
// much damage the shot would have dealt β€” sets it off instead of chipping
// its hp. Called from combat.js's performAttack before the normal damage/
// death path runs. Returns whether it detonated, so the caller can
// short-circuit (the normal hp/wreckage/entityKilled bookkeeping doesn't
// apply β€” detonateBomb already erased it, along with everything else caught,
// and already deposited its wreckage itself).
// Unlike the other two triggers, this one is instantaneous β€” no fuse.
export function detonateIfAttacked(state, target) {
if (!isBomb(target) || !target.armed) return false;
detonateBomb(state, target);
return true;
}
// Lights `bomb`'s fuse: BOMB_FUSE_DELAY sim-seconds from now, updateBombFuse
// will detonate it β€” no matter what else happens to it between now and then.
// Idempotent: re-triggering an already-lit fuse (another enemy wanders into
// range, "Detonate Now" pressed again) does NOT restart the clock. Fires a
// bombFused event (once, the moment it's actually lit) so the VFX/sound layer
// can warn the player something is about to go off, and where.
export function lightFuse(state, bomb) {
if (bomb.fuseUntil != null) return;
bomb.fuseUntil = state.time + BOMB_FUSE_DELAY;
state.events.push({ type: "bombFused", x: bomb.x, y: bomb.y, owner: bomb.owner, delay: BOMB_FUSE_DELAY });
}
// Per-tick check for an ARMED bomb: is any live enemy unit or building within
// BOMB_DETECT_RANGE of its RIM, not its center (rimDistance above β€” the same
// measure detonateBomb uses, so "close enough to trip the fuse" and "close
// enough to take full damage" never disagree on a big hull)? Lights the fuse
// (see lightFuse) rather than detonating outright. Called from updateBombFuse
// below; also safe to call standalone (e.g. tests) since lightFuse is
// idempotent either way.
export function checkBombProximity(state, bomb) {
if (!bomb.armed || bomb.fuseUntil != null) return false;
for (const u of state.units.values()) {
if (u.owner === bomb.owner || u.hp <= 0) continue;
if (rimDistance(u, bomb) <= BOMB_DETECT_RANGE) { lightFuse(state, bomb); return true; }
}
for (const b of state.buildings.values()) {
if (b.owner === bomb.owner || b.constructing) continue;
if (rimDistance(b, bomb) <= BOMB_DETECT_RANGE) { lightFuse(state, bomb); return true; }
}
return false;
}
// Per-tick update for an ARMED bomb (engine/sim.js, once per role:"bomb" unit,
// before its normal per-tick update): if the fuse is already lit, detonates
// it the instant state.time reaches bomb.fuseUntil; otherwise runs the
// proximity check, which may light it. Returns whether the bomb is
// "committed" this tick β€” fused (about to blow, or just did) β€” so sim.js can
// skip the rest of the tick's update for it: once lit, a bomb no longer takes
// orders, same as it never did in the old instant-detonate world.
export function updateBombFuse(state, bomb) {
if (!bomb.armed) return false;
if (bomb.fuseUntil != null) {
if (state.time >= bomb.fuseUntil) detonateBomb(state, bomb);
return true;
}
return checkBombProximity(state, bomb);
}