File size: 30,216 Bytes
1aaa2ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d213ade
b870df3
d86e12b
1aaa2ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85bfa6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45b37a3
 
85bfa6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f2921c
 
 
 
 
 
 
 
 
 
 
 
 
 
1aaa2ca
 
33fe16b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d213ade
 
 
 
 
 
1aaa2ca
 
 
 
 
c1ba8ce
1aaa2ca
85bfa6e
1aaa2ca
d213ade
 
 
 
 
 
 
1aaa2ca
d213ade
 
 
1aaa2ca
 
d213ade
1aaa2ca
 
 
d213ade
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1aaa2ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1ba8ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33fe16b
 
 
 
 
 
 
 
 
53d4247
33fe16b
 
 
53d4247
 
 
 
 
33fe16b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53d4247
 
 
 
 
1aaa2ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b870df3
1aaa2ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b870df3
1aaa2ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a53030d
 
 
 
 
 
 
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
/* ============================================================
   Cosmetic overlays and cues, drawn on top of the world β€” attack tracers /
   death flashes / under-attack pings (effects.js), the convoy-scenario route,
   selection rings, rally point, movement waypoints, escort links, the build
   ghost (with its power-grid / rig-survey placement cues), and the drag box.
   These are the only render paths that read wall-clock-timed state (effects.js)
   rather than the sim's own state. Split out of render.js.
   ============================================================ */

"use strict";

import { COM } from "./data.js";
import { UNITS, BUILDINGS } from "./engine/entities.js";
import { isNodeDiscovered } from "./engine/fog.js";
import { powerEfficiency } from "./engine/industry.js";
import { rigSurvey, SURVEY_RADIUS } from "./engine/rig.js";
import { canPlaceBuilding } from "./engine/colliders.js";
import { activeEffects, DEATH_BASE_RADIUS } from "./effects.js";
import { hexA, lerpXY, shade, pathPoints, polygonPoints, drawLabelChip } from "./renderShared.js";
import { POWER_TIER_COLOR, drawReactorBands } from "./renderBuildings.js";

// Cached once: whether the viewer asked the OS to reduce motion. Used to swap
// the repeating alert pulses for a static cue (see drawEffects).
let _reducedMotion = null;
function prefersReducedMotion() {
  if (_reducedMotion === null) {
    _reducedMotion = typeof window !== "undefined" && window.matchMedia
      ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false;
  }
  return _reducedMotion;
}

// The convoy route overlay (scenario mode): a dashed lane connecting the
// stations, a muted ring at the start, cyan rings at the waypoint stations, and
// a bright green gate at the destination. The station the convoy is currently
// heading for gets a solid halo so the objective reads at a glance.
export function drawScenario(ctx, state) {
  const sc = state.scenario;
  if (!sc) return;
  if (sc.type === "bounty") { drawBountyMarkers(ctx, sc); return; }
  if (!sc.route) return;
  const route = sc.route;

  ctx.save();
  ctx.strokeStyle = "rgba(79, 209, 255, 0.30)";
  ctx.lineWidth = 2;
  ctx.setLineDash([12, 10]);
  ctx.beginPath();
  ctx.moveTo(route[0].x, route[0].y);
  for (let i = 1; i < route.length; i++) ctx.lineTo(route[i].x, route[i].y);
  ctx.stroke();
  ctx.setLineDash([]);

  const activeTarget = sc.phase === "travel" ? sc.legIndex + 1 : -1;
  route.forEach((p, i) => {
    const dest = i === route.length - 1;
    const start = i === 0;
    const col = dest ? "#4ade80" : start ? "#8593c4" : "#4fd1ff";
    if (i === activeTarget) {                          // halo the station we're steering for
      ctx.beginPath(); ctx.arc(p.x, p.y, 44, 0, Math.PI * 2);
      ctx.fillStyle = dest ? "rgba(74,222,128,0.12)" : "rgba(79,209,255,0.12)";
      ctx.fill();
    }
    ctx.beginPath(); ctx.arc(p.x, p.y, dest ? 38 : 32, 0, Math.PI * 2);
    ctx.strokeStyle = col; ctx.lineWidth = dest ? 4 : 2.5; ctx.stroke();
    ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, Math.PI * 2);
    ctx.fillStyle = col; ctx.fill();
  });
  ctx.restore();
}

// Bounty Marshal has no route β€” it marks the scattered pirate camps instead. An
// uncleared camp gets a dashed red "wanted" ring and its bounty value (a hunt
// beacon that reads through fog, so the player always knows where to go); a
// cleared camp fades to a faint green ring so progress is visible on the map.
function drawBountyMarkers(ctx, sc) {
  ctx.save();
  ctx.textAlign = "center";
  for (const pack of sc.packs) {
    if (pack.cleared) {
      ctx.strokeStyle = "rgba(74, 222, 128, 0.28)";
      ctx.lineWidth = 2;
      ctx.beginPath(); ctx.arc(pack.x, pack.y, 30, 0, Math.PI * 2); ctx.stroke();
      continue;
    }
    ctx.strokeStyle = "rgba(248, 113, 113, 0.6)";
    ctx.lineWidth = 2.5;
    ctx.setLineDash([7, 7]);
    ctx.beginPath(); ctx.arc(pack.x, pack.y, 48, 0, Math.PI * 2); ctx.stroke();
    ctx.setLineDash([]);
    ctx.font = "12px sans-serif";
    ctx.fillStyle = "#fca5a5";
    ctx.fillText(`πŸ’° ${pack.bounty}`, pack.x, pack.y - 56);
  }
  ctx.restore();
}

// Per-weapon fire signature: shape + base color keyed by the firing unit's type (attackHit's
// unitType, engine/combat.js β€” a turret reads as its own "turret" type). Replaces the old
// two-case tracerColor() special-case (only bastion/lancer were colored; every other type β€”
// skiff, breacher, turret, dreadnought, wraith, colossus, including the player's OWN units β€”
// fell to the same hostile-red default). `shape` picks which branch of drawTracer (below) draws
// it; `color`/`width` are the base look BEFORE the counter-triangle bonus-hit brightening
// (drawTracer again). Every type that can actually fire (UNITS[]/BUILDINGS[].attack truthy) gets
// its own entry β€” see test/renderEffects.test.js's roster-completeness check. `default` is the
// fallback for a hypothetical future type this table hasn't caught up with yet β€” kept a neutral
// pale color rather than red, so a gap here reads as "unclassified", not "hostile".
export const TRACER_STYLE = {
  skiff:       { shape: "dart", color: "#8be9fd", width: 2 },
  bastion:     { shape: "bolt", color: "#ffd166", width: 4 },
  lancer:      { shape: "beam", color: "#4fd1ff", width: 1.5 },
  breacher:    { shape: "arc",  color: "#ff8a3d", width: 3 },
  dreadnought: { shape: "bolt", color: "#f87171", width: 4.5 },
  wraith:      { shape: "dart", color: "#c084fc", width: 2 },
  aegis:       { shape: "beam", color: "#7dd3fc", width: 1.5 },
  colossus:    { shape: "arc",  color: "#fb7185", width: 4 },
  leviathan:   { shape: "arc",  color: "#facc15", width: 5 },
  turret:      { shape: "beam", color: "#a78bfa", width: 2 },   // static defense β€” its own hue, distinct from every mobile hull
  bastille:       { shape: "bolt", color: "#c026d3", width: 3.5 },   // the turret's tankier, heavier-hitting sibling
  torpedobattery: { shape: "arc",  color: "#34d399", width: 4 },     // ammo-fed endgame siege β€” a heavy lobbed plasma hit
  worker:      { shape: "default", color: "#e2e8f0", width: 1.5 },
  ranger:      { shape: "default", color: "#e2e8f0", width: 1.5 },
  default:     { shape: "default", color: "#e2e8f0", width: 2 },
};

// The flash is fully gone well before the tracer itself finishes fading (TRACER_LIFETIME_MS,
// effects.js), so it reads as a one-frame flicker at the gun, not a second glow racing the shot
// down the line.
const MUZZLE_FLASH_FRACTION = 0.3;

// One tracer, in whatever shape its firing unit's TRACER_STYLE assigns, brightened and thickened
// when it carries a counter-triangle bonus hit (engine/combat.js's `bonus` flag β€” a Skiff
// catching a Lancer, etc.), plus a one-flicker muzzle flash at the firing end. Split out of
// drawEffects so each weapon shape reads as its own small branch, not one long loop body.
function drawTracer(ctx, t) {
  const style = TRACER_STYLE[t.unitType] || TRACER_STYLE.default;
  const color = t.bonus ? shade(style.color, 35) : style.color;
  const width = style.width * (t.bonus ? 1.6 : 1);
  const dx = t.toX - t.fromX, dy = t.toY - t.fromY;
  const len = Math.hypot(dx, dy) || 1;
  const ux = dx / len, uy = dy / len;   // unit vector shooter -> target, for the shape branches below

  ctx.globalAlpha = Math.max(0, 1 - t.age);
  ctx.strokeStyle = color;
  ctx.lineWidth = width;

  if (style.shape === "bolt") {
    // Thick and stubby: only the last stretch of the line, near the target β€” a heavy close-range
    // punch (Bastion/Dreadnought) rather than a full-length beam.
    const cut = Math.max(0, len - Math.min(len, 30 + len * 0.35));
    ctx.beginPath();
    ctx.moveTo(t.fromX + ux * cut, t.fromY + uy * cut);
    ctx.lineTo(t.toX, t.toY);
    ctx.stroke();
  } else if (style.shape === "beam") {
    // A full-length thin beam with a hot, slightly thicker tip near the target.
    ctx.beginPath();
    ctx.moveTo(t.fromX, t.fromY);
    ctx.lineTo(t.toX, t.toY);
    ctx.stroke();
    const tipStart = Math.max(0, len - Math.min(len, 22));
    ctx.strokeStyle = t.bonus ? "#fff7cc" : shade(color, 30);
    ctx.lineWidth = width + 1;
    ctx.beginPath();
    ctx.moveTo(t.fromX + ux * tipStart, t.fromY + uy * tipStart);
    ctx.lineTo(t.toX, t.toY);
    ctx.stroke();
  } else if (style.shape === "dart") {
    // A short bright segment that SLIDES from shooter to target as the tracer ages, over a faint
    // full-length guide line β€” reads as a fast, light round rather than a static drawn line.
    ctx.globalAlpha = Math.max(0, (1 - t.age) * 0.25);
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(t.fromX, t.fromY);
    ctx.lineTo(t.toX, t.toY);
    ctx.stroke();
    const cx = t.fromX + dx * t.age, cy = t.fromY + dy * t.age;
    const half = Math.min(len / 2, 9);
    ctx.globalAlpha = Math.max(0, 1 - t.age);
    ctx.strokeStyle = color;
    ctx.lineWidth = width;
    ctx.beginPath();
    ctx.moveTo(cx - ux * half, cy - uy * half);
    ctx.lineTo(cx + ux * half, cy + uy * half);
    ctx.stroke();
  } else if (style.shape === "arc") {
    // A lobbed shell: a quadratic curve bulging away from the straight line β€” siege fire
    // (Breacher/Colossus/Leviathan) reads as arcing in, not a flat direct shot.
    const bulge = Math.min(40, len * 0.18);
    const midX = (t.fromX + t.toX) / 2 - uy * bulge, midY = (t.fromY + t.toY) / 2 + ux * bulge;
    ctx.beginPath();
    ctx.moveTo(t.fromX, t.fromY);
    ctx.quadraticCurveTo(midX, midY, t.toX, t.toY);
    ctx.stroke();
  } else {
    ctx.beginPath();
    ctx.moveTo(t.fromX, t.fromY);
    ctx.lineTo(t.toX, t.toY);
    ctx.stroke();
  }

  // A one-flicker muzzle flash at the firing end β€” gone well before the tracer itself fades, so
  // it reads as the gun flashing, not a second glow racing the shot down the line. A little spin
  // (rotation keyed to age) sells the "flicker" rather than a static stamp.
  if (t.age < MUZZLE_FLASH_FRACTION) {
    ctx.globalAlpha = Math.max(0, 1 - t.age / MUZZLE_FLASH_FRACTION);
    ctx.fillStyle = t.bonus ? "#fff7cc" : color;
    pathPoints(ctx, polygonPoints(t.fromX, t.fromY, style.width + (t.bonus ? 2.5 : 1.5), 5, t.age * 3));
    ctx.fill();
  }

  // The impact spark where the round lands β€” a quick bright flash that fades faster than the
  // tracer, so a hit reads as connecting, not just a line drawn through the target. Bigger and
  // hotter on a counter-triangle bonus hit, so the extra damage reads at the point of impact too.
  ctx.globalAlpha = Math.max(0, 1 - t.age * 1.6);
  ctx.fillStyle = t.bonus ? "#fff7cc" : "#ffe9c2";
  ctx.beginPath();
  ctx.arc(t.toX, t.toY, (t.bonus ? 4 : 2.5) + (1 - t.age) * (t.bonus ? 2.5 : 1.5), 0, Math.PI * 2);
  ctx.fill();

  // A splash hit (engine/combat.js's attackHit `splashRadius` field β€” def.splash, Colossus
  // first) also draws a fading ring at the true impact radius, in the same per-weapon color this
  // shot already picked above β€” so an area hit reads as such at a glance, distinct from every
  // single-target tracer's plain impact spark just above. Falsy (0) for every non-splash hit, so
  // this is purely additive and data-driven for any future splash-capable unit.
  if (t.splashRadius) {
    ctx.globalAlpha = Math.max(0, 0.55 * (1 - t.age));
    ctx.strokeStyle = color;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(t.toX, t.toY, t.splashRadius, 0, Math.PI * 2);
    ctx.stroke();
  }
}

// The Helium Bomb shockwave (see drawEffects below): the fireball collapses
// within the first slice of the effect's life, the ring then spends most of
// the rest slowly traveling out to the true blast radius, and the remainder
// is left for it to fade out once it arrives.
const EXPLOSION_FLASH_FRACTION = 0.15;
const EXPLOSION_SHOCKWAVE_FRACTION = 0.85;
// Hot (near ground zero, peak damage) -> cool ash-grey (the tapering edge),
// matching bombDamageAt's own falloff from full HP loss to a scratch.
const SHOCKWAVE_HOT = [255, 180, 80];
const SHOCKWAVE_COOL = [148, 163, 184];
function lerpColor(a, b, t) {
  const r = Math.round(a[0] + (b[0] - a[0]) * t);
  const g = Math.round(a[1] + (b[1] - a[1]) * t);
  const b2 = Math.round(a[2] + (b[2] - a[2]) * t);
  return `rgb(${r}, ${g}, ${b2})`;
}

// Tiered destruction: a hull at or above this radius flings debris shards on death (Breacher 10,
// Dreadnought/Aegis/Colossus 11-12, Leviathan 14, and up β€” every Skiff/Lancer/worker-scale unit
// stays a plain ring).
const DEBRIS_MIN_RADIUS = 10;
const DEBRIS_SHARD_COUNT = 5;

// Attack tracers, death flashes, and under-attack pings: all purely
// cosmetic and short-lived (see effects.js), so this is the only place
// in render.js that reads wall-clock-timed state instead of the sim's
// own state object.
export function drawEffects(ctx) {
  const { tracers, deaths, pings, explosions, fuseWarnings } = activeEffects();

  for (const t of tracers) drawTracer(ctx, t);

  // Tiered destruction (docs/improvement-proposals.md): the ring's size/lifetime already scale
  // with the killed entity's own radius (effects.js addDeathFlash/deathLifeMs, DEATH_BASE_RADIUS
  // the baseline both files share) β€” this just draws the SHAPE differently by tier: a plain
  // single ring for most deaths, a few outward debris shards bolted on for a big hull
  // (radius >= DEBRIS_MIN_RADIUS), and a building gets a slower TWO-ring collapse (an outer ring
  // plus a lagging inner one) instead of one, reading as a structure caving in rather than a unit
  // popping.
  for (const d of deaths) {
    const scale = d.radius / DEATH_BASE_RADIUS;
    const rMax = 16 * scale;
    const r = 6 * scale + d.age * rMax;
    ctx.globalAlpha = Math.max(0, 1 - d.age);
    ctx.strokeStyle = "#ffab5e";
    ctx.lineWidth = Math.max(1.5, Math.min(3.2, 2 * scale));
    ctx.beginPath();
    ctx.arc(d.x, d.y, r, 0, Math.PI * 2);
    ctx.stroke();

    if (d.kind === "building") {
      // A second, inner ring trailing the outer one by a quarter of the effect's life β€” the core
      // caves in a beat behind the shockwave, instead of the whole structure vanishing on one ring.
      const innerAge = Math.max(0, d.age - 0.25);
      const innerR = 4 * scale + innerAge * rMax * 0.7;
      ctx.globalAlpha = Math.max(0, (1 - d.age) * 0.8);
      ctx.beginPath();
      ctx.arc(d.x, d.y, innerR, 0, Math.PI * 2);
      ctx.stroke();
    } else if (d.radius >= DEBRIS_MIN_RADIUS) {
      // A handful of shards flung outward for a heavy hull β€” angle hashed off position so it's
      // stable across the effect's own lifetime rather than reshuffling every frame.
      ctx.fillStyle = "#ffab5e";
      for (let i = 0; i < DEBRIS_SHARD_COUNT; i++) {
        const a = (i / DEBRIS_SHARD_COUNT) * Math.PI * 2 + (d.x + d.y) * 0.01;
        const dist = d.age * rMax * 1.4;
        ctx.beginPath();
        ctx.arc(d.x + Math.cos(a) * dist, d.y + Math.sin(a) * dist, Math.max(0.5, 2 * (1 - d.age)), 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }

  const reduced = prefersReducedMotion();
  for (const p of pings) {
    if (reduced) {
      // Motion-sensitive players get a steady ring that just fades out, instead
      // of the repeating expanding pulse.
      ctx.globalAlpha = Math.max(0, 0.6 * (1 - p.age));
      ctx.strokeStyle = "#f87171";
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(p.x, p.y, 24, 0, Math.PI * 2);
      ctx.stroke();
      continue;
    }
    // Two expanding rings on a repeating pulse read as an alarm rather
    // than a one-shot flash, matching a ping's much longer lifetime.
    const pulse = (p.age * 2.5) % 1;
    ctx.globalAlpha = Math.max(0, (1 - pulse) * (1 - p.age * 0.6));
    ctx.strokeStyle = "#f87171";
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(p.x, p.y, 14 + pulse * 40, 0, Math.PI * 2);
    ctx.stroke();
  }

  // A Helium Bomb's fuse just lit (engine/bomb.js's bombFused event): an amber warning ring
  // pulsing right at ground zero for the whole fuse burn β€” distinct from the red under-attack
  // ping above and the hot explosion ring below, its own place in the alert color language.
  // The pulse rate itself accelerates as f.age climbs toward 1, so the rhythm quickens right
  // up to the moment the real blast (drawn below, once detonateBomb actually fires) takes over.
  for (const f of fuseWarnings) {
    if (reduced) {
      ctx.globalAlpha = Math.max(0, 0.5 * (1 - f.age));
      ctx.strokeStyle = "#fbbf24";
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(f.x, f.y, 20, 0, Math.PI * 2);
      ctx.stroke();
      continue;
    }
    const pulseHz = 2 + f.age * 4;   // ~2Hz freshly lit, accelerating to ~6Hz right before it blows
    const pulse = (f.age * pulseHz) % 1;
    ctx.globalAlpha = Math.max(0, (1 - pulse) * 0.85);
    ctx.strokeStyle = "#fbbf24";
    ctx.lineWidth = 2.5;
    ctx.beginPath();
    ctx.arc(f.x, f.y, 10 + pulse * 22, 0, Math.PI * 2);
    ctx.stroke();
  }

  // The Helium Bomb's detonation (engine/bomb.js): a bright fireball flash at ground zero
  // that collapses fast, under a slow-moving shockwave ring that crawls out from
  // e.coreRadius (the guaranteed-kill distance) to e.radius (the true blast radius the
  // damage falloff reaches zero at) over most of the effect's life β€” a deliberately
  // unhurried, decelerating travel (real shockwaves lose speed as they lose energy) so the
  // blast reads as a big, dramatic event rather than a one-frame pop. The ring cools from a
  // hot core color to an ash-grey edge color as it travels, echoing bombDamageAt's own
  // falloff from peak damage to a scratch. Reduced-motion viewers get a single fast static
  // ring at the true radius instead of the slow travel.
  for (const e of explosions) {
    const flashT = Math.min(1, e.age / EXPLOSION_FLASH_FRACTION);
    const flashR = (e.coreRadius || e.radius * 0.3) * Math.max(0, 1 - flashT);
    ctx.globalAlpha = Math.max(0, 1 - flashT * 1.2);
    ctx.fillStyle = "#fff3c4";
    ctx.beginPath();
    ctx.arc(e.x, e.y, flashR, 0, Math.PI * 2);
    ctx.fill();

    if (reduced) {
      ctx.globalAlpha = Math.max(0, 0.6 * (1 - e.age));
      ctx.strokeStyle = "#ff8a3d";
      ctx.lineWidth = 3;
      ctx.beginPath();
      ctx.arc(e.x, e.y, e.radius, 0, Math.PI * 2);
      ctx.stroke();
      continue;
    }

    const travel = Math.min(1, e.age / EXPLOSION_SHOCKWAVE_FRACTION);
    const eased = 1 - (1 - travel) ** 3;   // ease-out: fast off the blast, decelerating outward
    const ringR = (e.coreRadius || 0) + (e.radius - (e.coreRadius || 0)) * eased;
    const ringAlpha = e.age < EXPLOSION_SHOCKWAVE_FRACTION
      ? 0.9 - 0.5 * travel
      : Math.max(0, 0.4 * (1 - (e.age - EXPLOSION_SHOCKWAVE_FRACTION) / (1 - EXPLOSION_SHOCKWAVE_FRACTION)));
    ctx.globalAlpha = Math.max(0, ringAlpha);
    ctx.strokeStyle = lerpColor(SHOCKWAVE_HOT, SHOCKWAVE_COOL, eased);
    ctx.lineWidth = Math.max(1, 5 * (1 - eased * 0.6));
    ctx.beginPath();
    ctx.arc(e.x, e.y, ringR, 0, Math.PI * 2);
    ctx.stroke();
  }

  ctx.globalAlpha = 1;
}

// The grid-efficiency cue drawn under a power-consumer build ghost: the nearest own
// Reactor's zones + a connector to it, and the ghost's resulting tier as a coloured ring
// and label ("On-grid Β· draw Γ—1.0" … "Isolated Β· draw Γ—2.3"). Mirrors how the Spaceport
// shows its jump radius β€” it turns "where do I put this factory?" into a visible choice.
function drawGhostPowerCue(ctx, state, ghost, def) {
  // The source that DETERMINES the ghost's tier: the nearest by RANGE-SCALED distance, so a
  // close short-range Generator can out-rank a far Reactor exactly as the efficiency math does.
  let nearest = null, bestD = Infinity, nearestScale = 1;
  for (const b of state.buildings.values()) {
    const bd = BUILDINGS[b.type];
    if (b.owner !== "player" || b.constructing || !(bd && bd.energyGrants > 0)) continue;
    const scale = bd.powerRange || 1;
    const d = Math.hypot(b.x - ghost.x, b.y - ghost.y) / scale;
    if (d < bestD) { bestD = d; nearest = b; nearestScale = scale; }
  }

  const tier = powerEfficiency(state, "player", ghost.x, ghost.y);
  const col = POWER_TIER_COLOR[tier.name];

  if (nearest) {
    drawReactorBands(ctx, nearest.x, nearest.y, nearestScale);
    ctx.save();
    ctx.setLineDash([5, 5]);
    ctx.strokeStyle = hexA(col, 0.8);
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(nearest.x, nearest.y);
    ctx.lineTo(ghost.x, ghost.y);
    ctx.stroke();
    ctx.restore();
  }

  // A ring in the tier colour around the ghost, plus a label above it.
  ctx.save();
  ctx.beginPath();
  ctx.arc(ghost.x, ghost.y, def.radius + 7, 0, Math.PI * 2);
  ctx.strokeStyle = col;
  ctx.lineWidth = 2.5;
  ctx.stroke();

  const label = nearest
    ? `${tier.label} Β· draw Γ—${tier.mult.toFixed(1)}`
    : "No Reactor β€” power it first";
  const ly = ghost.y - def.radius - 12;
  drawLabelChip(ctx, label, ghost.x, ly, nearest ? col : "#f87171");
  ctx.restore();
}

// The Plasma Rig placement survey: a dashed violet ring at the survey radius, a highlight on
// each VISIBLE deposit the rig would read inside it, and a label predicting the vein + seam
// richness below (engine/rig.js rigSurvey). Only the player's charted surface counts, so a spot
// with no visible deposits reads "blind β€” a gamble", and an unscouted cache stays a surprise.
function drawRigSurveyCue(ctx, state, ghost, def) {
  const VIOLET = "180, 140, 255";
  ctx.save();
  ctx.setLineDash([4, 8]);
  ctx.lineWidth = 1.2;
  ctx.strokeStyle = `rgba(${VIOLET}, 0.5)`;
  ctx.beginPath();
  ctx.arc(ghost.x, ghost.y, SURVEY_RADIUS, 0, Math.PI * 2);
  ctx.stroke();
  ctx.restore();

  const visible = state.map.nodes.filter(n => n.amount > 0 && isNodeDiscovered(state.fog, n)
    && Math.hypot(n.x - ghost.x, n.y - ghost.y) < SURVEY_RADIUS);
  ctx.save();
  ctx.setLineDash([]);
  ctx.strokeStyle = `rgba(${VIOLET}, 0.85)`;
  ctx.lineWidth = 1.5;
  for (const n of visible) {
    ctx.beginPath();
    ctx.arc(n.x, n.y, 7 + 9 * (n.amount / n.max) + 4, 0, Math.PI * 2);
    ctx.stroke();
  }
  ctx.restore();

  const survey = rigSurvey(visible, state.planetId, ghost.x, ghost.y);
  const meta = survey.likelyVein ? COM[survey.likelyVein] : null;
  const confWord = !survey.likelyVein ? "" : survey.confidence >= 0.6 ? "likely " : survey.confidence >= 0.35 ? "maybe " : "toss-up: ";
  const label = survey.likelyVein
    ? `⛏ ${confWord}${meta?.name || survey.likelyVein} Β· ${survey.richLabel} seam`
    : "⛏ blind spot β€” no surface to read (a gamble)";

  ctx.save();
  const ly = ghost.y - def.radius - 34;   // sits above the grid-efficiency label (at radius+12)
  drawLabelChip(ctx, label, ghost.x, ly, survey.likelyVein ? "#c4b5fd" : "#f0a0a0", "rgba(5, 7, 15, 0.8)");
  ctx.restore();
}

// Translucent footprint under the cursor while placing a building, green
// when the spot is buildable and red when it isn't (out of bounds,
// overlapping another building, or too close to a resource node β€” see
// engine/colliders.js) so invalid placement is obvious before the
// player even clicks, not just rejected silently after.
export function drawBuildGhost(ctx, state, ghost) {
  const def = BUILDINGS[ghost.buildingType];
  if (!def) return;

  // Placing a power consumer (a factory, the Plasma Rig, or the Gate)? Surface the
  // grid-efficiency zones of every nearby Reactor and tag the ghost with the tier it
  // would land in, so the "closer is cheaper to power" call is visible BEFORE the click.
  if (def.recipe || def.rig || def.wonder) drawGhostPowerCue(ctx, state, ghost, def);

  // Placing a Plasma Rig? Read the visible surface deposits and predict the vein + seam
  // richness below, so late-game placement is an educated guess off the map, not a blind pick.
  if (def.rig) drawRigSurveyCue(ctx, state, ghost, def);

  const valid = canPlaceBuilding(state, ghost.buildingType, ghost.x, ghost.y);
  const color = valid ? "#4ade80" : "#f87171";
  const r = def.radius;

  ctx.globalAlpha = 0.45;
  ctx.fillStyle = color;
  ctx.fillRect(ghost.x - r, ghost.y - r, r * 2, r * 2);
  ctx.globalAlpha = 1;
  ctx.strokeStyle = color;
  ctx.lineWidth = 2;
  // An INVALID spot carries a non-colour cue too β€” a dashed outline plus an βœ• across the
  // footprint β€” so it reads without relying on the red-vs-green hue alone (the same
  // colourblind-safe principle the health bars use). A valid spot stays a clean solid box.
  if (valid) {
    ctx.strokeRect(ghost.x - r, ghost.y - r, r * 2, r * 2);
  } else {
    ctx.setLineDash([5, 4]);
    ctx.strokeRect(ghost.x - r, ghost.y - r, r * 2, r * 2);
    ctx.beginPath();
    ctx.moveTo(ghost.x - r, ghost.y - r); ctx.lineTo(ghost.x + r, ghost.y + r);
    ctx.moveTo(ghost.x + r, ghost.y - r); ctx.lineTo(ghost.x - r, ghost.y + r);
    ctx.stroke();
    ctx.setLineDash([]);
  }
}

// `force` keeps the bar visible at full health for a selected entity, so you
// can confirm your army's condition at a glance instead of it vanishing the
// moment it's topped off. Three colour bands (green / amber / red) so the
// health tier reads without relying on the green-vs-red distinction alone.

export function drawSelectionRings(ctx, state, alpha = 1) {
  ctx.strokeStyle = "#4fd1ff";
  ctx.lineWidth = 2;
  for (const id of state.selection) {
    const unit = state.units.get(id);
    const e = unit || state.buildings.get(id);
    if (!e) continue;
    const baseRadius = unit ? UNITS[unit.type].radius : e.radius;
    const r = baseRadius + 4;
    const d = unit ? lerpXY(unit, alpha) : e;   // ring follows the interpolated hull (buildings are static)
    ctx.beginPath();
    ctx.arc(d.x, d.y, r, 0, Math.PI * 2);
    ctx.stroke();
  }
}

// Shown only for the single selected production building, matching the
// usual RTS convention of not cluttering the view with every building's
// rally line at once.
export function drawRallyPoint(ctx, state) {
  if (state.selection.length !== 1) return;
  const building = state.buildings.get(state.selection[0]);
  if (!building || building.owner !== "player" || !BUILDINGS[building.type].produces) return;

  // Guard before destructuring: `rally` is optional on a Building and engine/persist.js's
  // cleanEntity never defaults it, so an older or hand-edited save reaches here with none. A
  // TypeError from a single entity used to take the whole frame β€” and, before drawFrame's
  // try/finally, every frame after it.
  const rally = building.rally;
  if (!rally) return;
  const { x: rx, y: ry } = rally;
  ctx.save();
  ctx.setLineDash([6, 6]);
  ctx.strokeStyle = "rgba(79, 209, 255, 0.6)";
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.moveTo(building.x, building.y);
  ctx.lineTo(rx, ry);
  ctx.stroke();
  ctx.restore();

  ctx.beginPath();
  ctx.arc(rx, ry, 5, 0, Math.PI * 2);
  ctx.fillStyle = "#4fd1ff";
  ctx.fill();
  ctx.strokeStyle = "#05070f";
  ctx.lineWidth = 1;
  ctx.stroke();
}

// The queued-waypoint path for every selected player unit: a dashed line
// threading the unit through its active order and each queued step,
// with a dot at each stop. Only drawn for units that actually have a queue,
// so an ordinary single-destination move doesn't clutter the field.
export function drawWaypoints(ctx, state) {
  ctx.save();
  ctx.setLineDash([4, 5]);
  for (const id of state.selection) {
    const unit = state.units.get(id);
    if (!unit || unit.owner !== "player" || !unit.orderQueue || unit.orderQueue.length === 0) continue;

    const stops = [];
    for (const order of [unit.order, ...unit.orderQueue]) {
      const pt = orderPoint(state, order);
      if (pt) stops.push(pt);
    }
    if (!stops.length) continue;

    ctx.strokeStyle = "rgba(79, 209, 255, 0.5)";
    ctx.lineWidth = 1.2;
    ctx.beginPath();
    ctx.moveTo(unit.x, unit.y);
    for (const s of stops) ctx.lineTo(s.x, s.y);
    ctx.stroke();

    ctx.fillStyle = "#4fd1ff";
    for (const s of stops) {
      ctx.beginPath();
      ctx.arc(s.x, s.y, 2.5, 0, Math.PI * 2);
      ctx.fill();
    }
  }
  ctx.restore();
}

// A faint link from each selected escort to the friendly ship it's guarding, plus a ring on the
// target β€” so an active escort order reads at a glance (it carries no waypoint line otherwise).
// Escort green, distinct from the cyan waypoint colour.
export function drawEscortLinks(ctx, state) {
  const guarded = new Set();
  ctx.save();
  ctx.setLineDash([3, 4]);
  ctx.strokeStyle = "rgba(120, 230, 170, 0.5)";
  ctx.lineWidth = 1.1;
  for (const id of state.selection) {
    const u = state.units.get(id);
    if (!u || u.owner !== "player" || !u.order || u.order.type !== "escort") continue;
    const t = state.units.get(u.order.targetId);
    if (!t || t.hp <= 0) continue;
    ctx.beginPath(); ctx.moveTo(u.x, u.y); ctx.lineTo(t.x, t.y); ctx.stroke();
    guarded.add(t);
  }
  ctx.setLineDash([]);
  ctx.strokeStyle = "rgba(120, 230, 170, 0.75)";
  ctx.lineWidth = 1.4;
  for (const t of guarded) {
    ctx.beginPath();
    ctx.arc(t.x, t.y, (UNITS[t.type]?.radius || 10) + 6, 0, Math.PI * 2);
    ctx.stroke();
  }
  ctx.restore();
}

// Where an order points on the map, for its waypoint marker β€” a fixed spot
// for move/attack-move, or the live position of the unit/building/node it's
// chasing. Null for an order with nowhere to point.
function orderPoint(state, order) {
  if (!order) return null;
  if (order.type === "move" || order.type === "attack-move") return { x: order.x, y: order.y };
  if (order.type === "attack" || order.type === "escort") {
    const t = state.units.get(order.targetId) || state.buildings.get(order.targetId);
    return t ? { x: t.x, y: t.y } : null;
  }
  if (order.type === "gather") {
    const n = state.map.nodes.find(nd => nd.id === order.nodeId);
    return n ? { x: n.x, y: n.y } : null;
  }
  if (order.type === "build") {
    const b = state.buildings.get(order.buildingId);
    return b ? { x: b.x, y: b.y } : null;
  }
  return null;
}

export function drawDragBox(ctx, box) {
  const x = Math.min(box.x1, box.x2), y = Math.min(box.y1, box.y2);
  const w = Math.abs(box.x2 - box.x1), h = Math.abs(box.y2 - box.y1);
  ctx.fillStyle = "rgba(79, 209, 255, 0.15)";
  ctx.fillRect(x, y, w, h);
  ctx.strokeStyle = "#4fd1ff";
  ctx.lineWidth = 1;
  ctx.strokeRect(x, y, w, h);
}