File size: 27,860 Bytes
1aaa2ca
 
 
 
 
 
 
 
 
 
 
 
d213ade
1aaa2ca
b870df3
1aaa2ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ccad3e
1aaa2ca
 
53d4247
1aaa2ca
 
 
a53030d
 
 
 
 
 
 
 
 
71c607d
 
6bd2c72
 
 
 
1aaa2ca
 
 
 
 
 
 
b870df3
1aaa2ca
 
 
 
 
 
 
 
 
 
 
a53030d
1aaa2ca
 
 
 
b870df3
1aaa2ca
 
 
 
 
 
 
 
 
 
d213ade
 
 
 
 
 
1aaa2ca
 
 
 
d213ade
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693efa9
1aaa2ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ccad3e
 
 
 
 
 
1aaa2ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ccad3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1aaa2ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53d4247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/* ============================================================
   Unit rendering β€” the per-type vector hulls (Worker, Ranger, Skiff, Bastion,
   …, Freighter, Colony Ship), drawn in two passes (all hulls, then all overlays:
   cargo dot, enemy pip, health bar) so a later hull can't paint out an earlier
   unit's bar. Oriented hulls point the way they're moving (renderShared facing /
   updateFacing) and draw at the interpolated position (lerpXY). The shape
   dispatch (drawUnitShape) is shared with the HUD's button icons (spriteIcon).
   Split out of render.js; drawFrame calls drawUnits.
   ============================================================ */

"use strict";

import { UNITS, rankMults } from "./engine/entities.js";
import { DETAIL, facing, updateFacing, lerpXY, inView, drawHealthBar,
         shade, polygonPoints, pathPoints, toWorld, pathOriented, hiddenByFog } from "./renderShared.js";

/* ---------- units ---------- */

// The unit shape dispatch, factored out of drawUnits so the HUD's button icons render
// the exact same sprite. The caller sets ctx.fillStyle (owner colour) + strokeStyle
// (DETAIL) first, as drawUnits does. Oriented hulls default to facing "up" for a static
// icon (updateFacing has no movement to read).
export function drawUnitShape(ctx, u, def, color) {
  if (u.type === "worker") drawWorker(ctx, u, def, color);
  else if (u.type === "ranger") drawRanger(ctx, u, def, color);
  else if (u.type === "skiff") drawSkiff(ctx, u, def, color);
  else if (u.type === "bastion") drawBastion(ctx, u, def, color);
  else if (u.type === "lancer") drawLancer(ctx, u, def, color);
  else if (u.type === "breacher") drawBreacher(ctx, u, def, color);
  else if (u.type === "dreadnought") drawDreadnought(ctx, u, def, color);
  else if (u.type === "mender") drawMender(ctx, u, def, color);
  else if (u.type === "wraith") drawWraith(ctx, u, def, color);
  else if (u.type === "aegis") drawAegis(ctx, u, def, color);
  else if (u.type === "colossus") drawColossus(ctx, u, def, color);
  else if (u.type === "leviathan") drawLeviathan(ctx, u, def, color);
  else if (u.type === "freighter" || u.type === "hauler" || u.type === "heavyhauler" || u.type === "bulkfreighter") drawFreighter(ctx, u, def, color);
  else if (u.type === "colonyship") drawColonyShip(ctx, u, def, color);
  else if (u.type === "heliumbomb") drawHeliumBomb(ctx, u, def, color);
  else drawGenericUnit(ctx, u, def, color);   // any future unit still gets a silhouette, never an invisible blank
}

// A shallow view of a unit at its interpolated draw position. This used to be ONE module-level
// object refilled with Object.assign β€” which copies own properties but never DELETES stale ones,
// so any optional field a unit lacks was inherited from whichever unit drew before it. Two visible
// consequences, both reproduced: an INERT Helium Bomb rendered with the armed cue (long red spikes,
// hot core β€” a safety signal) whenever an armed one drew earlier in the same frame, and an idle
// unit snapped to whatever explicit heading the last click-and-drag had set on some other unit,
// defeating the explicit-facing feature. A fresh spread per unit costs one small object per unit
// per pass and cannot carry anything over.
const dispOf = (u, x, y) => ({ ...u, x, y });
// `selSet` is the current-selection Set, computed ONCE per frame by the caller (render.js
// drawFrame) and passed down here rather than allocated fresh on every call.
// observerMode (default false, so every existing caller/test is unaffected): bypasses the fog
// gate below entirely β€” Observer Mode's whole point is seeing units regardless of ownership or
// vision, never a mutation of state.fog itself (see observer.js's header comment).
export function drawUnits(ctx, state, view, alpha = 1, selSet, observerMode = false) {
  // Two passes over the same culled set: ALL hulls first, then ALL overlays (health bars,
  // enemy pips, cargo dots). Otherwise, in a dense melee, a unit drawn later paints its hull
  // over an earlier unit's health bar β€” exactly when the bar matters most. lerp+cull recompute
  // per pass is cheap (a Map.get + a couple of mults) and allocates nothing.
  for (const u of state.units.values()) {
    const d = lerpXY(u, alpha);   // interpolated {x,y} (or the live unit when there's no baseline / a teleport)
    if (view && !inView(view, d.x, d.y, 16)) continue;   // off-screen unit
    if (hiddenByFog(state, u, d.x, d.y, observerMode)) continue;
    const def = UNITS[u.type];
    const color = state.players[u.owner].color;
    ctx.fillStyle = color;
    // A dark outline disappears against the (equally dark) background β€” it only ever separated
    // overlapping same-color units, never defined the silhouette. A light one keeps the shape
    // crisp at small sizes, where anti-aliasing otherwise blurs a hull's corners into a blob.
    ctx.strokeStyle = DETAIL;
    ctx.lineWidth = 1.5;
    // Draw the hull at the interpolated position via a shallow scratch copy, so every shape
    // helper (and updateFacing, which it calls) sees the smoothed coordinates without threading
    // them through each one. The scratch is reused β€” no per-unit alloc.
    drawUnitShape(ctx, dispOf(u, d.x, d.y), def, color);
  }
  for (const u of state.units.values()) {
    const d = lerpXY(u, alpha);
    if (view && !inView(view, d.x, d.y, 16)) continue;
    if (hiddenByFog(state, u, d.x, d.y, observerMode)) continue;
    const def = UNITS[u.type];
    if (u.cargo && u.cargo.qty > 0) {
      ctx.beginPath();
      ctx.arc(d.x, d.y - def.radius - 5, 3, 0, Math.PI * 2);
      ctx.fillStyle = "#ffd166";
      ctx.fill();
    }
    // A small downward pip marks hostile units β€” a SHAPE cue, so telling friend from foe in a
    // melee doesn't rely on the cyan-vs-red colour alone. Friendlies carry no marker.
    if (u.owner !== "player") drawEnemyPip(ctx, d.x, d.y + def.radius + 6);
    // Veterancy chevrons (docs/improvement-proposals.md "Veterancy ranks"): drawn for EITHER
    // side (an enemy veteran's chevrons are exactly the intel a scout brings back, same as the
    // enemy pip above), and independent of drawHealthBar's own "hide at full hp" rule β€” a rank
    // is a permanent trait, not a damage state, so it stays visible on a topped-off veteran too.
    const rank = rankMults(u).rank;
    if (rank > 0) drawVeterancyChevrons(ctx, d.x, d.y - def.radius - 13, rank);
    drawHealthBar(ctx, d.x, d.y - def.radius - 9, 16, u.hp, u.maxHp, selSet.has(u.id));
  }
}

// A small stack of upward chevrons, one per veterancy rank, just above the health bar β€” a
// veteran line reads at a glance without opening its stat panel. Own color (distinct from every
// hull detail/cargo-dot/enemy-pip hue already in play here) so it never blends into whichever
// unit it's stamped over.
const CHEVRON_COLOR = "#a5f3fc";
function drawVeterancyChevrons(ctx, cx, y, rank) {
  ctx.strokeStyle = CHEVRON_COLOR;
  ctx.lineWidth = 1.4;
  for (let i = 0; i < rank; i++) {
    const cy = y - i * 4;
    ctx.beginPath();
    ctx.moveTo(cx - 4, cy + 2);
    ctx.lineTo(cx, cy - 2);
    ctx.lineTo(cx + 4, cy + 2);
    ctx.stroke();
  }
}

export function drawEnemyPip(ctx, x, y) {
  ctx.beginPath();
  ctx.moveTo(x - 4, y - 4);
  ctx.lineTo(x + 4, y - 4);
  ctx.lineTo(x, y + 1);
  ctx.closePath();
  ctx.fillStyle = "#f87171";
  ctx.fill();
  ctx.lineWidth = 1;
  ctx.strokeStyle = "#05070f";
  ctx.stroke();
}

// Worker β€” a small hex-bodied utility pod with two stub grabber arms and a
// sensor "eye", reading as a drone rather than a combatant. Unoriented
// (nothing about gathering/building implies a facing), unlike the two
// combat units below.
function drawWorker(ctx, u, def, color) {
  const r = def.radius, cx = u.x, cy = u.y;
  pathPoints(ctx, polygonPoints(cx, cy, r, 6, Math.PI / 6));
  ctx.fill();
  ctx.stroke();

  ctx.fillStyle = shade(color, -25);
  ctx.fillRect(cx - r - 2.5, cy - 1.5, 2.5, 3);
  ctx.fillRect(cx + r, cy - 1.5, 2.5, 3);

  ctx.beginPath();
  ctx.arc(cx, cy - r * 0.1, r * 0.3, 0, Math.PI * 2);
  ctx.fillStyle = DETAIL;
  ctx.fill();
}

// Ranger β€” a light, fast recon craft: a slim forward-swept hull ringed by a
// sensor scanner amidships with a lit eye at the nose, so the scout reads as
// "eyes, not guns" β€” distinct from the Skiff's winged dart. Oriented, since it's
// almost always on the move charting the map.
function drawRanger(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, L = r * 1.7, W = r * 0.85, cx = u.x, cy = u.y;
  pathOriented(ctx, cx, cy, angle, [
    [L, 0],
    [-L * 0.35, W],
    [-L * 0.7, 0],
    [-L * 0.35, -W],
  ]);
  ctx.fill();
  ctx.stroke();

  ctx.beginPath();                                   // sensor ring β€” signals its long sight
  ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2);
  ctx.strokeStyle = DETAIL;
  ctx.lineWidth = 1.1;
  ctx.stroke();

  const [nx, ny] = toWorld(cx, cy, angle, L * 0.5, 0);   // lit scanner eye at the nose
  ctx.beginPath();
  ctx.arc(nx, ny, r * 0.24, 0, Math.PI * 2);
  ctx.fillStyle = DETAIL;
  ctx.fill();
}

// Colony Ship (Odyssey) β€” a mobile Command Center: the CC's octagonal hull at unit
// scale with a raised central dome so it reads as "a base in transit", and a warm
// engine flare at the stern so its heading is clear. Deploys (engine/colony.js) into
// a real Command Center at its parked spot.
function drawColonyShip(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, cx = u.x, cy = u.y;

  const [fx, fy] = toWorld(cx, cy, angle, -r * 1.35, 0);   // engine flare behind the hull
  ctx.beginPath();
  ctx.arc(fx, fy, r * 0.42, 0, Math.PI * 2);
  ctx.fillStyle = "#ffb454";
  ctx.fill();

  pathPoints(ctx, polygonPoints(cx, cy, r, 8, Math.PI / 8));   // octagon hull (echoes the CC)
  ctx.fillStyle = color;
  ctx.fill();
  ctx.strokeStyle = DETAIL;
  ctx.lineWidth = 1.5;
  ctx.stroke();

  ctx.beginPath();                                            // raised central dome
  ctx.arc(cx, cy, r * 0.42, 0, Math.PI * 2);
  ctx.fillStyle = shade(color, 20);
  ctx.fill();
  ctx.strokeStyle = "#05070f";
  ctx.lineWidth = 1;
  ctx.stroke();
}

// Mender β€” a support drone, not a combatant. A rounded octagonal body carrying
// a bright green medic cross (a fixed heal-green on both sides, so "this one
// heals" reads independent of the friend/foe colour), flanked by two little
// emitter nubs. Unoriented like the Worker β€” it hovers and mends, it doesn't
// charge β€” so nothing about it says "gun", which is exactly the point.
const HEAL_GREEN = "#8ef5b0";
function drawMender(ctx, u, def, color) {
  const r = def.radius, cx = u.x, cy = u.y;
  pathPoints(ctx, polygonPoints(cx, cy, r, 8, Math.PI / 8));
  ctx.fill();
  ctx.stroke();

  // Twin emitter nubs at the flanks (where the repair beams would emit from).
  ctx.fillStyle = shade(color, -25);
  ctx.fillRect(cx - r - 2, cy - 1.5, 2.5, 3);
  ctx.fillRect(cx + r - 0.5, cy - 1.5, 2.5, 3);

  // The medic cross β€” the whole identity of the unit.
  const a = r * 0.72, t = r * 0.26;
  ctx.fillStyle = HEAL_GREEN;
  ctx.fillRect(cx - t / 2, cy - a / 2, t, a);
  ctx.fillRect(cx - a / 2, cy - t / 2, a, t);
}

// A last-resort silhouette for any unit type without a bespoke draw β€” a small
// diamond with a lit core. Every current roster type has its own drawUnitShape
// case below (test/render-roster.test.js's smoke test enforces this β€” it fails
// loudly the moment a type's trace matches this fallback's), so this exists
// purely to guarantee the "every entity has a graphical representation"
// invariant for anything added LATER: a new unit can never ship invisible, even
// in the gap before its own bespoke hull lands.
function drawGenericUnit(ctx, u, def, color) {
  const r = def.radius, cx = u.x, cy = u.y;
  pathPoints(ctx, [[cx, cy - r], [cx + r, cy], [cx, cy + r], [cx - r, cy]]);
  ctx.fill();
  ctx.stroke();
  ctx.beginPath();
  ctx.arc(cx, cy, r * 0.3, 0, Math.PI * 2);
  ctx.fillStyle = DETAIL;
  ctx.fill();
}

// Skiff β€” fast, ranged, cheap: drawn as a slim dart with swept wingtips and
// a lit engine tail, pointing the way it's moving so a mixed army reads at
// a glance and hints at facing mid-fight.
function drawSkiff(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, L = r * 1.6, W = r * 1.1;
  const cx = u.x, cy = u.y;

  pathOriented(ctx, cx, cy, angle, [
    [L, 0],
    [-L * 0.3, W],
    [-L * 0.6, W * 0.35],
    [-L * 0.75, 0],
    [-L * 0.6, -W * 0.35],
    [-L * 0.3, -W],
  ]);
  ctx.fill();
  ctx.stroke();

  ctx.fillStyle = DETAIL;
  for (const side of [1, -1]) {
    const [ex, ey] = toWorld(cx, cy, angle, -L * 0.7, side * W * 0.35);
    ctx.beginPath();
    ctx.arc(ex, ey, r * 0.16, 0, Math.PI * 2);
    ctx.fill();
  }
}

// Bastion β€” slow, tanky, short-ranged, bonus damage vs Skiffs: drawn as a
// heavier hull with side turret pods and twin nose cannons, so it reads
// as armored muscle rather than the Skiff's slim dart even at a glance.
function drawBastion(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, L = r * 1.3, W = r * 1.0;
  const cx = u.x, cy = u.y;

  pathOriented(ctx, cx, cy, angle, [
    [L, 0],
    [L * 0.45, W],
    [-L * 0.6, W * 0.85],
    [-L * 0.6, -W * 0.85],
    [L * 0.45, -W],
  ]);
  ctx.fill();
  ctx.stroke();

  ctx.fillStyle = shade(color, -25);
  for (const side of [1, -1]) {
    const [tx, ty] = toWorld(cx, cy, angle, -L * 0.05, side * W * 0.95);
    ctx.beginPath();
    ctx.arc(tx, ty, r * 0.3, 0, Math.PI * 2);
    ctx.fill();
    ctx.stroke();
  }

  ctx.fillStyle = DETAIL;
  for (const side of [1, -1]) {
    const [nx, ny] = toWorld(cx, cy, angle, L * 0.85, side * W * 0.25);
    ctx.beginPath();
    ctx.arc(nx, ny, r * 0.16, 0, Math.PI * 2);
    ctx.fill();
  }
}

// Lancer β€” long-ranged, armor-piercing, squishier than Bastion: drawn as a
// slender javelin hull with a lit lance-tip and small tail fins, reading as
// a precision skirmisher distinct from Skiff's stubby dart and Bastion's
// armored bulk.
function drawLancer(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, L = r * 2.0, W = r * 0.55;
  const cx = u.x, cy = u.y;

  pathOriented(ctx, cx, cy, angle, [
    [L, 0],
    [L * 0.2, W],
    [-L * 0.7, W * 0.4],
    [-L, 0],
    [-L * 0.7, -W * 0.4],
    [L * 0.2, -W],
  ]);
  ctx.fill();
  ctx.stroke();

  ctx.strokeStyle = DETAIL;
  ctx.lineWidth = 1;
  const [shaftX1, shaftY1] = toWorld(cx, cy, angle, L * 0.9, 0);
  const [shaftX2, shaftY2] = toWorld(cx, cy, angle, -L * 0.3, 0);
  ctx.beginPath();
  ctx.moveTo(shaftX1, shaftY1);
  ctx.lineTo(shaftX2, shaftY2);
  ctx.stroke();

  ctx.fillStyle = DETAIL;
  const [tipX, tipY] = toWorld(cx, cy, angle, L, 0);
  ctx.beginPath();
  ctx.arc(tipX, tipY, r * 0.14, 0, Math.PI * 2);
  ctx.fill();

  ctx.fillStyle = shade(color, -25);
  for (const side of [1, -1]) {
    const [fx1, fy1] = toWorld(cx, cy, angle, -L * 0.45, side * W * 0.3);
    const [fx2, fy2] = toWorld(cx, cy, angle, -L * 0.65, side * W * 0.55);
    const [fx3, fy3] = toWorld(cx, cy, angle, -L * 0.85, side * W * 0.25);
    ctx.beginPath();
    ctx.moveTo(fx1, fy1);
    ctx.lineTo(fx2, fy2);
    ctx.lineTo(fx3, fy3);
    ctx.closePath();
    ctx.fill();
  }
}

// Breacher β€” a wide, low siege chassis CARRYING an oversized gun, where the
// Lancer's whole hull instead IS its javelin. The barrel overhangs the hull
// well past the nose and two recoil spades brace the rear, so it reads as
// artillery hauling a cannon rather than a fighter.
function drawBreacher(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, L = r * 1.2, W = r * 0.9;
  const cx = u.x, cy = u.y;

  pathOriented(ctx, cx, cy, angle, [
    [L * 0.5, W],
    [L * 0.5, -W],
    [-L * 0.7, -W * 0.8],
    [-L * 0.7, W * 0.8],
  ]);
  ctx.fill();
  ctx.stroke();

  ctx.strokeStyle = shade(color, -25);
  ctx.lineWidth = r * 0.3;
  const [bx1, by1] = toWorld(cx, cy, angle, -L * 0.2, 0);
  const [bx2, by2] = toWorld(cx, cy, angle, L * 1.9, 0);
  ctx.beginPath();
  ctx.moveTo(bx1, by1);
  ctx.lineTo(bx2, by2);
  ctx.stroke();

  ctx.fillStyle = shade(color, -25);
  for (const side of [1, -1]) {
    pathOriented(ctx, cx, cy, angle, [
      [-L * 0.6, side * W * 0.45],
      [-L * 0.6, side * W * 0.8],
      [-L, side * W * 0.6],
    ]);
    ctx.fill();
  }

  ctx.fillStyle = DETAIL;
  const [tipX, tipY] = toWorld(cx, cy, angle, L * 1.9, 0);
  ctx.beginPath();
  ctx.arc(tipX, tipY, r * 0.16, 0, Math.PI * 2);
  ctx.fill();
}

// Dreadnought β€” the Tier-3 capital ship: a big, broad, armoured hull with a
// spinal cannon, four side batteries and a bright command bridge, so it reads
// as a fortress that dwarfs the line units even at a glance.
function drawDreadnought(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, L = r * 1.5, W = r * 1.15;
  const cx = u.x, cy = u.y;

  // Broad angular hull.
  pathOriented(ctx, cx, cy, angle, [
    [L, 0],
    [L * 0.55, W],
    [-L * 0.65, W],
    [-L, W * 0.5],
    [-L, -W * 0.5],
    [-L * 0.65, -W],
    [L * 0.55, -W],
  ]);
  ctx.fill();
  ctx.stroke();

  // Spinal cannon down the centreline.
  ctx.strokeStyle = shade(color, -30);
  ctx.lineWidth = r * 0.35;
  const [sx1, sy1] = toWorld(cx, cy, angle, -L * 0.4, 0);
  const [sx2, sy2] = toWorld(cx, cy, angle, L * 1.35, 0);
  ctx.beginPath(); ctx.moveTo(sx1, sy1); ctx.lineTo(sx2, sy2); ctx.stroke();

  // Four side battery pods.
  ctx.fillStyle = shade(color, -25);
  for (const side of [1, -1]) {
    for (const fx of [0.15, -0.5]) {
      const [px, py] = toWorld(cx, cy, angle, L * fx, side * W * 0.8);
      ctx.beginPath(); ctx.arc(px, py, r * 0.26, 0, Math.PI * 2); ctx.fill();
    }
  }

  // Command bridge glow.
  ctx.fillStyle = DETAIL;
  const [bx, by] = toWorld(cx, cy, angle, -L * 0.25, 0);
  ctx.beginPath(); ctx.arc(bx, by, r * 0.32, 0, Math.PI * 2); ctx.fill();
}

// Wraith β€” the gas-fuelled glass cannon: a long, forward-swept interceptor with
// wingtips raked back and a hot fusion core amidships, so it reads as the
// fastest, most dangerous, most fragile thing on the field β€” all engine and gun,
// no armour.
const FUSION = "#ffd166";   // Helium-3 core glow (warm) β€” distinct from the Mender's heal-green
function drawWraith(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, L = r * 1.9, W = r * 1.2;
  const cx = u.x, cy = u.y;

  pathOriented(ctx, cx, cy, angle, [
    [L, 0],
    [-L * 0.15, W * 0.5],
    [-L * 0.7, W],            // raked-back wingtip
    [-L * 0.5, W * 0.2],
    [-L * 0.85, 0],
    [-L * 0.5, -W * 0.2],
    [-L * 0.7, -W],
    [-L * 0.15, -W * 0.5],
  ]);
  ctx.fill();
  ctx.stroke();

  ctx.fillStyle = FUSION;                                   // fusion core amidships
  const [gx, gy] = toWorld(cx, cy, angle, -L * 0.08, 0);
  ctx.beginPath(); ctx.arc(gx, gy, r * 0.3, 0, Math.PI * 2); ctx.fill();

  ctx.fillStyle = DETAIL;                                   // lit nose
  const [nx, ny] = toWorld(cx, cy, angle, L * 0.6, 0);
  ctx.beginPath(); ctx.arc(nx, ny, r * 0.16, 0, Math.PI * 2); ctx.fill();
}

// Aegis β€” the ice-armoured wall: a broad, blocky hull wider than it is long,
// carrying a thick frontal armour plate and only a token gun, so it reads as a
// shield on legs β€” the anvil the Wraith is the hammer to.
function drawAegis(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, L = r * 1.1, W = r * 1.25;
  const cx = u.x, cy = u.y;

  pathOriented(ctx, cx, cy, angle, [
    [L, W * 0.6],
    [L, -W * 0.6],
    [-L * 0.7, -W],
    [-L, -W * 0.4],
    [-L, W * 0.4],
    [-L * 0.7, W],
  ]);
  ctx.fill();
  ctx.stroke();

  // Thick frontal armour plate, standing just off the nose.
  ctx.strokeStyle = shade(color, -30);
  ctx.lineWidth = r * 0.5;
  const [f1x, f1y] = toWorld(cx, cy, angle, L * 1.05, W * 0.8);
  const [f2x, f2y] = toWorld(cx, cy, angle, L * 1.05, -W * 0.8);
  ctx.beginPath(); ctx.moveTo(f1x, f1y); ctx.lineTo(f2x, f2y); ctx.stroke();

  ctx.fillStyle = DETAIL;
  const [cxx, cyy] = toWorld(cx, cy, angle, -L * 0.2, 0);
  ctx.beginPath(); ctx.arc(cxx, cyy, r * 0.28, 0, Math.PI * 2); ctx.fill();
}

// Colossus β€” the relic siege engine: a heavy hexagonal chassis behind an
// enormous barrel that overhangs far past the nose (the longest reach on the
// field), with an ancient-tech violet muzzle and core, so it reads as a slow,
// fragile-for-its-size superweapon that must be screened.
const RELIC = "#c4b5fd";   // ancient-tech violet
function drawColossus(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, L = r * 1.3, W = r * 1.05;
  const cx = u.x, cy = u.y;

  pathOriented(ctx, cx, cy, angle, [
    [L * 0.7, W],
    [L, 0],
    [L * 0.7, -W],
    [-L * 0.8, -W],
    [-L, 0],
    [-L * 0.8, W],
  ]);
  ctx.fill();
  ctx.stroke();

  // The oversized barrel β€” reaches further than any other unit's gun.
  ctx.strokeStyle = shade(color, -30);
  ctx.lineWidth = r * 0.32;
  const [b1x, b1y] = toWorld(cx, cy, angle, -L * 0.3, 0);
  const [b2x, b2y] = toWorld(cx, cy, angle, L * 2.4, 0);
  ctx.beginPath(); ctx.moveTo(b1x, b1y); ctx.lineTo(b2x, b2y); ctx.stroke();

  ctx.fillStyle = RELIC;                                    // muzzle + reactor core in relic-violet
  const [tx, ty] = toWorld(cx, cy, angle, L * 2.4, 0);
  ctx.beginPath(); ctx.arc(tx, ty, r * 0.2, 0, Math.PI * 2); ctx.fill();
  const [cxx, cyy] = toWorld(cx, cy, angle, -L * 0.15, 0);
  ctx.beginPath(); ctx.arc(cxx, cyy, r * 0.3, 0, Math.PI * 2); ctx.fill();
}

// Leviathan β€” the Strategic-tier endgame flagship: a long twin-spine super-capital hull, broader
// and longer than the Dreadnought (whose 4-battery/single-spine design it deliberately outdoes in
// every cue), so the last ship you ever build is unmistakably the biggest thing on the field. The
// stern tapers into two trailing spine prongs around a V-notch β€” the "twin-spine" silhouette β€”
// with a stern engine array glowing in the notch between them (a cue nothing else on the roster
// has), twin spinal cannon lines running the hull's length either side of the centerline (doubling
// the Dreadnought's single spinal cannon), six battery pods (half again the Dreadnought's four),
// and a bigger, outlined command bridge.
function drawLeviathan(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, L = r * 2.0, W = r * 1.3;
  const cx = u.x, cy = u.y;

  // Broad hull, tapering aft into twin trailing spines around a stern notch.
  pathOriented(ctx, cx, cy, angle, [
    [L, 0],
    [L * 0.55, W * 0.85],
    [-L * 0.5, W * 0.85],
    [-L * 0.85, W * 0.55],     // upper spine root
    [-L * 1.15, W * 0.5],      // upper spine tip
    [-L * 0.75, W * 0.08],     // notch inner, upper
    [-L * 0.75, -W * 0.08],    // notch inner, lower
    [-L * 1.15, -W * 0.5],     // lower spine tip
    [-L * 0.85, -W * 0.55],    // lower spine root
    [-L * 0.5, -W * 0.85],
    [L * 0.55, -W * 0.85],
  ]);
  ctx.fill();
  ctx.stroke();

  // Twin spinal cannons β€” a pair of lines flanking the centreline the full hull length, doubling
  // the Dreadnought's single spinal cannon.
  ctx.strokeStyle = shade(color, -30);
  ctx.lineWidth = r * 0.2;
  for (const off of [1, -1]) {
    const [x1, y1] = toWorld(cx, cy, angle, -L * 0.7, off * W * 0.16);
    const [x2, y2] = toWorld(cx, cy, angle, L * 1.4, off * W * 0.16);
    ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
  }

  // Six battery pods (three a side) β€” half again the Dreadnought's four, outlined as well as
  // filled so they read as mounted turrets rather than flat dots.
  ctx.fillStyle = shade(color, -25);
  ctx.strokeStyle = shade(color, -40);
  ctx.lineWidth = 1;
  for (const side of [1, -1]) {
    for (const fx of [0.35, -0.05, -0.45]) {
      const [px, py] = toWorld(cx, cy, angle, L * fx, side * W * 0.78);
      ctx.beginPath(); ctx.arc(px, py, r * 0.22, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
    }
  }

  // Lit command bridge β€” bigger than the Dreadnought's, and outlined for extra presence.
  ctx.fillStyle = DETAIL;
  const [bx, by] = toWorld(cx, cy, angle, -L * 0.12, 0);
  ctx.beginPath(); ctx.arc(bx, by, r * 0.34, 0, Math.PI * 2); ctx.fill();
  ctx.strokeStyle = shade(color, -30);
  ctx.lineWidth = 1.5;
  ctx.stroke();

  // Stern engine array β€” three glow points nested in the notch between the twin spines, a cue
  // unique to the Leviathan on the current roster.
  ctx.fillStyle = DETAIL;
  for (const off of [0, 0.42, -0.42]) {
    const [ex, ey] = toWorld(cx, cy, angle, -L * 0.95, off * W);
    ctx.beginPath(); ctx.arc(ex, ey, r * 0.13, 0, Math.PI * 2); ctx.fill();
  }
}

// Freighter β€” a slow, blocky cargo hauler for the convoy scenarios: a wide hull
// stacked with darker container blocks and a lit bridge at the nose, so it reads
// unmistakably as a civilian freighter to protect, not a warship.
function drawFreighter(ctx, u, def, color) {
  const angle = updateFacing(u);
  const r = def.radius, L = r * 1.5, W = r * 0.95;
  const cx = u.x, cy = u.y;

  pathOriented(ctx, cx, cy, angle, [
    [L, W * 0.45], [L, -W * 0.45],
    [L * 0.6, -W], [-L, -W],
    [-L, W], [L * 0.6, W],
  ]);
  ctx.fill();
  ctx.stroke();

  // Cargo containers stacked down the hull β€” gold when the freighter is actually laden (a player
  // hand-loaded hold, engine/galaxy.js freight), dim when empty, so a loaded ship reads at a glance.
  const laden = !!(u.freight && Object.keys(u.freight).length);
  ctx.fillStyle = laden ? "#e8b23a" : shade(color, -30);
  for (const fx of [0.35, -0.05, -0.45]) {
    pathOriented(ctx, cx, cy, angle, [
      [L * fx + r * 0.18, W * 0.72], [L * fx + r * 0.18, -W * 0.72],
      [L * fx - r * 0.22, -W * 0.72], [L * fx - r * 0.22, W * 0.72],
    ]);
    ctx.fill();
  }

  ctx.fillStyle = DETAIL;                                   // bridge light at the nose
  const [bx, by] = toWorld(cx, cy, angle, L * 0.82, 0);
  ctx.beginPath(); ctx.arc(bx, by, r * 0.18, 0, Math.PI * 2); ctx.fill();
}

// Helium Bomb β€” a spiked mine-like orb (engine/entities.js UNITS.heliumbomb, engine/bomb.js
// for the arm/detonate logic). Unoriented, like the Mender: it doesn't face anywhere, it just
// sits there being dangerous. UNARMED reads as inert β€” short spikes, a dark dead core β€” safe
// to park anywhere. ARMED has to be unmistakable at a glance, since walking anything (even a
// friendly) into it is the whole hazard: longer, more numerous spikes AND a hot glowing core,
// so the cue survives colourblindness on shape alone, not just the colour swap.
function drawHeliumBomb(ctx, u, def, color) {
  const r = def.radius, cx = u.x, cy = u.y;
  const armed = !!u.armed;

  const spikes = armed ? 10 : 6;
  const spikeLen = armed ? r * 0.65 : r * 0.35;
  ctx.strokeStyle = armed ? "#ff5e3d" : DETAIL;
  ctx.lineWidth = 2;
  for (let i = 0; i < spikes; i++) {
    const a = (i / spikes) * Math.PI * 2;
    const [ix, iy] = toWorld(cx, cy, a, r * 0.9, 0);
    const [ox, oy] = toWorld(cx, cy, a, r * 0.9 + spikeLen, 0);
    ctx.beginPath();
    ctx.moveTo(ix, iy);
    ctx.lineTo(ox, oy);
    ctx.stroke();
  }

  // The body: the owner's colour, so it still reads as a friendly/enemy unit at a glance,
  // armed or not.
  ctx.beginPath();
  ctx.arc(cx, cy, r * 0.75, 0, Math.PI * 2);
  ctx.fillStyle = color;
  ctx.fill();
  ctx.strokeStyle = DETAIL;
  ctx.lineWidth = 1.5;
  ctx.stroke();

  // The core: dark and inert unarmed, a bright hot glow armed β€” the primary "is this thing
  // live right now" signal, independent of the spike-count cue above.
  ctx.beginPath();
  ctx.arc(cx, cy, r * 0.35, 0, Math.PI * 2);
  ctx.fillStyle = armed ? "#ff8a3d" : "#1a2233";
  ctx.fill();
  if (armed) {
    ctx.strokeStyle = "#fff3c4";
    ctx.lineWidth = 1;
    ctx.stroke();
  }
}