TURNCRAFT/src/net/avatarLook.ts
jing 05c2765bd8 SOCIAL_RESET: avatars, emotes, the reset ritual, who's-here roster
Built by the Opus session per docs/briefs/SOCIAL_RESET.md; integrated and
verified by Fable (typecheck clean, relay validation reviewed, 3D avatar
gear/face pixel-confirmed live — the one item the build session couldn't
capture in its frozen preview pane).

- Avatars: hue/gear/face enums in src/net/avatarLook.ts shared by the 3D
  build and the Menu AVATAR tab's 2D mirror; live sync with client-side
  coalescing (two-tab testing caught the relay rate limit permanently
  desyncing rapid editors - trailing send fixes convergence).
- Emotes Z/X/C/V: wave, beat-synced nod, point, rate-limited airhorn
  (detuned saws + confetti), server + client caps.
- Reset ritual: after a win the fuse takes a deliberate 5s hold (reuses
  the workshop radial meter as the warning dial) -> server-authoritative
  reset with 10-min cooldown; music brakes to silence, lights die over 3s,
  workshop resets to wires-off with a SERVER-generated random weight notch
  (both tabs land identical), player builds survive, quest replayable.
- Roster: hold Tab for who's-in-the-booth with hue swatches.
- interact/ addition (flagged friction): additive setBreakGuard hook so
  interaction.ts never learns what a fuse is.
- launch.json: main dev config uses autoPort (5173 collisions with
  parallel sessions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:01:42 +10:00

88 lines
3.3 KiB
TypeScript

// Avatar look: the shared vocabulary for the customization enums (SOCIAL_RESET
// brief, Feature 1). Deliberately palette-only — every option is a bounded int,
// so there is no free-form content and nothing to moderate.
//
// Used by BOTH the 3D peer avatars (src/net/Avatars.ts) and the Menu's 2D
// preview (src/ui/Menu.ts), so the mirror always matches what peers see.
export interface AvatarLook {
hue: number; // 0..11 swatch index (0 = "auto": deterministic per-peer hue)
gear: number; // 0..4 headgear
face: number; // 0..3 face
}
export const AVATAR_RANGES = { hue: 12, gear: 5, face: 4 } as const;
export const GEAR_NAMES = ['phones', 'cap', 'beanie', 'crown', 'none'] as const;
export const FACE_NAMES = ['dots', 'shades', 'visor', 'happy'] as const;
export const HUE_NAMES = [
'auto', 'red', 'orange', 'amber', 'lime', 'green',
'teal', 'cyan', 'blue', 'indigo', 'violet', 'pink',
] as const;
export const DEFAULT_LOOK: AvatarLook = { hue: 0, gear: 0, face: 0 };
/** Clamp anything to a valid look (defaults on garbage) — mirrors the relay. */
export function validLook(a: unknown): AvatarLook {
const o = (a ?? {}) as Partial<Record<keyof AvatarLook, unknown>>;
const pick = (v: unknown, n: number) =>
(Number.isInteger(v) && (v as number) >= 0 && (v as number) < n ? v as number : 0);
return {
hue: pick(o.hue, AVATAR_RANGES.hue),
gear: pick(o.gear, AVATAR_RANGES.gear),
face: pick(o.face, AVATAR_RANGES.face),
};
}
/**
* Body colour as [h,s,l]. hue 0 = "auto" keeps the original golden-ratio
* per-peer hue so untouched avatars look exactly as they did before.
*/
export function bodyHSL(look: AvatarLook, peerId: number): [number, number, number] {
if (look.hue === 0) return [(peerId * 0.6180339887) % 1, 0.55, 0.55];
return [(look.hue - 1) / (AVATAR_RANGES.hue - 1), 0.6, 0.55];
}
/** CSS colour for the same look — used by the Menu preview + who's-here swatches. */
export function bodyCss(look: AvatarLook, peerId: number): string {
const [h, s, l] = bodyHSL(look, peerId);
return `hsl(${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%)`;
}
/**
* Paint a face onto a canvas 2D context in a 0..1 normalized box. Shared by the
* 3D head texture and the Menu preview so they can never drift apart.
*/
export function paintFace(
ctx: CanvasRenderingContext2D, face: number, x: number, y: number, w: number, h: number,
): void {
const px = (u: number, v: number, uw: number, vh: number) =>
ctx.fillRect(x + u * w, y + v * h, uw * w, vh * h);
ctx.save();
switch (FACE_NAMES[face] ?? 'dots') {
case 'dots':
ctx.fillStyle = '#141414';
px(0.24, 0.38, 0.14, 0.16); px(0.62, 0.38, 0.14, 0.16);
break;
case 'shades':
ctx.fillStyle = '#0d0d0f';
px(0.14, 0.36, 0.72, 0.2); // lens bar
ctx.fillStyle = '#3d4657';
px(0.19, 0.4, 0.24, 0.1); px(0.57, 0.4, 0.24, 0.1); // glints
break;
case 'visor':
ctx.fillStyle = '#123b2c';
px(0.1, 0.32, 0.8, 0.26);
ctx.fillStyle = '#39d98a';
px(0.14, 0.36, 0.72, 0.06); // scan line
break;
case 'happy':
ctx.fillStyle = '#141414';
px(0.24, 0.34, 0.13, 0.13); px(0.63, 0.34, 0.13, 0.13);
px(0.28, 0.62, 0.44, 0.08); // smile
px(0.24, 0.56, 0.06, 0.08); px(0.7, 0.56, 0.06, 0.08);
break;
}
ctx.restore();
}