Spaces:
Paused
Paused
Give the Helium Bomb distance-based damage falloff and a slow shockwave (#11)
Browse filesReplace 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>
- boot.js +3 -2
- effects.js +12 -6
- engine/bomb.js +70 -19
- engine/combat.js +3 -2
- hudSelection.js +4 -4
- renderEffects.js +48 -10
- 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 44 |
-
// that
|
| 45 |
-
// how far the
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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.
|
| 73 |
-
//
|
| 74 |
-
//
|
| 75 |
-
//
|
| 76 |
-
//
|
| 77 |
-
//
|
| 78 |
-
//
|
| 79 |
-
//
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 86 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 146 |
-
// before the normal damage/death path,
|
|
|
|
| 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 β
|
| 1060 |
-
: `Unarmed β safe to move anywhere. Once armed: ${BOMB_BLAST_RADIUS}-radius blast
|
| 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
|
| 168 |
-
//
|
| 169 |
-
//
|
| 170 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
for (const e of explosions) {
|
| 172 |
-
const
|
| 173 |
-
|
|
|
|
| 174 |
ctx.fillStyle = "#fff3c4";
|
| 175 |
ctx.beginPath();
|
| 176 |
ctx.arc(e.x, e.y, flashR, 0, Math.PI * 2);
|
| 177 |
ctx.fill();
|
| 178 |
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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:
|
| 179 |
|
| 180 |
-
test("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 186 |
-
const enemyWorker = makeUnit("worker", "ai", 4000,
|
| 187 |
-
const ownBuilding = makeBuilding("command", "player", 4000,
|
| 188 |
-
const enemyBuilding = makeBuilding("barracks", "ai",
|
| 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
|
| 195 |
assert.ok(!state.units.has(enemyWorker.id));
|
| 196 |
-
assert.ok(!state.buildings.has(ownBuilding.id), "even the owner's own Command Center
|
| 197 |
assert.ok(!state.buildings.has(enemyBuilding.id));
|
| 198 |
});
|
| 199 |
|
| 200 |
-
test("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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), "
|
| 212 |
-
assert.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
});
|
| 214 |
|
| 215 |
-
test("
|
| 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.
|
|
|
|
| 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 ---------- */
|