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>
88 lines
3.3 KiB
TypeScript
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();
|
|
}
|