Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 10:28:11 -05:00
parent 65bfc3dcb4
commit 6c08153e42
63 changed files with 15790 additions and 1 deletions
+245
View File
@@ -0,0 +1,245 @@
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;
}