SpaceCities / renderEffects.js
Claude
Claude Opus 5
Tier 3g: name the three render rules that were copied instead
b870df3 unverified
Raw History Blame Contribute Delete
30.2 kB
/* ============================================================
Cosmetic overlays and cues, drawn on top of the world — attack tracers /
death flashes / under-attack pings (effects.js), the convoy-scenario route,
selection rings, rally point, movement waypoints, escort links, the build
ghost (with its power-grid / rig-survey placement cues), and the drag box.
These are the only render paths that read wall-clock-timed state (effects.js)
rather than the sim's own state. Split out of render.js.
============================================================ */
"use strict";
import { COM } from "./data.js";
import { UNITS, BUILDINGS } from "./engine/entities.js";
import { isNodeDiscovered } from "./engine/fog.js";
import { powerEfficiency } from "./engine/industry.js";
import { rigSurvey, SURVEY_RADIUS } from "./engine/rig.js";
import { canPlaceBuilding } from "./engine/colliders.js";
import { activeEffects, DEATH_BASE_RADIUS } from "./effects.js";
import { hexA, lerpXY, shade, pathPoints, polygonPoints, drawLabelChip } from "./renderShared.js";
import { POWER_TIER_COLOR, drawReactorBands } from "./renderBuildings.js";
// Cached once: whether the viewer asked the OS to reduce motion. Used to swap
// the repeating alert pulses for a static cue (see drawEffects).
let _reducedMotion = null;
function prefersReducedMotion() {
if (_reducedMotion === null) {
_reducedMotion = typeof window !== "undefined" && window.matchMedia
? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false;
}
return _reducedMotion;
}
// The convoy route overlay (scenario mode): a dashed lane connecting the
// stations, a muted ring at the start, cyan rings at the waypoint stations, and
// a bright green gate at the destination. The station the convoy is currently
// heading for gets a solid halo so the objective reads at a glance.
export function drawScenario(ctx, state) {
const sc = state.scenario;
if (!sc) return;
if (sc.type === "bounty") { drawBountyMarkers(ctx, sc); return; }
if (!sc.route) return;
const route = sc.route;
ctx.save();
ctx.strokeStyle = "rgba(79, 209, 255, 0.30)";
ctx.lineWidth = 2;
ctx.setLineDash([12, 10]);
ctx.beginPath();
ctx.moveTo(route[0].x, route[0].y);
for (let i = 1; i < route.length; i++) ctx.lineTo(route[i].x, route[i].y);
ctx.stroke();
ctx.setLineDash([]);
const activeTarget = sc.phase === "travel" ? sc.legIndex + 1 : -1;
route.forEach((p, i) => {
const dest = i === route.length - 1;
const start = i === 0;
const col = dest ? "#4ade80" : start ? "#8593c4" : "#4fd1ff";
if (i === activeTarget) { // halo the station we're steering for
ctx.beginPath(); ctx.arc(p.x, p.y, 44, 0, Math.PI * 2);
ctx.fillStyle = dest ? "rgba(74,222,128,0.12)" : "rgba(79,209,255,0.12)";
ctx.fill();
}
ctx.beginPath(); ctx.arc(p.x, p.y, dest ? 38 : 32, 0, Math.PI * 2);
ctx.strokeStyle = col; ctx.lineWidth = dest ? 4 : 2.5; ctx.stroke();
ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, Math.PI * 2);
ctx.fillStyle = col; ctx.fill();
});
ctx.restore();
}
// Bounty Marshal has no route — it marks the scattered pirate camps instead. An
// uncleared camp gets a dashed red "wanted" ring and its bounty value (a hunt
// beacon that reads through fog, so the player always knows where to go); a
// cleared camp fades to a faint green ring so progress is visible on the map.
function drawBountyMarkers(ctx, sc) {
ctx.save();
ctx.textAlign = "center";
for (const pack of sc.packs) {
if (pack.cleared) {
ctx.strokeStyle = "rgba(74, 222, 128, 0.28)";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(pack.x, pack.y, 30, 0, Math.PI * 2); ctx.stroke();
continue;
}
ctx.strokeStyle = "rgba(248, 113, 113, 0.6)";
ctx.lineWidth = 2.5;
ctx.setLineDash([7, 7]);
ctx.beginPath(); ctx.arc(pack.x, pack.y, 48, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
ctx.font = "12px sans-serif";
ctx.fillStyle = "#fca5a5";
ctx.fillText(`💰 ${pack.bounty}`, pack.x, pack.y - 56);
}
ctx.restore();
}
// Per-weapon fire signature: shape + base color keyed by the firing unit's type (attackHit's
// unitType, engine/combat.js — a turret reads as its own "turret" type). Replaces the old
// two-case tracerColor() special-case (only bastion/lancer were colored; every other type —
// skiff, breacher, turret, dreadnought, wraith, colossus, including the player's OWN units —
// fell to the same hostile-red default). `shape` picks which branch of drawTracer (below) draws
// it; `color`/`width` are the base look BEFORE the counter-triangle bonus-hit brightening
// (drawTracer again). Every type that can actually fire (UNITS[]/BUILDINGS[].attack truthy) gets
// its own entry — see test/renderEffects.test.js's roster-completeness check. `default` is the
// fallback for a hypothetical future type this table hasn't caught up with yet — kept a neutral
// pale color rather than red, so a gap here reads as "unclassified", not "hostile".
export const TRACER_STYLE = {
skiff: { shape: "dart", color: "#8be9fd", width: 2 },
bastion: { shape: "bolt", color: "#ffd166", width: 4 },
lancer: { shape: "beam", color: "#4fd1ff", width: 1.5 },
breacher: { shape: "arc", color: "#ff8a3d", width: 3 },
dreadnought: { shape: "bolt", color: "#f87171", width: 4.5 },
wraith: { shape: "dart", color: "#c084fc", width: 2 },
aegis: { shape: "beam", color: "#7dd3fc", width: 1.5 },
colossus: { shape: "arc", color: "#fb7185", width: 4 },
leviathan: { shape: "arc", color: "#facc15", width: 5 },
turret: { shape: "beam", color: "#a78bfa", width: 2 }, // static defense — its own hue, distinct from every mobile hull
bastille: { shape: "bolt", color: "#c026d3", width: 3.5 }, // the turret's tankier, heavier-hitting sibling
torpedobattery: { shape: "arc", color: "#34d399", width: 4 }, // ammo-fed endgame siege — a heavy lobbed plasma hit
worker: { shape: "default", color: "#e2e8f0", width: 1.5 },
ranger: { shape: "default", color: "#e2e8f0", width: 1.5 },
default: { shape: "default", color: "#e2e8f0", width: 2 },
};
// The flash is fully gone well before the tracer itself finishes fading (TRACER_LIFETIME_MS,
// effects.js), so it reads as a one-frame flicker at the gun, not a second glow racing the shot
// down the line.
const MUZZLE_FLASH_FRACTION = 0.3;
// One tracer, in whatever shape its firing unit's TRACER_STYLE assigns, brightened and thickened
// when it carries a counter-triangle bonus hit (engine/combat.js's `bonus` flag — a Skiff
// catching a Lancer, etc.), plus a one-flicker muzzle flash at the firing end. Split out of
// drawEffects so each weapon shape reads as its own small branch, not one long loop body.
function drawTracer(ctx, t) {
const style = TRACER_STYLE[t.unitType] || TRACER_STYLE.default;
const color = t.bonus ? shade(style.color, 35) : style.color;
const width = style.width * (t.bonus ? 1.6 : 1);
const dx = t.toX - t.fromX, dy = t.toY - t.fromY;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len, uy = dy / len; // unit vector shooter -> target, for the shape branches below
ctx.globalAlpha = Math.max(0, 1 - t.age);
ctx.strokeStyle = color;
ctx.lineWidth = width;
if (style.shape === "bolt") {
// Thick and stubby: only the last stretch of the line, near the target — a heavy close-range
// punch (Bastion/Dreadnought) rather than a full-length beam.
const cut = Math.max(0, len - Math.min(len, 30 + len * 0.35));
ctx.beginPath();
ctx.moveTo(t.fromX + ux * cut, t.fromY + uy * cut);
ctx.lineTo(t.toX, t.toY);
ctx.stroke();
} else if (style.shape === "beam") {
// A full-length thin beam with a hot, slightly thicker tip near the target.
ctx.beginPath();
ctx.moveTo(t.fromX, t.fromY);
ctx.lineTo(t.toX, t.toY);
ctx.stroke();
const tipStart = Math.max(0, len - Math.min(len, 22));
ctx.strokeStyle = t.bonus ? "#fff7cc" : shade(color, 30);
ctx.lineWidth = width + 1;
ctx.beginPath();
ctx.moveTo(t.fromX + ux * tipStart, t.fromY + uy * tipStart);
ctx.lineTo(t.toX, t.toY);
ctx.stroke();
} else if (style.shape === "dart") {
// A short bright segment that SLIDES from shooter to target as the tracer ages, over a faint
// full-length guide line — reads as a fast, light round rather than a static drawn line.
ctx.globalAlpha = Math.max(0, (1 - t.age) * 0.25);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(t.fromX, t.fromY);
ctx.lineTo(t.toX, t.toY);
ctx.stroke();
const cx = t.fromX + dx * t.age, cy = t.fromY + dy * t.age;
const half = Math.min(len / 2, 9);
ctx.globalAlpha = Math.max(0, 1 - t.age);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(cx - ux * half, cy - uy * half);
ctx.lineTo(cx + ux * half, cy + uy * half);
ctx.stroke();
} else if (style.shape === "arc") {
// A lobbed shell: a quadratic curve bulging away from the straight line — siege fire
// (Breacher/Colossus/Leviathan) reads as arcing in, not a flat direct shot.
const bulge = Math.min(40, len * 0.18);
const midX = (t.fromX + t.toX) / 2 - uy * bulge, midY = (t.fromY + t.toY) / 2 + ux * bulge;
ctx.beginPath();
ctx.moveTo(t.fromX, t.fromY);
ctx.quadraticCurveTo(midX, midY, t.toX, t.toY);
ctx.stroke();
} else {
ctx.beginPath();
ctx.moveTo(t.fromX, t.fromY);
ctx.lineTo(t.toX, t.toY);
ctx.stroke();
}
// A one-flicker muzzle flash at the firing end — gone well before the tracer itself fades, so
// it reads as the gun flashing, not a second glow racing the shot down the line. A little spin
// (rotation keyed to age) sells the "flicker" rather than a static stamp.
if (t.age < MUZZLE_FLASH_FRACTION) {
ctx.globalAlpha = Math.max(0, 1 - t.age / MUZZLE_FLASH_FRACTION);
ctx.fillStyle = t.bonus ? "#fff7cc" : color;
pathPoints(ctx, polygonPoints(t.fromX, t.fromY, style.width + (t.bonus ? 2.5 : 1.5), 5, t.age * 3));
ctx.fill();
}
// The impact spark where the round lands — a quick bright flash that fades faster than the
// tracer, so a hit reads as connecting, not just a line drawn through the target. Bigger and
// hotter on a counter-triangle bonus hit, so the extra damage reads at the point of impact too.
ctx.globalAlpha = Math.max(0, 1 - t.age * 1.6);
ctx.fillStyle = t.bonus ? "#fff7cc" : "#ffe9c2";
ctx.beginPath();
ctx.arc(t.toX, t.toY, (t.bonus ? 4 : 2.5) + (1 - t.age) * (t.bonus ? 2.5 : 1.5), 0, Math.PI * 2);
ctx.fill();
// A splash hit (engine/combat.js's attackHit `splashRadius` field — def.splash, Colossus
// first) also draws a fading ring at the true impact radius, in the same per-weapon color this
// shot already picked above — so an area hit reads as such at a glance, distinct from every
// single-target tracer's plain impact spark just above. Falsy (0) for every non-splash hit, so
// this is purely additive and data-driven for any future splash-capable unit.
if (t.splashRadius) {
ctx.globalAlpha = Math.max(0, 0.55 * (1 - t.age));
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(t.toX, t.toY, t.splashRadius, 0, Math.PI * 2);
ctx.stroke();
}
}
// The Helium Bomb shockwave (see drawEffects below): the fireball collapses
// within the first slice of the effect's life, the ring then spends most of
// the rest slowly traveling out to the true blast radius, and the remainder
// is left for it to fade out once it arrives.
const EXPLOSION_FLASH_FRACTION = 0.15;
const EXPLOSION_SHOCKWAVE_FRACTION = 0.85;
// Hot (near ground zero, peak damage) -> cool ash-grey (the tapering edge),
// matching bombDamageAt's own falloff from full HP loss to a scratch.
const SHOCKWAVE_HOT = [255, 180, 80];
const SHOCKWAVE_COOL = [148, 163, 184];
function lerpColor(a, b, t) {
const r = Math.round(a[0] + (b[0] - a[0]) * t);
const g = Math.round(a[1] + (b[1] - a[1]) * t);
const b2 = Math.round(a[2] + (b[2] - a[2]) * t);
return `rgb(${r}, ${g}, ${b2})`;
}
// Tiered destruction: a hull at or above this radius flings debris shards on death (Breacher 10,
// Dreadnought/Aegis/Colossus 11-12, Leviathan 14, and up — every Skiff/Lancer/worker-scale unit
// stays a plain ring).
const DEBRIS_MIN_RADIUS = 10;
const DEBRIS_SHARD_COUNT = 5;
// Attack tracers, death flashes, and under-attack pings: all purely
// cosmetic and short-lived (see effects.js), so this is the only place
// in render.js that reads wall-clock-timed state instead of the sim's
// own state object.
export function drawEffects(ctx) {
const { tracers, deaths, pings, explosions, fuseWarnings } = activeEffects();
for (const t of tracers) drawTracer(ctx, t);
// Tiered destruction (docs/improvement-proposals.md): the ring's size/lifetime already scale
// with the killed entity's own radius (effects.js addDeathFlash/deathLifeMs, DEATH_BASE_RADIUS
// the baseline both files share) — this just draws the SHAPE differently by tier: a plain
// single ring for most deaths, a few outward debris shards bolted on for a big hull
// (radius >= DEBRIS_MIN_RADIUS), and a building gets a slower TWO-ring collapse (an outer ring
// plus a lagging inner one) instead of one, reading as a structure caving in rather than a unit
// popping.
for (const d of deaths) {
const scale = d.radius / DEATH_BASE_RADIUS;
const rMax = 16 * scale;
const r = 6 * scale + d.age * rMax;
ctx.globalAlpha = Math.max(0, 1 - d.age);
ctx.strokeStyle = "#ffab5e";
ctx.lineWidth = Math.max(1.5, Math.min(3.2, 2 * scale));
ctx.beginPath();
ctx.arc(d.x, d.y, r, 0, Math.PI * 2);
ctx.stroke();
if (d.kind === "building") {
// A second, inner ring trailing the outer one by a quarter of the effect's life — the core
// caves in a beat behind the shockwave, instead of the whole structure vanishing on one ring.
const innerAge = Math.max(0, d.age - 0.25);
const innerR = 4 * scale + innerAge * rMax * 0.7;
ctx.globalAlpha = Math.max(0, (1 - d.age) * 0.8);
ctx.beginPath();
ctx.arc(d.x, d.y, innerR, 0, Math.PI * 2);
ctx.stroke();
} else if (d.radius >= DEBRIS_MIN_RADIUS) {
// A handful of shards flung outward for a heavy hull — angle hashed off position so it's
// stable across the effect's own lifetime rather than reshuffling every frame.
ctx.fillStyle = "#ffab5e";
for (let i = 0; i < DEBRIS_SHARD_COUNT; i++) {
const a = (i / DEBRIS_SHARD_COUNT) * Math.PI * 2 + (d.x + d.y) * 0.01;
const dist = d.age * rMax * 1.4;
ctx.beginPath();
ctx.arc(d.x + Math.cos(a) * dist, d.y + Math.sin(a) * dist, Math.max(0.5, 2 * (1 - d.age)), 0, Math.PI * 2);
ctx.fill();
}
}
}
const reduced = prefersReducedMotion();
for (const p of pings) {
if (reduced) {
// Motion-sensitive players get a steady ring that just fades out, instead
// of the repeating expanding pulse.
ctx.globalAlpha = Math.max(0, 0.6 * (1 - p.age));
ctx.strokeStyle = "#f87171";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(p.x, p.y, 24, 0, Math.PI * 2);
ctx.stroke();
continue;
}
// Two expanding rings on a repeating pulse read as an alarm rather
// than a one-shot flash, matching a ping's much longer lifetime.
const pulse = (p.age * 2.5) % 1;
ctx.globalAlpha = Math.max(0, (1 - pulse) * (1 - p.age * 0.6));
ctx.strokeStyle = "#f87171";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(p.x, p.y, 14 + pulse * 40, 0, Math.PI * 2);
ctx.stroke();
}
// A Helium Bomb's fuse just lit (engine/bomb.js's bombFused event): an amber warning ring
// pulsing right at ground zero for the whole fuse burn — distinct from the red under-attack
// ping above and the hot explosion ring below, its own place in the alert color language.
// The pulse rate itself accelerates as f.age climbs toward 1, so the rhythm quickens right
// up to the moment the real blast (drawn below, once detonateBomb actually fires) takes over.
for (const f of fuseWarnings) {
if (reduced) {
ctx.globalAlpha = Math.max(0, 0.5 * (1 - f.age));
ctx.strokeStyle = "#fbbf24";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(f.x, f.y, 20, 0, Math.PI * 2);
ctx.stroke();
continue;
}
const pulseHz = 2 + f.age * 4; // ~2Hz freshly lit, accelerating to ~6Hz right before it blows
const pulse = (f.age * pulseHz) % 1;
ctx.globalAlpha = Math.max(0, (1 - pulse) * 0.85);
ctx.strokeStyle = "#fbbf24";
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(f.x, f.y, 10 + pulse * 22, 0, Math.PI * 2);
ctx.stroke();
}
// The Helium Bomb's detonation (engine/bomb.js): a bright fireball flash at ground zero
// that collapses fast, under a slow-moving shockwave ring that crawls out from
// e.coreRadius (the guaranteed-kill distance) to e.radius (the true blast radius the
// damage falloff reaches zero at) over most of the effect's life — a deliberately
// unhurried, decelerating travel (real shockwaves lose speed as they lose energy) so the
// blast reads as a big, dramatic event rather than a one-frame pop. The ring cools from a
// hot core color to an ash-grey edge color as it travels, echoing bombDamageAt's own
// falloff from peak damage to a scratch. Reduced-motion viewers get a single fast static
// ring at the true radius instead of the slow travel.
for (const e of explosions) {
const flashT = Math.min(1, e.age / EXPLOSION_FLASH_FRACTION);
const flashR = (e.coreRadius || e.radius * 0.3) * Math.max(0, 1 - flashT);
ctx.globalAlpha = Math.max(0, 1 - flashT * 1.2);
ctx.fillStyle = "#fff3c4";
ctx.beginPath();
ctx.arc(e.x, e.y, flashR, 0, Math.PI * 2);
ctx.fill();
if (reduced) {
ctx.globalAlpha = Math.max(0, 0.6 * (1 - e.age));
ctx.strokeStyle = "#ff8a3d";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(e.x, e.y, e.radius, 0, Math.PI * 2);
ctx.stroke();
continue;
}
const travel = Math.min(1, e.age / EXPLOSION_SHOCKWAVE_FRACTION);
const eased = 1 - (1 - travel) ** 3; // ease-out: fast off the blast, decelerating outward
const ringR = (e.coreRadius || 0) + (e.radius - (e.coreRadius || 0)) * eased;
const ringAlpha = e.age < EXPLOSION_SHOCKWAVE_FRACTION
? 0.9 - 0.5 * travel
: Math.max(0, 0.4 * (1 - (e.age - EXPLOSION_SHOCKWAVE_FRACTION) / (1 - EXPLOSION_SHOCKWAVE_FRACTION)));
ctx.globalAlpha = Math.max(0, ringAlpha);
ctx.strokeStyle = lerpColor(SHOCKWAVE_HOT, SHOCKWAVE_COOL, eased);
ctx.lineWidth = Math.max(1, 5 * (1 - eased * 0.6));
ctx.beginPath();
ctx.arc(e.x, e.y, ringR, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// The grid-efficiency cue drawn under a power-consumer build ghost: the nearest own
// Reactor's zones + a connector to it, and the ghost's resulting tier as a coloured ring
// and label ("On-grid · draw ×1.0" … "Isolated · draw ×2.3"). Mirrors how the Spaceport
// shows its jump radius — it turns "where do I put this factory?" into a visible choice.
function drawGhostPowerCue(ctx, state, ghost, def) {
// The source that DETERMINES the ghost's tier: the nearest by RANGE-SCALED distance, so a
// close short-range Generator can out-rank a far Reactor exactly as the efficiency math does.
let nearest = null, bestD = Infinity, nearestScale = 1;
for (const b of state.buildings.values()) {
const bd = BUILDINGS[b.type];
if (b.owner !== "player" || b.constructing || !(bd && bd.energyGrants > 0)) continue;
const scale = bd.powerRange || 1;
const d = Math.hypot(b.x - ghost.x, b.y - ghost.y) / scale;
if (d < bestD) { bestD = d; nearest = b; nearestScale = scale; }
}
const tier = powerEfficiency(state, "player", ghost.x, ghost.y);
const col = POWER_TIER_COLOR[tier.name];
if (nearest) {
drawReactorBands(ctx, nearest.x, nearest.y, nearestScale);
ctx.save();
ctx.setLineDash([5, 5]);
ctx.strokeStyle = hexA(col, 0.8);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(nearest.x, nearest.y);
ctx.lineTo(ghost.x, ghost.y);
ctx.stroke();
ctx.restore();
}
// A ring in the tier colour around the ghost, plus a label above it.
ctx.save();
ctx.beginPath();
ctx.arc(ghost.x, ghost.y, def.radius + 7, 0, Math.PI * 2);
ctx.strokeStyle = col;
ctx.lineWidth = 2.5;
ctx.stroke();
const label = nearest
? `${tier.label} · draw ×${tier.mult.toFixed(1)}`
: "No Reactor — power it first";
const ly = ghost.y - def.radius - 12;
drawLabelChip(ctx, label, ghost.x, ly, nearest ? col : "#f87171");
ctx.restore();
}
// The Plasma Rig placement survey: a dashed violet ring at the survey radius, a highlight on
// each VISIBLE deposit the rig would read inside it, and a label predicting the vein + seam
// richness below (engine/rig.js rigSurvey). Only the player's charted surface counts, so a spot
// with no visible deposits reads "blind — a gamble", and an unscouted cache stays a surprise.
function drawRigSurveyCue(ctx, state, ghost, def) {
const VIOLET = "180, 140, 255";
ctx.save();
ctx.setLineDash([4, 8]);
ctx.lineWidth = 1.2;
ctx.strokeStyle = `rgba(${VIOLET}, 0.5)`;
ctx.beginPath();
ctx.arc(ghost.x, ghost.y, SURVEY_RADIUS, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
const visible = state.map.nodes.filter(n => n.amount > 0 && isNodeDiscovered(state.fog, n)
&& Math.hypot(n.x - ghost.x, n.y - ghost.y) < SURVEY_RADIUS);
ctx.save();
ctx.setLineDash([]);
ctx.strokeStyle = `rgba(${VIOLET}, 0.85)`;
ctx.lineWidth = 1.5;
for (const n of visible) {
ctx.beginPath();
ctx.arc(n.x, n.y, 7 + 9 * (n.amount / n.max) + 4, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
const survey = rigSurvey(visible, state.planetId, ghost.x, ghost.y);
const meta = survey.likelyVein ? COM[survey.likelyVein] : null;
const confWord = !survey.likelyVein ? "" : survey.confidence >= 0.6 ? "likely " : survey.confidence >= 0.35 ? "maybe " : "toss-up: ";
const label = survey.likelyVein
? `⛏ ${confWord}${meta?.name || survey.likelyVein} · ${survey.richLabel} seam`
: "⛏ blind spot — no surface to read (a gamble)";
ctx.save();
const ly = ghost.y - def.radius - 34; // sits above the grid-efficiency label (at radius+12)
drawLabelChip(ctx, label, ghost.x, ly, survey.likelyVein ? "#c4b5fd" : "#f0a0a0", "rgba(5, 7, 15, 0.8)");
ctx.restore();
}
// Translucent footprint under the cursor while placing a building, green
// when the spot is buildable and red when it isn't (out of bounds,
// overlapping another building, or too close to a resource node — see
// engine/colliders.js) so invalid placement is obvious before the
// player even clicks, not just rejected silently after.
export function drawBuildGhost(ctx, state, ghost) {
const def = BUILDINGS[ghost.buildingType];
if (!def) return;
// Placing a power consumer (a factory, the Plasma Rig, or the Gate)? Surface the
// grid-efficiency zones of every nearby Reactor and tag the ghost with the tier it
// would land in, so the "closer is cheaper to power" call is visible BEFORE the click.
if (def.recipe || def.rig || def.wonder) drawGhostPowerCue(ctx, state, ghost, def);
// Placing a Plasma Rig? Read the visible surface deposits and predict the vein + seam
// richness below, so late-game placement is an educated guess off the map, not a blind pick.
if (def.rig) drawRigSurveyCue(ctx, state, ghost, def);
const valid = canPlaceBuilding(state, ghost.buildingType, ghost.x, ghost.y);
const color = valid ? "#4ade80" : "#f87171";
const r = def.radius;
ctx.globalAlpha = 0.45;
ctx.fillStyle = color;
ctx.fillRect(ghost.x - r, ghost.y - r, r * 2, r * 2);
ctx.globalAlpha = 1;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
// An INVALID spot carries a non-colour cue too — a dashed outline plus an ✕ across the
// footprint — so it reads without relying on the red-vs-green hue alone (the same
// colourblind-safe principle the health bars use). A valid spot stays a clean solid box.
if (valid) {
ctx.strokeRect(ghost.x - r, ghost.y - r, r * 2, r * 2);
} else {
ctx.setLineDash([5, 4]);
ctx.strokeRect(ghost.x - r, ghost.y - r, r * 2, r * 2);
ctx.beginPath();
ctx.moveTo(ghost.x - r, ghost.y - r); ctx.lineTo(ghost.x + r, ghost.y + r);
ctx.moveTo(ghost.x + r, ghost.y - r); ctx.lineTo(ghost.x - r, ghost.y + r);
ctx.stroke();
ctx.setLineDash([]);
}
}
// `force` keeps the bar visible at full health for a selected entity, so you
// can confirm your army's condition at a glance instead of it vanishing the
// moment it's topped off. Three colour bands (green / amber / red) so the
// health tier reads without relying on the green-vs-red distinction alone.
export function drawSelectionRings(ctx, state, alpha = 1) {
ctx.strokeStyle = "#4fd1ff";
ctx.lineWidth = 2;
for (const id of state.selection) {
const unit = state.units.get(id);
const e = unit || state.buildings.get(id);
if (!e) continue;
const baseRadius = unit ? UNITS[unit.type].radius : e.radius;
const r = baseRadius + 4;
const d = unit ? lerpXY(unit, alpha) : e; // ring follows the interpolated hull (buildings are static)
ctx.beginPath();
ctx.arc(d.x, d.y, r, 0, Math.PI * 2);
ctx.stroke();
}
}
// Shown only for the single selected production building, matching the
// usual RTS convention of not cluttering the view with every building's
// rally line at once.
export function drawRallyPoint(ctx, state) {
if (state.selection.length !== 1) return;
const building = state.buildings.get(state.selection[0]);
if (!building || building.owner !== "player" || !BUILDINGS[building.type].produces) return;
// Guard before destructuring: `rally` is optional on a Building and engine/persist.js's
// cleanEntity never defaults it, so an older or hand-edited save reaches here with none. A
// TypeError from a single entity used to take the whole frame — and, before drawFrame's
// try/finally, every frame after it.
const rally = building.rally;
if (!rally) return;
const { x: rx, y: ry } = rally;
ctx.save();
ctx.setLineDash([6, 6]);
ctx.strokeStyle = "rgba(79, 209, 255, 0.6)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(building.x, building.y);
ctx.lineTo(rx, ry);
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.arc(rx, ry, 5, 0, Math.PI * 2);
ctx.fillStyle = "#4fd1ff";
ctx.fill();
ctx.strokeStyle = "#05070f";
ctx.lineWidth = 1;
ctx.stroke();
}
// The queued-waypoint path for every selected player unit: a dashed line
// threading the unit through its active order and each queued step,
// with a dot at each stop. Only drawn for units that actually have a queue,
// so an ordinary single-destination move doesn't clutter the field.
export function drawWaypoints(ctx, state) {
ctx.save();
ctx.setLineDash([4, 5]);
for (const id of state.selection) {
const unit = state.units.get(id);
if (!unit || unit.owner !== "player" || !unit.orderQueue || unit.orderQueue.length === 0) continue;
const stops = [];
for (const order of [unit.order, ...unit.orderQueue]) {
const pt = orderPoint(state, order);
if (pt) stops.push(pt);
}
if (!stops.length) continue;
ctx.strokeStyle = "rgba(79, 209, 255, 0.5)";
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(unit.x, unit.y);
for (const s of stops) ctx.lineTo(s.x, s.y);
ctx.stroke();
ctx.fillStyle = "#4fd1ff";
for (const s of stops) {
ctx.beginPath();
ctx.arc(s.x, s.y, 2.5, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
// A faint link from each selected escort to the friendly ship it's guarding, plus a ring on the
// target — so an active escort order reads at a glance (it carries no waypoint line otherwise).
// Escort green, distinct from the cyan waypoint colour.
export function drawEscortLinks(ctx, state) {
const guarded = new Set();
ctx.save();
ctx.setLineDash([3, 4]);
ctx.strokeStyle = "rgba(120, 230, 170, 0.5)";
ctx.lineWidth = 1.1;
for (const id of state.selection) {
const u = state.units.get(id);
if (!u || u.owner !== "player" || !u.order || u.order.type !== "escort") continue;
const t = state.units.get(u.order.targetId);
if (!t || t.hp <= 0) continue;
ctx.beginPath(); ctx.moveTo(u.x, u.y); ctx.lineTo(t.x, t.y); ctx.stroke();
guarded.add(t);
}
ctx.setLineDash([]);
ctx.strokeStyle = "rgba(120, 230, 170, 0.75)";
ctx.lineWidth = 1.4;
for (const t of guarded) {
ctx.beginPath();
ctx.arc(t.x, t.y, (UNITS[t.type]?.radius || 10) + 6, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
}
// Where an order points on the map, for its waypoint marker — a fixed spot
// for move/attack-move, or the live position of the unit/building/node it's
// chasing. Null for an order with nowhere to point.
function orderPoint(state, order) {
if (!order) return null;
if (order.type === "move" || order.type === "attack-move") return { x: order.x, y: order.y };
if (order.type === "attack" || order.type === "escort") {
const t = state.units.get(order.targetId) || state.buildings.get(order.targetId);
return t ? { x: t.x, y: t.y } : null;
}
if (order.type === "gather") {
const n = state.map.nodes.find(nd => nd.id === order.nodeId);
return n ? { x: n.x, y: n.y } : null;
}
if (order.type === "build") {
const b = state.buildings.get(order.buildingId);
return b ? { x: b.x, y: b.y } : null;
}
return null;
}
export function drawDragBox(ctx, box) {
const x = Math.min(box.x1, box.x2), y = Math.min(box.y1, box.y2);
const w = Math.abs(box.x2 - box.x1), h = Math.abs(box.y2 - box.y1);
ctx.fillStyle = "rgba(79, 209, 255, 0.15)";
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = "#4fd1ff";
ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h);
}