Spaces:
Running
Running
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 };
|