import * as THREE from 'three'; import { physiqueFromBodyStyle } from '../../shared/bodyStyle.js'; import { FWD, V3, clamp, lerp, mergeGeoms, smooth } from '../core/math.js'; export const PART = { TORSO: 0, HEAD: 1, ARM_L: 2, ARM_R: 3, LEG_L: 4, LEG_R: 5 }; const _t1 = new THREE.Vector3(); const _t2 = new THREE.Vector3(); const _t3 = new THREE.Vector3(); /** * Loft a tube along keyframed rings. * keys: [{ t, c: Vector3, rx, rz }] — cross-section radii along the ring basis * u/w, which is derived from the path tangent. `shape` harmonics deform the * silhouette so no two seeds share a profile. */ export function loftPart(keys, ringCount, radial, partId, shape) { const rings = []; for (let i = 0; i < ringCount; i++) { const t = i / (ringCount - 1); let k = 0; while (k < keys.length - 2 && keys[k + 1].t < t) k++; const a = keys[k]; const b = keys[k + 1]; const ft = smooth(clamp((t - a.t) / Math.max(1e-6, b.t - a.t), 0, 1)); rings.push({ t, c: a.c.clone().lerp(b.c, ft), rx: lerp(a.rx, b.rx, ft), rz: lerp(a.rz, b.rz, ft) }); } for (let i = 0; i < ringCount; i++) { const p0 = rings[Math.max(0, i - 1)].c; const p1 = rings[Math.min(ringCount - 1, i + 1)].c; const tan = _t1.subVectors(p1, p0).normalize(); let u = _t2.crossVectors(tan, FWD); if (u.lengthSq() < 1e-6) u = _t2.set(1, 0, 0); else u.normalize(); const w = _t3.crossVectors(tan, u).normalize(); rings[i].u = u.clone(); rings[i].w = w.clone(); } const pos = [], uv = [], aPart = [], aT = [], idx = []; const cols = radial + 1; for (let i = 0; i < ringCount; i++) { const r = rings[i]; for (let j = 0; j <= radial; j++) { const th = (j / radial) * Math.PI * 2; const ct = Math.cos(th); const st = Math.sin(th); let sh = 1; if (shape) sh += shape.a1 * Math.cos(2 * th + shape.p1) + shape.a2 * Math.cos(3 * th + shape.p2); const px = r.rx * ct * sh; const pz = r.rz * st * sh; pos.push( r.c.x + r.u.x * px + r.w.x * pz, r.c.y + r.u.y * px + r.w.y * pz, r.c.z + r.u.z * px + r.w.z * pz, ); uv.push(j / radial, r.t); aPart.push(partId); aT.push(r.t); } } for (let i = 0; i < ringCount - 1; i++) { for (let j = 0; j < radial; j++) { const a = i * cols + j; const b = a + cols; idx.push(a, a + 1, b, b, a + 1, b + 1); } } const cap = (ringIdx, flip) => { const r = rings[ringIdx]; const ci = pos.length / 3; pos.push(r.c.x, r.c.y, r.c.z); uv.push(0.5, r.t); aPart.push(partId); aT.push(r.t); for (let j = 0; j < radial; j++) { const a = ringIdx * cols + j; const b = ringIdx * cols + j + 1; if (flip) idx.push(ci, b, a); else idx.push(ci, a, b); } }; cap(0, true); cap(ringCount - 1, false); const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); g.setAttribute('aPart', new THREE.Float32BufferAttribute(aPart, 1)); g.setAttribute('aT', new THREE.Float32BufferAttribute(aT, 1)); g.setIndex(idx); g.computeVertexNormals(); return g; } /** * Full body geometry for one fighter. `build` carries the physique parameters * so they can be reported to the physics layer: reach, centre of mass and limb * mass all follow from the same numbers that shaped the mesh (GDD 8). * * @param {*} rng seeded RNG (small natural jitter) * @param {{ mass?: number, muscle?: number, fat?: number } | null} [bodyStyle] * loadout body sliders (dreamfall-style mass / muscle / fat) */ export function buildBodyGeometry(rng, bodyStyle = null) { const phy = physiqueFromBodyStyle(bodyStyle, rng); const { bulk, waistF, shoulderF, headF, armF, legF } = phy; const parts = []; // Girdle half-width: follows physique, but floors so extreme lean never // collapses the clavicle to a point the deltoid cannot meet. const girdleRx = Math.max(0.105, 0.176 * bulk * shoulderF); const collarRx = Math.max(0.092, 0.15 * bulk * shoulderF); const tKeys = [ { t: 0.0, c: V3(0, 0.885, 0.002), rx: 0.15 * bulk, rz: 0.1 * bulk }, { t: 0.08, c: V3(0, 0.935, 0.004), rx: 0.172 * bulk, rz: 0.118 * bulk }, { t: 0.18, c: V3(0, 1.0, 0.005), rx: 0.164 * bulk, rz: 0.108 * bulk }, { t: 0.32, c: V3(0, 1.075, 0.004), rx: 0.15 * bulk * waistF, rz: 0.1 * bulk * waistF }, { t: 0.48, c: V3(0, 1.165, 0.006), rx: 0.156 * bulk, rz: 0.104 * bulk }, { t: 0.62, c: V3(0, 1.255, 0.008), rx: 0.168 * bulk, rz: 0.116 * bulk }, { t: 0.76, c: V3(0, 1.335, 0.009), rx: girdleRx, rz: 0.12 * bulk }, { t: 0.88, c: V3(0, 1.405, 0.01), rx: collarRx, rz: 0.105 * bulk }, { t: 0.95, c: V3(0, 1.445, 0.012), rx: 0.078 * bulk, rz: 0.072 * bulk }, { t: 1.0, c: V3(0, 1.475, 0.013), rx: 0.058 * bulk, rz: 0.056 * bulk }, ]; parts.push( loftPart(tKeys, 36, 24, PART.TORSO, { a1: rng.range(-0.03, 0.03), p1: rng.range(0, 6.28), a2: rng.range(-0.02, 0.02), p2: rng.range(0, 6.28), }), ); const hKeys = [ { t: 0.0, c: V3(0, 1.425, 0.012), rx: 0.056, rz: 0.058 }, { t: 0.14, c: V3(0, 1.47, 0.014), rx: 0.06 * headF, rz: 0.064 * headF }, { t: 0.3, c: V3(0, 1.52, 0.02), rx: 0.074 * headF, rz: 0.08 * headF }, { t: 0.48, c: V3(0, 1.575, 0.026), rx: 0.088 * headF, rz: 0.094 * headF }, { t: 0.64, c: V3(0, 1.625, 0.024), rx: 0.094 * headF, rz: 0.1 * headF }, { t: 0.8, c: V3(0, 1.668, 0.016), rx: 0.084 * headF, rz: 0.088 * headF }, { t: 0.92, c: V3(0, 1.7, 0.01), rx: 0.052 * headF, rz: 0.054 * headF }, { t: 1.0, c: V3(0, 1.716, 0.008), rx: 0.012, rz: 0.012 }, ]; parts.push(loftPart(hKeys, 24, 20, PART.HEAD, { a1: rng.range(-0.02, 0.02), p1: rng.range(0, 6.28), a2: 0, p2: 0 })); // ---- Arms + spherical shoulder sockets --------------------------------- // // Extreme skinny (mass/muscle floors) used to leave a hole between a thin // torso and a fixed arm root at x=0.15 — the "spike" sockets in the kit // preview. Rebuild the deltoid as a sphere that always spans from the // clavicle root (inside the torso half-width) out to the upper-arm shaft. // // shoulderHalf matches the torso girdle ring (same floor as girdleRx). const shoulderHalf = girdleRx; // Deltoid boulder radius: floors hard so lean builds still have a round // joint; grows with bulk/arm muscle for heavy / cut. const deltoidR = Math.max( 0.064, 0.072 * Math.sqrt(Math.max(bulk, 0.55)) * (0.72 + 0.38 * Math.min(armF, 1.45)), ); // Clavicle / socket layout in the coronal plane (absolute X later mirrored). const clavY = 1.402; const clavZ = 0.008; // Root sits inside the torso so the sphere always meets clavicle + neck. const clavRootX = Math.max(0.038, shoulderHalf * 0.42); // Sphere centre sits on the torso shoulder edge. const socketX = Math.max(shoulderHalf * 0.92, clavRootX + deltoidR * 0.55); // Outer deltoid / upper-arm takeoff — past the boulder equator. const armRootX = socketX + deltoidR * 0.72; for (const s of [1, -1]) { const partId = s > 0 ? PART.ARM_L : PART.ARM_R; const P = (x, y, z) => V3(s * x, y, z); // Near-equal rx/rz + short arc through one centre ⇒ spherical deltoid. // Mild shape harmonics only on the shaft so the boulder stays round. const aKeys = [ // Clavicle root — buried in the torso, always connected. { t: 0.0, c: P(clavRootX, clavY + 0.012, clavZ + 0.004), rx: deltoidR * 0.92, rz: deltoidR * 0.88 }, // Inner hemisphere (toward neck / traps). { t: 0.05, c: P(socketX * 0.78, clavY + 0.006, clavZ), rx: deltoidR * 1.02, rz: deltoidR * 0.98 }, // Deltoid equator — the shoulder boulder. { t: 0.11, c: P(socketX, clavY, clavZ), rx: deltoidR, rz: deltoidR }, // Outer hemisphere → upper-arm takeoff. { t: 0.18, c: P(armRootX, clavY - 0.012, clavZ + 0.002), rx: deltoidR * 0.86, rz: deltoidR * 0.82 }, // Upper arm shaft (path kept close to the original A-pose reach). { t: 0.28, c: P(Math.max(0.28, armRootX + 0.06), 1.30, 0.008), rx: 0.056 * armF, rz: 0.052 * armF }, { t: 0.40, c: P(0.355, 1.16, 0.01), rx: 0.048 * armF, rz: 0.044 * armF }, { t: 0.50, c: P(0.392, 1.098, 0.011), rx: 0.041 * armF, rz: 0.039 * armF }, { t: 0.66, c: P(0.445, 0.985, 0.014), rx: 0.045 * armF, rz: 0.042 * armF }, { t: 0.78, c: P(0.48, 0.905, 0.017), rx: 0.035 * armF, rz: 0.032 * armF }, { t: 0.86, c: P(0.5, 0.855, 0.02), rx: 0.038, rz: 0.026 }, { t: 0.95, c: P(0.52, 0.805, 0.024), rx: 0.034, rz: 0.02 }, { t: 1.0, c: P(0.53, 0.778, 0.026), rx: 0.012, rz: 0.01 }, ]; parts.push( // Extra rings through the deltoid so the sphere reads smooth, not faceted. loftPart(aKeys, 32, 20, partId, { a1: rng.range(-0.02, 0.02), p1: rng.range(0, 6.28), a2: rng.range(-0.01, 0.01), p2: rng.range(0, 6.28), }), ); } for (const s of [1, -1]) { const partId = s > 0 ? PART.LEG_L : PART.LEG_R; const P = (x, y, z) => V3(s * x, y, z); const lKeys = [ { t: 0.0, c: P(0.088, 1.02, 0.004), rx: 0.108 * bulk, rz: 0.102 * bulk }, { t: 0.1, c: P(0.112, 0.93, 0.006), rx: 0.104 * legF, rz: 0.098 * legF }, { t: 0.28, c: P(0.125, 0.76, 0.008), rx: 0.088 * legF, rz: 0.084 * legF }, { t: 0.44, c: P(0.13, 0.6, 0.009), rx: 0.068 * legF, rz: 0.064 * legF }, { t: 0.52, c: P(0.13, 0.512, 0.008), rx: 0.058 * legF, rz: 0.056 * legF }, { t: 0.64, c: P(0.132, 0.38, 0.006), rx: 0.064 * legF, rz: 0.06 * legF }, { t: 0.78, c: P(0.133, 0.22, 0.002), rx: 0.05 * legF, rz: 0.046 * legF }, { t: 0.86, c: P(0.132, 0.11, -0.004), rx: 0.042, rz: 0.038 }, { t: 0.92, c: P(0.13, 0.062, 0.03), rx: 0.044, rz: 0.034 }, { t: 0.97, c: P(0.128, 0.04, 0.095), rx: 0.042, rz: 0.028 }, { t: 1.0, c: P(0.126, 0.032, 0.155), rx: 0.02, rz: 0.014 }, ]; parts.push( loftPart(lKeys, 30, 18, partId, { a1: rng.range(-0.03, 0.03), p1: rng.range(0, 6.28), a2: rng.range(-0.015, 0.015), p2: rng.range(0, 6.28), }), ); } const merged = mergeGeoms(parts); merged.computeVertexNormals(); merged.userData.physique = { bulk, waistF, shoulderF, headF, armF, legF, style: phy.style }; return merged; } export function buildBodyMesh(geo, skelData, materials) { const mesh = new THREE.SkinnedMesh(geo, materials.skin); mesh.castShadow = true; mesh.receiveShadow = true; mesh.frustumCulled = false; mesh.add(skelData.bones.root); mesh.updateMatrixWorld(true); mesh.bind(skelData.skeleton, mesh.matrixWorld.clone()); mesh.userData.heatMat = new THREE.MeshBasicMaterial({ vertexColors: true }); mesh.userData.origMat = materials.skin; return mesh; }