SpaceCities / renderUnits.js
Claude
Claude Opus 5
Tier 3g: name the three render rules that were copied instead
b870df3 unverified
Raw History Blame Contribute Delete
27.9 kB
/* ============================================================
Unit rendering β€” the per-type vector hulls (Worker, Ranger, Skiff, Bastion,
…, Freighter, Colony Ship), drawn in two passes (all hulls, then all overlays:
cargo dot, enemy pip, health bar) so a later hull can't paint out an earlier
unit's bar. Oriented hulls point the way they're moving (renderShared facing /
updateFacing) and draw at the interpolated position (lerpXY). The shape
dispatch (drawUnitShape) is shared with the HUD's button icons (spriteIcon).
Split out of render.js; drawFrame calls drawUnits.
============================================================ */
"use strict";
import { UNITS, rankMults } from "./engine/entities.js";
import { DETAIL, facing, updateFacing, lerpXY, inView, drawHealthBar,
shade, polygonPoints, pathPoints, toWorld, pathOriented, hiddenByFog } from "./renderShared.js";
/* ---------- units ---------- */
// The unit shape dispatch, factored out of drawUnits so the HUD's button icons render
// the exact same sprite. The caller sets ctx.fillStyle (owner colour) + strokeStyle
// (DETAIL) first, as drawUnits does. Oriented hulls default to facing "up" for a static
// icon (updateFacing has no movement to read).
export function drawUnitShape(ctx, u, def, color) {
if (u.type === "worker") drawWorker(ctx, u, def, color);
else if (u.type === "ranger") drawRanger(ctx, u, def, color);
else if (u.type === "skiff") drawSkiff(ctx, u, def, color);
else if (u.type === "bastion") drawBastion(ctx, u, def, color);
else if (u.type === "lancer") drawLancer(ctx, u, def, color);
else if (u.type === "breacher") drawBreacher(ctx, u, def, color);
else if (u.type === "dreadnought") drawDreadnought(ctx, u, def, color);
else if (u.type === "mender") drawMender(ctx, u, def, color);
else if (u.type === "wraith") drawWraith(ctx, u, def, color);
else if (u.type === "aegis") drawAegis(ctx, u, def, color);
else if (u.type === "colossus") drawColossus(ctx, u, def, color);
else if (u.type === "leviathan") drawLeviathan(ctx, u, def, color);
else if (u.type === "freighter" || u.type === "hauler" || u.type === "heavyhauler" || u.type === "bulkfreighter") drawFreighter(ctx, u, def, color);
else if (u.type === "colonyship") drawColonyShip(ctx, u, def, color);
else if (u.type === "heliumbomb") drawHeliumBomb(ctx, u, def, color);
else drawGenericUnit(ctx, u, def, color); // any future unit still gets a silhouette, never an invisible blank
}
// A shallow view of a unit at its interpolated draw position. This used to be ONE module-level
// object refilled with Object.assign β€” which copies own properties but never DELETES stale ones,
// so any optional field a unit lacks was inherited from whichever unit drew before it. Two visible
// consequences, both reproduced: an INERT Helium Bomb rendered with the armed cue (long red spikes,
// hot core β€” a safety signal) whenever an armed one drew earlier in the same frame, and an idle
// unit snapped to whatever explicit heading the last click-and-drag had set on some other unit,
// defeating the explicit-facing feature. A fresh spread per unit costs one small object per unit
// per pass and cannot carry anything over.
const dispOf = (u, x, y) => ({ ...u, x, y });
// `selSet` is the current-selection Set, computed ONCE per frame by the caller (render.js
// drawFrame) and passed down here rather than allocated fresh on every call.
// observerMode (default false, so every existing caller/test is unaffected): bypasses the fog
// gate below entirely β€” Observer Mode's whole point is seeing units regardless of ownership or
// vision, never a mutation of state.fog itself (see observer.js's header comment).
export function drawUnits(ctx, state, view, alpha = 1, selSet, observerMode = false) {
// Two passes over the same culled set: ALL hulls first, then ALL overlays (health bars,
// enemy pips, cargo dots). Otherwise, in a dense melee, a unit drawn later paints its hull
// over an earlier unit's health bar β€” exactly when the bar matters most. lerp+cull recompute
// per pass is cheap (a Map.get + a couple of mults) and allocates nothing.
for (const u of state.units.values()) {
const d = lerpXY(u, alpha); // interpolated {x,y} (or the live unit when there's no baseline / a teleport)
if (view && !inView(view, d.x, d.y, 16)) continue; // off-screen unit
if (hiddenByFog(state, u, d.x, d.y, observerMode)) continue;
const def = UNITS[u.type];
const color = state.players[u.owner].color;
ctx.fillStyle = color;
// A dark outline disappears against the (equally dark) background β€” it only ever separated
// overlapping same-color units, never defined the silhouette. A light one keeps the shape
// crisp at small sizes, where anti-aliasing otherwise blurs a hull's corners into a blob.
ctx.strokeStyle = DETAIL;
ctx.lineWidth = 1.5;
// Draw the hull at the interpolated position via a shallow scratch copy, so every shape
// helper (and updateFacing, which it calls) sees the smoothed coordinates without threading
// them through each one. The scratch is reused β€” no per-unit alloc.
drawUnitShape(ctx, dispOf(u, d.x, d.y), def, color);
}
for (const u of state.units.values()) {
const d = lerpXY(u, alpha);
if (view && !inView(view, d.x, d.y, 16)) continue;
if (hiddenByFog(state, u, d.x, d.y, observerMode)) continue;
const def = UNITS[u.type];
if (u.cargo && u.cargo.qty > 0) {
ctx.beginPath();
ctx.arc(d.x, d.y - def.radius - 5, 3, 0, Math.PI * 2);
ctx.fillStyle = "#ffd166";
ctx.fill();
}
// A small downward pip marks hostile units β€” a SHAPE cue, so telling friend from foe in a
// melee doesn't rely on the cyan-vs-red colour alone. Friendlies carry no marker.
if (u.owner !== "player") drawEnemyPip(ctx, d.x, d.y + def.radius + 6);
// Veterancy chevrons (docs/improvement-proposals.md "Veterancy ranks"): drawn for EITHER
// side (an enemy veteran's chevrons are exactly the intel a scout brings back, same as the
// enemy pip above), and independent of drawHealthBar's own "hide at full hp" rule β€” a rank
// is a permanent trait, not a damage state, so it stays visible on a topped-off veteran too.
const rank = rankMults(u).rank;
if (rank > 0) drawVeterancyChevrons(ctx, d.x, d.y - def.radius - 13, rank);
drawHealthBar(ctx, d.x, d.y - def.radius - 9, 16, u.hp, u.maxHp, selSet.has(u.id));
}
}
// A small stack of upward chevrons, one per veterancy rank, just above the health bar β€” a
// veteran line reads at a glance without opening its stat panel. Own color (distinct from every
// hull detail/cargo-dot/enemy-pip hue already in play here) so it never blends into whichever
// unit it's stamped over.
const CHEVRON_COLOR = "#a5f3fc";
function drawVeterancyChevrons(ctx, cx, y, rank) {
ctx.strokeStyle = CHEVRON_COLOR;
ctx.lineWidth = 1.4;
for (let i = 0; i < rank; i++) {
const cy = y - i * 4;
ctx.beginPath();
ctx.moveTo(cx - 4, cy + 2);
ctx.lineTo(cx, cy - 2);
ctx.lineTo(cx + 4, cy + 2);
ctx.stroke();
}
}
export function drawEnemyPip(ctx, x, y) {
ctx.beginPath();
ctx.moveTo(x - 4, y - 4);
ctx.lineTo(x + 4, y - 4);
ctx.lineTo(x, y + 1);
ctx.closePath();
ctx.fillStyle = "#f87171";
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = "#05070f";
ctx.stroke();
}
// Worker β€” a small hex-bodied utility pod with two stub grabber arms and a
// sensor "eye", reading as a drone rather than a combatant. Unoriented
// (nothing about gathering/building implies a facing), unlike the two
// combat units below.
function drawWorker(ctx, u, def, color) {
const r = def.radius, cx = u.x, cy = u.y;
pathPoints(ctx, polygonPoints(cx, cy, r, 6, Math.PI / 6));
ctx.fill();
ctx.stroke();
ctx.fillStyle = shade(color, -25);
ctx.fillRect(cx - r - 2.5, cy - 1.5, 2.5, 3);
ctx.fillRect(cx + r, cy - 1.5, 2.5, 3);
ctx.beginPath();
ctx.arc(cx, cy - r * 0.1, r * 0.3, 0, Math.PI * 2);
ctx.fillStyle = DETAIL;
ctx.fill();
}
// Ranger β€” a light, fast recon craft: a slim forward-swept hull ringed by a
// sensor scanner amidships with a lit eye at the nose, so the scout reads as
// "eyes, not guns" β€” distinct from the Skiff's winged dart. Oriented, since it's
// almost always on the move charting the map.
function drawRanger(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, L = r * 1.7, W = r * 0.85, cx = u.x, cy = u.y;
pathOriented(ctx, cx, cy, angle, [
[L, 0],
[-L * 0.35, W],
[-L * 0.7, 0],
[-L * 0.35, -W],
]);
ctx.fill();
ctx.stroke();
ctx.beginPath(); // sensor ring β€” signals its long sight
ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2);
ctx.strokeStyle = DETAIL;
ctx.lineWidth = 1.1;
ctx.stroke();
const [nx, ny] = toWorld(cx, cy, angle, L * 0.5, 0); // lit scanner eye at the nose
ctx.beginPath();
ctx.arc(nx, ny, r * 0.24, 0, Math.PI * 2);
ctx.fillStyle = DETAIL;
ctx.fill();
}
// Colony Ship (Odyssey) β€” a mobile Command Center: the CC's octagonal hull at unit
// scale with a raised central dome so it reads as "a base in transit", and a warm
// engine flare at the stern so its heading is clear. Deploys (engine/colony.js) into
// a real Command Center at its parked spot.
function drawColonyShip(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, cx = u.x, cy = u.y;
const [fx, fy] = toWorld(cx, cy, angle, -r * 1.35, 0); // engine flare behind the hull
ctx.beginPath();
ctx.arc(fx, fy, r * 0.42, 0, Math.PI * 2);
ctx.fillStyle = "#ffb454";
ctx.fill();
pathPoints(ctx, polygonPoints(cx, cy, r, 8, Math.PI / 8)); // octagon hull (echoes the CC)
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = DETAIL;
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.beginPath(); // raised central dome
ctx.arc(cx, cy, r * 0.42, 0, Math.PI * 2);
ctx.fillStyle = shade(color, 20);
ctx.fill();
ctx.strokeStyle = "#05070f";
ctx.lineWidth = 1;
ctx.stroke();
}
// Mender β€” a support drone, not a combatant. A rounded octagonal body carrying
// a bright green medic cross (a fixed heal-green on both sides, so "this one
// heals" reads independent of the friend/foe colour), flanked by two little
// emitter nubs. Unoriented like the Worker β€” it hovers and mends, it doesn't
// charge β€” so nothing about it says "gun", which is exactly the point.
const HEAL_GREEN = "#8ef5b0";
function drawMender(ctx, u, def, color) {
const r = def.radius, cx = u.x, cy = u.y;
pathPoints(ctx, polygonPoints(cx, cy, r, 8, Math.PI / 8));
ctx.fill();
ctx.stroke();
// Twin emitter nubs at the flanks (where the repair beams would emit from).
ctx.fillStyle = shade(color, -25);
ctx.fillRect(cx - r - 2, cy - 1.5, 2.5, 3);
ctx.fillRect(cx + r - 0.5, cy - 1.5, 2.5, 3);
// The medic cross β€” the whole identity of the unit.
const a = r * 0.72, t = r * 0.26;
ctx.fillStyle = HEAL_GREEN;
ctx.fillRect(cx - t / 2, cy - a / 2, t, a);
ctx.fillRect(cx - a / 2, cy - t / 2, a, t);
}
// A last-resort silhouette for any unit type without a bespoke draw β€” a small
// diamond with a lit core. Every current roster type has its own drawUnitShape
// case below (test/render-roster.test.js's smoke test enforces this β€” it fails
// loudly the moment a type's trace matches this fallback's), so this exists
// purely to guarantee the "every entity has a graphical representation"
// invariant for anything added LATER: a new unit can never ship invisible, even
// in the gap before its own bespoke hull lands.
function drawGenericUnit(ctx, u, def, color) {
const r = def.radius, cx = u.x, cy = u.y;
pathPoints(ctx, [[cx, cy - r], [cx + r, cy], [cx, cy + r], [cx - r, cy]]);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, r * 0.3, 0, Math.PI * 2);
ctx.fillStyle = DETAIL;
ctx.fill();
}
// Skiff β€” fast, ranged, cheap: drawn as a slim dart with swept wingtips and
// a lit engine tail, pointing the way it's moving so a mixed army reads at
// a glance and hints at facing mid-fight.
function drawSkiff(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, L = r * 1.6, W = r * 1.1;
const cx = u.x, cy = u.y;
pathOriented(ctx, cx, cy, angle, [
[L, 0],
[-L * 0.3, W],
[-L * 0.6, W * 0.35],
[-L * 0.75, 0],
[-L * 0.6, -W * 0.35],
[-L * 0.3, -W],
]);
ctx.fill();
ctx.stroke();
ctx.fillStyle = DETAIL;
for (const side of [1, -1]) {
const [ex, ey] = toWorld(cx, cy, angle, -L * 0.7, side * W * 0.35);
ctx.beginPath();
ctx.arc(ex, ey, r * 0.16, 0, Math.PI * 2);
ctx.fill();
}
}
// Bastion β€” slow, tanky, short-ranged, bonus damage vs Skiffs: drawn as a
// heavier hull with side turret pods and twin nose cannons, so it reads
// as armored muscle rather than the Skiff's slim dart even at a glance.
function drawBastion(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, L = r * 1.3, W = r * 1.0;
const cx = u.x, cy = u.y;
pathOriented(ctx, cx, cy, angle, [
[L, 0],
[L * 0.45, W],
[-L * 0.6, W * 0.85],
[-L * 0.6, -W * 0.85],
[L * 0.45, -W],
]);
ctx.fill();
ctx.stroke();
ctx.fillStyle = shade(color, -25);
for (const side of [1, -1]) {
const [tx, ty] = toWorld(cx, cy, angle, -L * 0.05, side * W * 0.95);
ctx.beginPath();
ctx.arc(tx, ty, r * 0.3, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
ctx.fillStyle = DETAIL;
for (const side of [1, -1]) {
const [nx, ny] = toWorld(cx, cy, angle, L * 0.85, side * W * 0.25);
ctx.beginPath();
ctx.arc(nx, ny, r * 0.16, 0, Math.PI * 2);
ctx.fill();
}
}
// Lancer β€” long-ranged, armor-piercing, squishier than Bastion: drawn as a
// slender javelin hull with a lit lance-tip and small tail fins, reading as
// a precision skirmisher distinct from Skiff's stubby dart and Bastion's
// armored bulk.
function drawLancer(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, L = r * 2.0, W = r * 0.55;
const cx = u.x, cy = u.y;
pathOriented(ctx, cx, cy, angle, [
[L, 0],
[L * 0.2, W],
[-L * 0.7, W * 0.4],
[-L, 0],
[-L * 0.7, -W * 0.4],
[L * 0.2, -W],
]);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = DETAIL;
ctx.lineWidth = 1;
const [shaftX1, shaftY1] = toWorld(cx, cy, angle, L * 0.9, 0);
const [shaftX2, shaftY2] = toWorld(cx, cy, angle, -L * 0.3, 0);
ctx.beginPath();
ctx.moveTo(shaftX1, shaftY1);
ctx.lineTo(shaftX2, shaftY2);
ctx.stroke();
ctx.fillStyle = DETAIL;
const [tipX, tipY] = toWorld(cx, cy, angle, L, 0);
ctx.beginPath();
ctx.arc(tipX, tipY, r * 0.14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = shade(color, -25);
for (const side of [1, -1]) {
const [fx1, fy1] = toWorld(cx, cy, angle, -L * 0.45, side * W * 0.3);
const [fx2, fy2] = toWorld(cx, cy, angle, -L * 0.65, side * W * 0.55);
const [fx3, fy3] = toWorld(cx, cy, angle, -L * 0.85, side * W * 0.25);
ctx.beginPath();
ctx.moveTo(fx1, fy1);
ctx.lineTo(fx2, fy2);
ctx.lineTo(fx3, fy3);
ctx.closePath();
ctx.fill();
}
}
// Breacher β€” a wide, low siege chassis CARRYING an oversized gun, where the
// Lancer's whole hull instead IS its javelin. The barrel overhangs the hull
// well past the nose and two recoil spades brace the rear, so it reads as
// artillery hauling a cannon rather than a fighter.
function drawBreacher(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, L = r * 1.2, W = r * 0.9;
const cx = u.x, cy = u.y;
pathOriented(ctx, cx, cy, angle, [
[L * 0.5, W],
[L * 0.5, -W],
[-L * 0.7, -W * 0.8],
[-L * 0.7, W * 0.8],
]);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = shade(color, -25);
ctx.lineWidth = r * 0.3;
const [bx1, by1] = toWorld(cx, cy, angle, -L * 0.2, 0);
const [bx2, by2] = toWorld(cx, cy, angle, L * 1.9, 0);
ctx.beginPath();
ctx.moveTo(bx1, by1);
ctx.lineTo(bx2, by2);
ctx.stroke();
ctx.fillStyle = shade(color, -25);
for (const side of [1, -1]) {
pathOriented(ctx, cx, cy, angle, [
[-L * 0.6, side * W * 0.45],
[-L * 0.6, side * W * 0.8],
[-L, side * W * 0.6],
]);
ctx.fill();
}
ctx.fillStyle = DETAIL;
const [tipX, tipY] = toWorld(cx, cy, angle, L * 1.9, 0);
ctx.beginPath();
ctx.arc(tipX, tipY, r * 0.16, 0, Math.PI * 2);
ctx.fill();
}
// Dreadnought β€” the Tier-3 capital ship: a big, broad, armoured hull with a
// spinal cannon, four side batteries and a bright command bridge, so it reads
// as a fortress that dwarfs the line units even at a glance.
function drawDreadnought(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, L = r * 1.5, W = r * 1.15;
const cx = u.x, cy = u.y;
// Broad angular hull.
pathOriented(ctx, cx, cy, angle, [
[L, 0],
[L * 0.55, W],
[-L * 0.65, W],
[-L, W * 0.5],
[-L, -W * 0.5],
[-L * 0.65, -W],
[L * 0.55, -W],
]);
ctx.fill();
ctx.stroke();
// Spinal cannon down the centreline.
ctx.strokeStyle = shade(color, -30);
ctx.lineWidth = r * 0.35;
const [sx1, sy1] = toWorld(cx, cy, angle, -L * 0.4, 0);
const [sx2, sy2] = toWorld(cx, cy, angle, L * 1.35, 0);
ctx.beginPath(); ctx.moveTo(sx1, sy1); ctx.lineTo(sx2, sy2); ctx.stroke();
// Four side battery pods.
ctx.fillStyle = shade(color, -25);
for (const side of [1, -1]) {
for (const fx of [0.15, -0.5]) {
const [px, py] = toWorld(cx, cy, angle, L * fx, side * W * 0.8);
ctx.beginPath(); ctx.arc(px, py, r * 0.26, 0, Math.PI * 2); ctx.fill();
}
}
// Command bridge glow.
ctx.fillStyle = DETAIL;
const [bx, by] = toWorld(cx, cy, angle, -L * 0.25, 0);
ctx.beginPath(); ctx.arc(bx, by, r * 0.32, 0, Math.PI * 2); ctx.fill();
}
// Wraith β€” the gas-fuelled glass cannon: a long, forward-swept interceptor with
// wingtips raked back and a hot fusion core amidships, so it reads as the
// fastest, most dangerous, most fragile thing on the field β€” all engine and gun,
// no armour.
const FUSION = "#ffd166"; // Helium-3 core glow (warm) β€” distinct from the Mender's heal-green
function drawWraith(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, L = r * 1.9, W = r * 1.2;
const cx = u.x, cy = u.y;
pathOriented(ctx, cx, cy, angle, [
[L, 0],
[-L * 0.15, W * 0.5],
[-L * 0.7, W], // raked-back wingtip
[-L * 0.5, W * 0.2],
[-L * 0.85, 0],
[-L * 0.5, -W * 0.2],
[-L * 0.7, -W],
[-L * 0.15, -W * 0.5],
]);
ctx.fill();
ctx.stroke();
ctx.fillStyle = FUSION; // fusion core amidships
const [gx, gy] = toWorld(cx, cy, angle, -L * 0.08, 0);
ctx.beginPath(); ctx.arc(gx, gy, r * 0.3, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = DETAIL; // lit nose
const [nx, ny] = toWorld(cx, cy, angle, L * 0.6, 0);
ctx.beginPath(); ctx.arc(nx, ny, r * 0.16, 0, Math.PI * 2); ctx.fill();
}
// Aegis β€” the ice-armoured wall: a broad, blocky hull wider than it is long,
// carrying a thick frontal armour plate and only a token gun, so it reads as a
// shield on legs β€” the anvil the Wraith is the hammer to.
function drawAegis(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, L = r * 1.1, W = r * 1.25;
const cx = u.x, cy = u.y;
pathOriented(ctx, cx, cy, angle, [
[L, W * 0.6],
[L, -W * 0.6],
[-L * 0.7, -W],
[-L, -W * 0.4],
[-L, W * 0.4],
[-L * 0.7, W],
]);
ctx.fill();
ctx.stroke();
// Thick frontal armour plate, standing just off the nose.
ctx.strokeStyle = shade(color, -30);
ctx.lineWidth = r * 0.5;
const [f1x, f1y] = toWorld(cx, cy, angle, L * 1.05, W * 0.8);
const [f2x, f2y] = toWorld(cx, cy, angle, L * 1.05, -W * 0.8);
ctx.beginPath(); ctx.moveTo(f1x, f1y); ctx.lineTo(f2x, f2y); ctx.stroke();
ctx.fillStyle = DETAIL;
const [cxx, cyy] = toWorld(cx, cy, angle, -L * 0.2, 0);
ctx.beginPath(); ctx.arc(cxx, cyy, r * 0.28, 0, Math.PI * 2); ctx.fill();
}
// Colossus β€” the relic siege engine: a heavy hexagonal chassis behind an
// enormous barrel that overhangs far past the nose (the longest reach on the
// field), with an ancient-tech violet muzzle and core, so it reads as a slow,
// fragile-for-its-size superweapon that must be screened.
const RELIC = "#c4b5fd"; // ancient-tech violet
function drawColossus(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, L = r * 1.3, W = r * 1.05;
const cx = u.x, cy = u.y;
pathOriented(ctx, cx, cy, angle, [
[L * 0.7, W],
[L, 0],
[L * 0.7, -W],
[-L * 0.8, -W],
[-L, 0],
[-L * 0.8, W],
]);
ctx.fill();
ctx.stroke();
// The oversized barrel β€” reaches further than any other unit's gun.
ctx.strokeStyle = shade(color, -30);
ctx.lineWidth = r * 0.32;
const [b1x, b1y] = toWorld(cx, cy, angle, -L * 0.3, 0);
const [b2x, b2y] = toWorld(cx, cy, angle, L * 2.4, 0);
ctx.beginPath(); ctx.moveTo(b1x, b1y); ctx.lineTo(b2x, b2y); ctx.stroke();
ctx.fillStyle = RELIC; // muzzle + reactor core in relic-violet
const [tx, ty] = toWorld(cx, cy, angle, L * 2.4, 0);
ctx.beginPath(); ctx.arc(tx, ty, r * 0.2, 0, Math.PI * 2); ctx.fill();
const [cxx, cyy] = toWorld(cx, cy, angle, -L * 0.15, 0);
ctx.beginPath(); ctx.arc(cxx, cyy, r * 0.3, 0, Math.PI * 2); ctx.fill();
}
// Leviathan β€” the Strategic-tier endgame flagship: a long twin-spine super-capital hull, broader
// and longer than the Dreadnought (whose 4-battery/single-spine design it deliberately outdoes in
// every cue), so the last ship you ever build is unmistakably the biggest thing on the field. The
// stern tapers into two trailing spine prongs around a V-notch β€” the "twin-spine" silhouette β€”
// with a stern engine array glowing in the notch between them (a cue nothing else on the roster
// has), twin spinal cannon lines running the hull's length either side of the centerline (doubling
// the Dreadnought's single spinal cannon), six battery pods (half again the Dreadnought's four),
// and a bigger, outlined command bridge.
function drawLeviathan(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, L = r * 2.0, W = r * 1.3;
const cx = u.x, cy = u.y;
// Broad hull, tapering aft into twin trailing spines around a stern notch.
pathOriented(ctx, cx, cy, angle, [
[L, 0],
[L * 0.55, W * 0.85],
[-L * 0.5, W * 0.85],
[-L * 0.85, W * 0.55], // upper spine root
[-L * 1.15, W * 0.5], // upper spine tip
[-L * 0.75, W * 0.08], // notch inner, upper
[-L * 0.75, -W * 0.08], // notch inner, lower
[-L * 1.15, -W * 0.5], // lower spine tip
[-L * 0.85, -W * 0.55], // lower spine root
[-L * 0.5, -W * 0.85],
[L * 0.55, -W * 0.85],
]);
ctx.fill();
ctx.stroke();
// Twin spinal cannons β€” a pair of lines flanking the centreline the full hull length, doubling
// the Dreadnought's single spinal cannon.
ctx.strokeStyle = shade(color, -30);
ctx.lineWidth = r * 0.2;
for (const off of [1, -1]) {
const [x1, y1] = toWorld(cx, cy, angle, -L * 0.7, off * W * 0.16);
const [x2, y2] = toWorld(cx, cy, angle, L * 1.4, off * W * 0.16);
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
}
// Six battery pods (three a side) β€” half again the Dreadnought's four, outlined as well as
// filled so they read as mounted turrets rather than flat dots.
ctx.fillStyle = shade(color, -25);
ctx.strokeStyle = shade(color, -40);
ctx.lineWidth = 1;
for (const side of [1, -1]) {
for (const fx of [0.35, -0.05, -0.45]) {
const [px, py] = toWorld(cx, cy, angle, L * fx, side * W * 0.78);
ctx.beginPath(); ctx.arc(px, py, r * 0.22, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
}
// Lit command bridge β€” bigger than the Dreadnought's, and outlined for extra presence.
ctx.fillStyle = DETAIL;
const [bx, by] = toWorld(cx, cy, angle, -L * 0.12, 0);
ctx.beginPath(); ctx.arc(bx, by, r * 0.34, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = shade(color, -30);
ctx.lineWidth = 1.5;
ctx.stroke();
// Stern engine array β€” three glow points nested in the notch between the twin spines, a cue
// unique to the Leviathan on the current roster.
ctx.fillStyle = DETAIL;
for (const off of [0, 0.42, -0.42]) {
const [ex, ey] = toWorld(cx, cy, angle, -L * 0.95, off * W);
ctx.beginPath(); ctx.arc(ex, ey, r * 0.13, 0, Math.PI * 2); ctx.fill();
}
}
// Freighter β€” a slow, blocky cargo hauler for the convoy scenarios: a wide hull
// stacked with darker container blocks and a lit bridge at the nose, so it reads
// unmistakably as a civilian freighter to protect, not a warship.
function drawFreighter(ctx, u, def, color) {
const angle = updateFacing(u);
const r = def.radius, L = r * 1.5, W = r * 0.95;
const cx = u.x, cy = u.y;
pathOriented(ctx, cx, cy, angle, [
[L, W * 0.45], [L, -W * 0.45],
[L * 0.6, -W], [-L, -W],
[-L, W], [L * 0.6, W],
]);
ctx.fill();
ctx.stroke();
// Cargo containers stacked down the hull β€” gold when the freighter is actually laden (a player
// hand-loaded hold, engine/galaxy.js freight), dim when empty, so a loaded ship reads at a glance.
const laden = !!(u.freight && Object.keys(u.freight).length);
ctx.fillStyle = laden ? "#e8b23a" : shade(color, -30);
for (const fx of [0.35, -0.05, -0.45]) {
pathOriented(ctx, cx, cy, angle, [
[L * fx + r * 0.18, W * 0.72], [L * fx + r * 0.18, -W * 0.72],
[L * fx - r * 0.22, -W * 0.72], [L * fx - r * 0.22, W * 0.72],
]);
ctx.fill();
}
ctx.fillStyle = DETAIL; // bridge light at the nose
const [bx, by] = toWorld(cx, cy, angle, L * 0.82, 0);
ctx.beginPath(); ctx.arc(bx, by, r * 0.18, 0, Math.PI * 2); ctx.fill();
}
// Helium Bomb β€” a spiked mine-like orb (engine/entities.js UNITS.heliumbomb, engine/bomb.js
// for the arm/detonate logic). Unoriented, like the Mender: it doesn't face anywhere, it just
// sits there being dangerous. UNARMED reads as inert β€” short spikes, a dark dead core β€” safe
// to park anywhere. ARMED has to be unmistakable at a glance, since walking anything (even a
// friendly) into it is the whole hazard: longer, more numerous spikes AND a hot glowing core,
// so the cue survives colourblindness on shape alone, not just the colour swap.
function drawHeliumBomb(ctx, u, def, color) {
const r = def.radius, cx = u.x, cy = u.y;
const armed = !!u.armed;
const spikes = armed ? 10 : 6;
const spikeLen = armed ? r * 0.65 : r * 0.35;
ctx.strokeStyle = armed ? "#ff5e3d" : DETAIL;
ctx.lineWidth = 2;
for (let i = 0; i < spikes; i++) {
const a = (i / spikes) * Math.PI * 2;
const [ix, iy] = toWorld(cx, cy, a, r * 0.9, 0);
const [ox, oy] = toWorld(cx, cy, a, r * 0.9 + spikeLen, 0);
ctx.beginPath();
ctx.moveTo(ix, iy);
ctx.lineTo(ox, oy);
ctx.stroke();
}
// The body: the owner's colour, so it still reads as a friendly/enemy unit at a glance,
// armed or not.
ctx.beginPath();
ctx.arc(cx, cy, r * 0.75, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = DETAIL;
ctx.lineWidth = 1.5;
ctx.stroke();
// The core: dark and inert unarmed, a bright hot glow armed β€” the primary "is this thing
// live right now" signal, independent of the spike-count cue above.
ctx.beginPath();
ctx.arc(cx, cy, r * 0.35, 0, Math.PI * 2);
ctx.fillStyle = armed ? "#ff8a3d" : "#1a2233";
ctx.fill();
if (armed) {
ctx.strokeStyle = "#fff3c4";
ctx.lineWidth = 1;
ctx.stroke();
}
}