Initial commit
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user