File size: 78,884 Bytes
217da1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
809
810
// Turfbreak renderer: a rain-soaked night city in three.js r170 on the CyberMax stage (HDR bloom, ACES, grading,
// grain, shake). Physically based materials, wet asphalt that mirrors neon through a generated environment map,
// instanced buildings with world-space facades and lit windows, an x-ray cutaway so buildings never hide your
// operator, neon signs, sodium lamps, rain, tracers, muzzle lights, charges, Skyfall strikes and capture zones.
// Draws from a plain "view" object (live sim or a history frame), so kill-cams and replays use the same code.
// (c) 2026 CyberMax. All rights reserved.
import { Stage, THREE, col, arcMat, setArc, TUBE, damp, clamp01 } from './stage3d.js';
import { TEAMS, BOUND, BODY } from './core.js';
import { ViewModel, mergeParts, thirdPersonGun } from './fpv.js';

const R2 = (s) => { let a = s >>> 0; return () => { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; };
const cv = (w, h) => { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; };
const tex = (c, rep = true, srgb = true) => { const t = new THREE.CanvasTexture(c); if (rep) t.wrapS = t.wrapT = THREE.RepeatWrapping; t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace; t.anisotropy = 4; t.needsUpdate = true; return t; };

export const SKINS = [
  { id: 'standard', name: 'Field Issue', jacket: null, plate: null, visor: null, free: true },
  { id: 'nightshade', name: 'Nightshade', jacket: '#101014', plate: '#1C1A24', visor: '#B36BFF' },
  { id: 'ember', name: 'Ember', jacket: '#3A1A0E', plate: '#5A2A12', visor: '#FF5A1F', trace: '#FF7A2A' },
  { id: 'arctic', name: 'Arctic', jacket: '#B9C2CC', plate: '#E4E9EE', visor: '#7FE3FF' },
  { id: 'gilded', name: 'Gilded', jacket: '#16130C', plate: '#C9A43A', visor: '#FFE08A', metal: true, trace: '#FFD36A' },
  { id: 'static', name: 'Static', jacket: '#0B1418', plate: '#14262C', visor: '#4CFFB0', stripe: '#4CFFB0', trace: '#5CFFC0' },
  { id: 'carbon', name: 'Carbon', jacket: '#141618', plate: '#24272B', visor: '#FF2E4C', trace: '#FF4A5E' },
];

const THEMES = {
  row: { ground: '#1B1D22', side: '#2A2C31', fog: '#0C1322', fogD: 0.0105, hemi: ['#6070A8', '#2A2228', 1.45], moon: ['#A8BCFF', 1.5], neon: ['#FF2E88', '#2EE6FF', '#FFB13B', '#B36BFF', '#4CFF9A'], lamp: '#FFA347', look: { lift: [0.012, 0.01, 0.03], gain: [1.02, 0.98, 1.04] } },
  docks: { ground: '#1E2124', side: '#2B2E30', fog: '#081419', fogD: 0.0115, hemi: ['#5A8494', '#28241F', 1.45], moon: ['#B0D8FF', 1.6], neon: ['#2EE6FF', '#FFB13B', '#FF5A3D', '#2EE6FF', '#FFE16B'], lamp: '#FFB35C', look: { lift: [0.0, 0.015, 0.025], gain: [0.98, 1.0, 1.03] } },
  yard: { ground: '#1F1C1D', side: '#2C2829', fog: '#140D16', fogD: 0.011, hemi: ['#86689A', '#2A2222', 1.45], moon: ['#FFD0B0', 1.4], neon: ['#FF4A5E', '#FFB13B', '#B36BFF', '#2EE6FF', '#FF2E88'], lamp: '#FF9A3C', look: { lift: [0.02, 0.008, 0.02], gain: [1.04, 0.98, 1.0] } },
};
const WORDS = ['RAMEN', 'HOTEL', 'ARCADE', 'LAUNDRY', '24/7', 'KAITO', 'NOODLES', 'DINER', 'TATTOO', 'MOTEL'];

// ------------------------------------------------------------------ procedural textures
function facadeTexture(seed) {
  const W = 512, H = 410, c = cv(W, H), g = c.getContext('2d'); const r = R2(seed);
  const base = ['#6A6E78', '#7A6C62', '#5C646C', '#80786E'][seed % 4];
  g.fillStyle = base; g.fillRect(0, 0, W, H);
  const id = g.getImageData(0, 0, W, H); const d = id.data;
  for (let i = 0; i < d.length; i += 4) { const n = (r() - 0.5) * 22; d[i] += n; d[i + 1] += n; d[i + 2] += n; }
  g.putImageData(id, 0, 0);
  // floor bands, panel seams, grime streaks
  g.fillStyle = 'rgba(0,0,0,.28)'; for (const y of [0, H / 2]) g.fillRect(0, y, W, 16);
  g.strokeStyle = 'rgba(0,0,0,.22)'; g.lineWidth = 2; for (let x = 0; x <= W; x += 128) { g.beginPath(); g.moveTo(x, 0); g.lineTo(x, H); g.stroke(); }
  for (let k = 0; k < 40; k++) { const x = r() * W, y = r() * H; const gr = g.createLinearGradient(0, y, 0, y + 90); gr.addColorStop(0, 'rgba(0,0,0,.18)'); gr.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = gr; g.fillRect(x, y, 3 + r() * 6, 90); }
  // two windows per floor: frame + dark glass with a faint reflection
  for (const fy of [0, H / 2]) for (const fx of [0, W / 2]) {
    const x = fx + 38, y = fy + 46, w = W / 2 - 76, h = H / 2 - 92;
    g.fillStyle = '#16181C'; g.fillRect(x - 6, y - 6, w + 12, h + 12);
    const gl = g.createLinearGradient(x, y, x + w, y + h); gl.addColorStop(0, '#202A36'); gl.addColorStop(0.5, '#0C1016'); gl.addColorStop(1, '#1A2230'); g.fillStyle = gl; g.fillRect(x, y, w, h);
    g.fillStyle = '#16181C'; g.fillRect(x + w / 2 - 3, y, 6, h);
    g.fillStyle = 'rgba(0,0,0,.45)'; g.fillRect(x - 10, y + h + 6, w + 20, 8); // sill
  }
  return tex(c);
}
/** Lit-window atlas aligned with facadeTexture's windows: 16 x 16 floors-by-columns, random warm/cool/off. */
function windowAtlas(seed) {
  const N = 16, cw = 64, ch = 51.25, c = cv(N * cw, Math.round(N * ch)), g = c.getContext('2d'); const r = R2(seed);
  g.fillStyle = '#000'; g.fillRect(0, 0, c.width, c.height);
  // facade texture = 2x2 windows; atlas covers 8x8 facade tiles -> 16x16 windows, each window in its tile at the same spot
  const tw = c.width / 8, th = c.height / 8;
  for (let ty = 0; ty < 8; ty++) for (let tx = 0; tx < 8; tx++) for (const fy of [0, 0.5]) for (const fx of [0, 0.5]) {
    const v = r(); if (v < 0.52) continue;
    const warm = r() < 0.72; const k = 0.35 + r() * 0.65;
    const colr = warm ? [255, 170 + r() * 50, 90 + r() * 40] : [140, 200, 255];
    const x = tx * tw + fx * tw + tw * (38 / 512), y = ty * th + fy * th + th * (46 / 410), w = tw * (0.5 - 76 / 512), h = th * (0.5 - 92 / 410);
    const gr = g.createLinearGradient(0, y, 0, y + h); gr.addColorStop(0, `rgba(${colr[0]},${colr[1]},${colr[2]},${k * 0.55})`); gr.addColorStop(1, `rgba(${colr[0]},${colr[1]},${colr[2]},${k})`);
    g.fillStyle = gr; g.fillRect(x, y, w, h);
    if (r() < 0.4) { g.fillStyle = 'rgba(0,0,0,.6)'; g.fillRect(x + w * r() * 0.6, y, w * 0.3, h); } // a curtain / silhouette
  }
  return tex(c);
}
function roofTexture(seed) {
  const W = 256, c = cv(W, W), g = c.getContext('2d'); const r = R2(seed);
  g.fillStyle = '#2A2B2E'; g.fillRect(0, 0, W, W);
  const id = g.getImageData(0, 0, W, W); const d = id.data;
  for (let i = 0; i < d.length; i += 4) { const n = (r() - 0.5) * 30; d[i] += n; d[i + 1] += n; d[i + 2] += n * 0.9; }
  g.putImageData(id, 0, 0);
  g.strokeStyle = 'rgba(0,0,0,.35)'; g.lineWidth = 3; for (let x = 0; x <= W; x += 64) { g.beginPath(); g.moveTo(x, 0); g.lineTo(x, W); g.stroke(); g.beginPath(); g.moveTo(0, x); g.lineTo(W, x); g.stroke(); }
  for (let k = 0; k < 6; k++) { g.fillStyle = 'rgba(10,12,16,.5)'; g.beginPath(); g.ellipse(r() * W, r() * W, 10 + r() * 30, 6 + r() * 20, r() * 3, 0, 7); g.fill(); } // puddles on the roof
  return tex(c);
}
function containerTexture() {
  const c = cv(256, 256), g = c.getContext('2d');
  g.fillStyle = '#C8C8C8'; g.fillRect(0, 0, 256, 256);
  for (let x = 0; x < 256; x += 16) { const gr = g.createLinearGradient(x, 0, x + 16, 0); gr.addColorStop(0, 'rgba(0,0,0,.35)'); gr.addColorStop(0.5, 'rgba(255,255,255,.15)'); gr.addColorStop(1, 'rgba(0,0,0,.35)'); g.fillStyle = gr; g.fillRect(x, 0, 16, 256); }
  g.fillStyle = 'rgba(60,30,10,.35)'; for (let k = 0; k < 30; k++) g.fillRect(Math.random() * 256, Math.random() * 256, 2 + Math.random() * 8, 6 + Math.random() * 30);
  return tex(c);
}
/** Close-up surfaces for cover props (first person): crate slats, painted metal, concrete, rail-car panels. */
function propTexture(kind, seed = 1) {
  const S = 256, c = cv(S, S), g = c.getContext('2d'); const r = R2(seed * 31 + kind.length);
  const grain = (k, mono = true) => { const id = g.getImageData(0, 0, S, S); const d = id.data; for (let i = 0; i < d.length; i += 4) { const n = (r() - 0.5) * k; d[i] += n; d[i + 1] += mono ? n : n * 0.9; d[i + 2] += mono ? n : n * 0.8; } g.putImageData(id, 0, 0); };
  if (kind === 'crate') {
    g.fillStyle = '#7A5E3E'; g.fillRect(0, 0, S, S); grain(26, false);
    for (let y = 0; y < S; y += 32) { g.fillStyle = 'rgba(0,0,0,.35)'; g.fillRect(0, y, S, 3); g.fillStyle = 'rgba(255,230,190,.08)'; g.fillRect(0, y + 3, S, 2); }
    g.strokeStyle = '#3A2A18'; g.lineWidth = 18; g.strokeRect(9, 9, S - 18, S - 18); g.beginPath(); g.moveTo(18, 18); g.lineTo(S - 18, S - 18); g.stroke();
    g.fillStyle = 'rgba(20,20,20,.75)'; g.font = '800 30px "Arial Narrow", Arial, sans-serif'; g.textAlign = 'center'; g.fillText(['PORT VARO', 'VARO-7', 'HANDLE', 'FRAGILE'][seed % 4], S / 2, S / 2 + 48);
    for (const [x, y] of [[22, 22], [S - 22, 22], [22, S - 22], [S - 22, S - 22]]) { g.fillStyle = '#9A9A9A'; g.beginPath(); g.arc(x, y, 4, 0, 7); g.fill(); }
  } else if (kind === 'dumpster') {
    g.fillStyle = '#2E5A44'; g.fillRect(0, 0, S, S); grain(20);
    for (let x = 0; x < S; x += 42) { g.fillStyle = 'rgba(0,0,0,.28)'; g.fillRect(x, 0, 5, S); g.fillStyle = 'rgba(255,255,255,.06)'; g.fillRect(x + 5, 0, 3, S); }
    g.fillStyle = 'rgba(120,60,20,.45)'; for (let k = 0; k < 40; k++) g.fillRect(r() * S, S * 0.6 + r() * S * 0.4, 2 + r() * 10, 4 + r() * 20);
    g.fillStyle = '#E8E2D0'; g.fillRect(S * 0.28, S * 0.3, S * 0.44, S * 0.16); g.fillStyle = '#1A1A1A'; g.font = '800 26px "Arial Narrow", Arial, sans-serif'; g.textAlign = 'center'; g.fillText('NO PARKING', S / 2, S * 0.42);
  } else if (kind === 'concrete' || kind === 'planter' || kind === 'pillar' || kind === 'platform' || kind === 'wall') {
    g.fillStyle = kind === 'planter' ? '#7A7670' : '#6E6C68'; g.fillRect(0, 0, S, S); grain(34);
    for (let k = 0; k < 30; k++) { g.fillStyle = `rgba(0,0,0,${0.05 + r() * 0.1})`; g.beginPath(); g.ellipse(r() * S, r() * S, 4 + r() * 30, 3 + r() * 14, r() * 3, 0, 7); g.fill(); }
    g.fillStyle = 'rgba(0,0,0,.25)'; g.fillRect(0, S / 2 - 1, S, 2);
    if (kind === 'platform') { g.fillStyle = '#E2B21A'; g.fillRect(0, 0, S, 18); g.fillStyle = 'rgba(0,0,0,.3)'; for (let x = 0; x < S; x += 12) g.fillRect(x, 4, 4, 10); }
    for (let k = 0; k < 6; k++) { const x = r() * S; const gr = g.createLinearGradient(0, 0, 0, S); gr.addColorStop(0, 'rgba(0,0,0,.25)'); gr.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = gr; g.fillRect(x, 0, 3 + r() * 5, S); }
  } else if (kind === 'kiosk') {
    g.fillStyle = '#3C4450'; g.fillRect(0, 0, S, S); grain(16);
    // a lit shop window: warm interior, shelves with goods, a reflection streak (reads as a kiosk, not a black slab)
    { const x0 = S * 0.1, y0 = S * 0.18, w = S * 0.8, h = S * 0.42; const gr = g.createLinearGradient(0, y0, 0, y0 + h); gr.addColorStop(0, '#6A5A44'); gr.addColorStop(1, '#3A3228'); g.fillStyle = gr; g.fillRect(x0, y0, w, h);
      for (let k = 1; k < 3; k++) { g.fillStyle = '#2A241E'; g.fillRect(x0, y0 + (h * k) / 3, w, 4); for (let j = 0; j < 9; j++) { g.fillStyle = ['#C8503A', '#E2B21A', '#3A7AB0', '#4E9A62', '#D8D0C0'][(j + k) % 5]; g.fillRect(x0 + 6 + j * (w - 12) / 9, y0 + (h * k) / 3 - 14, (w - 12) / 9 - 4, 12); } }
      g.fillStyle = 'rgba(255,255,255,.12)'; g.beginPath(); g.moveTo(x0 + w * 0.15, y0); g.lineTo(x0 + w * 0.32, y0); g.lineTo(x0 + w * 0.12, y0 + h); g.lineTo(x0 - w * 0.05, y0 + h); g.fill();
      g.strokeStyle = '#1A1E24'; g.lineWidth = 6; g.strokeRect(x0, y0, w, h); }
    g.fillStyle = 'rgba(46,230,255,.35)'; g.fillRect(S * 0.12, S * 0.2, S * 0.76, S * 0.05);
    g.strokeStyle = 'rgba(0,0,0,.4)'; g.lineWidth = 3; for (let y = S * 0.66; y < S; y += 20) { g.beginPath(); g.moveTo(0, y); g.lineTo(S, y); g.stroke(); }
  } else if (kind === 'rail') {
    g.fillStyle = '#9A9A9A'; g.fillRect(0, 0, S, S); grain(22);
    for (let x = 0; x < S; x += 32) { g.fillStyle = 'rgba(0,0,0,.3)'; g.fillRect(x, 0, 4, S); }
    g.fillStyle = 'rgba(0,0,0,.35)'; g.fillRect(0, S * 0.15, S, 6); g.fillRect(0, S * 0.85, S, 6);
    g.fillStyle = 'rgba(255,255,255,.75)'; g.font = '800 24px "Arial Narrow", Arial, sans-serif'; g.fillText(`PVR ${100 + (seed % 900)}`, 18, S * 0.3);
    g.fillStyle = 'rgba(110,60,20,.4)'; for (let k = 0; k < 50; k++) g.fillRect(r() * S, r() * S, 2 + r() * 6, 6 + r() * 30);
  }
  return tex(c);
}
function stripeTexture() {
  const c = cv(128, 32), g = c.getContext('2d'); g.fillStyle = '#C8C3B8'; g.fillRect(0, 0, 128, 32);
  g.fillStyle = '#E2A21A'; for (let x = -32; x < 160; x += 32) { g.beginPath(); g.moveTo(x, 32); g.lineTo(x + 16, 32); g.lineTo(x + 32, 0); g.lineTo(x + 16, 0); g.fill(); }
  g.fillStyle = 'rgba(0,0,0,.25)'; g.fillRect(0, 0, 128, 4);
  return tex(c);
}
function signTexture(word, color) {
  const c = cv(512, 128), g = c.getContext('2d');
  g.fillStyle = 'rgba(8,8,12,.85)'; g.fillRect(0, 0, 512, 128);
  g.font = '800 84px "Arial Narrow", "Roboto Condensed", Arial, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
  g.shadowColor = color; g.shadowBlur = 18; g.fillStyle = color; g.fillText(word, 256, 68);
  g.shadowBlur = 0; g.fillStyle = '#FFFFFF'; g.globalAlpha = 0.75; g.fillText(word, 256, 68); g.globalAlpha = 1;
  g.strokeStyle = color; g.lineWidth = 6; g.strokeRect(10, 10, 492, 108);
  return tex(c, false);
}
function tagTexture(text, color) {
  const c = cv(512, 192), g = c.getContext('2d'); g.clearRect(0, 0, 512, 192);
  g.translate(256, 100); g.rotate(-0.08); g.font = 'italic 900 108px Impact, "Arial Black", sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
  g.lineWidth = 14; g.strokeStyle = 'rgba(0,0,0,.85)'; g.strokeText(text, 0, 0); g.fillStyle = color; g.fillText(text, 0, 0);
  g.fillStyle = color; for (let k = 0; k < 14; k++) g.fillRect(-200 + Math.random() * 400, 40 + Math.random() * 10, 3, 10 + Math.random() * 30); // drips
  return tex(c, false);
}
function letterTexture(L) {
  const c = cv(128, 128), g = c.getContext('2d'); g.clearRect(0, 0, 128, 128);
  g.font = '900 96px "Arial Narrow", Arial, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = '#FFFFFF'; g.fillText(L, 64, 70);
  return tex(c, false);
}
function blobTexture() {
  const c = cv(128, 128), g = c.getContext('2d'); const gr = g.createRadialGradient(64, 64, 0, 64, 64, 64);
  gr.addColorStop(0, 'rgba(255,255,255,1)'); gr.addColorStop(0.35, 'rgba(255,255,255,.45)'); gr.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = gr; g.fillRect(0, 0, 128, 128);
  return tex(c, false);
}
function detailTexture() {
  const S = 256, c = cv(S, S), g = c.getContext('2d'); const r = R2(4242);
  const id = g.createImageData(S, S); const d = id.data;
  const N = 16, grid = []; for (let i = 0; i < N * N; i++) grid.push(r());
  const at = (x, y) => grid[((y % N + N) % N) * N + ((x % N + N) % N)];
  for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
    const fx = (x / S) * N, fy = (y / S) * N; const ix = Math.floor(fx), iy = Math.floor(fy); const tx = fx - ix, ty = fy - iy; const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty);
    const v = (at(ix, iy) * (1 - sx) + at(ix + 1, iy) * sx) * (1 - sy) + (at(ix, iy + 1) * (1 - sx) + at(ix + 1, iy + 1) * sx) * sy;
    const n = Math.max(0, Math.min(255, (0.35 + v * 0.4 + (r() - 0.5) * 0.35) * 255)); const o = (y * S + x) * 4; d[o] = d[o + 1] = d[o + 2] = n; d[o + 3] = 255;
  }
  g.putImageData(id, 0, 0);
  g.strokeStyle = 'rgba(0,0,0,.55)'; g.lineWidth = 1.2; for (let k = 0; k < 14; k++) { let x = r() * S, y = r() * S; g.beginPath(); g.moveTo(x, y); for (let j = 0; j < 6; j++) { x += (r() - 0.5) * 40; y += (r() - 0.5) * 40; g.lineTo(x, y); } g.stroke(); }
  for (let k = 0; k < 60; k++) { g.fillStyle = r() < 0.5 ? 'rgba(255,255,255,.25)' : 'rgba(0,0,0,.3)'; g.fillRect(r() * S, r() * S, 1 + r() * 2, 1 + r() * 2); }
  const t = new THREE.CanvasTexture(c); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.colorSpace = THREE.NoColorSpace; t.anisotropy = 8; return t;
}
function envTexture(theme) {
  const W = 512, H = 256, c = cv(W, H), g = c.getContext('2d');
  const gr = g.createLinearGradient(0, 0, 0, H); gr.addColorStop(0, '#1C2440'); gr.addColorStop(0.3, '#232B48'); gr.addColorStop(0.45, theme.fog); gr.addColorStop(0.55, '#141826'); gr.addColorStop(1, '#040406'); g.fillStyle = gr; g.fillRect(0, 0, W, H);
  const r = R2(77);
  for (let k = 0; k < 40; k++) { const x = r() * W, y = H * (0.12 + r() * 0.38); const cc = theme.neon[k % theme.neon.length]; const rg = g.createRadialGradient(x, y, 0, x, y, 18 + r() * 40); rg.addColorStop(0, cc); rg.addColorStop(1, 'rgba(0,0,0,0)'); g.globalAlpha = 0.55; g.fillStyle = rg; g.fillRect(x - 60, y - 60, 120, 120); }
  g.globalAlpha = 1; for (let k = 0; k < 400; k++) { g.fillStyle = r() < 0.7 ? 'rgba(255,190,120,.8)' : 'rgba(160,210,255,.8)'; g.fillRect(r() * W, H * (0.3 + r() * 0.2), 1.5, 1.5); }
  const t = new THREE.CanvasTexture(c); t.mapping = THREE.EquirectangularReflectionMapping; t.colorSpace = THREE.SRGBColorSpace; return t;
}
/** The ground in map space (120 m square): asphalt, sidewalks, markings, crosswalks, lots, plaza, docks/rail details. */
function groundTextures(map, theme, size) {
  const S = size, M = 120, px = S / M, c = cv(S, S), g = c.getContext('2d'); const rc = cv(S / 2, S / 2), rg = rc.getContext('2d'); const r = R2(map.seed);
  const X = (x) => (x + M / 2) * px, Z = (z) => (z + M / 2) * px;
  g.fillStyle = theme.ground; g.fillRect(0, 0, S, S);
  const id = g.getImageData(0, 0, S, S); const d = id.data;
  for (let i = 0; i < d.length; i += 4) { const n = (r() - 0.5) * 18 + (r() < 0.02 ? 14 : 0); d[i] += n; d[i + 1] += n; d[i + 2] += n; }
  g.putImageData(id, 0, 0);
  rg.fillStyle = '#6A6A6A'; rg.fillRect(0, 0, S / 2, S / 2); // roughness ~0.42 on asphalt (wet)
  const rect = (x, z, w, dd, fill) => { g.fillStyle = fill; g.fillRect(X(x - w / 2), Z(z - dd / 2), w * px, dd * px); };
  const blocks = [-30, 0, 30];
  // sidewalks around every block + curbs
  for (const bx of blocks) for (const bz of blocks) {
    rect(bx, bz, 22, 22, theme.side); g.strokeStyle = 'rgba(200,200,200,.18)'; g.lineWidth = px * 0.25; g.strokeRect(X(bx - 11), Z(bz - 11), 22 * px, 22 * px);
    g.strokeStyle = 'rgba(0,0,0,.25)'; g.lineWidth = 1; for (let k = -10; k <= 10; k += 2) { g.beginPath(); g.moveTo(X(bx + k), Z(bz - 11)); g.lineTo(X(bx + k), Z(bz + 11)); g.stroke(); }
    rg.fillStyle = '#B8B8B8'; rg.fillRect(X(bx - 11) / 2, Z(bz - 11) / 2, 11 * px, 11 * px);
  }
  // plaza tiles (centre) and the two lots
  rect(0, 0, 20, 20, '#2E2C2A'); g.strokeStyle = 'rgba(0,0,0,.35)'; g.lineWidth = 1.5; for (let k = -10; k <= 10; k += 1.25) { g.beginPath(); g.moveTo(X(-10), Z(k)); g.lineTo(X(10), Z(k)); g.stroke(); g.beginPath(); g.moveTo(X(k), Z(-10)); g.lineTo(X(k), Z(10)); g.stroke(); }
  for (const lx of [-30, 30]) { rect(lx, 0, 20, 20, theme.ground); g.strokeStyle = 'rgba(230,230,230,.35)'; g.lineWidth = px * 0.15; for (let k = -9; k <= 9; k += 3) { g.beginPath(); g.moveTo(X(lx + k), Z(-9)); g.lineTo(X(lx + k), Z(-4)); g.stroke(); g.beginPath(); g.moveTo(X(lx + k), Z(4)); g.lineTo(X(lx + k), Z(9)); g.stroke(); } rg.fillStyle = '#6A6A6A'; rg.fillRect(X(lx - 10) / 2, Z(-10) / 2, 10 * px, 10 * px); }
  // lane markings and crosswalks
  g.strokeStyle = 'rgba(235,225,200,.32)'; g.lineWidth = px * 0.16; g.setLineDash([3 * px, 3 * px]);
  for (const s of [-15, 15]) { g.beginPath(); g.moveTo(X(s), Z(-60)); g.lineTo(X(s), Z(60)); g.stroke(); g.beginPath(); g.moveTo(X(-60), Z(s)); g.lineTo(X(60), Z(s)); g.stroke(); }
  g.setLineDash([]);
  for (const s of [-15, 15]) for (const t of [-41, -19, -11, 11, 19, 41]) {
    for (let k = -4; k <= 4; k += 1.1) { g.fillStyle = 'rgba(220,220,220,.26)'; g.fillRect(X(s + k - 0.3), Z(t - 1.2), 0.6 * px, 2.4 * px); g.fillRect(X(t - 1.2), Z(s + k - 0.3), 2.4 * px, 0.6 * px); }
  }
  if (map.id === 'docks') { g.strokeStyle = 'rgba(240,190,40,.6)'; g.lineWidth = px * 0.3; for (const s of [-45, -20, 20, 45]) { g.beginPath(); g.moveTo(X(-60), Z(s)); g.lineTo(X(60), Z(s)); g.stroke(); } }
  if (map.id === 'yard') { g.strokeStyle = 'rgba(120,110,100,.9)'; g.lineWidth = px * 0.18; for (const bz of blocks) for (const o of [-6.2, -3.8, 0.4, 2.8]) { g.beginPath(); g.moveTo(X(-60), Z(bz + o)); g.lineTo(X(60), Z(bz + o)); g.stroke(); } g.fillStyle = 'rgba(60,45,35,.8)'; for (const bz of blocks) for (let x = -60; x < 60; x += 0.9) for (const o of [-5, 1.6]) g.fillRect(X(x), Z(bz + o - 1.4), 0.25 * px, 2.8 * px); }
  // spawn pads
  for (const [z, cc] of [[45.5, TEAMS[0].color], [-45.5, TEAMS[1].color]]) { g.strokeStyle = cc; g.globalAlpha = 0.4; g.lineWidth = px * 0.3; g.strokeRect(X(-26), Z(z - 3), 52 * px, 6 * px); g.globalAlpha = 1; }
  // puddles: dark + mirror smooth
  for (const p of map.decor.filter((q) => q.kind === 'puddle')) {
    g.fillStyle = 'rgba(8,10,16,.22)'; g.beginPath(); g.ellipse(X(p.x), Z(p.z), p.s * 2.4 * px, p.s * 1.4 * px, p.a, 0, 7); g.fill();
    rg.fillStyle = '#383838'; rg.beginPath(); rg.ellipse(X(p.x) / 2, Z(p.z) / 2, p.s * 1.2 * px, p.s * 0.7 * px, p.a, 0, 7); rg.fill();
  }
  // baked contact shadows: soft dark skirts around everything that stands on the street (fake ambient occlusion)
  for (const o of map.obstacles) {
    if (o.h < 0.5 || Math.abs(o.x) > 62 || Math.abs(o.z) > 62) continue;
    const big = o.kind === 'bldg' || o.kind === 'wall'; const rings = big ? [[0.25, 0.2], [0.8, 0.11], [1.8, 0.06]] : [[0.12, 0.26], [0.45, 0.13], [0.95, 0.06]];
    for (const [pad, a] of rings) { g.fillStyle = `rgba(0,0,0,${a})`; g.fillRect(X(o.x - o.w / 2 - pad), Z(o.z - o.d / 2 - pad), (o.w + pad * 2) * px, (o.d + pad * 2) * px); }
  }
  // crew tags sprayed on the ground near the spawns, cracks, oil
  g.globalAlpha = 0.12; g.font = `italic 900 ${6 * px}px Impact, sans-serif`; g.textAlign = 'center';
  g.fillStyle = TEAMS[0].color; g.fillText(TEAMS[0].name, X(0), Z(40)); g.fillStyle = TEAMS[1].color; g.fillText(TEAMS[1].name, X(0), Z(-38)); g.globalAlpha = 1;
  g.strokeStyle = 'rgba(0,0,0,.4)'; g.lineWidth = 1.2; for (let k = 0; k < 90; k++) { let x = X(r() * 110 - 55), z = Z(r() * 110 - 55); g.beginPath(); g.moveTo(x, z); for (let j = 0; j < 5; j++) { x += (r() - 0.5) * 30; z += (r() - 0.5) * 30; g.lineTo(x, z); } g.stroke(); }
  const map1 = tex(c, false); const rough = tex(rc, false, false);
  return { map: map1, rough };
}

// ------------------------------------------------------------------ the renderer
export class World {
  constructor(canvas, { record = false, tier } = {}) {
    // bloom only catches what is truly brighter than white (threshold 1.3), so neon, lamps and muzzle flashes get a soft
    // halo instead of a screen-filling smear up close
    this.stage = new Stage(canvas, { record, tier, shadows: true, bloom: 0.55, threshold: 1.3, exposure: 1.18, sat: 1.1, con: 1.1, vig: 0.5, grain: 0.022, fov: 38, particles: 2400 });
    const S = this.stage; this.THREE = THREE;
    this.low = S.tier === 'low';
    // phones / low tier: no bloom, no shadows, capped pixel ratio (first person fills the screen with lit walls)
    if (this.low) { S.q.maxPr = Math.min(S.q.maxPr, 1.3); S.q.msaa = 2; S.q.bloom = false; }
    // first person: sparks and smoke that drift right past the lens must not balloon into screen-sized blobs. Cap the
    // sprite size (about 6 % of the screen height) and fade particles out inside the last 1.2 m before the camera.
    { const pm = S.parts.mesh.material; pm.vertexShader = pm.vertexShader.replace('gl_PointSize = aSize * uScale / max(-mv.z, 0.1);', 'gl_PointSize = min(aSize * uScale / max(-mv.z, 0.1), uScale * 0.06); vA *= smoothstep(0.35, 1.2, -mv.z);'); pm.needsUpdate = true; }
    S.r.shadowMap.enabled = !this.low;
    this.scene = S.scene; this.cam = S.cam; this.cam.far = 600;
    this.blob = blobTexture();
    this.camPos = new THREE.Vector3(0, 40, 30); this.camLook = new THREE.Vector3();
    this.ops = []; this.tr = []; this.booms = []; this.scorch = []; this.marks = [];
    this.t = 0; this.cutA = new THREE.Vector3(); this.cutB = new THREE.Vector3(); this.cutR = { value: 0 };
    this.mode = 'menu'; this.orbit = 0;
    this.vm = new ViewModel(S, this.cam); this.scene.add(this.cam); this.focus = new THREE.Vector3();
  }

  /** Build the whole city for a map. */
  build(map) {
    const S = this.stage, scene = this.scene; const theme = THEMES[map.id] || THEMES.row; this.theme = theme; this.map = map;
    for (const o of [...scene.children]) if (o !== S.parts.mesh && o !== S.skyMesh && o !== this.cam) scene.remove(o);
    this.ops = []; this.tr = []; this.booms = []; this.scorch = []; this.marks = []; S.parts.clear();
    S.look = { ...S.look, ...theme.look };
    S.sky({ top: '#020308', hor: theme.fog, low: '#020204', glow: theme.neon[0], glowDir: [0, 0.15, -1], glowK: 0.16, stars: 0.0 });
    scene.fog = new THREE.FogExp2(col(theme.fog), theme.fogD);
    // environment for reflections (wet ground, car paint, visors)
    if (!this.pmrem) this.pmrem = new THREE.PMREMGenerator(S.r);
    if (this.envRT) this.envRT.dispose();
    this.envRT = this.pmrem.fromEquirectangular(envTexture(theme)); scene.environment = this.envRT.texture; scene.environmentIntensity = 0.85;
    // lights: dim sky fill, a cold moon with shadows, 4 sodium lamps that follow the camera, 3 muzzle/boom lights
    // sky fill strong enough that walls facing away from the moon still read as surfaces (not black slabs), plus a soft
    // cool bounce from the opposite side with no shadows
    const hemi = new THREE.HemisphereLight(col(theme.hemi[0]), col(theme.hemi[1]), theme.hemi[2] * 2.6); scene.add(hemi);
    const fill = new THREE.DirectionalLight(col('#8FA0D8'), 1.1); fill.position.set(20, 25, 40); scene.add(fill); scene.add(fill.target); this.fill = fill;
    const moon = new THREE.DirectionalLight(col(theme.moon[0]), theme.moon[1]); moon.position.set(-30, 60, -20); moon.castShadow = !this.low;
    moon.shadow.mapSize.set(2048, 2048); const sc = moon.shadow.camera; sc.left = -34; sc.right = 34; sc.top = 34; sc.bottom = -34; sc.near = 1; sc.far = 160; moon.shadow.bias = -0.0006; moon.shadow.normalBias = 0.03;
    scene.add(moon); scene.add(moon.target); this.moon = moon;
    this.lampLights = (this.low ? [0, 1] : [0, 1, 2, 3]).map(() => { const l = new THREE.PointLight(col(theme.lamp), 0, 16, 1.6); l.position.set(0, 5.2, 0); scene.add(l); return l; });
    this.fxLights = (this.low ? [0, 1] : [0, 1, 2]).map(() => { const l = new THREE.PointLight(col('#FFC27A'), 0, 12, 2); scene.add(l); l.life = 0; return l; });
    this.fxI = 0;
    // ground
    const gt = groundTextures(map, theme, this.low ? 1024 : 2048);
    const gm = new THREE.MeshStandardMaterial({ map: gt.map, roughnessMap: gt.rough, roughness: 1, metalness: 0.0, envMapIntensity: 2.2 });
    // close-up detail for first person: two octaves of tiled grit + cracks, in world space, on top of the map texture
    const det = detailTexture();
    gm.onBeforeCompile = (s) => {
      s.uniforms.tDet = { value: det };
      s.vertexShader = s.vertexShader.replace('#include <common>', '#include <common>\nvarying vec2 vGW;').replace('#include <worldpos_vertex>', '#include <worldpos_vertex>\nvGW = (modelMatrix * vec4(transformed, 1.0)).xz;');
      s.fragmentShader = s.fragmentShader.replace('#include <common>', '#include <common>\nuniform sampler2D tDet; varying vec2 vGW;')
        .replace('#include <map_fragment>', '#include <map_fragment>\nfloat dtl = texture2D(tDet, vGW * 0.31).r * 0.6 + texture2D(tDet, vGW * 0.071 + 0.37).r * 0.4; diffuseColor.rgb *= 0.62 + 0.76 * dtl;')
        .replace('#include <roughnessmap_fragment>', '#include <roughnessmap_fragment>\nroughnessFactor = clamp(roughnessFactor * (0.75 + 0.5 * texture2D(tDet, vGW * 0.31 + 0.5).r), 0.08, 1.0);');
    };
    gm.customProgramCacheKey = () => 'tbGround';
    const ground = new THREE.Mesh(new THREE.PlaneGeometry(120, 120), gm); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
    const outer = new THREE.Mesh(new THREE.PlaneGeometry(600, 600), new THREE.MeshStandardMaterial({ color: col('#0E0F12'), roughness: 0.6 })); outer.rotation.x = -Math.PI / 2; outer.position.y = -0.02; scene.add(outer);
    this.buildCity(map, theme);
    this.buildZones(map);
    this.buildRain();
    this.buildTracers();
    this.ground = ground;
  }

  buildCity(map, theme) {
    const scene = this.scene; const cut = { a: this.cutA, b: this.cutB, r: this.cutR };
    // ---- buildings: one instanced mesh, world-space facade + windows + roof, x-ray cutaway near the camera line
    const bl = map.obstacles.filter((o) => o.kind === 'bldg');
    const fac = facadeTexture(map.seed % 4), win = windowAtlas(map.seed), roof = roofTexture(map.seed);
    const bm = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.82, metalness: 0.05, envMapIntensity: 0.6, side: THREE.DoubleSide, shadowSide: THREE.BackSide });
    bm.onBeforeCompile = (s) => {
      Object.assign(s.uniforms, { tFac: { value: fac }, tWin: { value: win }, tRoof: { value: roof }, uCutA: { value: cut.a }, uCutB: { value: cut.b }, uCutR: cut.r, uWinK: { value: 2.4 } });
      s.vertexShader = s.vertexShader.replace('#include <common>', '#include <common>\nattribute float aSeed; varying vec3 vWP; varying vec3 vWN; varying float vSeed;')
        .replace('#include <worldpos_vertex>', '#include <worldpos_vertex>\nvec4 wpp = modelMatrix * instanceMatrix * vec4(transformed, 1.0); vWP = wpp.xyz; vWN = normalize(mat3(modelMatrix * instanceMatrix) * objectNormal); vSeed = aSeed;');
      s.fragmentShader = s.fragmentShader.replace('#include <common>', '#include <common>\nuniform sampler2D tFac, tWin, tRoof; uniform vec3 uCutA, uCutB; uniform float uCutR, uWinK; varying vec3 vWP; varying vec3 vWN; varying float vSeed; vec3 winE;')
        .replace('#include <clipping_planes_fragment>', `#include <clipping_planes_fragment>
          { float dx = abs(vWP.x - uCutB.x); float south = vWP.z - (uCutB.z - 1.5);
            float yc = 3.0 + max(0., dx - 13.) * 1.6 + max(0., -south) * 40.;
            if (uCutR > 0.5 && vWP.y > yc) discard; }`)
        .replace('#include <map_fragment>', `vec3 an = abs(vWN); winE = vec3(0.);
          if (!gl_FrontFacing) { diffuseColor.rgb = vec3(0.035, 0.037, 0.045); } else
          if (vWN.y > 0.5) { diffuseColor.rgb *= texture2D(tRoof, vWP.xz / 10.).rgb * 1.1; }
          else { vec2 fuv = (an.x > an.z ? vec2(vWP.z, vWP.y) : vec2(vWP.x, vWP.y)) / vec2(8.0, 6.4);
            diffuseColor.rgb *= texture2D(tFac, fuv).rgb;
            vec2 wuv = fuv / 8.0 + vec2(vSeed, fract(vSeed * 7.31));
            winE = texture2D(tWin, wuv).rgb * step(2.8, vWP.y) * uWinK; }
          diffuseColor.rgb *= mix(0.45, 1.0, smoothstep(0.0, 1.6, vWP.y));`)
        .replace('#include <emissivemap_fragment>', '#include <emissivemap_fragment>\ntotalEmissiveRadiance += winE * winE * 1.6;');
    };
    bm.customProgramCacheKey = () => 'tbBldg';
    const bmesh = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), bm, bl.length);
    const seeds = new Float32Array(bl.length); const m4 = new THREE.Matrix4(); const r = R2(map.seed + 5);
    bl.forEach((o, i) => { m4.makeScale(o.w, o.h, o.d).setPosition(o.x, o.h / 2, o.z); bmesh.setMatrixAt(i, m4); seeds[i] = r(); });
    bmesh.geometry.setAttribute('aSeed', new THREE.InstancedBufferAttribute(seeds, 1));
    bmesh.castShadow = bmesh.receiveShadow = true; scene.add(bmesh); this.bldg = bmesh;
    // roof clutter: parapets, AC units, water tanks (instanced boxes)
    const roofBits = []; const parapet = [];
    for (const o of bl) {
      parapet.push([o.x, o.h + 0.25, o.z - o.d / 2 + 0.15, o.w, 0.5, 0.3], [o.x, o.h + 0.25, o.z + o.d / 2 - 0.15, o.w, 0.5, 0.3], [o.x - o.w / 2 + 0.15, o.h + 0.25, o.z, 0.3, 0.5, o.d], [o.x + o.w / 2 - 0.15, o.h + 0.25, o.z, 0.3, 0.5, o.d]);
      const n = Math.max(1, Math.floor((o.w * o.d) / 40)); for (let k = 0; k < n; k++) roofBits.push([o.x + (r() - 0.5) * (o.w - 2.5), o.h + 0.6, o.z + (r() - 0.5) * (o.d - 2.5), 1.2 + r() * 1.6, 1.2, 1 + r() * 1.4]);
    }
    this.inst(parapet, new THREE.MeshStandardMaterial({ color: col('#3E4046'), roughness: 0.9 }), { cut });
    // the harbour wall on the south edge: concrete with a hazard stripe and a row of dim lights
    const walls = map.obstacles.filter((o) => o.kind === 'wall');
    this.inst(walls.map((o) => [o.x, o.h / 2, o.z - o.d / 2 + 0.5, o.w, o.h, 1]), new THREE.MeshStandardMaterial({ map: propTexture('wall', 3), roughness: 0.85 }));
    this.inst(walls.map((o) => [o.x, o.h + 0.06, o.z - o.d / 2 + 0.5, o.w, 0.12, 1.02]), new THREE.MeshStandardMaterial({ map: stripeTexture(), roughness: 0.7 }));
    this.inst(walls.map((o) => [o.x, 1.4, o.z - o.d / 2 - 0.02, o.w * 0.9, 0.08, 0.04]), S_glow(this.stage, theme.neon[1], 1.6), { shadow: false });
    this.inst(roofBits, new THREE.MeshStandardMaterial({ color: col('#6A6E74'), roughness: 0.55, metalness: 0.5 }), { cut });
    // ---- street props by kind
    const P = (kind) => map.obstacles.filter((o) => o.kind === kind);
    const tints = ['#C8503A', '#3A7AB0', '#4E9A62', '#D8A030', '#8A929E', '#B04A7A'];
    const ctex = containerTexture();
    const contMat = new THREE.MeshStandardMaterial({ map: ctex, roughness: 0.62, metalness: 0.28, envMapIntensity: 0.8 });
    this.inst(P('container').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d, tints[o.tint % tints.length]]), contMat, { colors: true, cut });
    const trainMat = new THREE.MeshStandardMaterial({ map: propTexture('rail', map.seed), roughness: 0.55, metalness: 0.25 });
    this.inst(P('train').map((o) => [o.x, o.h / 2 + 0.3, o.z, o.w, o.h - 0.3, o.d, ['#5E6670', '#844634', '#4A6680', '#6E6E54'][o.tint % 4]]), trainMat, { colors: true, cut });
    this.inst(P('train').map((o) => [o.x, 0.15, o.z, o.w - 1, 0.3, o.d - 0.6]), new THREE.MeshStandardMaterial({ color: col('#101012'), roughness: 0.9 }));
    this.inst(P('platform').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('platform'), roughness: 0.85 }));
    this.inst(P('pillar').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('pillar', 2), roughness: 0.8 }), { cut });
    this.inst(P('barrier').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: stripeTexture(), roughness: 0.7 }));
    this.inst(P('dumpster').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('dumpster'), roughness: 0.6, metalness: 0.2 }));
    this.inst(P('crate').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('crate', map.seed), roughness: 0.85 }));
    this.inst(P('planter').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('planter'), roughness: 0.9 }));
    this.inst(P('planter').map((o) => [o.x, o.h + 0.25, o.z, o.w - 0.25, 0.5, o.d - 0.25]), new THREE.MeshStandardMaterial({ color: col('#1C2A1C'), roughness: 1 }));
    this.inst(P('kiosk').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('kiosk'), roughness: 0.45, metalness: 0.2 }), { cut });
    this.inst(P('kiosk').map((o) => [o.x, o.h - 0.5, o.z, o.w + 0.05, 0.35, o.d + 0.05]), S_glow(this.stage, theme.neon[1], 1.7));
    // cars: body + cabin glass + head/tail lights
    const cars = P('car');
    const paint = ['#3A3E46', '#7A1E22', '#2E4A62', '#A8A8A4', '#4A4A52', '#6A4A22'];
    this.inst(cars.map((o) => [o.x, 0.5, o.z, o.w, 0.7, o.d, paint[o.tint % paint.length]]), new THREE.MeshPhysicalMaterial({ roughness: 0.38, metalness: 0.18, clearcoat: 1, clearcoatRoughness: 0.1, envMapIntensity: 1.4 }), { colors: true });
    this.inst(cars.map((o) => { const along = o.d > o.w; return [o.x, 1.08, o.z, along ? o.w * 0.86 : o.w * 0.5, 0.48, along ? o.d * 0.5 : o.d * 0.86]; }), new THREE.MeshStandardMaterial({ color: col('#24303C'), roughness: 0.08, metalness: 0.6, envMapIntensity: 2.2 }));
    const head = [], tail = [];
    for (const o of cars) { const along = o.d > o.w; const s = o.mir ? -1 : 1; for (const k of [-1, 1]) { if (along) { head.push([o.x + k * o.w * 0.3, 0.55, o.z - s * o.d / 2, 0.3, 0.14, 0.06]); tail.push([o.x + k * o.w * 0.3, 0.6, o.z + s * o.d / 2, 0.3, 0.12, 0.06]); } else { head.push([o.x - s * o.w / 2, 0.55, o.z + k * o.d * 0.3, 0.06, 0.14, 0.3]); tail.push([o.x + s * o.w / 2, 0.6, o.z + k * o.d * 0.3, 0.06, 0.12, 0.3]); } } }
    this.inst(head, S_glow(this.stage, '#FFF2D8', 1.9)); this.inst(tail, S_glow(this.stage, '#FF2030', 1.8));
    // ---- lamps: poles + glowing heads + light pools on the wet ground
    this.lamps = map.lamps;
    this.inst(map.lamps.map((l) => [l.x, 2.7, l.z, 0.16, 5.4, 0.16]), new THREE.MeshStandardMaterial({ color: col('#2A2C30'), roughness: 0.5, metalness: 0.7 }));
    this.inst(map.lamps.map((l) => [l.x, 5.45, l.z, 0.7, 0.18, 0.35]), S_glow(this.stage, theme.lamp, 2.6));
    const pool = new THREE.MeshBasicMaterial({ map: this.blob, color: col(theme.lamp).multiplyScalar(0.55), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending });
    const pg = new THREE.PlaneGeometry(1, 1); pg.rotateX(-Math.PI / 2);
    const pools = new THREE.InstancedMesh(pg, pool, map.lamps.length); map.lamps.forEach((l, i) => { m4.makeScale(10, 1, 10).setPosition(l.x, 0.03, l.z); pools.setMatrixAt(i, m4); }); scene.add(pools);
    // ---- neon signs on facades + their glow on the street
    const words = new Map();
    for (const s of map.signs) {
      const o = s.on; const color = theme.neon[s.hue % theme.neon.length]; const word = WORDS[s.word % WORDS.length];
      const key = word + color; if (!words.has(key)) words.set(key, new THREE.MeshBasicMaterial({ map: signTexture(word, color), color: col('#FFFFFF').multiplyScalar(1.6), toneMapped: false, transparent: true, depthWrite: false }));
      const w = Math.min(4.2, (s.side % 2 ? o.d : o.w) * 0.7), h = w * 0.25;
      const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), words.get(key));
      const off = 0.06; const side = s.side % 4; // 0:+z 1:+x 2:-z 3:-x
      if (side === 0) { m.position.set(o.x, s.y, o.z + o.d / 2 + off); } else if (side === 1) { m.position.set(o.x + o.w / 2 + off, s.y, o.z); m.rotation.y = Math.PI / 2; } else if (side === 2) { m.position.set(o.x, s.y, o.z - o.d / 2 - off); m.rotation.y = Math.PI; } else { m.position.set(o.x - o.w / 2 - off, s.y, o.z); m.rotation.y = -Math.PI / 2; }
      scene.add(m); m.userData.cut = true;
      const glow = new THREE.Mesh(pg, new THREE.MeshBasicMaterial({ map: this.blob, color: col(color).multiplyScalar(0.7), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }));
      const nx = side === 1 ? 1 : side === 3 ? -1 : 0, nz = side === 0 ? 1 : side === 2 ? -1 : 0;
      glow.position.set(m.position.x + nx * 2.4, 0.04, m.position.z + nz * 2.4); glow.scale.set(w * 1.9, 1, w * 1.9); scene.add(glow);
    }
    // ---- storefront neon strips along every building base, with their smeared reflections on the wet street
    const strips = {}; const refl = []; const r4 = R2(map.seed + 17);
    for (const o of bl) for (let side = 0; side < 4; side++) {
      if (r4() < 0.45) continue; const c = theme.neon[Math.floor(r4() * theme.neon.length)]; const along = side % 2 === 0; const L = (along ? o.w : o.d) * (0.35 + r4() * 0.45);
      const nx = side === 1 ? 1 : side === 3 ? -1 : 0, nz = side === 0 ? 1 : side === 2 ? -1 : 0; const off = (r4() - 0.5) * ((along ? o.w : o.d) - L) * 0.8;
      const x = o.x + nx * (o.w / 2 + 0.06) + (along ? off : 0), z = o.z + nz * (o.d / 2 + 0.06) + (along ? 0 : off);
      (strips[c] = strips[c] || []).push([x, 2.95, z, along ? L : 0.1, 0.16, along ? 0.1 : L]);
      refl.push({ x: x + nx * 2.2, z: z + nz * 2.2, sx: along ? L * 1.2 : 2.4, sz: along ? 4.6 : L * 1.2, c });
    }
    for (const [c, rows] of Object.entries(strips)) this.inst(rows, S_glow(this.stage, c, 2.0), { shadow: false });
    const rm = {}; for (const q of refl) (rm[q.c] = rm[q.c] || []).push(q);
    for (const [c, qs] of Object.entries(rm)) {
      const mesh = new THREE.InstancedMesh(pg, new THREE.MeshBasicMaterial({ map: this.blob, color: col(c).multiplyScalar(0.55), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }), qs.length);
      qs.forEach((q, i) => { m4.makeScale(q.sx, 1, q.sz).setPosition(q.x, 0.035, q.z); mesh.setMatrixAt(i, m4); }); scene.add(mesh);
    }
    // lamp reflections: long streaks towards the camera on the wet asphalt
    const lr = new THREE.InstancedMesh(pg, new THREE.MeshBasicMaterial({ map: this.blob, color: col(theme.lamp).multiplyScalar(0.5), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }), map.lamps.length);
    map.lamps.forEach((l, i) => { m4.makeScale(1.6, 1, 9).setPosition(l.x, 0.036, l.z + 4.2); lr.setMatrixAt(i, m4); }); scene.add(lr);
    // ---- crew tags on walls near each spawn (fictional crews)
    for (const [team, z] of [[0, 1], [1, -1]]) {
      const T = TEAMS[team]; const cands = bl.filter((o) => Math.sign(o.z) === z && Math.abs(o.z) < 44 && o.h > 4).slice(0, 3);
      for (const o of cands) { const m = new THREE.Mesh(new THREE.PlaneGeometry(4.2, 1.6), new THREE.MeshStandardMaterial({ map: tagTexture(T.name, T.color), transparent: true, roughness: 0.8, emissive: col(T.color), emissiveIntensity: 0.25, emissiveMap: null, depthWrite: false })); m.position.set(o.x, 1.9, o.z + z * (o.d / 2 + 0.05)); if (z < 0) m.rotation.y = Math.PI; scene.add(m); }
    }
    // ---- steam vents (render-only particles) and cones
    this.vents = map.decor.filter((d) => d.kind === 'vent');
    this.inst(this.vents.map((v) => [v.x, 0.03, v.z, 1.1, 0.05, 1.1]), new THREE.MeshStandardMaterial({ color: col('#1A1A1A'), roughness: 0.4, metalness: 0.8 }));
    this.inst(map.decor.filter((d) => d.kind === 'cone').map((v) => [v.x, 0.35, v.z, 0.4, 0.7, 0.4]), new THREE.MeshStandardMaterial({ color: col('#E2561A'), roughness: 0.6 }));
    this.inst(map.decor.filter((d) => d.kind === 'trash').map((v) => [v.x, 0.25, v.z, 0.7 * v.s, 0.5, 0.5 * v.s]), new THREE.MeshStandardMaterial({ color: col('#121214'), roughness: 0.3, metalness: 0.2 }));
    // perimeter skyline beyond the walls (dark silhouettes with windows, fogged)
    const far = []; const r3 = R2(map.seed + 99);
    for (let a = 0; a < 40; a++) { const ang = (a / 40) * Math.PI * 2; const d = 82 + r3() * 30; far.push([Math.cos(ang) * d, 0, Math.sin(ang) * d, 10 + r3() * 10, 20 + r3() * 50, 10 + r3() * 10]); }
    const farM = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), bm, far.length); const fs = new Float32Array(far.length);
    far.forEach((b, i) => { m4.makeScale(b[3], b[4], b[5]).setPosition(b[0], b[4] / 2, b[2]); farM.setMatrixAt(i, m4); fs[i] = r3(); });
    farM.geometry.setAttribute('aSeed', new THREE.InstancedBufferAttribute(fs, 1)); scene.add(farM);
  }

  /** Instanced boxes: rows of [x, y, z, w, h, d, color?]. */
  inst(rows, mat, { colors = false, cut = null, shadow = true } = {}) {
    if (!rows.length) return null;
    if (cut && !mat.userData.cutDone) {
      mat.userData.cutDone = true;
      const prev = mat.onBeforeCompile;
      mat.onBeforeCompile = (s, r) => {
        prev && prev.call(mat, s, r);
        Object.assign(s.uniforms, { uCutA: { value: cut.a }, uCutB: { value: cut.b }, uCutR: cut.r });
        s.vertexShader = s.vertexShader.replace('#include <common>', '#include <common>\nvarying vec3 vWP2;').replace('#include <worldpos_vertex>', '#include <worldpos_vertex>\nvWP2 = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).xyz;');
        s.fragmentShader = s.fragmentShader.replace('#include <common>', '#include <common>\nuniform vec3 uCutA, uCutB; uniform float uCutR; varying vec3 vWP2;')
          .replace('#include <clipping_planes_fragment>', `#include <clipping_planes_fragment>
          { float dx = abs(vWP2.x - uCutB.x); float south = vWP2.z - (uCutB.z - 1.5); float yc = 3.0 + max(0., dx - 13.) * 1.6 + max(0., -south) * 40.; if (uCutR > 0.5 && vWP2.y > yc) discard; }`);
      };
      mat.customProgramCacheKey = () => 'tbCut'; // same shader code for every cut material (uniforms stay per material), so they share one program
    }
    if (mat.isMeshStandardMaterial && !mat.userData.aoDone) aoPatch(mat);
    const m = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), mat, rows.length); const m4 = new THREE.Matrix4();
    rows.forEach((b, i) => { m4.makeScale(b[3], b[4], b[5]).setPosition(b[0], b[1], b[2]); m.setMatrixAt(i, m4); if (colors) m.setColorAt(i, col(b[6])); });
    m.castShadow = shadow && !(mat.blending === THREE.AdditiveBlending); m.receiveShadow = true; this.scene.add(m); return m;
  }

  buildZones(map) {
    this.zoneFx = map.zones.map((z) => {
      const g = new THREE.Group(); g.position.set(z.x, 0, z.z);
      const ring = new THREE.Mesh(new THREE.RingGeometry(z.r - 0.18, z.r, 72), new THREE.MeshBasicMaterial({ color: col('#FFFFFF').multiplyScalar(1.6), transparent: true, opacity: 0.8, depthWrite: false, side: THREE.DoubleSide }));
      ring.rotation.x = -Math.PI / 2; ring.position.y = 0.05; g.add(ring);
      const fill = new THREE.Mesh(new THREE.CircleGeometry(z.r, 72), new THREE.MeshBasicMaterial({ color: col('#FFFFFF'), transparent: true, opacity: 0.06, depthWrite: false, blending: THREE.AdditiveBlending }));
      fill.rotation.x = -Math.PI / 2; fill.position.y = 0.04; g.add(fill);
      const arc = new THREE.Mesh(TUBE, arcMat('#FFFFFF', 3)); setArc(arc, -Math.PI / 2, 0, z.r + 0.35, 0.12, 0.1); g.add(arc);
      const col1 = new THREE.Mesh(new THREE.CylinderGeometry(z.r * 0.98, z.r, 7, 48, 1, true), new THREE.ShaderMaterial({
        uniforms: { uC: { value: col('#FFFFFF') }, uA: { value: 0.12 } }, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide,
        vertexShader: 'varying float vY; void main(){ vY = uv.y; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.); }',
        fragmentShader: 'uniform vec3 uC; uniform float uA; varying float vY; void main(){ gl_FragColor = vec4(uC * 1.4, uA * pow(1. - vY, 2.2)); }' }));
      col1.position.y = 3.5; g.add(col1);
      const lt = new THREE.Sprite(new THREE.SpriteMaterial({ map: letterTexture(z.id), color: col('#FFFFFF').multiplyScalar(1.2), depthWrite: false, toneMapped: false, transparent: true, opacity: 0.7 })); lt.scale.set(2.2, 2.2, 1); lt.position.y = 4.6; g.add(lt);
      this.scene.add(g);
      return { g, ring, fill, arc, col1, lt, z };
    });
  }

  buildRain() {
    const N = this.low ? 700 : 1600; const pos = new Float32Array(N * 6); const seed = new Float32Array(N * 2);
    for (let i = 0; i < N; i++) { const x = Math.random() * 60 - 30, z = Math.random() * 60 - 30, y = Math.random() * 30; pos.set([x, y, z, x, y, z], i * 6); seed[i * 2] = seed[i * 2 + 1] = Math.random(); }
    const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); g.setAttribute('aS', new THREE.BufferAttribute(seed, 1));
    const ends = new Float32Array(N * 2); for (let i = 0; i < N; i++) { ends[i * 2] = 0; ends[i * 2 + 1] = 1; } g.setAttribute('aE', new THREE.BufferAttribute(ends, 1));
    this.rainU = { uT: { value: 0 }, uC: { value: new THREE.Vector3() } };
    const m = new THREE.ShaderMaterial({ uniforms: this.rainU, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
      vertexShader: `uniform float uT; uniform vec3 uC; attribute float aS; attribute float aE; varying float vA;
        void main(){ vec3 p = position; float fall = mod(p.y - uT * (24. + aS * 8.), 30.); vec3 w = vec3(mod(p.x - uC.x + 30., 60.) - 30. + uC.x, fall, mod(p.z - uC.z + 30., 60.) - 30. + uC.z);
          w += vec3(0.25, 1.1, 0.12) * aE; vA = (0.08 + 0.14 * aS) * (aE > .5 ? 1. : 0.1); gl_Position = projectionMatrix * viewMatrix * vec4(w, 1.); }`,
      fragmentShader: 'varying float vA; void main(){ gl_FragColor = vec4(vec3(0.55,0.65,0.85), vA); }' });
    const L = new THREE.LineSegments(g, m); L.frustumCulled = false; L.renderOrder = 5; this.scene.add(L); this.rain = L;
  }

  buildTracers() {
    const N = 128; const g = new THREE.BoxGeometry(1, 1, 1);
    const m = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, toneMapped: false });
    this.trMesh = new THREE.InstancedMesh(g, m, N); this.trMesh.frustumCulled = false; this.trMesh.renderOrder = 8;
    this.trMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
    for (let i = 0; i < N; i++) { this.trMesh.setMatrixAt(i, new THREE.Matrix4().makeScale(0, 0, 0)); this.trMesh.setColorAt(i, col('#000000')); }
    this.scene.add(this.trMesh); this.trI = 0; this.trs = new Array(N).fill(null);
    // muzzle flash sprites
    this.flashMat = new THREE.SpriteMaterial({ map: this.blob, color: col('#FFB860').multiplyScalar(1.7), blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false });
    // scorch decals
    const sg = new THREE.PlaneGeometry(1, 1); sg.rotateX(-Math.PI / 2);
    this.scMesh = new THREE.InstancedMesh(sg, new THREE.MeshBasicMaterial({ map: this.blob, color: col('#000000'), transparent: true, opacity: 0.75, depthWrite: false }), 24);
    for (let i = 0; i < 24; i++) this.scMesh.setMatrixAt(i, new THREE.Matrix4().makeScale(0, 0, 0)); this.scMesh.renderOrder = 1; this.scene.add(this.scMesh); this.scI = 0;
    // shock rings
    this.ringPool = [0, 1, 2, 3, 4, 5].map(() => { const m = new THREE.Mesh(new THREE.RingGeometry(0.85, 1, 48), new THREE.MeshBasicMaterial({ color: col('#FFB070').multiplyScalar(3), transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide })); m.rotation.x = -Math.PI / 2; m.position.y = 0.15; this.scene.add(m); m.life = 0; return m; });
    this.ringI = 0;
    // skyfall warning lines + sentry/drone meshes are created lazily
    this.warn = [];
  }

  // ------------------------------------------------------------------ operators
  // A human-scale tactical operator (1.8 m, eye at 1.62 m, matching the sim's hitboxes): thighs and shins that walk
  // and crouch, a vest with a crew-coloured plate strip, a helmet with a glowing visor, arms that pitch with the aim
  // and the operator's real weapon. Each rigid part is one merged mesh (vertex colours), ~9 draw calls per operator.
  makeOp(team, skinId = 0, you = false, weapon = 'vk9') {
    const T = TEAMS[team]; const sk = SKINS[skinId] || SKINS[0];
    const jacket = sk.jacket || (team === 0 ? '#45473A' : '#34465A'), plate = sk.plate || (team === 0 ? '#7A6650' : '#4A6278');
    const rimC = col(sk.visor || T.color).multiplyScalar(you ? 1.2 : 1.0);
    if (!this.opMats) this.opMats = {};
    const key = `${team}-${skinId}-${you ? 1 : 0}`;
    if (!this.opMats[key]) {
      const m = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.6, metalness: sk.metal ? 0.6 : 0.12, envMapIntensity: 0.8 });
      m.onBeforeCompile = (s) => { s.uniforms.uRimC = { value: rimC }; s.fragmentShader = s.fragmentShader.replace('#include <common>', '#include <common>\nuniform vec3 uRimC;').replace('#include <opaque_fragment>', 'float rimF = 1.0 - max(dot(normalize(vViewPosition), normal), 0.0);\noutgoingLight += uRimC * pow(rimF, 2.6) * 0.75;\n#include <opaque_fragment>'); };
      m.customProgramCacheKey = () => 'tbOp2';
      this.opMats[key] = { body: m, vis: new THREE.MeshBasicMaterial({ color: col(sk.visor || T.color).multiplyScalar(2.6), toneMapped: false }), gun: new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.4, metalness: 0.6 }) };
    }
    const MT = this.opMats[key];
    const V = (x, y, z) => new THREE.Vector3(x, y, z);
    const BX = new THREE.BoxGeometry(1, 1, 1), CP = new THREE.CapsuleGeometry(1, 1, 3, 8), SP = new THREE.SphereGeometry(1, 14, 10);
    const part = (g, x, y, z, sx, sy, sz, c, rx = 0, ry = 0, rz = 0) => ({ g, m: new THREE.Matrix4().compose(V(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), V(sx, sy, sz)), c });
    const mesh = (parts, mat, parent) => { const m = new THREE.Mesh(mergeParts(parts), mat); m.castShadow = true; m.receiveShadow = false; parent.add(m); return m; };
    const dark = '#22252B', boot = '#16171A', stripe = sk.stripe || T.color;
    const root = new THREE.Group(); const hip = new THREE.Group(); hip.position.y = 0.95; root.add(hip);
    const legs = [];
    for (const s of [-1, 1]) {
      const thigh = new THREE.Group(); thigh.position.set(0, -0.02, s * 0.11); hip.add(thigh);
      mesh([part(BX, 0, -0.23, 0, 0.18, 0.46, 0.17, dark), part(BX, 0.03, -0.18, s * 0.06, 0.12, 0.14, 0.06, plate)], MT.body, thigh);
      const shin = new THREE.Group(); shin.position.set(0, -0.46, 0); thigh.add(shin);
      mesh([part(BX, 0, -0.21, 0, 0.15, 0.42, 0.15, dark), part(BX, 0.06, -0.06, 0, 0.06, 0.14, 0.13, plate), part(BX, 0.05, -0.44, 0, 0.27, 0.1, 0.15, boot)], MT.body, shin);
      legs.push({ thigh, shin });
    }
    const torso = new THREE.Group(); hip.add(torso);
    mesh([
      part(BX, 0, 0.12, 0, 0.24, 0.26, 0.34, dark), part(BX, 0, 0.42, 0, 0.27, 0.36, 0.44, jacket), part(BX, 0.03, 0.42, 0, 0.26, 0.32, 0.42, plate),
      part(BX, -0.19, 0.4, 0, 0.13, 0.36, 0.3, '#2A2C30'), part(BX, 0.17, 0.33, 0, 0.04, 0.16, 0.3, dark),
      part(CP, 0, 0.6, -0.25, 0.075, 0.08, 0.075, jacket), part(CP, 0, 0.6, 0.25, 0.075, 0.08, 0.075, jacket), part(BX, 0.16, 0.5, 0, 0.012, 0.035, 0.36, stripe),
      // shoulder armour, a pack on the back and pouches: a broad, readable silhouette at any range
      part(BX, 0, 0.63, -0.27, 0.18, 0.07, 0.13, plate, 0.35), part(BX, 0, 0.63, 0.27, 0.18, 0.07, 0.13, plate, -0.35),
      part(BX, -0.23, 0.42, 0, 0.13, 0.32, 0.3, '#1C1E22'), part(BX, -0.3, 0.46, 0, 0.03, 0.2, 0.2, '#26292E'),
      part(BX, 0.17, 0.25, -0.09, 0.05, 0.08, 0.07, dark), part(BX, 0.17, 0.25, 0.09, 0.05, 0.08, 0.07, dark),
    ], MT.body, torso);
    // crew-colour light strips (chest, shoulders, pack): who is who reads instantly, even in the dark and the rain
    { const lm = new THREE.Mesh(mergeParts([part(BX, 0.168, 0.5, 0, 0.01, 0.03, 0.3, '#FFFFFF'), part(BX, 0.02, 0.672, -0.27, 0.12, 0.012, 0.022, '#FFFFFF'), part(BX, 0.02, 0.672, 0.27, 0.12, 0.012, 0.022, '#FFFFFF'), part(BX, -0.316, 0.5, 0, 0.006, 0.14, 0.02, '#FFFFFF')]), MT.vis); torso.add(lm); }
    const head = new THREE.Group(); head.position.set(0.02, 0.67, 0); torso.add(head);
    mesh([part(CP, 0, -0.02, 0, 0.07, 0.06, 0.07, dark), part(SP, 0, 0.0, 0, 0.115, 0.13, 0.11, '#2E3036'), part(SP, -0.01, 0.06, 0, 0.14, 0.11, 0.135, plate), part(BX, -0.02, 0.0, 0, 0.2, 0.05, 0.27, plate)], MT.body, head);
    const visor = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.05, 0.19), MT.vis); visor.position.set(0.105, 0.02, 0); head.add(visor);
    const arms = new THREE.Group(); arms.position.set(0.02, 0.56, 0); torso.add(arms);
    mesh([
      part(CP, 0.12, -0.08, -0.2, 0.06, 0.2, 0.06, jacket, 0, 0, 1.2), part(CP, 0.3, -0.13, -0.08, 0.05, 0.18, 0.05, jacket, 0.9, 0, 1.45),
      part(CP, 0.14, -0.09, 0.2, 0.06, 0.2, 0.06, jacket, 0, 0, 1.2), part(CP, 0.38, -0.11, 0.08, 0.05, 0.2, 0.05, jacket, -0.9, 0, 1.5),
      part(BX, 0.43, -0.13, -0.0, 0.08, 0.08, 0.08, boot), part(BX, 0.58, -0.08, 0.0, 0.08, 0.08, 0.08, boot),
    ], MT.body, arms);
    const gun = thirdPersonGun(weapon, MT.gun); gun.position.set(0.42, -0.06, 0); gun.scale.setScalar(1.15); arms.add(gun);
    const muzzle = new THREE.Sprite(this.flashMat); muzzle.position.set(weapon === 'lancer' ? 1.62 : weapon === 'wasp' ? 0.98 : weapon === 'breach' ? 1.36 : 1.32, -0.05, 0); muzzle.scale.setScalar(0); arms.add(muzzle);
    // team ring under the feet (only for the overhead menu camera) and a teammate marker over the head (first person)
    const ringM = new THREE.MeshBasicMaterial({ color: col(T.color).multiplyScalar(you ? 2.4 : 1.2), transparent: true, opacity: you ? 0.9 : 0.55, depthWrite: false, blending: THREE.AdditiveBlending });
    const ring = new THREE.Mesh(new THREE.RingGeometry(0.55, you ? 0.72 : 0.64, 32), ringM); ring.rotation.x = -Math.PI / 2; ring.position.y = 0.04; root.add(ring);
    const mark = new THREE.Sprite(this.markMat(team)); mark.position.y = 2.2; mark.scale.set(0.028, 0.028, 1); mark.renderOrder = 20; root.add(mark); mark.visible = false;
    this.scene.add(root);
    return { root, hip, legs, torso, head, arms, gun, muzzle, ring, mark, team, vis: MT.vis, skin: sk, fade: 1, wasAlive: true, ph: Math.random() * 6, dieT: 0, weapon };
  }
  markMat(team) {
    if (!this.marks2) this.marks2 = {};
    if (!this.marks2[team]) {
      const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d'); g.fillStyle = TEAMS[team].color; g.strokeStyle = 'rgba(0,0,0,.8)'; g.lineWidth = 6;
      g.beginPath(); g.moveTo(10, 16); g.lineTo(32, 46); g.lineTo(54, 16); g.lineTo(32, 28); g.closePath(); g.stroke(); g.fill();
      const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace;
      this.marks2[team] = new THREE.SpriteMaterial({ map: t, color: col('#FFFFFF').multiplyScalar(1.6), depthTest: false, depthWrite: false, transparent: true, toneMapped: false, sizeAttenuation: false }); // same size on screen at any distance
    }
    return this.marks2[team];
  }
  setOps(ents, youSkin = 0) {
    for (const o of this.ops) this.scene.remove(o.root);
    this.ops = ents.map((e) => this.makeOp(e.team, e.you ? youSkin : (e.skin || 0) % 1, e.you, e.weapon));
    this.youSkin = SKINS[youSkin] || SKINS[0];
    const T = TEAMS[0]; if (this.vm) this.vm.setLook(this.youSkin.jacket || '#45473A', this.youSkin.visor || T.color);
  }

  // ------------------------------------------------------------------ effects from sim events
  fx(e, view) {
    const S = this.stage; const T = (t) => TEAMS[t];
    if (e.k === 'shot') {
      const shooter = view && view.ents[e.from];
      const mine = view && view.cam && view.cam.mode === 'fps' && e.from === view.cam.follow;
      const colr = e.w === 'sentry' ? '#FFFFFF' : (shooter && shooter.you && this.youSkin && this.youSkin.trace) || (e.team === 0 ? '#FFC07A' : '#9AF0FF');
      const y0 = e.y0 != null ? e.y0 : 1.35, y1 = e.y1 != null ? e.y1 : 1.35;
      let sx = e.x0, sy = y0, sz = e.z0;
      if (mine) { const m = this.vm.muzzleWorld; if (m) { sx = m.x; sy = m.y; sz = m.z; } this.vm.shot(e.w); }
      else if (shooter && e.from >= 0) { const a = shooter.yaw || 0; sx += Math.cos(a) * 0.9 - Math.sin(a) * 0.12; sz += Math.sin(a) * 0.9 + Math.cos(a) * 0.12; sy -= 0.1; }
      if (!mine || (Math.random() < 0.6 && this.vm.st.ads < 0.5)) this.tracer(sx, sy, sz, e.x1, y1, e.z1, colr, e.w === 'lancer' ? 0.035 : e.w === 'breach' ? 0.018 : 0.025, e.w === 'lancer' ? 9 : 4.5);
      if (e.hit === -1) {
        const nx = e.nx || 0, ny = e.ny || 0, nz = e.nz || 0;
        S.parts.burst(e.x1, y1, e.z1, { n: 5, colors: ['#FFD8A0', '#FFFFFF'], speed: 5, life: 0.22, size: 1.1, hdr: 3, dir: [nx * 3, ny * 3, nz * 3] });
        S.parts.burst(e.x1, y1, e.z1, { n: 2, colors: ['#6A6660', '#4A4844'], speed: 1, life: 0.6, size: 3.5, hdr: 0.5, drag: 3, dir: [nx * 1.2, ny * 1.2 + 0.4, nz * 1.2] });
        if (e.world) this.decal(e.x1, y1, e.z1, nx, ny, nz);
      } else if (this.fpsMode) S.parts.burst(e.x1, y1, e.z1, { n: e.hs ? 6 : 3, colors: [T(1 - e.team).glow, '#FFFFFF'], speed: e.hs ? 5 : 3.5, life: 0.18, size: e.hs ? 0.7 : 0.45, hdr: 1.6 });
      else S.parts.burst(e.x1, y1, e.z1, { n: e.hs ? 12 : 7, colors: [T(1 - e.team).glow, '#FFFFFF'], speed: e.hs ? 7 : 5, life: 0.3, size: e.hs ? 1.6 : 1.2, hdr: 3.5 });
      if (Math.random() < (mine ? 0.9 : 0.5)) this.fxLight(sx, sy + 0.1, sz, e.team === 0 ? '#FFB060' : '#80E8FF', mine ? 3.2 : 2.6, 0.05);
      if (mine) { const p = this.vm.muzzleWorld; if (p) S.parts.burst(p.x, p.y, p.z, { n: 1, colors: ['#D8A040'], speed: 2.4, life: 0.5, size: 0.5, hdr: 1.4, up: 1.6, drag: 0.6 }); }
    } else if (e.k === 'kill') {
      const vt = view.ents[e.victim] ? view.ents[e.victim].team : 0; const c = T(vt);
      const ky = e.y || 0; const fp = this.fpsMode;
      S.parts.burst(e.x, ky + 1.1, e.z, { n: 30, colors: [c.color, c.glow, c.color], speed: fp ? 6 : 12, life: 0.8, size: fp ? 1.4 : 2.2, up: 3, hdr: 1.9 });
      S.parts.burst(e.x, ky + 0.6, e.z, { n: 14, colors: ['#40444C', '#2A2C30'], speed: 2.5, life: 1.2, size: fp ? 3 : 5, up: 1.5, hdr: 0.6, drag: 3 });
      this.shock(e.x, e.z, c.color, 2.6, ky);
    } else if (e.k === 'boom') {
      const big = !e.small; const r = e.r || 3; const by = e.y || 0;
      S.parts.burst(e.x, by + 1, e.z, { n: big ? 26 : 12, colors: ['#FFB060', '#FF7A1C', '#E2400C'], speed: r * 5.2, life: 0.55, size: big ? 3.2 : 2.4, up: 5, hdr: 0.95 });
      S.parts.burst(e.x, by + 0.6, e.z, { n: big ? 40 : 14, colors: ['#FF9A3A', '#FFD27A'], speed: r * 7, life: 0.35, size: 1.2, up: 7, hdr: 2.2, drag: 1.2 });
      S.parts.burst(e.x, by + 1.4, e.z, { n: big ? 40 : 12, colors: ['#3A3634', '#24221F', '#4A4440'], speed: r * 1.6, life: 1.8, size: 9, up: 3, hdr: 0.5, drag: 1.6 });
      this.fxLight(e.x, by + 2.5, e.z, '#FF9A40', big ? 16 : 6, 0.35); this.shock(e.x, e.z, '#FF9A50', r * 1.15, by);
      if (big) { this.scorchAt(e.x, e.z, r * 1.5); }
      if (view && view.cam) { const d = Math.hypot(e.x - view.cam.x, e.z - view.cam.z); S.shake(Math.max(0, (big ? 0.45 : 0.18) - d * 0.015) * (this.fpsMode ? 0.5 : 1)); }
    } else if (e.k === 'dash') { const en = view.ents[e.id]; if (en && !(this.fpsMode && view.cam && e.id === view.cam.follow)) S.parts.burst(en.x, (en.y || 0) + 0.4, en.z, { n: 14, colors: [T(en.team).glow], speed: 5, life: 0.35, size: 2.4, hdr: 2.2 }); }
    else if (e.k === 'spawn') { const en = view.ents[e.id]; if (en) { S.parts.burst(en.x, 1.2, en.z, { n: 30, colors: [T(en.team).glow, '#FFFFFF'], speed: 3, life: 0.8, size: 2.5, up: 6, hdr: 2.6 }); this.shock(en.x, en.z, T(en.team).color, 2); } }
    else if (e.k === 'cap') { const z = this.zoneFx.find((q) => q.z.id === e.zone); if (z) { S.parts.burst(z.z.x, 2, z.z.z, { n: 80, colors: [T(e.team).color, T(e.team).glow, '#FFFFFF'], speed: 14, life: 1, size: 3, up: 8, hdr: 3 }); this.shock(z.z.x, z.z.z, T(e.team).color, 7); } }
    else if (e.k === 'skyfall') { for (const [x, z] of e.pts) this.warnAt(x, z, e.team); }
  }
  tracer(x0, y0, z0, x1, y1, z1, c, w, len = 5) {
    const dx = x1 - x0, dy = y1 - y0, dz = z1 - z0, L = Math.hypot(dx, dy, dz); if (L < 0.3) return;
    const i = this.trI = (this.trI + 1) % this.trs.length; this.trs[i] = { x0, y0, z0, dx: dx / L, dy: dy / L, dz: dz / L, L, c: col(c).multiplyScalar(2.3), w, age: 0, len };
  }
  /** Bullet marks on walls and the ground (oriented to the surface; the pool of 96 recycles the oldest). */
  decal(x, y, z, nx, ny, nz) {
    if (!this.dcMesh) {
      const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d');
      const gr = g.createRadialGradient(32, 32, 0, 32, 32, 30); gr.addColorStop(0, 'rgba(0,0,0,1)'); gr.addColorStop(0.25, 'rgba(10,10,12,.95)'); gr.addColorStop(0.45, 'rgba(40,38,36,.5)'); gr.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = gr; g.fillRect(0, 0, 64, 64);
      const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace;
      this.dcMesh = new THREE.InstancedMesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: t, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2, opacity: 0.85 }), 96);
      for (let k = 0; k < 96; k++) this.dcMesh.setMatrixAt(k, new THREE.Matrix4().makeScale(0, 0, 0)); this.dcMesh.renderOrder = 2; this.dcMesh.frustumCulled = false; this.dcI = 0;
    }
    if (this.dcMesh.parent !== this.scene) this.scene.add(this.dcMesh);
    const k = this.dcI = (this.dcI + 1) % 96; const n = new THREE.Vector3(nx, ny, nz); if (n.lengthSq() < 0.5) return;
    const qq = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), n);
    qq.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.random() * 6.28)); const s = 0.09 + Math.random() * 0.05;
    this.dcMesh.setMatrixAt(k, new THREE.Matrix4().compose(new THREE.Vector3(x + nx * 0.012, y + ny * 0.012, z + nz * 0.012), qq, new THREE.Vector3(s, s, s))); this.dcMesh.instanceMatrix.needsUpdate = true;
  }
  fxLight(x, y, z, c, k, life) { const l = this.fxLights[this.fxI = (this.fxI + 1) % this.fxLights.length]; l.position.set(x, y, z); l.color.set(c); l.intensity = k; l.life = life; l.max = life; l.k = k; }
  shock(x, z, c, r, y = 0) { const m = this.ringPool[this.ringI = (this.ringI + 1) % this.ringPool.length]; m.position.set(x, y + 0.12, z); m.material.color.set(c).multiplyScalar(1.8); m.life = 0.5; m.r = r; }
  scorchAt(x, z, r) { const i = this.scI = (this.scI + 1) % 24; this.scMesh.setMatrixAt(i, new THREE.Matrix4().makeScale(r, 1, r).setPosition(x, 0.025, z)); this.scMesh.instanceMatrix.needsUpdate = true; }
  warnAt(x, z, team) {
    const m = new THREE.Mesh(new THREE.RingGeometry(0.4, 4.8, 40), new THREE.MeshBasicMaterial({ color: col(team === 0 ? '#FF6A1A' : '#2EC8FF').multiplyScalar(1.5), transparent: true, opacity: 0.35, depthWrite: false, blending: THREE.AdditiveBlending }));
    m.rotation.x = -Math.PI / 2; m.position.set(x, 0.08, z); this.scene.add(m); this.warn.push({ m, life: 2.6 });
  }

  // ------------------------------------------------------------------ per-frame draw
  /**
   * view: { ents: [{x,z,yaw,alive,moving,firing,hp,dash,kick,team,you,show}], sentries: [[x,z,yaw,team,firing]], zones: [[owner, prog, contest]],
   *         charges: [[x0,z0,x,z,t,fly]], cam: {x, z, ax, az, zoom, mode}, recon: [a,b], slow }
   */
  draw(view, dt) {
    const S = this.stage; this.t += dt;
    S.r.info.autoReset = false; S.r.info.reset();
    // camera: first person (eye, yaw, pitch, horizontal FOV, zoom) or the overhead menu orbit / follow camera
    const cam = this.cam; const c = view.cam;
    const aspect = S.w / S.h || 1.6; const portrait = aspect < 1;
    const fps = c.mode === 'fps'; this.fpsMode = fps;
    if (fps) {
      const hf = ((c.fov || 82) * Math.PI) / 180; let vf = Math.min(1.75, 2 * Math.atan(Math.tan(hf / 2) / aspect));
      vf = 2 * Math.atan(Math.tan(vf / 2) / (c.zoom || 1)); const deg = (vf * 180) / Math.PI;
      if (Math.abs(cam.fov - deg) > 0.01 || cam.near !== 0.05) { cam.fov = deg; cam.near = 0.05; cam.updateProjectionMatrix(); }
      const cp = Math.cos(c.pitch || 0), dx = Math.cos(c.yaw) * cp, dy = Math.sin(c.pitch || 0), dz = Math.sin(c.yaw) * cp;
      this.camPos.set(c.x, c.y, c.z); this.camLook.set(c.x + dx * 30, c.y + dy * 30, c.z + dz * 30);
      this.focus.set(c.x + dx * 12, 0, c.z + dz * 12);
    } else {
      if (Math.abs(cam.fov - 38) > 0.01 || cam.near !== 0.1) { cam.fov = 38; cam.near = 0.1; cam.updateProjectionMatrix(); }
      if (c.mode === 'orbit') {
        this.orbit += dt * 0.06; const R = c.r || 62; const h = c.h || 46;
        this.camPos.set(Math.cos(this.orbit) * R, h, Math.sin(this.orbit) * R); this.camLook.set(c.x || 0, 0, c.z || 0);
      } else {
        const zoom = (c.zoom || 1) * (portrait ? 1.55 / Math.max(0.5, aspect) ** 0.25 : 1);
        const lead = c.mode === 'kill' ? 0 : 3.4;
        const lx = c.x + (c.ax || 0) * lead, lz = c.z + (c.az || 0) * lead;
        const tx = lx, ty = 21 * zoom, tz = lz + 17 * zoom;
        const k = c.snap ? 1e3 : c.mode === 'kill' ? 4 : 6;
        this.camPos.set(damp(this.camPos.x, tx, k, dt), damp(this.camPos.y, ty, k, dt), damp(this.camPos.z, tz, k, dt));
        this.camLook.set(damp(this.camLook.x, lx, k, dt), 0.8, damp(this.camLook.z, lz, k, dt));
      }
      this.focus.set(this.camLook.x, 0, this.camLook.z);
    }
    S.setCam(this.camPos, this.camLook);
    // cutaway between the camera and the followed operator (overhead follow camera only)
    if (c.mode === 'follow' || c.mode === 'kill') { this.cutA.copy(this.camPos); this.cutB.set(c.x, 1.0, c.z); this.cutR.value = 1; } else this.cutR.value = 0;
    // the first-person viewmodel
    if (fps && c.weapon) this.vm.use(c.weapon);
    this.vm.update(dt, { fov: cam.fov, visible: fps && !!c.weapon && c.alive !== false, moving: c.moving, sprint: c.sprint, ads: c.ads || 0, ground: c.ground !== false, crouch: c.crouch || 0, reload: c.reload == null ? -1 : c.reload, lookDX: c.lookDX, lookDY: c.lookDY, hideOnScope: c.weapon === 'lancer', slide: !!c.slide });
    // moon shadow follows the camera target
    const F = this.focus;
    if (this.moon) { this.moon.target.position.set(F.x, 0, F.z); this.moon.position.set(F.x - 26, 60, F.z - 18); }
    // lamps: the 4 nearest get a real light
    if (this.lamps && this.lamps.length) {
      const near = this.lamps.map((l) => [l, (l.x - F.x) ** 2 + (l.z - F.z) ** 2]).sort((a, b) => a[1] - b[1]).slice(0, 4);
      near.forEach(([l], i) => { const L = this.lampLights[i]; if (!L) return; L.position.set(l.x, 5.2, l.z); L.intensity = 26 * (0.94 + 0.06 * Math.sin(this.t * 13 + i)); });
    }
    for (const l of this.fxLights) { if (l.life > 0) { l.life -= dt; l.intensity = l.k * Math.max(0, l.life / l.max); } else l.intensity = 0; }
    // operators: walk / crouch / aim poses; a tagged-out operator staggers, drops and breaks into sparks
    view.ents.forEach((e, i) => {
      const op = this.ops[i]; if (!op) return;
      if (op.wasAlive && !e.alive) op.dieT = 0.75; op.wasAlive = e.alive;
      if (op.dieT > 0) op.dieT -= dt;
      const show = (e.alive || op.dieT > 0) && e.show !== false && !(fps && i === c.follow);
      op.fade = damp(op.fade, show ? 1 : 0, show ? 20 : 9, dt);
      op.root.visible = show && op.fade > 0.05;
      if (!op.root.visible) return;
      const cr = e.crouch || 0, mv = Math.min(1, (e.moving || 0) / 6.3), dying = !e.alive ? 1 - Math.max(0, op.dieT) / 0.75 : 0;
      op.root.position.set(e.x, (e.y || 0) - dying * 0.25, e.z); op.root.rotation.set(0, -e.yaw, -dying * 1.25, 'YXZ');
      op.root.scale.setScalar(dying > 0.8 ? 1 - (dying - 0.8) * 4 : 1);
      op.ph += dt * (2 + (e.moving || 0) * 1.45);
      const sw = Math.sin(op.ph) * mv * 0.7;
      op.hip.position.y = 0.95 - cr * 0.4 + Math.abs(Math.cos(op.ph)) * 0.04 * mv;
      op.legs[0].thigh.rotation.z = sw + cr * 1.2; op.legs[0].shin.rotation.z = -(cr * 2.1 + Math.max(0, Math.sin(op.ph)) * mv * 0.9);
      op.legs[1].thigh.rotation.z = -sw + cr * 1.2; op.legs[1].shin.rotation.z = -(cr * 2.1 + Math.max(0, -Math.sin(op.ph)) * mv * 0.9);
      const pitch = e.pitch || 0;
      op.fl = damp(op.fl || 0, e.flinch ? 1 : 0, e.flinch ? 40 : 10, dt); // hit flinch: the torso snaps back for a moment
      op.torso.rotation.z = -0.06 * mv - cr * 0.22 - (e.dash ? 0.3 : 0) + op.fl * 0.22; op.head.rotation.z = pitch * 0.5 + cr * 0.15 + op.fl * 0.15;
      op.torso.rotation.y = Math.sin(op.ph) * 0.09 * mv; // shoulders counter-swing the stride
      op.arms.rotation.z = pitch + cr * 0.22 + Math.sin(op.ph * 2) * 0.025 * mv; op.arms.position.x = 0.02 - (e.kick || 0) * 0.07;
      const fl = e.firing > 0 ? 1 : 0; op.muzzle.scale.setScalar(fl ? 0.16 + Math.random() * 0.08 : 0); // sized to the gun, not the body
      op.ring.visible = !fps; op.ring.material.opacity = (e.you ? 0.9 : 0.5) * op.fade;
      op.mark.visible = fps && !!e.mark && e.alive; if (op.mark.visible) op.mark.position.y = 2.15 - cr * 0.45;
    });
    // sentries
    this.syncSentries(view.sentries || []);
    // zones
    const T = TEAMS;
    view.zones.forEach(([owner, prog, contest], i) => {
      const z = this.zoneFx[i]; if (!z) return; const c0 = owner >= 0 ? col(T[owner].color) : col('#C8CCD8');
      const pulse = contest ? 0.6 + 0.4 * Math.sin(this.t * 14) : 1;
      z.ring.material.color.copy(c0).multiplyScalar((owner >= 0 ? 1.4 : 0.9) * pulse); z.fill.material.color.copy(c0); z.fill.material.opacity = owner >= 0 ? 0.08 : 0.03;
      z.col1.material.uniforms.uC.value.copy(c0); z.col1.material.uniforms.uA.value = owner >= 0 ? 0.16 : 0.07;
      z.lt.material.color.copy(c0).multiplyScalar(1.15 * pulse); z.lt.position.y = 4.6 + Math.sin(this.t * 1.6 + i) * 0.15;
      const pc = prog >= 0 ? T[0].color : T[1].color; z.arc.material.color.set(pc).multiplyScalar(3); setArc(z.arc, -Math.PI / 2, Math.abs(prog) * Math.PI * 2, z.z.r + 0.35, 0.12, 0.1);
    });
    // charges in flight + blinking
    this.syncCharges(view.charges || []);
    // tracers: a short bright streak travelling from the muzzle to the hit point
    const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), ax = new THREE.Vector3(1, 0, 0), v = new THREE.Vector3(), sc = new THREE.Vector3(), dv = new THREE.Vector3();
    for (let i = 0; i < this.trs.length; i++) {
      const t = this.trs[i]; if (!t) continue; t.age += dt;
      const L = t.L; const head = Math.min(L, t.age * 320); const tail = Math.max(0, head - t.len);
      if (tail >= L - 0.01 || t.age > 0.5) { this.trs[i] = null; this.trMesh.setMatrixAt(i, m4.makeScale(0, 0, 0)); continue; }
      dv.set(t.dx, t.dy, t.dz); q.setFromUnitVectors(ax, dv); const mid = (head + tail) / 2;
      v.set(t.x0 + t.dx * mid, t.y0 + t.dy * mid, t.z0 + t.dz * mid); sc.set(Math.max(0.01, head - tail), t.w, t.w);
      this.trMesh.setMatrixAt(i, m4.compose(v, q, sc)); this.trMesh.setColorAt(i, t.c);
    }
    this.trMesh.instanceMatrix.needsUpdate = true; if (this.trMesh.instanceColor) this.trMesh.instanceColor.needsUpdate = true;
    for (const m of this.ringPool) { if (m.life > 0) { m.life -= dt; const k = 1 - m.life / 0.5; m.scale.setScalar(0.4 + k * m.r); m.material.opacity = (1 - k) * 0.9; } else m.material.opacity = 0; }
    this.warn = this.warn.filter((w) => { w.life -= dt; w.m.material.opacity = 0.2 + 0.25 * (Math.sin(this.t * 22) * 0.5 + 0.5); if (w.life <= 0) { this.scene.remove(w.m); return false; } return true; });
    // ambience: rain follows the camera, steam from vents, rain splashes
    if (this.rainU) { this.rainU.uT.value = this.t; this.rainU.uC.value.set(F.x, 0, F.z); }
    if (this.vents) for (const vnt of this.vents) if (Math.random() < dt * 9 && Math.abs(vnt.x - F.x) < 30 && Math.abs(vnt.z - F.z) < 30) S.parts.burst(vnt.x, 0.3, vnt.z, { n: 1, colors: ['#8A9098'], speed: 0.6, life: 2.2, size: fps ? 5 : 11, up: 2.2, hdr: 0.32, drag: 0.6 });
    for (let k = 0; k < (this.low ? 2 : 5); k++) S.parts.burst(F.x + (Math.random() - 0.5) * (fps ? 24 : 36), 0.05, F.z + (Math.random() - 0.5) * (fps ? 24 : 30), { n: 1, colors: ['#9AB0D0'], speed: 1.0, life: 0.14, size: fps ? 0.35 : 0.6, up: 1.2, hdr: 0.7 });
    S.render(dt);
  }

  syncSentries(list) {
    if (!this.sentryMeshes) this.sentryMeshes = [];
    while (this.sentryMeshes.length < list.length) {
      const g = new THREE.Group(); const base = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.6, 0.6, 8), new THREE.MeshStandardMaterial({ color: col('#2A2C30'), roughness: 0.4, metalness: 0.8 })); base.position.y = 0.3; g.add(base);
      const head = new THREE.Group(); head.position.y = 0.85; g.add(head);
      head.add(new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.35, 0.5), new THREE.MeshStandardMaterial({ color: col('#3A3E44'), roughness: 0.35, metalness: 0.8 })));
      const barrel = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.1, 0.1), new THREE.MeshStandardMaterial({ color: col('#111'), metalness: 0.9, roughness: 0.3 })); barrel.position.x = 0.55; head.add(barrel);
      const eye = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.08, 0.3), new THREE.MeshBasicMaterial({ color: col('#FF3040').multiplyScalar(4), toneMapped: false })); eye.position.set(0.36, 0.06, 0); head.add(eye);
      g.userData = { head, eye }; g.traverse((o) => { if (o.isMesh) o.castShadow = true; }); this.scene.add(g); this.sentryMeshes.push(g);
    }
    this.sentryMeshes.forEach((g, i) => { const s = list[i]; g.visible = !!s; if (!s) return; g.position.set(s[0], 0, s[1]); g.userData.head.rotation.y = -s[2]; g.userData.eye.material.color.set(TEAMS[s[3]].color).multiplyScalar(4); });
  }
  syncCharges(list) {
    if (!this.chargeMeshes) this.chargeMeshes = [];
    while (this.chargeMeshes.length < list.length) { const m = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), new THREE.MeshBasicMaterial({ color: col('#FF4030').multiplyScalar(4), toneMapped: false })); this.scene.add(m); this.chargeMeshes.push(m); }
    this.chargeMeshes.forEach((m, i) => {
      const c = list[i]; m.visible = !!c; if (!c) return; const [x0, z0, x, z, t, fly, y0 = 1.2, y1 = 0] = c; const k = Math.min(1, t / fly);
      m.position.set(x0 + (x - x0) * k, y0 + (y1 + 0.16 - y0) * k + Math.sin(k * Math.PI) * 2.4, z0 + (z - z0) * k);
      m.material.color.set(Math.sin(t * 30) > 0 ? '#FF3020' : '#FFC0A0').multiplyScalar(4);
    });
  }

  /** Pick the ground point under a screen position (CSS px). */
  pick(px, py, w, h) {
    const ndc = new THREE.Vector2((px / w) * 2 - 1, -(py / h) * 2 + 1); const ray = new THREE.Raycaster(); ray.setFromCamera(ndc, this.cam);
    const o = ray.ray.origin, d = ray.ray.direction; const t = (1.2 - o.y) / d.y; return t > 0 ? { x: o.x + d.x * t, z: o.z + d.z * t } : null;
  }
  /** World point -> CSS px on the canvas. */
  toScreen(x, y, z, w, h) { const v = new THREE.Vector3(x, y, z).project(this.cam); return { x: (v.x * 0.5 + 0.5) * w, y: (-v.y * 0.5 + 0.5) * h, on: v.z < 1 && Math.abs(v.x) < 1.05 && Math.abs(v.y) < 1.05 }; }
  stats() { const i = this.stage.r.info.render; return { calls: i.calls, tris: i.triangles, w: this.stage.w, h: this.stage.h, hdr: this.stage.hdr, tier: this.stage.tier }; }
}
/**
 * Fake ambient occlusion for instanced props (no extra pass): surfaces darken towards the ground they stand on and
 * along the edges of every face, so crates, cars and barriers sit in the street and read as solid cover.
 */
function aoPatch(mat) {
  mat.userData.aoDone = true;
  const prev = mat.onBeforeCompile; const prevKey = mat.customProgramCacheKey && mat.customProgramCacheKey !== THREE.Material.prototype.customProgramCacheKey ? mat.customProgramCacheKey.bind(mat) : null;
  mat.onBeforeCompile = (s, r) => {
    if (prev) prev.call(mat, s, r);
    s.vertexShader = s.vertexShader.replace('#include <common>', '#include <common>\nvarying float vAoY; varying vec2 vAoUv;')
      .replace('#include <worldpos_vertex>', '#include <worldpos_vertex>\n#ifdef USE_INSTANCING\nvAoY = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).y;\n#else\nvAoY = (modelMatrix * vec4(transformed, 1.0)).y;\n#endif\nvAoUv = uv;');
    s.fragmentShader = s.fragmentShader.replace('#include <common>', '#include <common>\nvarying float vAoY; varying vec2 vAoUv;')
      .replace('#include <map_fragment>', `#include <map_fragment>
        { float ao = mix(0.42, 1.0, smoothstep(0.0, 1.15, vAoY));
          vec2 eu = min(vAoUv, 1.0 - vAoUv); float edge = min(eu.x, eu.y); ao *= mix(0.72, 1.0, smoothstep(0.0, 0.06, edge));
          diffuseColor.rgb *= ao; }`);
  };
  mat.customProgramCacheKey = () => `${prevKey ? prevKey() : 'tb'}-ao`; // shared programs: one per feature set, not one per material
  return mat;
}
function S_glow(stage, c, k) { return new THREE.MeshBasicMaterial({ color: col(c).multiplyScalar(k), toneMapped: false }); }
export { THEMES };