Spaces:
Paused
Paused
File size: 40,238 Bytes
1aaa2ca 524c178 6f3b37b b870df3 693efa9 1aaa2ca 6f3b37b 1aaa2ca 6f3b37b 1aaa2ca 6f3b37b 57456d0 1aaa2ca 524c178 1aaa2ca 6f3b37b 6bd2c72 1aaa2ca b870df3 1aaa2ca 79c7af7 1aaa2ca 71c607d 6bd2c72 1aaa2ca b870df3 1aaa2ca df7e459 b870df3 df7e459 1aaa2ca 71c607d 1aaa2ca d86e12b 1aaa2ca d86e12b 1aaa2ca 71c607d 1aaa2ca 6f3b37b 1aaa2ca 505ae6b 1aaa2ca 6f3b37b 1aaa2ca 6f3b37b 1aaa2ca 6f3b37b 1aaa2ca 6f3b37b 1aaa2ca 27d00a7 6f3b37b 1aaa2ca 57456d0 1aaa2ca 524c178 1aaa2ca 524c178 45b37a3 505ae6b 45b37a3 505ae6b 45b37a3 1aaa2ca 524c178 6f3b37b 1aaa2ca 6f3b37b 1aaa2ca 524c178 1aaa2ca b870df3 1aaa2ca | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 | /* ============================================================
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.
============================================================ */
"use strict";
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;
}
|