turfbreak / render.js
CyberMax-tools's picture
Deploy turfbreak
217da1a verified
Raw History Blame Contribute Delete
78.9 kB
// 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 };