Files
tilt/shared/bodyStyle.js
T
2026-08-03 10:28:11 -05:00

166 lines
4.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};
}