Almaatla Claude commited on
Commit
33fe16b
Β·
unverified Β·
1 Parent(s): 08bb8f9

Give the Helium Bomb distance-based damage falloff and a slow shockwave (#11)

Browse files

Replace the flat "erase everything within blast radius" kill with a
distance-squared decay of HP loss: entities within BOMB_CORE_RADIUS
(1.5x the bomb's own radius) still take a guaranteed-kill hit, but
damage tapers off with the square of distance beyond that, reaching
zero at BOMB_BLAST_RADIUS. Blast-killed entities still grant no
salvage, same as before.

Also slows and reworks the detonation VFX into a dramatic, decelerating
shockwave ring that crawls out to the true blast radius and cools from
a hot core color to ash-grey at the tapering edge, echoing the new
damage curve. Updates the HUD panel text and test suite to match.

Co-authored-by: Claude <noreply@anthropic.com>

Files changed (7) hide show
  1. boot.js +3 -2
  2. effects.js +12 -6
  3. engine/bomb.js +70 -19
  4. engine/combat.js +3 -2
  5. hudSelection.js +4 -4
  6. renderEffects.js +48 -10
  7. test/bomb.test.js +73 -18
boot.js CHANGED
@@ -453,10 +453,11 @@ function processFrameEvents() {
453
  addDeathFlash(ev.x, ev.y);
454
  break;
455
  // The Helium Bomb's detonation (engine/bomb.js) β€” one event for the blast itself,
456
- // alongside the individual entityKilled events for everything it erased.
 
457
  case "bombDetonated":
458
  sound.playExplosion(pan);
459
- addExplosion(ev.x, ev.y, ev.radius);
460
  if (ev.owner === "ai") triggerUnderAttack(ev.x, ev.y);
461
  break;
462
  // A Helium Bomb crater (engine/bomb.js) finished terraforming β€” the payoff for the
 
453
  addDeathFlash(ev.x, ev.y);
454
  break;
455
  // The Helium Bomb's detonation (engine/bomb.js) β€” one event for the blast itself,
456
+ // alongside the individual entityKilled events for everything the falloff-damaged
457
+ // blast actually killed outright.
458
  case "bombDetonated":
459
  sound.playExplosion(pan);
460
+ addExplosion(ev.x, ev.y, ev.radius, ev.coreRadius);
461
  if (ev.owner === "ai") triggerUnderAttack(ev.x, ev.y);
462
  break;
463
  // A Helium Bomb crater (engine/bomb.js) finished terraforming β€” the payoff for the
effects.js CHANGED
@@ -17,7 +17,10 @@ const TRACER_LIFETIME_MS = 120;
17
  const DEATH_LIFETIME_MS = 280;
18
  const PING_LIFETIME_MS = 3000;
19
  const FIREWORK_LIFETIME_MS = 1500;
20
- const EXPLOSION_LIFETIME_MS = 900;
 
 
 
21
 
22
  let tracers = [];
23
  let deaths = [];
@@ -40,11 +43,14 @@ export function addUnderAttackPing(x, y) {
40
  pings.push({ x, y, born: performance.now() });
41
  }
42
 
43
- // The Helium Bomb's detonation (engine/bomb.js's bombDetonated event): a shockwave ring
44
- // that expands out to the blast's real radius (so the VFX itself shows the player exactly
45
- // how far the erasure reached), not a fixed cosmetic size like the death flash above.
46
- export function addExplosion(x, y, radius) {
47
- explosions.push({ x, y, radius, born: performance.now() });
 
 
 
48
  }
49
 
50
  // A celebratory burst show for Odyssey progress milestones (engine/galaxy.js). Screen-space,
 
17
  const DEATH_LIFETIME_MS = 280;
18
  const PING_LIFETIME_MS = 3000;
19
  const FIREWORK_LIFETIME_MS = 1500;
20
+ // Long enough for the shockwave ring (renderEffects.js) to visibly crawl
21
+ // out to its real blast radius rather than snapping there in a couple of
22
+ // frames β€” the slow travel is what sells the blast's true reach.
23
+ const EXPLOSION_LIFETIME_MS = 2400;
24
 
25
  let tracers = [];
26
  let deaths = [];
 
43
  pings.push({ x, y, born: performance.now() });
44
  }
45
 
46
+ // The Helium Bomb's detonation (engine/bomb.js's bombDetonated event): a slow-moving
47
+ // shockwave ring that crawls out to the blast's real radius (so the VFX itself shows the
48
+ // player exactly how far the damage reached), not a fixed cosmetic size like the death
49
+ // flash above. `coreRadius` is the inner "guaranteed kill" distance (BOMB_CORE_RADIUS) the
50
+ // ring starts from, so the render side can shade the traveling front from hot (peak
51
+ // damage, near ground zero) to cool (the tapering edge) as it goes.
52
+ export function addExplosion(x, y, radius, coreRadius = 0) {
53
+ explosions.push({ x, y, radius, coreRadius, born: performance.now() });
54
  }
55
 
56
  // A celebratory burst show for Odyssey progress milestones (engine/galaxy.js). Screen-space,
engine/bomb.js CHANGED
@@ -18,10 +18,22 @@
18
 
19
  The blast radius is deliberately tied to POWER_TIERS[0] (engine/industry.js)
20
  β€” the same "on-grid" ring every Reactor/Generator projects β€” rather than an
21
- independent magic number: the bomb erases everything inside the reach of a
22
  power station's innermost efficiency zone, so the number on the tooltip can
23
  never silently drift from what the power-grid UI already shows the player.
24
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  TERRAFORMING: a detonation doesn't just erase β€” it also schedules a crater
26
  (state.craters) at the blast center. Once CRATER_SPAWN_DELAY of sim time
27
  passes, updateCraters (called every tick from engine/sim.js) turns it into
@@ -45,7 +57,8 @@ import { hashStr } from "./rng.js";
45
  import { COM } from "../data.js";
46
 
47
  // The power station's "first circle": POWER_TIERS[0] is the innermost,
48
- // on-grid efficiency band every Reactor/Generator projects.
 
49
  export const BOMB_BLAST_RADIUS = POWER_TIERS[0].max;
50
 
51
  // How close a live enemy has to come before an ARMED bomb detonates on its
@@ -53,6 +66,31 @@ export const BOMB_BLAST_RADIUS = POWER_TIERS[0].max;
53
  // already too late to back out of the blast.
54
  export const BOMB_DETECT_RANGE = BOMB_BLAST_RADIUS + 30;
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  // How long (sim seconds β€” state.time, not wall clock) after a detonation the
57
  // crater takes to mature into a mineable deposit.
58
  export const CRATER_SPAWN_DELAY = 60;
@@ -69,28 +107,41 @@ function isBomb(e) {
69
  }
70
 
71
  // Detonate `bomb` right now, unconditionally β€” the ONE place the blast itself
72
- // happens, so every trigger produces the exact same result. Erases (outright
73
- // removes β€” no salvage, no partial hp, no survivors) every unit and building
74
- // within BOMB_BLAST_RADIUS, ANY owner included: the bomb's own side's units
75
- // and buildings, and the bomb itself, are not exempt. Snapshots the caught
76
- // set before removing anything, so the sweep can't be thrown off by entities
77
- // disappearing mid-scan. One bombDetonated event drives the explosion VFX +
78
- // sound (boot.js/effects.js/sound.js); each erased entity still gets its own
79
- // entityKilled event so the ordinary death-flash plays across the whole blast.
 
 
 
80
  export function detonateBomb(state, bomb) {
81
  if (!bomb || bomb.hp <= 0) return; // already gone β€” e.g. two triggers fired the same tick
82
 
83
  const caught = [];
84
- for (const u of state.units.values())
85
- if (Math.hypot(u.x - bomb.x, u.y - bomb.y) <= BOMB_BLAST_RADIUS) caught.push(u);
86
- for (const b of state.buildings.values())
87
- if (Math.hypot(b.x - bomb.x, b.y - bomb.y) <= BOMB_BLAST_RADIUS) caught.push(b);
88
-
89
- for (const e of caught) {
90
- removeEntity(state, e.id);
91
- state.events.push({ type: "entityKilled", x: e.x, y: e.y, owner: e.owner });
92
  }
93
- state.events.push({ type: "bombDetonated", x: bomb.x, y: bomb.y, radius: BOMB_BLAST_RADIUS, owner: bomb.owner });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  // Schedule the crater. Named off the bomb's own (globally-unique, module-global-minted)
96
  // id rather than a fresh counter, so it can never collide with a map-generated node's
 
18
 
19
  The blast radius is deliberately tied to POWER_TIERS[0] (engine/industry.js)
20
  β€” the same "on-grid" ring every Reactor/Generator projects β€” rather than an
21
+ independent magic number: the bomb's damage reaches exactly as far as a
22
  power station's innermost efficiency zone, so the number on the tooltip can
23
  never silently drift from what the power-grid UI already shows the player.
24
 
25
+ DAMAGE FALLOFF: the blast isn't a flat "erase everything inside the
26
+ radius" cutoff β€” HP loss decays with the square of distance from ground
27
+ zero (bombDamageAt below), same shape as a real blast's inverse-square
28
+ falloff. The curve is referenced against BOMB_CORE_RADIUS (1.5x the bomb's
29
+ own footprint) rather than the true center, since 1/d^2 diverges as d->0;
30
+ inside that "unitary distance" everything takes the same peak hit
31
+ (BOMB_MAX_DAMAGE, enough to one-shot even the toughest building in the
32
+ game), and it tapers off from there out to zero at BOMB_BLAST_RADIUS. So
33
+ ground zero is still an indiscriminate kill, but a lightly-built unit
34
+ caught out near the edge may only take a scratch instead of vanishing
35
+ outright.
36
+
37
  TERRAFORMING: a detonation doesn't just erase β€” it also schedules a crater
38
  (state.craters) at the blast center. Once CRATER_SPAWN_DELAY of sim time
39
  passes, updateCraters (called every tick from engine/sim.js) turns it into
 
57
  import { COM } from "../data.js";
58
 
59
  // The power station's "first circle": POWER_TIERS[0] is the innermost,
60
+ // on-grid efficiency band every Reactor/Generator projects. Nothing this far
61
+ // out (or beyond) takes any blast damage at all.
62
  export const BOMB_BLAST_RADIUS = POWER_TIERS[0].max;
63
 
64
  // How close a live enemy has to come before an ARMED bomb detonates on its
 
66
  // already too late to back out of the blast.
67
  export const BOMB_DETECT_RANGE = BOMB_BLAST_RADIUS + 30;
68
 
69
+ // The falloff curve's "unitary distance": 1.5x the bomb unit's own radius
70
+ // (engine/entities.js). Derived, not a separate magic number, so it can
71
+ // never drift out of proportion to the unit it's describing. Everything at
72
+ // or inside this distance takes the full BOMB_MAX_DAMAGE hit β€” an inverse-
73
+ // square curve diverges as distance approaches zero, so it has to be capped
74
+ // somewhere, and this is the natural "ground zero" radius to cap it at.
75
+ export const BOMB_CORE_RADIUS = UNITS.heliumbomb.radius * 1.5;
76
+
77
+ // Peak HP loss at/inside BOMB_CORE_RADIUS β€” comfortably past the toughest
78
+ // building in the game (the Antimatter Gate, 1200hp) so ground zero still
79
+ // reads as an outright kill, same as the old flat-erasure blast.
80
+ export const BOMB_MAX_DAMAGE = 3000;
81
+
82
+ // HP lost to the blast at `dist` from ground zero: flat BOMB_MAX_DAMAGE out
83
+ // to BOMB_CORE_RADIUS, then falling off with the square of distance the rest
84
+ // of the way to BOMB_BLAST_RADIUS (0 beyond it). A pre-baked lookup table
85
+ // isn't worth it here β€” this only ever runs once per detonation, over the
86
+ // handful of entities caught in range, never per-frame β€” so the curve is
87
+ // just computed directly rather than approximated.
88
+ export function bombDamageAt(dist) {
89
+ if (dist > BOMB_BLAST_RADIUS) return 0;
90
+ const d = Math.max(dist, BOMB_CORE_RADIUS);
91
+ return BOMB_MAX_DAMAGE * (BOMB_CORE_RADIUS / d) ** 2;
92
+ }
93
+
94
  // How long (sim seconds β€” state.time, not wall clock) after a detonation the
95
  // crater takes to mature into a mineable deposit.
96
  export const CRATER_SPAWN_DELAY = 60;
 
107
  }
108
 
109
  // Detonate `bomb` right now, unconditionally β€” the ONE place the blast itself
110
+ // happens, so every trigger produces the exact same result. Every unit and
111
+ // building within BOMB_BLAST_RADIUS, ANY owner included (the bomb's own
112
+ // side's units and buildings, and the bomb itself, are not exempt), takes
113
+ // bombDamageAt(dist) HP loss and dies (outright removed β€” no salvage, this
114
+ // is blast damage, not a normal kill) if that drops it to 0 or below;
115
+ // anything too far out to be reduced to 0 just survives, scarred. Snapshots
116
+ // the caught set with its distances before removing anything, so the sweep
117
+ // can't be thrown off by entities disappearing mid-scan. One bombDetonated
118
+ // event drives the explosion VFX + sound (boot.js/effects.js/sound.js); each
119
+ // killed entity still gets its own entityKilled event so the ordinary
120
+ // death-flash plays across the whole blast.
121
  export function detonateBomb(state, bomb) {
122
  if (!bomb || bomb.hp <= 0) return; // already gone β€” e.g. two triggers fired the same tick
123
 
124
  const caught = [];
125
+ for (const u of state.units.values()) {
126
+ const dist = Math.hypot(u.x - bomb.x, u.y - bomb.y);
127
+ if (dist <= BOMB_BLAST_RADIUS) caught.push({ e: u, dist });
 
 
 
 
 
128
  }
129
+ for (const b of state.buildings.values()) {
130
+ const dist = Math.hypot(b.x - bomb.x, b.y - bomb.y);
131
+ if (dist <= BOMB_BLAST_RADIUS) caught.push({ e: b, dist });
132
+ }
133
+
134
+ for (const { e, dist } of caught) {
135
+ e.hp -= bombDamageAt(dist);
136
+ if (e.hp <= 0) {
137
+ removeEntity(state, e.id);
138
+ state.events.push({ type: "entityKilled", x: e.x, y: e.y, owner: e.owner });
139
+ }
140
+ }
141
+ state.events.push({
142
+ type: "bombDetonated", x: bomb.x, y: bomb.y,
143
+ radius: BOMB_BLAST_RADIUS, coreRadius: BOMB_CORE_RADIUS, owner: bomb.owner,
144
+ });
145
 
146
  // Schedule the crater. Named off the bomb's own (globally-unique, module-global-minted)
147
  // id rather than a fresh counter, so it can never collide with a map-generated node's
engine/combat.js CHANGED
@@ -142,8 +142,9 @@ function nearestEnemyUnitWithin(state, unit, radius) {
142
  // its own "turret" type), and `heavy` so a siege hit thumps deeper.
143
  function performAttack(state, attacker, def, target) {
144
  // An ARMED Helium Bomb doesn't take damage from a hit β€” it detonates instead,
145
- // erasing everything (attacker included) within its blast radius. Checked
146
- // before the normal damage/death path, which never runs for it.
 
147
  if (detonateIfAttacked(state, target)) return true;
148
  target.hp -= attackDamage(state, attacker, def, target);
149
  state.events.push({
 
142
  // its own "turret" type), and `heavy` so a siege hit thumps deeper.
143
  function performAttack(state, attacker, def, target) {
144
  // An ARMED Helium Bomb doesn't take damage from a hit β€” it detonates instead,
145
+ // dealing distance-falloff blast damage to everything (attacker included)
146
+ // within its blast radius. Checked before the normal damage/death path,
147
+ // which never runs for it.
148
  if (detonateIfAttacked(state, target)) return true;
149
  target.hp -= attackDamage(state, attacker, def, target);
150
  state.events.push({
hudSelection.js CHANGED
@@ -26,7 +26,7 @@ import { supplyUsed, supplyCap } from "./engine/supply.js";
26
  import { powerCap, powerDraw, recipeOf, powerThrottle, planetIndustryScale, powerEfficiency, onPowerGrid, electrifyBoost, ELECTRIFY_POWER } from "./engine/industry.js";
27
  import { storeTotal, storeCapOf, storeRoom, inputTotal, inputCapOf, isElectrifiable } from "./engine/entities.js";
28
  import { rigInfo } from "./engine/rig.js";
29
- import { detonateBomb, BOMB_BLAST_RADIUS, BOMB_DETECT_RANGE } from "./engine/bomb.js";
30
  import { TECHS, researchTech, techMult } from "./engine/techtree.js";
31
  import { BUILDINGS, UNITS, UPGRADES, canAfford, prereqsMet, committedDoctrine } from "./engine/entities.js";
32
  import { repairCost, repairConvoy, departNow } from "./engine/scenarios.js";
@@ -730,7 +730,7 @@ function rebuildSelectionPanel(sel) {
730
  const bombLocked = !prereqsMet(state, "player", bombDef);
731
  panelEl.appendChild(prodButton(`Produce ${bombDef.name} (${costText(bombDef.cost)})`,
732
  () => queueProduction(state, stardock.id, "heliumbomb"),
733
- { cost: bombDef.cost, tip: `${unitTip(bombDef)} Β· ${BOMB_BLAST_RADIUS}-radius blast when armed and triggered`,
734
  locked: bombLocked, lockTip: bombLocked ? lockTipFor(bombDef) : null, icon: { kind: "unit", type: "heliumbomb" } }));
735
  if (stardock.queue.length) renderQueueRows(stardock);
736
  }
@@ -1056,8 +1056,8 @@ function rebuildSelectionPanel(sel) {
1056
  const note = document.createElement("div");
1057
  note.className = "sel-note " + (armed ? "bad" : "good");
1058
  note.textContent = armed
1059
- ? `⚠ ARMED β€” erases every unit and building (any owner, including yours) within ${BOMB_BLAST_RADIUS} β€” a power station's on-grid reach. Detonates on the next hit it takes, if an enemy comes within ${BOMB_DETECT_RANGE}, or on your command.`
1060
- : `Unarmed β€” safe to move anywhere. Once armed: ${BOMB_BLAST_RADIUS}-radius blast erasing every unit and building inside it, any owner.`;
1061
  panelEl.appendChild(note);
1062
  panelEl.appendChild(makeButton(armed ? "β—― Disarm" : "☒ Arm the Bomb",
1063
  () => { const v = !armed; for (const e of sel) if (e.kind === "unit" && UNITS[e.type].role === "bomb") e.armed = v; },
 
26
  import { powerCap, powerDraw, recipeOf, powerThrottle, planetIndustryScale, powerEfficiency, onPowerGrid, electrifyBoost, ELECTRIFY_POWER } from "./engine/industry.js";
27
  import { storeTotal, storeCapOf, storeRoom, inputTotal, inputCapOf, isElectrifiable } from "./engine/entities.js";
28
  import { rigInfo } from "./engine/rig.js";
29
+ import { detonateBomb, BOMB_BLAST_RADIUS, BOMB_CORE_RADIUS, BOMB_DETECT_RANGE } from "./engine/bomb.js";
30
  import { TECHS, researchTech, techMult } from "./engine/techtree.js";
31
  import { BUILDINGS, UNITS, UPGRADES, canAfford, prereqsMet, committedDoctrine } from "./engine/entities.js";
32
  import { repairCost, repairConvoy, departNow } from "./engine/scenarios.js";
 
730
  const bombLocked = !prereqsMet(state, "player", bombDef);
731
  panelEl.appendChild(prodButton(`Produce ${bombDef.name} (${costText(bombDef.cost)})`,
732
  () => queueProduction(state, stardock.id, "heliumbomb"),
733
+ { cost: bombDef.cost, tip: `${unitTip(bombDef)} Β· ${BOMB_BLAST_RADIUS}-radius blast when armed and triggered β€” damage falls off with distance from ground zero`,
734
  locked: bombLocked, lockTip: bombLocked ? lockTipFor(bombDef) : null, icon: { kind: "unit", type: "heliumbomb" } }));
735
  if (stardock.queue.length) renderQueueRows(stardock);
736
  }
 
1056
  const note = document.createElement("div");
1057
  note.className = "sel-note " + (armed ? "bad" : "good");
1058
  note.textContent = armed
1059
+ ? `⚠ ARMED β€” blasts every unit and building (any owner, including yours) within ${BOMB_BLAST_RADIUS} β€” a power station's on-grid reach. Anything within ${Math.round(BOMB_CORE_RADIUS)} of ground zero is destroyed outright; damage tapers off with distance beyond that. Detonates on the next hit it takes, if an enemy comes within ${BOMB_DETECT_RANGE}, or on your command.`
1060
+ : `Unarmed β€” safe to move anywhere. Once armed: ${BOMB_BLAST_RADIUS}-radius blast, any owner β€” a guaranteed kill within ${Math.round(BOMB_CORE_RADIUS)} of ground zero, tapering off with distance beyond that.`;
1061
  panelEl.appendChild(note);
1062
  panelEl.appendChild(makeButton(armed ? "β—― Disarm" : "☒ Arm the Bomb",
1063
  () => { const v = !armed; for (const e of sel) if (e.kind === "unit" && UNITS[e.type].role === "bomb") e.armed = v; },
renderEffects.js CHANGED
@@ -104,6 +104,23 @@ function tracerColor(unitType) {
104
  return "#f87171";
105
  }
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  // Attack tracers, death flashes, and under-attack pings: all purely
108
  // cosmetic and short-lived (see effects.js), so this is the only place
109
  // in render.js that reads wall-clock-timed state instead of the sim's
@@ -164,22 +181,43 @@ export function drawEffects(ctx) {
164
  ctx.stroke();
165
  }
166
 
167
- // The Helium Bomb's detonation (engine/bomb.js): a bright flash that collapses fast, under
168
- // an expanding shockwave ring that reaches `e.radius` β€” the ACTUAL blast radius that just
169
- // erased everything inside it β€” a third of the way through its life, then fades out. The
170
- // ring's real size (not a fixed cosmetic one) is what tells the player how far it reached.
 
 
 
 
 
171
  for (const e of explosions) {
172
- const flashR = e.radius * 0.3 * Math.max(0, 1 - e.age * 3);
173
- ctx.globalAlpha = Math.max(0, 1 - e.age * 4);
 
174
  ctx.fillStyle = "#fff3c4";
175
  ctx.beginPath();
176
  ctx.arc(e.x, e.y, flashR, 0, Math.PI * 2);
177
  ctx.fill();
178
 
179
- const ringR = e.radius * Math.min(1, e.age * 3);
180
- ctx.globalAlpha = Math.max(0, 1 - e.age);
181
- ctx.strokeStyle = "#ff8a3d";
182
- ctx.lineWidth = 4 * Math.max(0, 1 - e.age);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  ctx.beginPath();
184
  ctx.arc(e.x, e.y, ringR, 0, Math.PI * 2);
185
  ctx.stroke();
 
104
  return "#f87171";
105
  }
106
 
107
+ // The Helium Bomb shockwave (see drawEffects below): the fireball collapses
108
+ // within the first slice of the effect's life, the ring then spends most of
109
+ // the rest slowly traveling out to the true blast radius, and the remainder
110
+ // is left for it to fade out once it arrives.
111
+ const EXPLOSION_FLASH_FRACTION = 0.15;
112
+ const EXPLOSION_SHOCKWAVE_FRACTION = 0.85;
113
+ // Hot (near ground zero, peak damage) -> cool ash-grey (the tapering edge),
114
+ // matching bombDamageAt's own falloff from full HP loss to a scratch.
115
+ const SHOCKWAVE_HOT = [255, 180, 80];
116
+ const SHOCKWAVE_COOL = [148, 163, 184];
117
+ function lerpColor(a, b, t) {
118
+ const r = Math.round(a[0] + (b[0] - a[0]) * t);
119
+ const g = Math.round(a[1] + (b[1] - a[1]) * t);
120
+ const b2 = Math.round(a[2] + (b[2] - a[2]) * t);
121
+ return `rgb(${r}, ${g}, ${b2})`;
122
+ }
123
+
124
  // Attack tracers, death flashes, and under-attack pings: all purely
125
  // cosmetic and short-lived (see effects.js), so this is the only place
126
  // in render.js that reads wall-clock-timed state instead of the sim's
 
181
  ctx.stroke();
182
  }
183
 
184
+ // The Helium Bomb's detonation (engine/bomb.js): a bright fireball flash at ground zero
185
+ // that collapses fast, under a slow-moving shockwave ring that crawls out from
186
+ // e.coreRadius (the guaranteed-kill distance) to e.radius (the true blast radius the
187
+ // damage falloff reaches zero at) over most of the effect's life β€” a deliberately
188
+ // unhurried, decelerating travel (real shockwaves lose speed as they lose energy) so the
189
+ // blast reads as a big, dramatic event rather than a one-frame pop. The ring cools from a
190
+ // hot core color to an ash-grey edge color as it travels, echoing bombDamageAt's own
191
+ // falloff from peak damage to a scratch. Reduced-motion viewers get a single fast static
192
+ // ring at the true radius instead of the slow travel.
193
  for (const e of explosions) {
194
+ const flashT = Math.min(1, e.age / EXPLOSION_FLASH_FRACTION);
195
+ const flashR = (e.coreRadius || e.radius * 0.3) * Math.max(0, 1 - flashT);
196
+ ctx.globalAlpha = Math.max(0, 1 - flashT * 1.2);
197
  ctx.fillStyle = "#fff3c4";
198
  ctx.beginPath();
199
  ctx.arc(e.x, e.y, flashR, 0, Math.PI * 2);
200
  ctx.fill();
201
 
202
+ if (reduced) {
203
+ ctx.globalAlpha = Math.max(0, 0.6 * (1 - e.age));
204
+ ctx.strokeStyle = "#ff8a3d";
205
+ ctx.lineWidth = 3;
206
+ ctx.beginPath();
207
+ ctx.arc(e.x, e.y, e.radius, 0, Math.PI * 2);
208
+ ctx.stroke();
209
+ continue;
210
+ }
211
+
212
+ const travel = Math.min(1, e.age / EXPLOSION_SHOCKWAVE_FRACTION);
213
+ const eased = 1 - (1 - travel) ** 3; // ease-out: fast off the blast, decelerating outward
214
+ const ringR = (e.coreRadius || 0) + (e.radius - (e.coreRadius || 0)) * eased;
215
+ const ringAlpha = e.age < EXPLOSION_SHOCKWAVE_FRACTION
216
+ ? 0.9 - 0.5 * travel
217
+ : Math.max(0, 0.4 * (1 - (e.age - EXPLOSION_SHOCKWAVE_FRACTION) / (1 - EXPLOSION_SHOCKWAVE_FRACTION)));
218
+ ctx.globalAlpha = Math.max(0, ringAlpha);
219
+ ctx.strokeStyle = lerpColor(SHOCKWAVE_HOT, SHOCKWAVE_COOL, eased);
220
+ ctx.lineWidth = Math.max(1, 5 * (1 - eased * 0.6));
221
  ctx.beginPath();
222
  ctx.arc(e.x, e.y, ringR, 0, Math.PI * 2);
223
  ctx.stroke();
test/bomb.test.js CHANGED
@@ -5,8 +5,9 @@ import { updateCombat } from "../engine/combat.js";
5
  import { tick } from "../engine/sim.js";
6
  import { UNITS, BUILDINGS } from "../engine/entities.js";
7
  import { POWER_TIERS } from "../engine/industry.js";
8
- import { detonateBomb, detonateIfAttacked, checkBombProximity, updateCraters,
9
- BOMB_BLAST_RADIUS, BOMB_DETECT_RANGE, CRATER_SPAWN_DELAY, CRATER_NODE_AMOUNT, CRATER_COMMODITIES } from "../engine/bomb.js";
 
10
  import { serializeGame, deserializeGame } from "../engine/persist.js";
11
  import { queueProduction } from "../engine/production.js";
12
  import { COM } from "../data.js";
@@ -175,54 +176,108 @@ test("detonateBomb on an already-gone bomb is a harmless no-op", () => {
175
  assert.doesNotThrow(() => detonateBomb(state, bomb)); // calling again on the same (now-orphaned) object
176
  });
177
 
178
- /* ---------- the blast itself: radius, indiscriminate, "erasing" ---------- */
179
 
180
- test("the blast erases every unit and building within radius, ANY owner β€” including the bomb's own side", () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  const state = createGameState({ planetId: "ferros", endless: true });
182
  const bomb = makeUnit("heliumbomb", "player", 4000, 4000);
183
  state.units.set(bomb.id, bomb);
184
 
185
- const ownWorker = makeUnit("worker", "player", 4050, 4000); // friendly, well inside
186
- const enemyWorker = makeUnit("worker", "ai", 4000, 4050); // enemy, well inside
187
- const ownBuilding = makeBuilding("command", "player", 4000, 4100); // friendly building, inside
188
- const enemyBuilding = makeBuilding("barracks", "ai", 3900, 4000); // enemy building, inside
189
  for (const u of [ownWorker, enemyWorker]) state.units.set(u.id, u);
190
  for (const b of [ownBuilding, enemyBuilding]) state.buildings.set(b.id, b);
191
 
192
  detonateBomb(state, bomb);
193
 
194
- assert.ok(!state.units.has(ownWorker.id), "the bomb owner's own worker is erased too β€” indiscriminate");
195
  assert.ok(!state.units.has(enemyWorker.id));
196
- assert.ok(!state.buildings.has(ownBuilding.id), "even the owner's own Command Center is erased");
197
  assert.ok(!state.buildings.has(enemyBuilding.id));
198
  });
199
 
200
- test("something just outside the blast radius survives untouched", () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  const state = createGameState({ planetId: "ferros", endless: true });
202
  const bomb = makeUnit("heliumbomb", "player", 4000, 4000);
203
  state.units.set(bomb.id, bomb);
204
  const safe = makeUnit("worker", "ai", 4000 + BOMB_BLAST_RADIUS + 5, 4000);
205
  state.units.set(safe.id, safe);
206
- const caught = makeUnit("worker", "ai", 4000 + BOMB_BLAST_RADIUS - 5, 4000);
207
- state.units.set(caught.id, caught);
208
 
209
  detonateBomb(state, bomb);
210
 
211
- assert.ok(state.units.has(safe.id), "just past the blast radius β€” untouched");
212
- assert.ok(!state.units.has(caught.id), "just inside β€” erased");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  });
214
 
215
- test("erased entities grant no salvage β€” a total loss, not a normal kill", () => {
216
  const state = createGameState({ planetId: "ferros", endless: true });
217
  const bomb = makeUnit("heliumbomb", "player", 5000, 5000);
218
  state.units.set(bomb.id, bomb);
219
- const skiff = makeUnit("skiff", "ai", 5010, 5000);
220
  state.units.set(skiff.id, skiff);
221
  const resourcesBefore = { ...state.players.ai.resources };
222
 
223
  detonateBomb(state, bomb);
224
 
225
- assert.deepEqual(state.players.ai.resources, resourcesBefore, "no salvage was granted for the erased skiff");
 
226
  });
227
 
228
  /* ---------- terraforming: the crater matures into a mineable deposit ---------- */
 
5
  import { tick } from "../engine/sim.js";
6
  import { UNITS, BUILDINGS } from "../engine/entities.js";
7
  import { POWER_TIERS } from "../engine/industry.js";
8
+ import { detonateBomb, detonateIfAttacked, checkBombProximity, updateCraters, bombDamageAt,
9
+ BOMB_BLAST_RADIUS, BOMB_CORE_RADIUS, BOMB_MAX_DAMAGE, BOMB_DETECT_RANGE,
10
+ CRATER_SPAWN_DELAY, CRATER_NODE_AMOUNT, CRATER_COMMODITIES } from "../engine/bomb.js";
11
  import { serializeGame, deserializeGame } from "../engine/persist.js";
12
  import { queueProduction } from "../engine/production.js";
13
  import { COM } from "../data.js";
 
176
  assert.doesNotThrow(() => detonateBomb(state, bomb)); // calling again on the same (now-orphaned) object
177
  });
178
 
179
+ /* ---------- the blast itself: distance-squared damage falloff ---------- */
180
 
181
+ test("BOMB_CORE_RADIUS is exactly 1.5x the bomb unit's own footprint", () => {
182
+ assert.equal(BOMB_CORE_RADIUS, UNITS.heliumbomb.radius * 1.5);
183
+ });
184
+
185
+ test("bombDamageAt: flat peak damage inside the core, inverse-square falloff beyond it, zero past the blast radius", () => {
186
+ assert.equal(bombDamageAt(0), BOMB_MAX_DAMAGE, "ground zero itself takes the peak hit");
187
+ assert.equal(bombDamageAt(BOMB_CORE_RADIUS), BOMB_MAX_DAMAGE, "still full damage exactly at the core boundary");
188
+ assert.equal(bombDamageAt(BOMB_CORE_RADIUS / 2), BOMB_MAX_DAMAGE, "inside the core the curve is clamped flat, not left to diverge");
189
+
190
+ // Distance-SQUARED decay referenced off BOMB_CORE_RADIUS: doubling the distance past the
191
+ // core should quarter the damage, tripling it should cut damage to a ninth.
192
+ const at2x = bombDamageAt(BOMB_CORE_RADIUS * 2);
193
+ const at3x = bombDamageAt(BOMB_CORE_RADIUS * 3);
194
+ assert.ok(Math.abs(at2x - BOMB_MAX_DAMAGE / 4) < 1e-9, `2x core distance should be ~1/4 damage, got ${at2x}`);
195
+ assert.ok(Math.abs(at3x - BOMB_MAX_DAMAGE / 9) < 1e-9, `3x core distance should be ~1/9 damage, got ${at3x}`);
196
+
197
+ assert.ok(bombDamageAt(BOMB_BLAST_RADIUS) > 0, "still some token damage right at the edge of the blast radius");
198
+ assert.equal(bombDamageAt(BOMB_BLAST_RADIUS + 0.01), 0, "nothing at all past the blast radius");
199
+ });
200
+
201
+ test("ground zero (within BOMB_CORE_RADIUS) still kills everything outright, ANY owner β€” indiscriminate", () => {
202
  const state = createGameState({ planetId: "ferros", endless: true });
203
  const bomb = makeUnit("heliumbomb", "player", 4000, 4000);
204
  state.units.set(bomb.id, bomb);
205
 
206
+ const ownWorker = makeUnit("worker", "player", 4005, 4000); // friendly, well within the core
207
+ const enemyWorker = makeUnit("worker", "ai", 4000, 4010); // enemy, well within the core
208
+ const ownBuilding = makeBuilding("command", "player", 4000, 3986); // friendly, 1000hp β€” but at dist 14, inside the core
209
+ const enemyBuilding = makeBuilding("barracks", "ai", 3985, 4000); // enemy, at dist 15 β€” exactly the core boundary
210
  for (const u of [ownWorker, enemyWorker]) state.units.set(u.id, u);
211
  for (const b of [ownBuilding, enemyBuilding]) state.buildings.set(b.id, b);
212
 
213
  detonateBomb(state, bomb);
214
 
215
+ assert.ok(!state.units.has(ownWorker.id), "the bomb owner's own worker dies too β€” indiscriminate");
216
  assert.ok(!state.units.has(enemyWorker.id));
217
+ assert.ok(!state.buildings.has(ownBuilding.id), "even the owner's own 1000hp Command Center dies at ground zero");
218
  assert.ok(!state.buildings.has(enemyBuilding.id));
219
  });
220
 
221
+ test("further from ground zero, damage falls off β€” a fragile unit dies where a tough building only takes a scratch", () => {
222
+ const state = createGameState({ planetId: "ferros", endless: true });
223
+ const bomb = makeUnit("heliumbomb", "player", 4000, 4000);
224
+ state.units.set(bomb.id, bomb);
225
+
226
+ const dist = 90;
227
+ const worker = makeUnit("worker", "ai", 4000 + dist, 4000); // 40hp
228
+ const command = makeBuilding("command", "ai", 4000, 4000 + dist); // 1000hp, same distance
229
+ state.units.set(worker.id, worker);
230
+ state.buildings.set(command.id, command);
231
+ const expectedDmg = bombDamageAt(dist);
232
+
233
+ detonateBomb(state, bomb);
234
+
235
+ assert.ok(!state.units.has(worker.id), "40hp worker doesn't survive this far out");
236
+ assert.ok(state.buildings.has(command.id), "1000hp Command Center survives the same distance");
237
+ const survivor = state.buildings.get(command.id);
238
+ assert.ok(Math.abs(survivor.hp - (1000 - expectedDmg)) < 1e-6, "took real, partial damage β€” not untouched, not erased");
239
+ });
240
+
241
+ test("something well past the blast radius survives completely untouched", () => {
242
  const state = createGameState({ planetId: "ferros", endless: true });
243
  const bomb = makeUnit("heliumbomb", "player", 4000, 4000);
244
  state.units.set(bomb.id, bomb);
245
  const safe = makeUnit("worker", "ai", 4000 + BOMB_BLAST_RADIUS + 5, 4000);
246
  state.units.set(safe.id, safe);
 
 
247
 
248
  detonateBomb(state, bomb);
249
 
250
+ assert.ok(state.units.has(safe.id), "past the blast radius β€” untouched");
251
+ assert.equal(state.units.get(safe.id).hp, safe.hp, "not even scratched");
252
+ });
253
+
254
+ test("right at the ragged edge of the blast radius, a fragile unit only takes a scratch β€” not necessarily lethal", () => {
255
+ const state = createGameState({ planetId: "ferros", endless: true });
256
+ const bomb = makeUnit("heliumbomb", "player", 4000, 4000);
257
+ state.units.set(bomb.id, bomb);
258
+ const dist = BOMB_BLAST_RADIUS - 5;
259
+ const edge = makeUnit("worker", "ai", 4000 + dist, 4000);
260
+ state.units.set(edge.id, edge);
261
+ const expectedDmg = bombDamageAt(dist);
262
+
263
+ detonateBomb(state, bomb);
264
+
265
+ assert.ok(state.units.has(edge.id), "the tapering tail of the blast isn't lethal to a full-hp worker this far out");
266
+ assert.ok(Math.abs(state.units.get(edge.id).hp - (40 - expectedDmg)) < 1e-6);
267
  });
268
 
269
+ test("entities killed by the blast grant no salvage β€” a total loss, not a normal kill", () => {
270
  const state = createGameState({ planetId: "ferros", endless: true });
271
  const bomb = makeUnit("heliumbomb", "player", 5000, 5000);
272
  state.units.set(bomb.id, bomb);
273
+ const skiff = makeUnit("skiff", "ai", 5010, 5000); // well within the core β€” guaranteed kill
274
  state.units.set(skiff.id, skiff);
275
  const resourcesBefore = { ...state.players.ai.resources };
276
 
277
  detonateBomb(state, bomb);
278
 
279
+ assert.ok(!state.units.has(skiff.id), "sanity check: it actually died");
280
+ assert.deepEqual(state.players.ai.resources, resourcesBefore, "no salvage was granted for the blast-killed skiff");
281
  });
282
 
283
  /* ---------- terraforming: the crater matures into a mineable deposit ---------- */