Spaces:
Paused
Paused
Claude
Claude Opus 5
Tier 3g: name the three render rules that were copied instead
b870df3 unverified Download renderUnits.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 27.9 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/renderUnits.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities/renderUnits.js
-
curl -L -o renderUnits.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/renderUnits.js
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. | |
| ============================================================ */ | |
| ; | |
| 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(); | |
| } | |
| } | |