SpaceCities / effects.js
Claude
P8 Combat Depth: veterancy chevrons + tiered destruction visuals and audio
d213ade unverified
Raw History Blame
10.3 kB
/* ============================================================
Transient visual effects (attack tracers, death flashes, under-attack
pings): purely cosmetic, client-side, and short-lived, so they live
here instead of in engine/ state β€” the sim's fixed-timestep tick
never reads or needs to know about them.
main.js feeds this each render frame by draining state.events (the
same sim event queue that also drives sound.js), and render.js reads
activeEffects() each frame to draw whatever's still alive. Timing is
wall-clock (performance.now()), not sim time, since these should
animate at real display speed regardless of simulation rate.
============================================================ */
"use strict";
const TRACER_LIFETIME_MS = 120;
// Tiered destruction (docs/improvement-proposals.md "Tiered destruction: deaths scale with what
// died"): DEATH_LIFETIME_MS is the BASE lifetime, tuned against DEATH_BASE_RADIUS (a small,
// Skiff-scale hull) β€” deathLifeMs below stretches it for anything bigger, and again for a
// building's slower double-ring collapse (renderEffects.js's own drawing scales the same way off
// the death's `radius`/`kind`, via the exported DEATH_BASE_RADIUS so the two files share one
// baseline instead of two independently-tuned literals).
const DEATH_LIFETIME_MS = 280;
export const DEATH_BASE_RADIUS = 8;
const PING_LIFETIME_MS = 3000;
// How long an under-attack ping stays alive for the MINIMAP's own alert ring (minimap.js,
// activePings() below) β€” deliberately much longer than the world-space ping's PING_LIFETIME_MS
// above (which only needs to outlast the ~2.5s banner), so a raid on a distant flank is still
// findable on the minimap long after the banner and the on-screen ping have both faded
// (docs/improvement-proposals.md "Attack pings on the minimap plus a jump-to-last-alert key").
const MINIMAP_PING_LIFETIME_MS = 9000;
const FIREWORK_LIFETIME_MS = 1500;
// Long enough for the shockwave ring (renderEffects.js) to visibly crawl
// out to its real blast radius rather than snapping there in a couple of
// frames β€” the slow travel is what sells the blast's true reach. 20% slower
// than the ring's original travel time (2400ms).
const EXPLOSION_LIFETIME_MS = 2880;
let tracers = [];
let deaths = [];
let pings = [];
let fireworks = [];
let explosions = [];
let fuseWarnings = [];
// `bonus` (engine/combat.js's attackHit event field) marks a counter-triangle hit β€” a Skiff
// catching a Lancer, etc. β€” so renderEffects.js can telegraph it hotter and thicker instead of
// drawing it identically to a futile plink. `splashRadius` (same event, def.splash's radius β€”
// Colossus first) lets renderEffects.js draw a fading impact ring at the true blast radius, on
// top of the plain impact spark every tracer already gets. Both default falsy, so every
// pre-existing call (and every event that never sets them) keeps its plain look.
export function addTracer(fromX, fromY, toX, toY, unitType, bonus = false, splashRadius = 0) {
tracers.push({ fromX, fromY, toX, toY, unitType, bonus, splashRadius, born: performance.now() });
}
// How long one death flash lives: scaled up for a bigger hull (clamped so a Worker's flick and a
// titan's collapse both stay sane β€” 0.7x..2.2x the base), and again for a building's slower
// double-ring collapse, which is animating two rings instead of one and needs the extra time to
// read as a collapse rather than a blink.
function deathLifeMs(d) {
const scale = Math.max(0.7, Math.min(2.2, d.radius / DEATH_BASE_RADIUS));
return DEATH_LIFETIME_MS * scale * (d.kind === "building" ? 1.6 : 1);
}
// `radius`/`kind` (def.radius, "unit"|"building" β€” engine/combat.js's entityKilled event, added
// alongside the pre-existing x/y/owner) default to a small unit-scale hull, so a hypothetical
// stray 2-arg call site still reads exactly as it did before tiered destruction landed.
export function addDeathFlash(x, y, radius = DEATH_BASE_RADIUS, kind = "unit") {
deaths.push({ x, y, radius, kind, born: performance.now() });
}
// A longer-lived pulsing marker for the under-attack alert, so the
// player can find the fight on the minimap (or the main view, if it's
// on-screen) even after the alert banner itself has faded.
export function addUnderAttackPing(x, y) {
pings.push({ x, y, born: performance.now() });
}
// The Helium Bomb's detonation (engine/bomb.js's bombDetonated event): a slow-moving
// shockwave ring that crawls out to the blast's real radius (so the VFX itself shows the
// player exactly how far the damage reached), not a fixed cosmetic size like the death
// flash above. `coreRadius` is the inner "guaranteed kill" distance (BOMB_CORE_RADIUS) the
// ring starts from, so the render side can shade the traveling front from hot (peak
// damage, near ground zero) to cool (the tapering edge) as it goes.
export function addExplosion(x, y, radius, coreRadius = 0) {
explosions.push({ x, y, radius, coreRadius, born: performance.now() });
}
// A Helium Bomb's fuse just lit (engine/bomb.js's bombFused event β€” an enemy came within
// range, or the player hit "Detonate Now"): a fast, urgent pulsing warning ring at ground
// zero for exactly as long as the fuse itself burns (`durationMs`, straight from the sim's
// BOMB_FUSE_DELAY so it can never drift out of sync with when the real blast actually
// lands), so the countdown reads as a ticking hazard rather than a silent, invisible timer.
export function addFuseWarning(x, y, durationMs) {
fuseWarnings.push({ x, y, born: performance.now(), life: durationMs });
}
// A celebratory burst show for Odyssey progress milestones (engine/galaxy.js). Screen-space,
// so it plays over the whole viewport regardless of which world you're on. `bursts` shells go
// off staggered in time at random points in the upper screen; each is a ring of sparks that fly
// out, arc down under gravity, and fade. Purely cosmetic + client-side β€” Math.random/now are
// fine here (this never touches the deterministic sim). Positions are normalized 0..1 of the
// viewport, scaled at draw time so a resize can't misplace an in-flight shell.
export function addFireworks(bursts = 5) {
const now = performance.now();
for (let i = 0; i < bursts; i++) {
const base = Math.floor(Math.random() * 360);
const n = 22 + Math.floor(Math.random() * 16);
const parts = [];
for (let p = 0; p < n; p++) {
const a = (p / n) * Math.PI * 2 + Math.random() * 0.25;
const spd = 0.05 + Math.random() * 0.09; // fraction of viewport height per unit age
parts.push({ a, spd, hue: (base + Math.random() * 44 - 22 + 360) % 360 });
}
fireworks.push({
cx: 0.18 + Math.random() * 0.64, // clear of the extreme edges
cy: 0.16 + Math.random() * 0.40, // upper portion of the screen
parts,
born: now + i * 240, // staggered launches β†’ a sustained show
});
}
}
// Live fireworks with a 0β†’1 age baked in: a shell whose staggered launch hasn't arrived yet
// (age<0) is withheld, one still mid-flight is returned, spent ones are pruned.
export function activeFireworks() {
const now = performance.now();
fireworks = fireworks.filter(f => now - f.born < FIREWORK_LIFETIME_MS);
return fireworks
.map(f => ({ ...f, age: (now - f.born) / FIREWORK_LIFETIME_MS }))
.filter(f => f.age >= 0);
}
// Called once per render frame: prunes expired effects and returns the
// survivors with a 0 (just spawned) -> 1 (about to vanish) age fraction
// baked in, so render.js can fade/animate them without needing to know
// the lifetime constants itself.
export function activeEffects() {
const now = performance.now();
tracers = tracers.filter(t => now - t.born < TRACER_LIFETIME_MS);
deaths = deaths.filter(d => now - d.born < deathLifeMs(d));
// Pruned against the LONGER minimap lifetime, not the short PING_LIFETIME_MS used for the
// world-space age below β€” this is the same shared array activePings() (just past this
// function) reads for the minimap's own alert ring, and it must never lose an entry the
// minimap still wants. The world-space ping's own fade/pulse timing is unaffected: its `age`
// still divides by the short PING_LIFETIME_MS, so it reads as fully faded (alpha clamped to 0
// by renderEffects.js's drawEffects) long before MINIMAP_PING_LIFETIME_MS is actually up.
pings = pings.filter(p => now - p.born < MINIMAP_PING_LIFETIME_MS);
explosions = explosions.filter(e => now - e.born < EXPLOSION_LIFETIME_MS);
fuseWarnings = fuseWarnings.filter(f => now - f.born < f.life);
return {
tracers: tracers.map(t => ({ ...t, age: (now - t.born) / TRACER_LIFETIME_MS })),
deaths: deaths.map(d => ({ ...d, age: (now - d.born) / deathLifeMs(d) })),
pings: pings.map(p => ({ ...p, age: (now - p.born) / PING_LIFETIME_MS })),
explosions: explosions.map(e => ({ ...e, age: (now - e.born) / EXPLOSION_LIFETIME_MS })),
fuseWarnings: fuseWarnings.map(f => ({ ...f, age: (now - f.born) / f.life })),
};
}
// The minimap's own read of the same pings addUnderAttackPing feeds above (drawMinimap in
// minimap.js, wired in via boot.js's render callback) β€” kept alive far longer than the
// world-space ping (MINIMAP_PING_LIFETIME_MS, not PING_LIFETIME_MS), so a raid on a distant flank
// is still findable on the minimap long after the on-screen banner + world-space ping have both
// faded. Filters defensively against the same lifetime activeEffects() prunes the shared array
// to, so this reads correctly even called on its own β€” a render pass that draws the minimap
// before the main view, or a unit test that never calls activeEffects() at all.
export function activePings() {
const now = performance.now();
return pings
.filter(p => now - p.born < MINIMAP_PING_LIFETIME_MS)
.map(p => ({ ...p, age: (now - p.born) / MINIMAP_PING_LIFETIME_MS }));
}
// Cleared on a fresh game start so effects from a previous match don't
// bleed into the next one's first frame.
export function resetEffects() {
tracers = [];
deaths = [];
pings = [];
fireworks = [];
explosions = [];
fuseWarnings = [];
}