/** * Player definition — identity traits that are not sim state. * * Appearance (body style, jersey) and locomotion (velocity, effort) live * elsewhere. This bag is the stable "who is this skater" record: how they hold * a stick, which side they shoot from, and anything else that picks a pose set * rather than a frame of motion. * * Kept plain and three.js-free so the same record can travel over the wire. */ /** Which side of the body the stick lives on, and which way they shoot. */ export const SHOT_SIDE = Object.freeze({ LEFT: 'left', RIGHT: 'right', }); /** * Defaults for a new player bag. * * Authored stickwork (GRIP targets, arm poses) is written for a **right** * shot: top hand on the right, lower hand on the left, forehand at −X in * skater space. A **left** shot mirrors that set across the body midline. */ export const PLAYER_DEFAULTS = Object.freeze({ shotSide: SHOT_SIDE.RIGHT, }); /** * Accept the spellings we are likely to see and fold them to `'left' | 'right'`. * * @param {unknown} v * @returns {'left' | 'right'} */ export function normalizeShotSide(v) { if (v === SHOT_SIDE.LEFT || v === 'L' || v === 'l' || v === -1 || v === false) { return SHOT_SIDE.LEFT; } if (v === SHOT_SIDE.RIGHT || v === 'R' || v === 'r' || v === 1 || v === true) { return SHOT_SIDE.RIGHT; } return PLAYER_DEFAULTS.shotSide; } /** * Sign used to mirror authored right-shot content onto a left shot. * `+1` = as authored (right), `-1` = mirror across the sagittal plane (left). * * @param {unknown} side * @returns {1 | -1} */ export function shotSign(side) { return normalizeShotSide(side) === SHOT_SIDE.LEFT ? -1 : 1; } /** * Top hand on the stick for this shot side. * Right shot: right hand on top. Left shot: left hand on top. * * @param {unknown} side * @returns {'L' | 'R'} */ export function topHandFor(side) { return normalizeShotSide(side) === SHOT_SIDE.LEFT ? 'L' : 'R'; } /** * Lower (blade-side) hand — the one IK pins to the shaft. * * @param {unknown} side * @returns {'L' | 'R'} */ export function lowerHandFor(side) { return normalizeShotSide(side) === SHOT_SIDE.LEFT ? 'R' : 'L'; } /** * Rough real-world split (~62% left shot in the NHL). Used when a roster * entry does not specify a side, so a lineup is not all clones. * * @param {number} u01 uniform in [0, 1) * @returns {'left' | 'right'} */ export function rollShotSide(u01) { const u = Number.isFinite(u01) ? u01 : 0.5; return u < 0.62 ? SHOT_SIDE.LEFT : SHOT_SIDE.RIGHT; } /** * Normalize a partial player bag. Missing keys take the defaults. * * @param {unknown} raw * @returns {{ shotSide: 'left' | 'right' }} */ export function normalizePlayer(raw) { if (!raw || typeof raw !== 'object') { return { shotSide: PLAYER_DEFAULTS.shotSide }; } return { shotSide: normalizeShotSide(/** @type {{ shotSide?: unknown }} */ (raw).shotSide), }; } /** * Compact wire form for roster snapshots. * @returns {{ ss: 'L' | 'R' }} */ export function packPlayer(player) { const p = normalizePlayer(player); return { ss: p.shotSide === SHOT_SIDE.LEFT ? 'L' : 'R' }; } /** Inverse of packPlayer — also accepts a full `{ shotSide }` bag. */ export function unpackPlayer(raw) { if (!raw || typeof raw !== 'object') return normalizePlayer(null); if ('shotSide' in raw) return normalizePlayer(raw); const ss = /** @type {{ ss?: unknown }} */ (raw).ss; if (ss === 'L' || ss === 'l') return { shotSide: SHOT_SIDE.LEFT }; if (ss === 'R' || ss === 'r') return { shotSide: SHOT_SIDE.RIGHT }; return normalizePlayer(null); }