// Turfbreak first-person view: procedural weapon models (all original designs, built from primitives, no imported // art), the viewmodel you hold (arms + weapon) with bob, sway, recoil kick, reload, weapon switch, sprint and // aim-down-sights poses, muzzle flash and ejected brass. The viewmodel is a child of the camera and is drawn in its // own depth range (it never clips into walls) with its own field of view (it keeps its size when you zoom). // Every model is merged into a few meshes (one draw call per material) to stay cheap on phones. // (c) 2026 CyberMax. All rights reserved. import { THREE, col, damp } from './stage3d.js'; const V3 = (x, y, z) => new THREE.Vector3(x, y, z); /** Merge [{ g, m (Matrix4), c (colour) }] into one non-indexed geometry with vertex colours. */ export function mergeParts(parts) { let n = 0; const gs = parts.map((p) => { const g = (p.g.index ? p.g.toNonIndexed() : p.g.clone()); g.applyMatrix4(p.m); n += g.attributes.position.count; return g; }); // uv: kept for boxes (per-face 0..1, used for edge shading), centred for round parts so they get no false edges const pos = new Float32Array(n * 3), nor = new Float32Array(n * 3), colr = new Float32Array(n * 3), uvs = new Float32Array(n * 2).fill(0.5); let o = 0; gs.forEach((g, i) => { const c = col(parts[i].c || '#FFFFFF'); const P = g.attributes.position.array, N = g.attributes.normal.array; pos.set(P, o * 3); nor.set(N, o * 3); if (parts[i].g.type === 'BoxGeometry' && g.attributes.uv) uvs.set(g.attributes.uv.array, o * 2); for (let k = 0; k < g.attributes.position.count; k++) { colr[(o + k) * 3] = c.r; colr[(o + k) * 3 + 1] = c.g; colr[(o + k) * 3 + 2] = c.b; } o += g.attributes.position.count; g.dispose(); }); const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); g.setAttribute('normal', new THREE.BufferAttribute(nor, 3)); g.setAttribute('color', new THREE.BufferAttribute(colr, 3)); g.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); g.computeBoundingSphere(); return g; } const M = (x, y, z, rx = 0, ry = 0, rz = 0, sx = 1, sy = 1, sz = 1) => new THREE.Matrix4().compose(V3(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), V3(sx, sy, sz)); const BOX = new THREE.BoxGeometry(1, 1, 1); const CYL = new THREE.CylinderGeometry(1, 1, 1, 14, 1); // along y /** Cylinder from a to b (radius r). */ function rod(a, b, r) { const d = b.clone().sub(a); const L = d.length(); const q = new THREE.Quaternion().setFromUnitVectors(V3(0, 1, 0), d.normalize()); return new THREE.Matrix4().compose(a.clone().add(b).multiplyScalar(0.5), q, V3(r, L, r)); } // Weapon designs (metres; barrel along -z, grip near the origin, sight line at y = sight). Colours are base tints. // Parts are sorted by surface: P = machined metal, Q = polymer / furniture, MAG = the magazine (moves on reload), // A = glowing accent strips (skin colour), L = optics glass. All original designs built from primitives. const DARK = '#2E3138', MET = '#5A606A', MID = '#6C727C', POLY = '#3A3E45', TAN = '#9A8A6E', BLK = '#1A1C20', STEEL = '#8E949E'; function design(id) { const P = [], Q = [], A = [], MAG = [], L = []; let muzzle = V3(0, 0, -0.76), sight = 0.095, sightZ = -0.1, port = V3(0.035, 0.03, -0.16), kind = 'dot'; const b = (x, y, z, w, h, d, c, rx = 0, list = P, ry = 0, rz = 0) => list.push({ g: BOX, m: M(x, y, z, rx, ry, rz, w, h, d), c }); const cz = (z0, z1, r, y, c, list = P, x = 0) => list.push({ g: CYL, m: rod(V3(x, y, z0), V3(x, y, z1), r), c }); // a picatinny rail: a base strip with cross teeth const rail = (z0, z1, y, w = 0.026) => { b(0, y, (z0 + z1) / 2, w, 0.008, Math.abs(z1 - z0), DARK); for (let z = Math.max(z0, z1) - 0.006; z > Math.min(z0, z1); z -= 0.014) b(0, y + 0.006, z, w + 0.004, 0.005, 0.007, BLK); }; // vent slots on both sides of a handguard const vents = (z0, z1, y, half, n, h = 0.012) => { const st = (z1 - z0) / n; for (let k = 0; k < n; k++) for (const s of [-1, 1]) b(s * (half + 0.0008), y, z0 + st * (k + 0.5), 0.003, h, st * 0.55, BLK); }; // a muzzle brake with side ports const brake = (z, y, r, len = 0.05) => { cz(z, z - len, r, y, DARK); for (const f of [0.3, 0.65]) for (const s of [-1, 1]) b(s * r * 0.9, y, z - len * f, 0.004, r * 0.9, len * 0.18, BLK); }; const trigger = (z, y) => { b(0, y - 0.03, z, 0.006, 0.004, 0.06, DARK); b(0, y - 0.017, z + 0.028, 0.006, 0.03, 0.004, DARK); b(0, y - 0.012, z - 0.006, 0.004, 0.02, 0.006, STEEL, 0.25); }; if (id === 'vk9') { // carbine: flat-top upper, slotted handguard, red dot b(0, 0.012, -0.17, 0.06, 0.062, 0.33, MET); b(0, -0.03, -0.12, 0.056, 0.04, 0.2, POLY, 0, Q); b(0.031, 0.02, -0.15, 0.003, 0.022, 0.07, BLK); b(0.03, 0.012, -0.11, 0.008, 0.008, 0.018, STEEL); // ejection port + forward assist b(0, 0.046, 0.0, 0.03, 0.012, 0.03, DARK); b(0.022, 0.046, 0.012, 0.016, 0.01, 0.012, STEEL); // charging handle rail(-0.33, -0.02, 0.046); b(0, -0.002, -0.47, 0.068, 0.07, 0.28, POLY, 0, Q); vents(-0.58, -0.37, -0.002, 0.034, 5); rail(-0.6, -0.36, 0.037, 0.022); for (const s of [-1, 1]) b(s * 0.0345, 0.012, -0.47, 0.003, 0.008, 0.18, '#FFFFFF', 0, A); cz(-0.6, -0.72, 0.011, 0.004, DARK); b(0, 0.03, -0.66, 0.012, 0.03, 0.012, DARK); brake(-0.72, 0.004, 0.017); b(0, -0.088, -0.02, 0.034, 0.105, 0.046, POLY, -0.32, Q); for (let k = 0; k < 4; k++) b(0, -0.06 - k * 0.018, -0.042 + k * 0.006, 0.036, 0.004, 0.006, BLK, -0.32, Q); trigger(-0.07, -0.02); b(0, -0.008, 0.06, 0.04, 0.05, 0.05, DARK); b(0, -0.012, 0.13, 0.046, 0.07, 0.14, POLY, 0, Q); b(0, -0.022, 0.205, 0.05, 0.11, 0.02, BLK, 0, Q); b(0, -0.1, -0.19, 0.038, 0.11, 0.068, POLY, 0.12, MAG); b(0, -0.175, -0.205, 0.036, 0.07, 0.064, POLY, 0.32, MAG); for (let k = 0; k < 3; k++) b(0.0195, -0.08 - k * 0.03, -0.19 - k * 0.008, 0.002, 0.012, 0.05, BLK, 0.15, MAG); b(0, -0.212, -0.218, 0.04, 0.012, 0.07, DARK, 0.32, MAG); // red dot: mount, housing, glass, front cap b(0, 0.058, -0.105, 0.022, 0.012, 0.05, DARK); b(0, 0.083, -0.105, 0.04, 0.04, 0.056, DARK); b(0, 0.083, -0.105, 0.03, 0.03, 0.058, BLK); b(0, 0.085, -0.131, 0.03, 0.028, 0.002, '#1A2A30', 0, L); b(0.022, 0.08, -0.1, 0.006, 0.014, 0.014, STEEL); b(0, 0.068, -0.36, 0.008, 0.022, 0.01, DARK); // folding front post muzzle = V3(0, 0.004, -0.78); sight = 0.085; sightZ = -0.105; kind = 'dot'; } else if (id === 'wasp') { // compact SMG: tan polymer, vertical foregrip, peep sights b(0, 0, -0.12, 0.058, 0.09, 0.27, TAN, 0, Q); b(0, 0.05, -0.13, 0.034, 0.012, 0.25, DARK); rail(-0.24, -0.02, 0.057, 0.022); b(0.03, 0.018, -0.1, 0.003, 0.02, 0.06, BLK); b(-0.03, 0.02, -0.04, 0.006, 0.01, 0.03, STEEL); b(0, -0.006, -0.31, 0.06, 0.07, 0.13, MID); vents(-0.36, -0.26, -0.004, 0.03, 3, 0.02); for (const s of [-1, 1]) b(s * 0.0305, 0.012, -0.31, 0.003, 0.008, 0.1, '#FFFFFF', 0, A); cz(-0.37, -0.44, 0.012, 0.0, DARK); brake(-0.44, 0, 0.016, 0.035); b(0, -0.085, 0.0, 0.034, 0.1, 0.044, POLY, -0.25, Q); b(0, -0.08, -0.3, 0.03, 0.09, 0.032, POLY, 0.1, Q); for (let k = 0; k < 3; k++) b(0, -0.06 - k * 0.025, -0.317, 0.032, 0.004, 0.004, BLK, 0.1, Q); trigger(-0.05, -0.02); b(0, -0.12, -0.17, 0.036, 0.19, 0.05, DARK, 0.12, MAG); b(0, -0.222, -0.183, 0.04, 0.014, 0.056, BLK, 0.12, MAG); b(0, 0.01, 0.11, 0.035, 0.05, 0.16, DARK); b(0, -0.01, 0.19, 0.04, 0.09, 0.016, BLK); b(0, 0.068, 0.0, 0.03, 0.026, 0.02, DARK); b(-0.011, 0.084, 0.0, 0.006, 0.012, 0.02, DARK); b(0.011, 0.084, 0.0, 0.006, 0.012, 0.02, DARK); b(0, 0.076, -0.23, 0.018, 0.012, 0.012, DARK); b(0, 0.086, -0.23, 0.005, 0.014, 0.006, STEEL); muzzle = V3(0, 0.0, -0.48); sight = 0.082; sightZ = 0.0; kind = 'iron'; } else if (id === 'breach') { // pump shotgun: tube magazine, ribbed forend, bead sight b(0, 0, -0.16, 0.07, 0.1, 0.36, MET); b(0.036, 0.015, -0.12, 0.003, 0.03, 0.09, BLK); b(0, 0.055, -0.28, 0.028, 0.012, 0.52, DARK); cz(-0.34, -0.8, 0.019, 0.022, DARK); cz(-0.34, -0.74, 0.016, -0.025, MID); cz(-0.74, -0.75, 0.018, -0.025, STEEL); b(0, -0.025, -0.52, 0.074, 0.06, 0.2, POLY, 0, Q); for (let k = 0; k < 7; k++) b(0, -0.025, -0.44 - k * 0.025, 0.078, 0.05, 0.008, BLK, 0, Q); for (const s of [-1, 1]) b(s * 0.0375, -0.012, -0.52, 0.003, 0.008, 0.16, '#FFFFFF', 0, A); b(0, -0.09, -0.01, 0.036, 0.11, 0.05, POLY, -0.3, Q); trigger(-0.06, -0.03); b(0, -0.012, 0.11, 0.05, 0.085, 0.22, POLY, 0, Q); b(0, -0.025, 0.225, 0.054, 0.12, 0.024, BLK, 0, Q); b(0, -0.1, -0.22, 0.05, 0.12, 0.09, POLY, 0.08, MAG); for (let k = 0; k < 4; k++) b(0.026, -0.07 - k * 0.022, -0.22, 0.003, 0.008, 0.07, '#B8862E', 0.08, MAG); b(0, 0.074, -0.06, 0.03, 0.022, 0.02, DARK); b(-0.012, 0.09, -0.06, 0.006, 0.014, 0.02, DARK); b(0.012, 0.09, -0.06, 0.006, 0.014, 0.02, DARK); b(0, 0.068, -0.78, 0.006, 0.012, 0.01, '#E04A2A'); muzzle = V3(0, 0.022, -0.82); sight = 0.085; sightZ = -0.06; kind = 'iron'; } else if (id === 'lancer') { // marksman rifle: long free-float handguard, bipod rails, 2.6x scope b(0, 0.01, -0.18, 0.062, 0.07, 0.38, '#3A3F34'); b(0, -0.03, -0.13, 0.058, 0.04, 0.2, '#30352C', 0, Q); rail(-0.36, -0.02, 0.048); b(0.032, 0.02, -0.16, 0.003, 0.024, 0.08, BLK); b(0.03, 0.03, -0.04, 0.02, 0.012, 0.012, STEEL); b(0, -0.004, -0.53, 0.07, 0.075, 0.32, '#30352C', 0, Q); vents(-0.67, -0.4, -0.004, 0.035, 6, 0.018); rail(-0.68, -0.38, 0.037, 0.022); for (const s of [-1, 1]) b(s * 0.0355, 0.016, -0.53, 0.003, 0.008, 0.22, '#FFFFFF', 0, A); cz(-0.68, -0.98, 0.012, 0.004, DARK); brake(-0.97, 0.004, 0.02, 0.07); b(0, -0.088, -0.02, 0.034, 0.105, 0.046, POLY, -0.3, Q); trigger(-0.07, -0.02); b(0, -0.005, 0.12, 0.048, 0.1, 0.24, '#30352C', 0, Q); b(0, -0.03, 0.245, 0.052, 0.14, 0.02, BLK, 0, Q); b(0, 0.045, 0.1, 0.03, 0.025, 0.13, POLY, 0, Q); b(0, -0.1, -0.19, 0.042, 0.11, 0.08, POLY, 0.1, MAG); b(0, -0.158, -0.196, 0.046, 0.012, 0.084, DARK, 0.1, MAG); // scope: rings, tube, bells, turrets, glass both ends for (const z of [-0.07, -0.25]) { b(0, 0.068, z, 0.03, 0.03, 0.018, DARK); } cz(-0.04, -0.3, 0.021, 0.098, DARK); cz(-0.27, -0.34, 0.03, 0.098, DARK); cz(0.02, -0.04, 0.026, 0.098, DARK); cz(-0.338, -0.342, 0.031, 0.098, STEEL); b(0, 0.124, -0.16, 0.018, 0.02, 0.026, MID); b(0.026, 0.098, -0.16, 0.02, 0.018, 0.026, MID); L.push({ g: CYL, m: rod(V3(0, 0.098, -0.343), V3(0, 0.098, -0.346), 0.027), c: '#2A4A5A' }); muzzle = V3(0, 0.004, -1.04); sight = 0.098; sightZ = -0.02; kind = 'scope'; } else { // p9 sidearm: slide with serrations, frame with rail, tritium sights b(0, 0.022, -0.09, 0.032, 0.036, 0.19, MID); for (let k = 0; k < 6; k++) for (const s of [-1, 1]) b(s * 0.0162, 0.026, -0.005 - k * 0.007, 0.002, 0.024, 0.003, BLK); b(0.0165, 0.03, -0.08, 0.002, 0.012, 0.04, BLK); // ejection port b(0, -0.004, -0.085, 0.03, 0.03, 0.17, POLY, 0, Q); b(0, -0.016, -0.14, 0.024, 0.008, 0.06, DARK, 0, Q); b(0, -0.06, 0.0, 0.03, 0.1, 0.045, POLY, -0.28, Q); for (let k = 0; k < 4; k++) b(0, -0.035 - k * 0.018, -0.024 + k * 0.005, 0.032, 0.003, 0.006, BLK, -0.28, Q); b(0, -0.026, -0.05, 0.005, 0.004, 0.04, DARK); b(0, -0.016, -0.03, 0.004, 0.018, 0.006, STEEL, 0.2); b(0, -0.065, -0.0, 0.026, 0.09, 0.034, DARK, -0.28, MAG); b(0, -0.112, 0.013, 0.03, 0.01, 0.04, BLK, -0.28, MAG); cz(-0.17, -0.19, 0.008, 0.022, DARK); for (const s of [-1, 1]) b(s * 0.0165, 0.012, -0.09, 0.002, 0.006, 0.1, '#FFFFFF', 0, A); b(0, 0.046, -0.17, 0.006, 0.012, 0.01, DARK); b(-0.009, 0.046, 0.0, 0.005, 0.012, 0.01, DARK); b(0.009, 0.046, 0.0, 0.005, 0.012, 0.01, DARK); b(0, 0.051, -0.17, 0.003, 0.003, 0.003, '#7CFF9A', 0, A); for (const s of [-1, 1]) b(s * 0.009, 0.051, 0.0, 0.003, 0.003, 0.003, '#7CFF9A', 0, A); muzzle = V3(0, 0.022, -0.195); sight = 0.05; sightZ = 0.0; port = V3(0.02, 0.03, -0.06); kind = 'iron'; } return { P, Q, A, MAG, L, muzzle, sight, sightZ, port, kind }; } /** Bevel-style shading for the viewmodel: box faces darken towards their edges and catch a thin highlight right at * the edge, so machined parts read as solid objects instead of flat-shaded boxes. */ const edgeShade = (m) => { m.userData.edge = true; const prev = m.onBeforeCompile; m.onBeforeCompile = (s, r) => { prev && prev(s, r); s.vertexShader = s.vertexShader.replace('#include ', '#include \nvarying vec2 vEdgeUv;').replace('#include ', '#include \nvEdgeUv = uv;'); s.fragmentShader = s.fragmentShader.replace('#include ', '#include \nvarying vec2 vEdgeUv;') // a key light from above-right and a cool rim from the left, computed for the viewmodel's own pixels only (no // scene lights, so the world costs nothing extra): the gun and gloves always show their shape at night .replace('#include ', `{ vec3 vn = normalize(normal); vec3 kd = normalize(vec3(0.55, 0.75, 0.35)); float key = max(dot(vn, kd), 0.0); float rim = pow(1.0 - max(dot(vn, normalize(vViewPosition)), 0.0), 3.0); vec3 h = normalize(kd + normalize(vViewPosition)); float sp = pow(max(dot(vn, h), 0.0), mix(8.0, 48.0, 1.0 - roughnessFactor)) * (0.15 + metalnessFactor * 0.85); outgoingLight += diffuseColor.rgb * (key * 0.42 + 0.05) + vec3(1.0, 0.92, 0.82) * sp * 0.35 + vec3(0.45, 0.65, 1.0) * rim * 0.12; } #include `) .replace('#include ', '#include \n{ vec2 e2 = min(vEdgeUv, 1.0 - vEdgeUv); float e = min(e2.x, e2.y);diffuseColor.rgb *= mix(0.62, 1.0, smoothstep(0.0, 0.16, e)) + 0.35 * (1.0 - smoothstep(0.0, 0.03, e)); }'); }; return m; }; const depthHack = (m) => { const prev = m.onBeforeCompile; m.onBeforeCompile = (s, r) => { prev && prev(s, r); s.vertexShader = s.vertexShader.replace('#include ', '#include \ngl_Position.z = gl_Position.z * 0.03 - 0.97 * gl_Position.w;'); }; m.customProgramCacheKey = () => `vm-${m.type}-${m.vertexColors ? 1 : 0}-${m.userData.edge ? 1 : 0}`; return m; }; /** A third-person weapon (one merged mesh, pointing +x) for operators. */ const tpCache = new Map(); export function thirdPersonGun(id, mat) { if (!tpCache.has(id)) { const d = design(id); tpCache.set(id, mergeParts([...d.P, ...d.Q, ...d.MAG, ...d.L, ...d.A.map((a) => ({ ...a, c: '#3A3F46' }))].map((p) => ({ ...p, m: new THREE.Matrix4().makeRotationY(-Math.PI / 2).multiply(p.m) })))); } const m = new THREE.Mesh(tpCache.get(id), mat); m.castShadow = true; return m; } function flashTexture() { const c = document.createElement('canvas'); c.width = c.height = 128; const g = c.getContext('2d'); const gr = g.createRadialGradient(64, 64, 0, 64, 64, 64); gr.addColorStop(0, 'rgba(255,255,240,1)'); gr.addColorStop(0.2, 'rgba(255,220,140,.9)'); gr.addColorStop(0.5, 'rgba(255,140,40,.35)'); gr.addColorStop(1, 'rgba(255,90,0,0)'); g.fillStyle = gr; g.fillRect(0, 0, 128, 128); g.globalCompositeOperation = 'lighter'; g.fillStyle = 'rgba(255,230,170,.8)'; for (let k = 0; k < 6; k++) { g.save(); g.translate(64, 64); g.rotate((k / 6) * Math.PI * 2 + 0.3); g.beginPath(); g.moveTo(0, -5); g.lineTo(62, 0); g.lineTo(0, 5); g.fill(); g.restore(); } const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; } function dotTexture(color) { const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d'); const gr = g.createRadialGradient(32, 32, 0, 32, 32, 7); gr.addColorStop(0, color); 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; return t; } export class ViewModel { /** cam: the scene camera (the viewmodel is added as its child). */ constructor(stage, cam) { this.stage = stage; this.cam = cam; this.root = new THREE.Group(); this.root.renderOrder = 50; cam.add(this.root); this.pivot = new THREE.Group(); this.root.add(this.pivot); this.models = {}; this.cur = null; this.vmFov = 72; // machined metal (sharp reflections of the neon city), polymer furniture (matte), cloth + gloves, optics glass this.matGun = depthHack(edgeShade(new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.3, metalness: 0.78, envMapIntensity: 1.9, emissive: col('#1E222B') }))); this.matPoly = depthHack(edgeShade(new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.62, metalness: 0.08, envMapIntensity: 0.9, emissive: col('#1A1D24') }))); this.matArm = depthHack(edgeShade(new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.86, metalness: 0.02, envMapIntensity: 0.6, emissive: col('#1A1B20') }))); this.matLens = depthHack(new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.04, metalness: 1, envMapIntensity: 2.6, emissive: col('#0A2030') })); this.matAcc = depthHack(new THREE.MeshBasicMaterial({ color: col('#FF8A1F').multiplyScalar(1.6), toneMapped: false })); this.matDot = depthHack(new THREE.MeshBasicMaterial({ map: dotTexture('rgba(255,40,40,1)'), color: col('#FFFFFF').multiplyScalar(1.3), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, toneMapped: false })); // the flash is bright but small: about the size of the muzzle device, and never a screen-wide bloom this.matFlash = depthHack(new THREE.MeshBasicMaterial({ map: flashTexture(), color: col('#FFFFFF').multiplyScalar(1.5), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, toneMapped: false, side: THREE.DoubleSide })); const fg = new THREE.PlaneGeometry(1, 1); this.flash = new THREE.Group(); for (let k = 0; k < 3; k++) { const p = new THREE.Mesh(fg, this.matFlash); p.rotation.set(k === 0 ? 0 : Math.PI / 2, k === 2 ? Math.PI / 2 : 0, 0); if (k === 0) p.scale.set(0.9, 0.9, 1); else p.scale.set(0.45, 1.5, 1); p.position.z = k ? -0.35 : 0; this.flash.add(p); p.frustumCulled = false; p.renderOrder = 60; } this.st = { ph: 0, bobA: 0, swayX: 0, swayY: 0, kick: 0, kickR: 0, kickX: 0, reload: -1, sw: 1, ads: 0, sprint: 0, land: 0, flashT: 0, air: 0, crouch: 0 }; this.sleeve = '#3E3A34'; this.accent = '#FF8A1F'; } setLook(sleeve, accent) { if (sleeve === this.sleeve && accent === this.accent) return; this.sleeve = sleeve; this.accent = accent; this.matAcc.color.set(accent).multiplyScalar(1.6); for (const k of Object.keys(this.models)) { const m = this.models[k]; if (this.flash.parent === m.g) m.g.remove(this.flash); this.pivot.remove(m.g); m.g.traverse((o) => { if (o.isMesh && o.geometry) o.geometry.dispose(); }); } this.models = {}; const c = this.cur; this.cur = null; if (c) this.use(c, true); } build(id) { const d = design(id); const g = new THREE.Group(); const add = (geo, mat) => { const m = new THREE.Mesh(geo, mat); m.frustumCulled = false; m.renderOrder = 50; g.add(m); return m; }; // first person: parts behind the grip (stock, butt pad) sit at your cheek and shoulder, outside the view; leave them out const front0 = (q) => new THREE.Vector3().setFromMatrixPosition(q.m).z < 0.06; add(mergeParts(d.P.filter(front0)), this.matGun); add(mergeParts(d.Q.filter(front0)), this.matPoly); const mag = add(mergeParts(d.MAG), this.matPoly); if (d.A.length) add(mergeParts(d.A), this.matAcc); if (d.L.length) add(mergeParts(d.L), this.matLens); if (d.kind === 'dot') { const dot = add(new THREE.PlaneGeometry(0.012, 0.012), this.matDot); dot.position.set(0, d.sight, d.sightZ - 0.03); } // arms: sleeves with cuffs, padded gloves with real fingers around the grip and under the handguard const glove = '#202227', pad = '#2C2F35', S = this.sleeve, cuff = '#141518'; const pist = id === 'p9'; const fz = id === 'wasp' ? -0.3 : id === 'breach' ? -0.5 : id === 'lancer' ? -0.5 : -0.45; const front = pist ? V3(-0.012, -0.075, -0.02) : V3(0, -0.05, fz); const gripR = V3(0.004, -0.085, -0.005); const box = (x, y, z, w, h, dd, c, rx = 0, ry = 0, rz = 0) => ({ g: BOX, m: M(x, y, z, rx, ry, rz, w, h, dd), c }); const cyl = (a, b2, r, c) => ({ g: CYL, m: rod(a, b2, r), c }); const arm = [ // right forearm + cuff + hand on the grip cyl(V3(0.14, -0.27, 0.24), V3(0.04, -0.13, 0.075), 0.05, S), cyl(V3(0.05, -0.145, 0.088), V3(0.034, -0.122, 0.064), 0.054, cuff), box(0.026, -0.085, 0.005, 0.03, 0.1, 0.07, glove, -0.3), box(0.01, -0.05, 0.022, 0.05, 0.03, 0.05, glove, -0.2), box(0.012, -0.036, 0.026, 0.034, 0.008, 0.03, pad, -0.2), ...[0, 1, 2].map((k) => box(0.002, -0.072 - k * 0.021, -0.047 + k * 0.007, 0.054, 0.018, 0.024, glove, -0.32)), ...[0, 1, 2].map((k) => box(0.028, -0.072 - k * 0.021, -0.04 + k * 0.007, 0.012, 0.016, 0.02, pad, -0.32)), cyl(V3(0.016, -0.046, -0.022), V3(0.004, -0.048, -0.07), 0.009, glove), // trigger finger along the frame cyl(V3(-0.019, -0.068, 0.018), V3(-0.022, -0.046, -0.038), 0.011, glove), // thumb ]; if (pist) arm.push( // left hand wraps the right one (two-hand grip) cyl(V3(-0.22, -0.3, 0.14), V3(-0.05, -0.12, 0.06), 0.05, S), cyl(V3(-0.062, -0.135, 0.072), V3(-0.044, -0.112, 0.052), 0.054, cuff), box(-0.03, -0.095, 0.0, 0.03, 0.08, 0.07, glove, -0.3), box(-0.006, -0.1, -0.062, 0.05, 0.06, 0.02, glove, -0.32), box(-0.006, -0.084, -0.074, 0.044, 0.01, 0.008, pad, -0.32), cyl(V3(-0.026, -0.05, 0.0), V3(-0.024, -0.034, -0.06), 0.01, glove)); else arm.push( // left forearm + hand under the handguard: palm below, fingers up the far side, thumb on the near side cyl(V3(-0.2, -0.3, fz + 0.43), front.clone().add(V3(-0.03, -0.05, 0.075)), 0.05, S), cyl(front.clone().add(V3(-0.034, -0.058, 0.085)), front.clone().add(V3(-0.026, -0.046, 0.06)), 0.054, cuff), box(0, front.y - 0.032, fz + 0.01, 0.062, 0.024, 0.085, glove), box(0.041, front.y - 0.008, fz, 0.016, 0.05, 0.08, glove), ...[0, 1, 2].map((k) => box(0.045, front.y + 0.016, fz - 0.03 + k * 0.025, 0.012, 0.012, 0.02, pad)), cyl(V3(-0.036, front.y - 0.03, fz + 0.04), V3(-0.039, front.y + 0.006, fz - 0.025), 0.011, glove), box(-0.03, front.y - 0.046, fz + 0.06, 0.03, 0.012, 0.028, '#0E0F12')); { // sleeve detail: a strap and a pocket flap on each forearm, so the arms read as clothing and not tubes const segs = pist ? [[V3(0.14, -0.27, 0.24), V3(0.04, -0.13, 0.075)], [V3(-0.22, -0.3, 0.14), V3(-0.05, -0.12, 0.06)]] : [[V3(0.14, -0.27, 0.24), V3(0.04, -0.13, 0.075)], [V3(-0.2, -0.3, fz + 0.43), front.clone().add(V3(-0.03, -0.05, 0.075))]]; for (const [a, b2] of segs) { for (const f of [0.35, 0.62]) { const c = a.clone().lerp(b2, f), d2 = a.clone().lerp(b2, f + 0.05); arm.push(cyl(c, d2, 0.053, f < 0.5 ? cuff : '#2A2C26')); } } } add(mergeParts(arm), this.matArm); if (!pist) { const face = add(mergeParts([box(-0.03, front.y - 0.053, fz + 0.06, 0.022, 0.003, 0.02, '#FFFFFF')]), this.matAcc); face.userData.watch = true; } // a small glowing watch face const fl = this.flash; g.userData = { d, mag }; this.pivot.add(g); g.visible = false; return { g, d, mag, fl }; } use(id, force = false) { if (this.cur === id && !force) return; if (!this.models[id]) this.models[id] = this.build(id); for (const [k, m] of Object.entries(this.models)) m.g.visible = k === id; this.cur = id; const m = this.models[id]; m.g.add(this.flash); this.flash.position.copy(m.d.muzzle); this.st.sw = 0; } get muzzleWorld() { const m = this.models[this.cur]; if (!m) return null; return this.flash.getWorldPosition(new THREE.Vector3()); } shot(w) { const s = this.st; const big = w === 'breach' ? 2.2 : w === 'lancer' ? 1.8 : w === 'p9' ? 1.2 : 1; s.kick += 0.03 * big; s.kickR += 0.05 * big; s.kickX += (Math.random() - 0.5) * 0.01 * big; s.flashT = 0.045; this.flash.rotation.z = Math.random() * 6.28; this.flashS = (w === 'p9' ? 0.08 : w === 'breach' ? 0.17 : 0.12) * (0.8 + Math.random() * 0.4); } /** * o: { fov (camera vertical deg), moving (m/s), sprint, ads (0..1), ground, crouch, reload (0..1 or -1), lookDX, lookDY (px this frame), * visible, hideOnScope } */ update(dt, o) { const s = this.st; const m = this.models[this.cur]; if (!m) return; const k = Math.tan((o.fov * Math.PI) / 360) / Math.tan((this.vmFov * Math.PI) / 360); this.root.visible = o.visible !== false; s.ads = damp(s.ads, o.ads, 34, dt); s.sprint = damp(s.sprint, o.sprint ? 1 : 0, 9, dt); s.slide = damp(s.slide || 0, o.slide ? 1 : 0, 12, dt); s.crouch = damp(s.crouch, o.crouch || 0, 10, dt); s.air = damp(s.air, o.ground ? 0 : 1, 8, dt); s.sw = Math.min(1, s.sw + dt / 0.32); const mv = Math.min(1.4, (o.moving || 0) / 6.3) * (o.ground ? 1 : 0.2); s.ph += dt * (6.5 + 3 * s.sprint) * Math.min(1, mv + 0.001); s.bobA = damp(s.bobA, mv, 10, dt); s.swayX = damp(s.swayX, Math.max(-1, Math.min(1, -(o.lookDX || 0) * 0.02)), 10, dt); s.swayY = damp(s.swayY, Math.max(-1, Math.min(1, (o.lookDY || 0) * 0.02)), 10, dt); s.kick = damp(s.kick, 0, 16, dt); s.kickR = damp(s.kickR, 0, 13, dt); s.kickX = damp(s.kickX, 0, 12, dt); s.land = damp(s.land, 0, 9, dt); const d = m.d; const A = s.ads; const bobK = 1 - 0.85 * A; const hip = V3(0.16, -0.172 - 0.01 * s.crouch, -0.36); // a little further out: the gun sits in the lower-right corner and leaves the centre clear const ads = V3(0, -d.sight, d.kind === 'scope' ? -0.12 : d.kind === 'iron' ? -0.3 : -0.26); hip.x *= Math.max(1, Math.min(1.45, (this.cam.aspect || 1.78) / 1.78)); // wide phone screens: keep the gun out in the corner const p = hip.lerp(ads, A); p.x += Math.sin(s.ph) * 0.011 * s.bobA * bobK + s.swayX * 0.012 * bobK + s.kickX; p.y += -Math.abs(Math.cos(s.ph)) * 0.012 * s.bobA * bobK + s.swayY * 0.01 * bobK - s.land * 0.04 + s.air * 0.012 - (1 - s.sw) * 0.25 - s.sprint * 0.05; p.z += s.kick * (1 - 0.4 * A); // reload: dip, roll and tilt; the magazine drops out and comes back let rx = s.kickR * (1 - 0.55 * A) - s.swayY * 0.05 * bobK, ry = s.swayX * 0.06 * bobK + s.sprint * 0.5, rz = s.sprint * 0.35 + s.slide * 0.22; p.y -= s.slide * 0.03; rz += 0.08 * (1 - A); ry += 0.035 * (1 - A); rx -= 0.035 * (1 - A); // hip pose: canted a little so you see the top and side of the gun, not a flat slab const r = o.reload; if (r >= 0) { const e = Math.sin(Math.min(1, r) * Math.PI); p.y -= e * 0.07; rz += e * 0.55; rx += e * 0.25; const md = r < 0.25 ? 0 : r < 0.45 ? (r - 0.25) / 0.2 : r < 0.62 ? 1 : r < 0.8 ? 1 - (r - 0.62) / 0.18 : 0; m.mag.position.set(0, -md * 0.22, md * 0.04); m.mag.visible = md < 0.98; } else { m.mag.position.set(0, 0, 0); m.mag.visible = true; } this.root.scale.set(k, k, 1); this.root.position.set(0, 0, 0); this.pivot.position.set(p.x * k, p.y * k, p.z); this.pivot.rotation.set(rx, ry, rz); // scoped rifle: the scope fills the screen at full zoom (the HUD draws the reticle) m.g.visible = !(o.hideOnScope && A > 0.86); // aimed down the sights the flash sits right on the target: keep it small there (the HUD hit marker must stay readable) s.flashT -= dt; this.flash.visible = s.flashT > 0 && m.g.visible; this.flash.scale.setScalar((this.flashS || 0.1) * (1 - 0.72 * s.ads)); } }