Spaces:
Paused
Paused
Claude
Claude Opus 5
Tier 3g: name the three render rules that were copied instead
b870df3 unverified Download renderBuildings.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 40.2 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/renderBuildings.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities/renderBuildings.js
-
curl -L -o renderBuildings.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/renderBuildings.js
40.2 kB
| /* ============================================================ | |
| Building rendering β the per-type structure silhouettes (Command Center, | |
| Barracks, Refinery, factories, Turret, β¦), their health-bar and store-bar | |
| overlays, and the Odyssey selection cues (jump-staging ring, power grid). The | |
| shape dispatch (drawBuildingShape) is shared with the HUD's button icons | |
| (render.js spriteIcon). Split out of render.js; drawFrame calls drawBuildings / | |
| drawBuildingBars / drawJumpStaging / drawPowerGrid. | |
| ============================================================ */ | |
| ; | |
| import { COM, RECIPES } from "./data.js"; | |
| import { BUILDINGS, storeCapOf, storeTotal } from "./engine/entities.js"; | |
| import { JUMP_LOAD_RADIUS } from "./engine/galaxy.js"; | |
| import { POWER_TIERS, powerThrottle, buildingConcern, recipeOf } from "./engine/industry.js"; | |
| import { hashStr } from "./engine/rng.js"; | |
| import { DETAIL, facing, shade, hexA, polygonPoints, pathPoints, inView, drawHealthBar, centeredText, hiddenByFog } from "./renderShared.js"; | |
| import { drawEnemyPip } from "./renderUnits.js"; | |
| /* ---------- buildings ---------- */ | |
| // The shape dispatch, factored out of drawBuildings so the HUD's button icons | |
| // (spriteIcon) render the exact same silhouette the map does. The turret (aims at its live | |
| // target) and every ELECTRIFIABLE building (Command Center / Barracks / Habitat / Star Dock β | |
| // their lit-window cue below) read `state`; an icon passes a stub state with empty Maps and no | |
| // `players`/`time`, which is safe here because electrifiedLight bails out before touching either | |
| // unless the building it's drawing is actually electrified β and an icon's stub building never is. | |
| export function drawBuildingShape(ctx, state, b, color) { | |
| if (b.type === "command") drawCommandCenter(ctx, state, b, color); | |
| else if (b.type === "barracks") drawBarracks(ctx, state, b, color); | |
| else if (b.type === "refinery") drawRefinery(ctx, b, color); | |
| else if (b.type === "foundry") drawFoundry(ctx, b, color); | |
| else if (b.type === "arsenal") drawArsenal(ctx, b, color); | |
| else if (b.type === "turret") drawTurret(ctx, state, b, color); | |
| else if (b.type === "habitat") drawHabitat(ctx, state, b, color); | |
| else if (b.type === "market") drawMarket(ctx, b, color); | |
| else if (b.type === "spaceport") drawSpaceport(ctx, b, color); | |
| else if (b.type === "reactor") drawReactor(ctx, b, color); | |
| else if (b.type === "combustor") drawCombustor(ctx, b, color); | |
| else if (b.type === "biomassreactor") drawBiomassReactor(ctx, b, color); | |
| else if (factoryGlyph(b.type)) drawFactory(ctx, state, b, color); // Odyssey factories + datacenter/stardock/etc: stamp a function glyph | |
| else drawGenericBuilding(ctx, b, color); // any future building still gets a silhouette, never an invisible blank | |
| } | |
| // A small lit cue on an ELECTRIFIED building (Odyssey: Command Center / Barracks / Habitat / Star | |
| // Dock wired into the power grid for +30% β engine/entities.js isElectrifiable, engine/industry.js | |
| // electrifyBoost). Steady gold while the grid can carry its full load; FLICKERING β the same | |
| // throttle that scales the boost itself down, so the light is an honest read of "is this actually | |
| // getting its +30% right now" β whenever it can't, including a dead grid (throttle 0). The flicker | |
| // is deterministic in state.time (this is presentation, not sim, so that's only for a stable | |
| // screenshot/replay-scrub, not a determinism requirement) with a per-building phase (hashStr(b.id)) | |
| // so several flickering lights don't blink in lockstep. Draws nothing for a non-electrified | |
| // building β the pre-existing look (a plain DETAIL-coloured fixture, or nothing at all) is | |
| // untouched by this. | |
| function electrifiedLight(ctx, state, b, x, y, r) { | |
| if (!b.electrified) return; | |
| let on = true; | |
| const throttle = powerThrottle(state, b.owner); | |
| if (throttle < 0.999) { | |
| const t = state.time * 9 + (hashStr(b.id) % 100); | |
| on = Math.sin(t) + Math.sin(t * 2.7 + 1.3) * 0.5 > 0.15; // mostly lit, brief irregular dark beats | |
| } | |
| ctx.beginPath(); | |
| ctx.arc(x, y, r, 0, Math.PI * 2); | |
| ctx.fillStyle = on ? "#ffd166" : "#3a2f14"; // lit gold, or a dim, currently-unlit fixture | |
| ctx.fill(); | |
| } | |
| // observerMode (default false β every existing caller/test stays byte-identical): bypasses the | |
| // fog gate below, same reasoning as renderUnits.js's drawUnits. | |
| export function drawBuildings(ctx, state, view, observerMode = false) { | |
| for (const b of state.buildings.values()) { | |
| if (view && !inView(view, b.x, b.y, b.radius + 12)) continue; // off-screen (pad for the hp bar above it) | |
| if (hiddenByFog(state, b, b.x, b.y, observerMode)) continue; | |
| const color = state.players[b.owner].color; | |
| ctx.globalAlpha = b.constructing ? 0.5 : b.recycling ? 0.65 : 1; // ghosted while going up OR coming down | |
| drawBuildingShape(ctx, state, b, color); | |
| ctx.globalAlpha = 1; | |
| // A foe marker under every enemy building, matching the one under enemy units: | |
| // friend/foe is then a SHAPE cue, not colour alone, so a colourblind player can | |
| // tell an enemy base from their own without relying on the cyan-vs-red hue. | |
| if (b.owner !== "player") drawEnemyPip(ctx, b.x, b.y + b.radius + 8); | |
| } | |
| } | |
| // Building health bars, drawn in a LATER pass than every hull (see drawFrame) so a ship | |
| // passing over a base no longer paints out the base's bar. `selSet` is the current-selection | |
| // Set β computed ONCE per frame by the caller (drawFrame) and threaded down here, rather than | |
| // each of drawBuildingBars/drawJumpStaging/drawPowerGrid/drawUnits reallocating their own | |
| // `new Set(state.selection)` sixty times a second. | |
| export function drawBuildingBars(ctx, state, view, selSet, observerMode = false) { | |
| for (const b of state.buildings.values()) { | |
| if (view && !inView(view, b.x, b.y, b.radius + 12)) continue; | |
| if (hiddenByFog(state, b, b.x, b.y, observerMode)) continue; | |
| drawHealthBar(ctx, b.x, b.y - b.radius - 8, b.radius * 2, b.hp, b.maxHp, selSet.has(b.id)); | |
| drawStoreBar(ctx, b); // a producer's output-buffer gauge, under the hull | |
| drawConcernBadge(ctx, state, b); // paused / stalled / throttled flag, own producers only | |
| } | |
| } | |
| // The paused/stalled/throttled colour legend, shared by drawConcernBadge and any HUD legend that | |
| // wants the exact same read the map uses. "paused" has no glyph string: the Unicode pause symbol | |
| // (βΈ) renders as a blank tofu box in Chromium's default sans-serif (no glyph in that font), so its | |
| // badge is hand-drawn (two bars) instead of text β see drawConcernBadge. | |
| const CONCERN_STYLE = { | |
| paused: { bg: "#2c3560", fg: "#aab4e0" }, // muted β a deliberate player choice, not a problem | |
| bad: { bg: "#5c1f1f", fg: "#f87171", glyph: "!" }, // stalled outright β nothing is happening | |
| warn: { bg: "#7a5b12", fg: "#fbbf24", glyph: "β‘" }, // running, just not at full rate | |
| }; | |
| // A small badge at a producer's top-right corner flagging "is this actually doing its job right | |
| // now" (engine/industry.js buildingConcern) β paused (muted, a deliberate choice), stalled (red β | |
| // no Power/fuel, starved of an input, or its output buffer is brimming), or power-throttled (gold β | |
| // running under-rate). Own producers only: this exposes internal economic state (what's paused, what's | |
| // starved) that fog-of-war otherwise hides about an opponent's base, matching drawStoreBar's | |
| // player-only scope. Silent for anything running fine, so a healthy base stays uncluttered. | |
| function drawConcernBadge(ctx, state, b) { | |
| if (b.owner !== "player") return; | |
| const concern = buildingConcern(state, b); | |
| if (!concern) return; | |
| const style = CONCERN_STYLE[concern.level]; | |
| const r = 6.5, x = b.x + b.radius + 2, y = b.y - b.radius - 8; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, r, 0, Math.PI * 2); | |
| ctx.fillStyle = style.bg; | |
| ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| ctx.fillStyle = style.fg; | |
| if (style.glyph) { | |
| centeredText(ctx, style.glyph, x, y + 0.5, "bold 9px sans-serif"); | |
| } else { | |
| ctx.fillRect(x - 3, y - 2.5, 2, 5); // paused: two small bars, font-independent | |
| ctx.fillRect(x + 1, y - 2.5, 2, 5); | |
| } | |
| } | |
| // A producer's finite output buffer (engine/haul.js) as a gauge UNDER the hull: how | |
| // full it is, gold while there's room, red when it's brimming (production has stalled | |
| // until a worker hauls it off). Only drawn for own producers with something in the | |
| // buffer, so it reads as live logistics pressure without cluttering the field. | |
| function drawStoreBar(ctx, b) { | |
| const cap = storeCapOf(b.type); | |
| if (cap <= 0 || b.owner !== "player") return; | |
| const total = storeTotal(b); | |
| if (total <= 0) return; | |
| const pct = Math.min(1, total / cap); | |
| const w = b.radius * 2, x = b.x - b.radius, y = b.y + b.radius + 4; | |
| ctx.fillStyle = "#243162"; | |
| ctx.fillRect(x, y, w, 3); | |
| ctx.fillStyle = pct >= 0.999 ? "#f87171" : "#fbbf24"; // red = full/stalled, gold = filling | |
| ctx.fillRect(x, y, w * pct, 3); | |
| } | |
| // The jump staging area around a SELECTED player Spaceport: a dashed ring at | |
| // JUMP_LOAD_RADIUS with a faint fill (the disc whose units ride along on a jump β | |
| // engine/galaxy.js stagedRiders), and a highlight on each unit currently inside it. | |
| // Answers "where do I park units so they come with me?" β drawn only when the | |
| // Spaceport is selected, so it never clutters the map otherwise. | |
| export function drawJumpStaging(ctx, state, view, selSet) { | |
| for (const b of state.buildings.values()) { | |
| if (b.type !== "spaceport" || b.owner !== "player" || b.constructing || !selSet.has(b.id)) continue; | |
| if (view && !inView(view, b.x, b.y, JUMP_LOAD_RADIUS + 8)) continue; | |
| ctx.save(); | |
| ctx.beginPath(); | |
| ctx.arc(b.x, b.y, JUMP_LOAD_RADIUS, 0, Math.PI * 2); | |
| ctx.fillStyle = "rgba(120, 200, 255, 0.06)"; // faint disc so the AREA reads | |
| ctx.fill(); | |
| ctx.setLineDash([9, 6]); | |
| ctx.lineWidth = 2; | |
| ctx.strokeStyle = "rgba(120, 200, 255, 0.7)"; | |
| ctx.stroke(); | |
| ctx.restore(); | |
| // Ring the units that would ride along, so it's clear WHICH entities jump. | |
| for (const u of state.units.values()) { | |
| if (u.owner !== "player") continue; | |
| if (Math.hypot(u.x - b.x, u.y - b.y) > JUMP_LOAD_RADIUS) continue; | |
| ctx.beginPath(); | |
| ctx.arc(u.x, u.y, (u.radius || 6) + 3, 0, Math.PI * 2); | |
| ctx.strokeStyle = "rgba(120, 200, 255, 0.85)"; | |
| ctx.lineWidth = 1.5; | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| // The grid-efficiency tier palette (engine/industry.js POWER_TIERS): the closer a | |
| // factory/rig sits to a Reactor the cheaper it is to power, so these zones tell the | |
| // player WHERE to drop one. Green (on-grid, no loss) β red (isolated, worst). Exported: | |
| // renderEffects.js's drawGhostPowerCue reads it too, for the same tier coloring on a | |
| // build-placement ghost. | |
| export const POWER_TIER_COLOR = { linked: "#4ade80", near: "#a3e635", far: "#fbbf24", isolated: "#f87171" }; | |
| // Concentric efficiency zones around one power source at (rx, ry): a faint tinted disc per | |
| // finite band (painted largest-first so each inner band shows its own hue) with a dashed | |
| // boundary ring. `scale` is the source's powerRange β a short-range Generator's zones shrink, | |
| // so its rings sit tighter than a Reactor's. Shared by the selected-source overlay (below) and | |
| // renderEffects.js's build-placement ghost cue β exported for that second caller. | |
| export function drawReactorBands(ctx, rx, ry, scale = 1) { | |
| const bands = POWER_TIERS.filter(t => Number.isFinite(t.max)); | |
| ctx.save(); | |
| for (let i = bands.length - 1; i >= 0; i--) { // largest radius first, so inner hues win | |
| const t = bands[i], col = POWER_TIER_COLOR[t.name]; | |
| ctx.beginPath(); | |
| ctx.arc(rx, ry, t.max * scale, 0, Math.PI * 2); | |
| ctx.fillStyle = hexA(col, 0.05); | |
| ctx.fill(); | |
| } | |
| ctx.setLineDash([8, 7]); | |
| ctx.lineWidth = 1.5; | |
| for (const t of bands) { | |
| ctx.beginPath(); | |
| ctx.arc(rx, ry, t.max * scale, 0, Math.PI * 2); | |
| ctx.strokeStyle = hexA(POWER_TIER_COLOR[t.name], 0.55); | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| } | |
| // The power-grid overlay around a SELECTED player power source (Reactor or Combustion Generator): | |
| // its efficiency zones, so a player can see the on-grid / near / far bands β scaled to the source's | |
| // reach β before placing a factory or rig near it. Drawn only for a selected source (mirrors the | |
| // Spaceport's jump-staging ring), so it never clutters the field otherwise. | |
| export function drawPowerGrid(ctx, state, view, selSet) { | |
| const outer = POWER_TIERS[POWER_TIERS.length - 2].max; | |
| for (const b of state.buildings.values()) { | |
| const def = BUILDINGS[b.type]; | |
| if (!(def && def.energyGrants > 0) || b.owner !== "player" || b.constructing || !selSet.has(b.id)) continue; | |
| const scale = def.powerRange || 1; | |
| if (view && !inView(view, b.x, b.y, outer * scale + 8)) continue; | |
| drawReactorBands(ctx, b.x, b.y, scale); | |
| } | |
| } | |
| // Command Center β the base's biggest, most "important-looking" structure: | |
| // an octagonal hull, a raised central dome, four corner struts and a | |
| // blinking antenna, so it reads as the hub building even before checking HP. | |
| function drawCommandCenter(ctx, state, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y; | |
| pathPoints(ctx, polygonPoints(cx, cy, r, 8, Math.PI / 8)); | |
| ctx.fillStyle = color; | |
| ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Loop-invariant: the same colour for all four struts, so set it once instead of per iteration. | |
| ctx.fillStyle = shade(color, -25); | |
| for (const [x, y] of polygonPoints(cx, cy, r * 0.92, 4, Math.PI / 4)) { | |
| ctx.fillRect(x - 2.5, y - 2.5, 5, 5); | |
| } | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, r * 0.5, 0, Math.PI * 2); | |
| ctx.fillStyle = shade(color, 20); | |
| ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; | |
| ctx.lineWidth = 1.5; | |
| ctx.stroke(); | |
| ctx.strokeStyle = DETAIL; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, cy - r * 0.85); | |
| ctx.lineTo(cx, cy - r * 1.2); | |
| ctx.stroke(); | |
| if (b.electrified) { | |
| electrifiedLight(ctx, state, b, cx, cy - r * 1.2, 2.2); | |
| } else { | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy - r * 1.2, 2, 0, Math.PI * 2); | |
| ctx.fillStyle = DETAIL; | |
| ctx.fill(); | |
| } | |
| // The anchored Capital (engine/galaxy.js upgradeToCapital) wears a gold ring, so a | |
| // fortified, non-jumping Capital reads apart from a normal Command Center at a glance. | |
| if (b.capital) { | |
| pathPoints(ctx, polygonPoints(cx, cy, r * 1.3, 8, Math.PI / 8)); | |
| ctx.strokeStyle = "#f2c14e"; | |
| ctx.lineWidth = 2.5; | |
| ctx.stroke(); | |
| } | |
| } | |
| // Barracks β an angular bunker (a "home plate" silhouette with a pointed | |
| // front) with hangar-door stripes and a radar dish, distinct from the | |
| // Command Center's rounded dome and the Refinery's cylindrical tanks. | |
| function drawBarracks(ctx, state, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y, w = r * 1.9, h = r * 1.6; | |
| pathPoints(ctx, [ | |
| [cx - w / 2, cy - h / 2], | |
| [cx + w / 2, cy - h / 2], | |
| [cx + w / 2, cy + h * 0.05], | |
| [cx, cy + h / 2], | |
| [cx - w / 2, cy + h * 0.05], | |
| ]); | |
| ctx.fillStyle = color; | |
| ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| ctx.fillStyle = shade(color, -25); | |
| ctx.fillRect(cx - w * 0.28, cy - h * 0.4, w * 0.16, h * 0.55); | |
| ctx.fillRect(cx + w * 0.12, cy - h * 0.4, w * 0.16, h * 0.55); | |
| ctx.strokeStyle = DETAIL; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx + w * 0.4, cy - h / 2); | |
| ctx.lineTo(cx + w * 0.48, cy - h * 0.75); | |
| ctx.stroke(); | |
| if (b.electrified) { | |
| electrifiedLight(ctx, state, b, cx + w * 0.48, cy - h * 0.75, 2.2); | |
| } else { | |
| ctx.beginPath(); | |
| ctx.arc(cx + w * 0.48, cy - h * 0.75, 2, 0, Math.PI * 2); | |
| ctx.fillStyle = DETAIL; | |
| ctx.fill(); | |
| } | |
| } | |
| // Refinery β a low industrial base with two cylindrical storage tanks | |
| // (each given a highlight stripe to read as round, not just circular | |
| // blobs) joined by a connecting pipe. | |
| function drawRefinery(ctx, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y, w = r * 1.7, h = r * 0.9; | |
| ctx.fillStyle = shade(color, -15); | |
| ctx.fillRect(cx - w / 2, cy + h * 0.05, w, h * 0.55); | |
| ctx.strokeStyle = "#05070f"; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(cx - w / 2, cy + h * 0.05, w, h * 0.55); | |
| ctx.strokeStyle = shade(color, -10); | |
| ctx.lineWidth = r * 0.35; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx - w * 0.28, cy); | |
| ctx.lineTo(cx + w * 0.28, cy); | |
| ctx.stroke(); | |
| const tankR = r * 0.5; | |
| for (const tx of [cx - w * 0.28, cx + w * 0.28]) { | |
| ctx.beginPath(); | |
| ctx.arc(tx, cy - h * 0.1, tankR, 0, Math.PI * 2); | |
| ctx.fillStyle = color; | |
| ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; | |
| ctx.lineWidth = 1.5; | |
| ctx.stroke(); | |
| ctx.strokeStyle = DETAIL; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(tx - tankR * 0.3, cy - h * 0.1 - tankR * 0.6); | |
| ctx.lineTo(tx - tankR * 0.3, cy - h * 0.1 + tankR * 0.6); | |
| ctx.stroke(); | |
| } | |
| } | |
| // Habitat β a small residential dome: a squat foundation slab, a half-dome | |
| // roof and a row of lit windows, so a supply building reads as "people live | |
| // here" rather than as another weapons platform. | |
| function drawHabitat(ctx, state, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y, w = r * 1.8, h = r * 0.9; | |
| ctx.fillStyle = shade(color, -20); | |
| ctx.fillRect(cx - w / 2, cy, w, h * 0.7); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 2; | |
| ctx.strokeRect(cx - w / 2, cy, w, h * 0.7); | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, w * 0.42, Math.PI, 0); | |
| ctx.closePath(); | |
| ctx.fillStyle = color; ctx.fill(); ctx.stroke(); | |
| ctx.fillStyle = DETAIL; | |
| for (const dx of [-w * 0.25, 0, w * 0.25]) ctx.fillRect(cx + dx - 1.5, cy + h * 0.2, 3, 3); | |
| // Electrified (Odyssey): wired into the power grid for +30% supply capacity | |
| // (engine/industry.js electrifyBoost, toggled in hudSelection.js) β a small lit dot inside the | |
| // dome, so a powered Habitat reads at a glance without opening its panel. | |
| electrifiedLight(ctx, state, b, cx, cy - w * 0.42 * 0.55, Math.max(1.8, r * 0.16)); | |
| } | |
| // Market β an open-air trading stall: a striped, scalloped awning over a counter | |
| // stocked with goods, so the building that opens the commodity panel reads as a | |
| // bazaar at a glance, distinct from every other building's bunkers and tanks. | |
| function drawMarket(ctx, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y, w = r * 2.1, h = r * 1.5; | |
| const awningTop = cy - h * 0.62, awningBot = cy - h * 0.32; | |
| const counterY = cy + h * 0.04, counterH = h * 0.32; | |
| // Support poles β full height, so the counter and awning (drawn over them below) | |
| // read as resting ON the poles rather than floating. | |
| ctx.strokeStyle = shade(color, -30); | |
| ctx.lineWidth = Math.max(1.5, r * 0.14); | |
| for (const px of [cx - w * 0.4, cx + w * 0.4]) { | |
| ctx.beginPath(); | |
| ctx.moveTo(px, awningTop + h * 0.05); | |
| ctx.lineTo(px, counterY + counterH); | |
| ctx.stroke(); | |
| } | |
| // Counter β the table the goods sit on. | |
| ctx.fillStyle = shade(color, -18); | |
| ctx.fillRect(cx - w / 2, counterY, w, counterH); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1.5; | |
| ctx.strokeRect(cx - w / 2, counterY, w, counterH); | |
| // Wares on the counter, in the same gold the trade panel prices credits with β | |
| // standing for "everything here is for sale." | |
| ctx.fillStyle = "#f2c14e"; | |
| for (const dx of [-w * 0.26, 0, w * 0.26]) ctx.fillRect(cx + dx - 2, counterY - 2.6, 4, 4); | |
| // Awning β a scalloped canopy in alternating stripes, the stall's signature silhouette. | |
| const teeth = 3, tw = w / teeth; | |
| for (let i = 0; i < teeth; i++) { | |
| const x0 = cx - w / 2 + i * tw; | |
| pathPoints(ctx, [ | |
| [x0, awningTop], [x0 + tw, awningTop], [x0 + tw, awningBot], | |
| [x0 + tw / 2, awningBot + h * 0.12], [x0, awningBot], | |
| ]); | |
| ctx.fillStyle = i % 2 === 0 ? color : shade(color, -18); | |
| ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| } | |
| // A small trade pennant above the canopy peak β the building's one distinguishing | |
| // top accent, the same idiom as the Command Center's antenna or the Barracks' dish. | |
| ctx.strokeStyle = DETAIL; ctx.lineWidth = 1.3; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, awningTop); | |
| ctx.lineTo(cx, awningTop - h * 0.3); | |
| ctx.stroke(); | |
| pathPoints(ctx, [[cx, awningTop - h * 0.3], [cx + w * 0.22, awningTop - h * 0.2], [cx, awningTop - h * 0.1]]); | |
| ctx.fillStyle = "#f2c14e"; | |
| ctx.fill(); | |
| } | |
| // Foundry β the Tier-2 war-smeltery that unlocks the Lancer and Breacher: an | |
| // industrial hall under a sawtooth factory roofline, a tall smokestack tipped | |
| // with a hot ember and a molten forge vent glowing orange through its face, so | |
| // the building that opens the advanced units reads as a working forge β not | |
| // just another bunker. | |
| function drawFoundry(ctx, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y, w = r * 2.0, h = r * 1.4; | |
| ctx.fillStyle = color; // main hall | |
| ctx.fillRect(cx - w / 2, cy - h * 0.2, w, h * 0.7); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 2; | |
| ctx.strokeRect(cx - w / 2, cy - h * 0.2, w, h * 0.7); | |
| const teeth = 3, tw = w / teeth; // sawtooth roof | |
| for (let i = 0; i < teeth; i++) { | |
| const x0 = cx - w / 2 + i * tw; | |
| pathPoints(ctx, [[x0, cy - h * 0.2], [x0, cy - h * 0.52], [x0 + tw, cy - h * 0.2]]); | |
| ctx.fillStyle = shade(color, -20); ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1; ctx.stroke(); | |
| } | |
| ctx.fillStyle = "#ff8c42"; // molten forge vent | |
| ctx.fillRect(cx - w * 0.18, cy + h * 0.08, w * 0.36, h * 0.24); | |
| ctx.fillStyle = shade(color, -30); // smokestack | |
| ctx.fillRect(cx + w * 0.3, cy - h * 0.78, w * 0.15, h * 0.6); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1.5; | |
| ctx.strokeRect(cx + w * 0.3, cy - h * 0.78, w * 0.15, h * 0.6); | |
| ctx.beginPath(); // ember at the stack tip | |
| ctx.arc(cx + w * 0.375, cy - h * 0.8, 2.2, 0, Math.PI * 2); | |
| ctx.fillStyle = "#ffd166"; ctx.fill(); | |
| } | |
| // Arsenal β the Tier-3 weapons manufactory that unlocks the Dreadnought capital | |
| // ship: a squat armoured bunker with chamfered corners, a reinforced cap, a rack | |
| // of stubby missile tubes on the roof and a lit reactor core, so the top of the | |
| // tech tree reads as the most fortified, most militarised structure on the field. | |
| function drawArsenal(ctx, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y, w = r * 1.9, h = r * 1.5, ch = r * 0.5; | |
| pathPoints(ctx, [ // chamfered armoured hull | |
| [cx - w / 2 + ch, cy - h * 0.35], [cx + w / 2 - ch, cy - h * 0.35], | |
| [cx + w / 2, cy - h * 0.35 + ch], [cx + w / 2, cy + h * 0.4 - ch], | |
| [cx + w / 2 - ch, cy + h * 0.4], [cx - w / 2 + ch, cy + h * 0.4], | |
| [cx - w / 2, cy + h * 0.4 - ch], [cx - w / 2, cy - h * 0.35 + ch], | |
| ]); | |
| ctx.fillStyle = color; ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 2; ctx.stroke(); | |
| ctx.fillStyle = shade(color, -22); // reinforced cap | |
| ctx.fillRect(cx - w * 0.3, cy - h * 0.5, w * 0.6, h * 0.18); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1; | |
| ctx.strokeRect(cx - w * 0.3, cy - h * 0.5, w * 0.6, h * 0.18); | |
| ctx.fillStyle = shade(color, -35); // roof missile tubes | |
| for (const dx of [-w * 0.2, 0, w * 0.2]) ctx.fillRect(cx + dx - 2, cy - h * 0.62, 4, h * 0.16); | |
| ctx.beginPath(); // lit reactor core | |
| ctx.arc(cx, cy + h * 0.02, r * 0.34, 0, Math.PI * 2); | |
| ctx.fillStyle = shade(color, 25); ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1.5; ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy + h * 0.02, r * 0.15, 0, Math.PI * 2); | |
| ctx.fillStyle = DETAIL; ctx.fill(); | |
| } | |
| // Reactor β the grid's primary power source: a tapered cooling-tower hull (a pulled-in waist, | |
| // echoing the real thing) on a squat foundation, with a lit core glowing at its base. It's the | |
| // GRID itself, not a recipe factory, so it earns its own bespoke hull here β same art tier as the | |
| // Foundry/Arsenal above β instead of sharing the glyph-disc hex every recipe factory uses below | |
| // (drawFactory). `lit` mirrors hudSelection.js's own "is it actually fed" read (`!paused && | |
| // powered`, set by engine/industry.js updateCombustors from the Reactor's own fuel larder): bright | |
| // gold while burning fuel, the same dim fixture color electrifiedLight uses for "currently unlit" | |
| // while dry or paused β so a dead grid reads off the hull, not just the corner concern badge. | |
| function drawReactor(ctx, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y; | |
| ctx.fillStyle = shade(color, -25); // foundation slab | |
| ctx.fillRect(cx - r * 0.9, cy + r * 0.55, r * 1.8, r * 0.35); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 2; | |
| ctx.strokeRect(cx - r * 0.9, cy + r * 0.55, r * 1.8, r * 0.35); | |
| const top = cy - r * 1.15, bot = cy + r * 0.6; // hourglass tower: wide rim/base, pulled-in waist | |
| pathPoints(ctx, [ | |
| [cx - r * 0.8, bot], [cx - r * 0.32, cy - r * 0.05], [cx - r * 0.55, top], | |
| [cx + r * 0.55, top], [cx + r * 0.32, cy - r * 0.05], [cx + r * 0.8, bot], | |
| ]); | |
| ctx.fillStyle = color; ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 2; ctx.stroke(); | |
| ctx.strokeStyle = shade(color, 18); ctx.lineWidth = 1; // waist highlight so the taper reads round, not folded | |
| ctx.beginPath(); | |
| ctx.moveTo(cx - r * 0.28, cy - r * 0.5); | |
| ctx.lineTo(cx - r * 0.28, cy + r * 0.3); | |
| ctx.stroke(); | |
| const lit = !b.paused && b.powered; | |
| ctx.beginPath(); // the lit core | |
| ctx.arc(cx, cy + r * 0.2, r * 0.3, 0, Math.PI * 2); | |
| ctx.fillStyle = lit ? "#ffd166" : "#3a2f14"; | |
| ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1.5; ctx.stroke(); | |
| } | |
| // Combustion Generator β the cheap fuel-burning alternative to the Reactor: a squat housing and a | |
| // fuel tank under a single stack tipped with a flame. The flare is lit only while its own larder is | |
| // actually feeding it (same `lit` read as the Reactor's core above); it goes to cold embers on an | |
| // empty larder or a paused generator, so a dead backup grid reads off the hull too. | |
| function drawCombustor(ctx, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y, w = r * 1.6, h = r * 0.85; | |
| ctx.fillStyle = color; // housing | |
| ctx.fillRect(cx - w / 2, cy + h * 0.1, w, h * 0.6); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 2; | |
| ctx.strokeRect(cx - w / 2, cy + h * 0.1, w, h * 0.6); | |
| ctx.fillStyle = shade(color, -30); // a fuel tank beside the stack | |
| ctx.beginPath(); | |
| ctx.arc(cx - w * 0.26, cy + h * 0.28, r * 0.3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1.5; ctx.stroke(); | |
| const stackX = cx + w * 0.18, stackW = w * 0.28, stackTop = cy - r * 1.05; | |
| ctx.fillStyle = shade(color, -20); // the stack itself | |
| ctx.fillRect(stackX - stackW / 2, stackTop, stackW, cy + h * 0.1 - stackTop); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1.5; | |
| ctx.strokeRect(stackX - stackW / 2, stackTop, stackW, cy + h * 0.1 - stackTop); | |
| const lit = !b.paused && b.powered; | |
| pathPoints(ctx, [ // the flare β a teardrop lit only while actually burning fuel | |
| [stackX, stackTop - r * 0.55], [stackX + stackW * 0.6, stackTop - r * 0.05], | |
| [stackX, stackTop + r * 0.15], [stackX - stackW * 0.6, stackTop - r * 0.05], | |
| ]); | |
| ctx.fillStyle = lit ? "#ff8c42" : "#3a2412"; | |
| ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1; ctx.stroke(); | |
| if (lit) { | |
| ctx.beginPath(); // a hot inner core to the flame | |
| ctx.arc(stackX, stackTop - r * 0.15, stackW * 0.22, 0, Math.PI * 2); | |
| ctx.fillStyle = "#ffd166"; ctx.fill(); | |
| } | |
| } | |
| // Biomass Reactor β the Combustion Generator's biomass-burning sibling: a rounded vat instead of a | |
| // hard-edged tank, glowing green while its larder is actually feeding it (same `lit` read as its | |
| // two siblings above), dull and murky when dry or paused. Organic where the Reactor and Combustion | |
| // Generator read mechanical, so the three power buildings stay distinguishable from EACH OTHER | |
| // too, not just from the recipe factories they no longer share a silhouette with. | |
| function drawBiomassReactor(ctx, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y; | |
| ctx.fillStyle = shade(color, -22); // collar foundation | |
| ctx.beginPath(); | |
| ctx.ellipse(cx, cy + r * 0.58, r * 0.85, r * 0.26, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1.5; ctx.stroke(); | |
| ctx.beginPath(); // the vat body β a rounded-top tank, not a hard box | |
| ctx.moveTo(cx - r * 0.72, cy + r * 0.5); | |
| ctx.lineTo(cx - r * 0.62, cy - r * 0.6); | |
| ctx.quadraticCurveTo(cx, cy - r * 1.05, cx + r * 0.62, cy - r * 0.6); | |
| ctx.lineTo(cx + r * 0.72, cy + r * 0.5); | |
| ctx.closePath(); | |
| ctx.fillStyle = color; ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 2; ctx.stroke(); | |
| const lit = !b.paused && b.powered; | |
| ctx.beginPath(); // the biomass glow, filling most of the vat | |
| ctx.ellipse(cx, cy - r * 0.05, r * 0.4, r * 0.5, 0, 0, Math.PI * 2); | |
| ctx.fillStyle = lit ? "#7ed957" : "#233a1c"; | |
| ctx.fill(); | |
| ctx.strokeStyle = lit ? "#c8f7a8" : shade(color, -12); // rising bubbles, brighter while actually fed | |
| ctx.lineWidth = 1.2; | |
| for (const dx of [-r * 0.14, r * 0.16]) { | |
| ctx.beginPath(); | |
| ctx.moveTo(cx + dx, cy + r * 0.28); | |
| ctx.lineTo(cx + dx, cy - r * 0.32); | |
| ctx.stroke(); | |
| } | |
| } | |
| // A last-resort silhouette for any building type without a bespoke draw β a | |
| // hexagonal hull with a lit core. Nothing on the current roster falls through | |
| // to it (every type above is handled), but it guarantees the "every entity has | |
| // a graphical representation" invariant holds for anything added later, so a new | |
| // building can never ship as an invisible click target. | |
| // recipe id -> the commodity it OUTPUTS (data.js RECIPES), so a factory can show what it makes. | |
| const RECIPE_OUT = Object.fromEntries(RECIPES.map(r => [r.id, r.out])); | |
| // A distinguishing glyph for buildings that otherwise share the plain hex silhouette. A | |
| // recipe-running factory shows the commodity it OUTPUTS; a handful of non-recipe industrial | |
| // buildings get an explicit emoji for what they DO β the Datacenter runs research (π¬), the | |
| // Stardock is a capital-ship yard (π°οΈ), the Antimatter Gate is the wonder (π), the Plasma Rig digs | |
| // (βοΈ), the Substation relays the grid (π, engine/entities.js `powerRelay`). The static-defense | |
| // ladder's two non-turret rungs get one too β the Bastille a heavy emplacement (π°), the Aegis | |
| // Bastion its guard-aura emblem (π°) β and the Torpedo Battery shows the same icon as the | |
| // commodity it fires (π₯ plasmatorp, data.js COM), matching "a factory shows what it makes"'s | |
| // spirit even though it consumes rather than produces that good. The Reactor / Combustion | |
| // Generator / Biomass Reactor are the grid itself, not a recipe factory sharing this hex β they | |
| // get their own bespoke hulls (drawReactor/drawCombustor/drawBiomassReactor above), dispatched in | |
| // drawBuildingShape before this glyph fallback ever sees them. | |
| // A glyph must not collide with the COMMODITY icon a factory producing that good already draws | |
| // (factoryGlyph below falls back to COM[recipe.out].ico). Two did, and the result was two pairs of | |
| // buildings rendered byte-identically in hull, glyph and colour, differing only in radius: | |
| // the Antimatter Gate β the Odyssey WONDER, a victory condition β was a slightly larger Antimatter | |
| // Forge, and the Torpedo Battery, a gun that shoots back, was a smaller Torpedo Works. Guarded now | |
| // by a pairwise silhouette test (test/render-roster.test.js), so a future glyph can't re-collide. | |
| const BUILDING_GLYPH = { | |
| datacenter: "π¬", stardock: "π°οΈ", antimatter_gate: "π", plasmarig: "βοΈ", substation: "π", | |
| bastille: "π°", aegisbastion: "π°", torpedobattery: "π―", | |
| }; | |
| function factoryGlyph(type) { | |
| const def = BUILDINGS[type]; | |
| if (def && def.recipe && RECIPE_OUT[def.recipe]) return COM[RECIPE_OUT[def.recipe]]?.ico || null; | |
| return BUILDING_GLYPH[type] || null; | |
| } | |
| // True for a genuinely productive recipe factory THIS frame: completed (not still going up or | |
| // coming down), not paused, and buildingConcern (engine/industry.js β the exact same read | |
| // drawConcernBadge uses for the corner badge) comes back clean β no stall, no starvation, no | |
| // power throttle to flag. Checked independently of buildingConcern's OWN null cases rather than | |
| // trusting a bare `=== null`: buildingConcern also reads null while constructing/recycling | |
| // (nothing to flag yet β not the same as "running") and for any building without a recipe/rig at | |
| // all (nothing to concern-check β also not "running"), so both are excluded here explicitly. | |
| // Guards a missing `state.players` too: render.js's spriteIcon draws every building type β | |
| // including every recipe factory β through a stub state with no players/time/owner at all | |
| // (render.js:76), and buildingConcern reaches into state.players[owner] unconditionally once past | |
| // this function's own recipe check. | |
| function isRunningFactory(state, b) { | |
| if (b.constructing || b.recycling || b.paused) return false; | |
| if (!b.owner || !state.players) return false; | |
| if (!recipeOf(b)) return false; // scoped to recipe-running factories β not the Rig, not a wonder, not a relay | |
| return buildingConcern(state, b) === null; | |
| } | |
| // A subtle "this factory is actually working" glow behind its glyph disc β the positive | |
| // counterpart to drawConcernBadge's silence-when-fine: a badge only ever appears when something's | |
| // WRONG, so a healthy producer used to look identical to an idle one. Only ever called for a | |
| // genuinely running recipe factory (isRunningFactory above). Phased by hashStr(b.id) so a base full | |
| // of factories doesn't breathe in lockstep, and driven by state.time, never a wall-clock β the same | |
| // deterministic-presentation idiom electrifiedLight uses just above. | |
| function drawWorkPulse(ctx, state, b, r) { | |
| const t = state.time * 1.4 + (hashStr(b.id) % 100) * 0.15; | |
| const glow = 0.5 + Math.sin(t) * 0.5; // 0..1, slow breathing cycle | |
| ctx.beginPath(); | |
| ctx.arc(b.x, b.y, r * 0.6 + 1.5 + glow * 1.5, 0, Math.PI * 2); | |
| ctx.strokeStyle = hexA("#8fd3ff", 0.25 + glow * 0.35); | |
| ctx.lineWidth = 1.5; | |
| ctx.stroke(); | |
| } | |
| // Every recipe-running factory (Smelter, Assembly Plant, Chip Fab, β¦) and the remaining non-recipe | |
| // industrial buildings (Datacenter, Star Dock, Antimatter Gate, Plasma Rig, Substation) otherwise | |
| // share the plain hex silhouette below β indistinguishable on the map and in the build menu. Stamp | |
| // the building's glyph (its product's emoji, or the explicit icon) on a dark disc so each reads at | |
| // a glance as what it does. Keyed on the building TYPE, so the HUD button icon (spriteIcon renders | |
| // this same shape) gets the glyph too. A RECIPE factory that's actually running right now gets a | |
| // slow work-pulse glow behind its disc as well (isRunningFactory/drawWorkPulse above) β the | |
| // positive read a silent concern badge alone can't give you. | |
| function drawFactory(ctx, state, b, color) { | |
| drawGenericBuilding(ctx, b, color); | |
| // Electrified (Odyssey): only the Star Dock among everything sharing this silhouette is | |
| // ELECTRIFIABLE (engine/entities.js isElectrifiable) β a lit beacon above the hex, same cue as | |
| // the Command Center/Barracks/Habitat. electrifiedLight no-ops for every other type here (their | |
| // `electrified` flag is never set), so this is safe to call unconditionally. | |
| electrifiedLight(ctx, state, b, b.x, b.y - b.radius * 0.85, 2.2); | |
| const ico = factoryGlyph(b.type); | |
| if (!ico) return; | |
| const r = b.radius; | |
| if (isRunningFactory(state, b)) drawWorkPulse(ctx, state, b, r); | |
| ctx.beginPath(); | |
| ctx.arc(b.x, b.y, r * 0.6, 0, Math.PI * 2); | |
| ctx.fillStyle = "rgba(5,7,15,0.74)"; ctx.fill(); | |
| centeredText(ctx, ico, b.x, b.y, `${Math.round(r * 0.95)}px sans-serif`); | |
| } | |
| function drawGenericBuilding(ctx, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y; | |
| pathPoints(ctx, polygonPoints(cx, cy, r, 6, Math.PI / 6)); | |
| ctx.fillStyle = color; ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 2; ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, r * 0.4, 0, Math.PI * 2); | |
| ctx.fillStyle = shade(color, 20); ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1; ctx.stroke(); | |
| } | |
| // Spaceport β a round launch pad ringed by a gantry with an upright rocket | |
| // standing on it, so the "leave this world" building reads at a glance. | |
| function drawSpaceport(ctx, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y; | |
| ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); | |
| ctx.fillStyle = color; ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 2; ctx.stroke(); | |
| ctx.beginPath(); ctx.arc(cx, cy, r * 0.66, 0, Math.PI * 2); | |
| ctx.strokeStyle = shade(color, -30); ctx.lineWidth = 3; ctx.stroke(); | |
| const bw = r * 0.26, bh = r * 0.92; // upright rocket body | |
| ctx.fillStyle = DETAIL; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx, cy - bh); | |
| ctx.lineTo(cx + bw, cy - bh * 0.4); | |
| ctx.lineTo(cx + bw, cy + bh * 0.5); | |
| ctx.lineTo(cx - bw, cy + bh * 0.5); | |
| ctx.lineTo(cx - bw, cy - bh * 0.4); | |
| ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = shade(color, -20); // fins | |
| ctx.beginPath(); | |
| ctx.moveTo(cx - bw, cy + bh * 0.12); ctx.lineTo(cx - bw * 2.1, cy + bh * 0.5); ctx.lineTo(cx - bw, cy + bh * 0.5); ctx.closePath(); | |
| ctx.moveTo(cx + bw, cy + bh * 0.12); ctx.lineTo(cx + bw * 2.1, cy + bh * 0.5); ctx.lineTo(cx + bw, cy + bh * 0.5); ctx.closePath(); | |
| ctx.fill(); | |
| // Tier pips (1β3): the launch pad's jump-capacity rank (engine/galaxy.js), so a bigger | |
| // Spaceport reads at a glance on the map. | |
| const tier = Math.min(3, Math.max(1, b.tier || 1)); | |
| const pipR = r * 0.11, gap = pipR * 2.6, py = cy + r * 0.66; | |
| for (let i = 0; i < tier; i++) { | |
| const px = cx + (i - (tier - 1) / 2) * gap; | |
| ctx.beginPath(); ctx.arc(px, py, pipR, 0, Math.PI * 2); | |
| ctx.fillStyle = "#ffd76a"; ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1; ctx.stroke(); | |
| } | |
| } | |
| // Sentinel Turret β a hexagonal base pad with a single barrel that swings to | |
| // track its current target, so a defended base reads as actively guarded | |
| // rather than as just another building. The barrel angle comes from the | |
| // sim's auto-acquired targetId, not from any movement (a turret never moves). | |
| function drawTurret(ctx, state, b, color) { | |
| const r = b.radius, cx = b.x, cy = b.y; | |
| pathPoints(ctx, polygonPoints(cx, cy, r, 6, Math.PI / 6)); // base pad | |
| ctx.fillStyle = color; ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 2; ctx.stroke(); | |
| const angle = turretFacing(state, b); | |
| ctx.strokeStyle = shade(color, -25); ctx.lineWidth = 3.5; | |
| ctx.beginPath(); ctx.moveTo(cx, cy); | |
| ctx.lineTo(cx + Math.cos(angle) * r * 1.5, cy + Math.sin(angle) * r * 1.5); ctx.stroke(); | |
| ctx.beginPath(); ctx.arc(cx, cy, r * 0.5, 0, Math.PI * 2); // mount | |
| ctx.fillStyle = shade(color, 20); ctx.fill(); | |
| ctx.strokeStyle = "#05070f"; ctx.lineWidth = 1.5; ctx.stroke(); | |
| ctx.beginPath(); // muzzle tip light | |
| ctx.arc(cx + Math.cos(angle) * r * 1.5, cy + Math.sin(angle) * r * 1.5, 1.8, 0, Math.PI * 2); | |
| ctx.fillStyle = DETAIL; ctx.fill(); | |
| } | |
| // Reuses the module-level facing Map β building "b*" ids can't collide with | |
| // unit "u*" ids. Holds its last aim when idle (targetId null) instead of | |
| // snapping back to a default, so a turret between shots keeps pointing where | |
| // it last fired. | |
| function turretFacing(state, b) { | |
| const prev = facing.get(b.id); | |
| let angle = prev ? prev.angle : -Math.PI / 2; | |
| const t = b.targetId ? (state.units.get(b.targetId) || state.buildings.get(b.targetId)) : null; | |
| if (t) angle = Math.atan2(t.y - b.y, t.x - b.x); | |
| facing.set(b.id, { x: b.x, y: b.y, angle }); | |
| return angle; | |
| } | |