/** * Body style — three appearance sliders inspired by dreamfall simhuman * global morphs (mass / muscle / fat). * * Plain numbers only: Party, C path, and client mesh build all share this. * Ranges match vibe-human MODELING_CONTROLS for body.global.*: * mass −1 lean … +1 heavy * muscle −1 soft … +1 muscular * fat 0 base … +1 fat (no negative target in the reference) */ export const BODY_STYLE_DEFAULTS = Object.freeze({ mass: 0, muscle: 0, fat: 0, }); /** Slider metadata for the kit chest UI. */ export const BODY_STYLE_SLIDERS = Object.freeze([ { id: 'mass', label: 'Mass', min: -1, max: 1, step: 0.01, left: 'Lean', right: 'Heavy', }, { id: 'muscle', label: 'Muscle', min: -1, max: 1, step: 0.01, left: 'Soft', right: 'Muscular', }, { id: 'fat', label: 'Fat', min: 0, max: 1, step: 0.01, left: 'Base', right: 'Heavy', }, ]); function clamp(v, lo, hi) { const n = Number(v); if (!Number.isFinite(n)) return lo; return Math.max(lo, Math.min(hi, n)); } /** * Normalize a partial body bag. Missing keys become 0 (average build). * @param {unknown} raw * @returns {{ mass: number, muscle: number, fat: number }} */ export function normalizeBodyStyle(raw) { if (!raw || typeof raw !== 'object') { return { ...BODY_STYLE_DEFAULTS }; } return { mass: clamp(raw.mass, -1, 1), muscle: clamp(raw.muscle, -1, 1), fat: clamp(raw.fat, 0, 1), }; } /** True when every channel is at the neutral default. */ export function isDefaultBodyStyle(style) { const s = normalizeBodyStyle(style); return s.mass === 0 && s.muscle === 0 && s.fat === 0; } /** * Compact wire form for full-roster snapshots (two decimals). * @returns {{ m: number, u: number, f: number }} */ export function packBodyStyle(style) { const s = normalizeBodyStyle(style); return { m: Math.round(s.mass * 100) / 100, u: Math.round(s.muscle * 100) / 100, f: Math.round(s.fat * 100) / 100, }; } /** Inverse of packBodyStyle — also accepts full { mass, muscle, fat }. */ export function unpackBodyStyle(raw) { if (!raw || typeof raw !== 'object') return normalizeBodyStyle(null); if ('mass' in raw || 'muscle' in raw || 'fat' in raw) { return normalizeBodyStyle(raw); } return normalizeBodyStyle({ mass: raw.m, muscle: raw.u, fat: raw.f, }); } /** * Ease unit strength so mid-slider stays mild and the last third punches * harder into caricature (leaner / bulkier / more cut / fatter). * @param {number} t 0..1 * @returns {number} 0..1 */ function easeExtreme(t) { const a = clamp(t, 0, 1); // ~0.35 at half travel, 1.0 at the end — more of the gain sits near the stop. return a * 0.35 + a * a * 0.25 + a * a * a * 0.4; } /** Signed ease: preserves direction, applies easeExtreme on |v|. */ function shapedSigned(v) { if (v === 0) return 0; return Math.sign(v) * easeExtreme(Math.abs(v)); } /** * Map mass/muscle/fat onto the loft physique scalars used by buildBodyGeometry. * * Ends of each slider push hard (ease-in + large peak gains). Mid values stay * readable so average builds do not jump. Seeded rng still adds a small natural * variation so two fighters with the same sliders are not voxel-identical. * * @param {{ mass: number, muscle: number, fat: number }} style * @param {{ range: (a: number, b: number) => number }} rng */ export function physiqueFromBodyStyle(style, rng) { const s = normalizeBodyStyle(style); // Shaped channels: mild near 0, extreme at ±1 / 1. const mass = shapedSigned(s.mass); const muscle = shapedSigned(s.muscle); const fat = easeExtreme(s.fat); // Peak gains (at shaped = ±1 / 1) — roughly 2× the first-pass response so // full lean / tank / soft / cut / fat reads clearly under armor. // Overall girth: mass + fat dominate; muscle adds a little. const bulkBase = 1 + mass * 0.32 + fat * 0.26 + muscle * 0.1; // Waist: fat fills hard, muscle nips, mass thickens. const waistBase = 1 + fat * 0.48 + mass * 0.2 - muscle * 0.18; // Shoulders / chest: muscle primary. const shoulderBase = 1 + muscle * 0.42 + mass * 0.16 + fat * 0.1; // Arms: muscle, with a little mass/fat. const armBase = 1 + muscle * 0.48 + mass * 0.14 + fat * 0.12; // Legs: fat + mass, muscle secondary. const legBase = 1 + fat * 0.36 + mass * 0.2 + muscle * 0.16; // Head: slight only — keeps helm fit. const headBase = 1 + mass * 0.06 + fat * 0.05; const jitter = (base, lo = 0.97, hi = 1.03) => base * (rng?.range?.(lo, hi) ?? 1); return { bulk: jitter(bulkBase), // Lower floors so full lean/soft can actually go thin. waistF: Math.max(0.52, jitter(waistBase, 0.98, 1.02)), shoulderF: Math.max(0.62, jitter(shoulderBase, 0.98, 1.02)), armF: Math.max(0.58, jitter(armBase, 0.97, 1.03)), legF: Math.max(0.6, jitter(legBase, 0.97, 1.03)), headF: Math.max(0.85, jitter(headBase, 0.98, 1.02)), style: s, }; }