TURNCRAFT/src/net/Avatars.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

295 lines
11 KiB
TypeScript

// Remote-player avatars: a chunky voxel-DJ per peer (body + head + arms) with a
// name tag, position/heading smoothed toward the last network update.
//
// SOCIAL_RESET: avatars now carry a customizable look (hue / headgear / face —
// all bounded enums, see avatarLook.ts) and play emotes (wave / nod-on-beat /
// point). Your own avatar isn't rendered (first person) — the Menu preview is
// the mirror.
import * as THREE from 'three';
import { bus } from '../core/events';
import type { Vec3 } from '../core/types';
import {
bodyHSL, paintFace, validLook, GEAR_NAMES, DEFAULT_LOOK, type AvatarLook,
} from './avatarLook';
const LERP_RATE = 12; // 1/s — snappy but smooth at 10 Hz updates
const EMOTE_SECS = 1.5; // wave / point duration
const NOD_BARS = 16; // auto-off after 16 bars
const SILENT_BPM = 118; // nod tempo when the booth is dead (funnier than not nodding)
export const EMOTE = { WAVE: 0, NOD: 1, POINT: 2, AIRHORN: 3 } as const;
interface Peer {
group: THREE.Group;
nameTag: THREE.Sprite;
target: THREE.Vector3;
targetYaw: number;
yaw: number;
look: AvatarLook;
/** rebuilt visual parts (everything except the name tag) */
parts: THREE.Object3D[];
head: THREE.Object3D | null;
armL: THREE.Object3D | null;
armR: THREE.Object3D | null;
emote: number; // -1 = none
emoteT: number; // seconds remaining
nodT: number; // seconds of nodding remaining (0 = not nodding)
}
function nameSprite(name: string): THREE.Sprite {
const c = document.createElement('canvas');
const ctx = c.getContext('2d')!;
ctx.font = '28px ui-monospace, monospace';
const w = Math.min(320, Math.ceil(ctx.measureText(name).width) + 24);
c.width = w; c.height = 40;
const ctx2 = c.getContext('2d')!;
ctx2.fillStyle = 'rgba(0,0,0,0.55)';
ctx2.fillRect(0, 0, w, 40);
ctx2.font = '28px ui-monospace, monospace';
ctx2.fillStyle = '#ffe9c0';
ctx2.textAlign = 'center';
ctx2.textBaseline = 'middle';
ctx2.fillText(name, w / 2, 21);
const tex = new THREE.CanvasTexture(c);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
sprite.scale.set(w / 40, 1, 1);
return sprite;
}
/** Head texture: base colour with the chosen face painted on the front. */
function faceTexture(face: number, base: THREE.Color): THREE.CanvasTexture {
const S = 64;
const c = document.createElement('canvas');
c.width = S; c.height = S;
const ctx = c.getContext('2d')!;
ctx.fillStyle = `#${base.getHexString()}`;
ctx.fillRect(0, 0, S, S);
paintFace(ctx, face, 0, 0, S, S);
const t = new THREE.CanvasTexture(c);
t.magFilter = THREE.NearestFilter;
t.minFilter = THREE.NearestFilter;
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
export class Avatars {
private readonly scene: THREE.Object3D;
private readonly peers = new Map<number, Peer>();
private readonly unsubs: Array<() => void> = [];
/** nod phase 0..1, advanced by dt and re-synced on every real beat */
private nodPhase = 0;
private beatPeriod = 60 / SILENT_BPM;
constructor(scene: THREE.Object3D) {
this.scene = scene;
// Nod on the beat when the mix is live; free-run at 118 BPM when it isn't.
this.unsubs.push(bus.on('audio:beat', () => { this.nodPhase = 0; }));
}
add(id: number, name: string, look: unknown = DEFAULT_LOOK): void {
if (this.peers.has(id)) return;
const group = new THREE.Group();
const tag = nameSprite(name);
tag.position.y = 2.15;
group.add(tag);
group.visible = false; // until the first pos update
this.scene.add(group);
const peer: Peer = {
group, nameTag: tag, target: new THREE.Vector3(), targetYaw: 0, yaw: 0,
look: validLook(look), parts: [], head: null, armL: null, armR: null,
emote: -1, emoteT: 0, nodT: 0,
};
this.peers.set(id, peer);
this.build(id, peer);
}
/** Live avatar change: rebuild that peer's meshes (they're tiny). */
updateAvatar(id: number, look: unknown): void {
const peer = this.peers.get(id);
if (!peer) return;
peer.look = validLook(look);
this.build(id, peer);
}
/** Play an emote on a peer's avatar. */
emote(id: number, kind: number): void {
const peer = this.peers.get(id);
if (!peer) return;
if (kind === EMOTE.NOD) {
// toggle; auto-off after 16 bars (4 beats each)
peer.nodT = peer.nodT > 0 ? 0 : NOD_BARS * 4 * this.beatPeriod;
return;
}
peer.emote = kind;
peer.emoteT = EMOTE_SECS;
}
private disposeParts(peer: Peer): void {
for (const o of peer.parts) {
peer.group.remove(o);
o.traverse((n) => {
const mesh = n as THREE.Mesh;
mesh.geometry?.dispose();
const mat = mesh.material as THREE.Material | THREE.Material[] | undefined;
if (Array.isArray(mat)) mat.forEach((mm) => mm.dispose());
else mat?.dispose();
});
}
peer.parts.length = 0;
peer.head = peer.armL = peer.armR = null;
}
/** (Re)build the voxel-DJ for this peer's look. Keeps the name tag. */
private build(id: number, peer: Peer): void {
this.disposeParts(peer);
const [h, s, l] = bodyHSL(peer.look, id);
const color = new THREE.Color().setHSL(h, s, l);
const headColor = color.clone().offsetHSL(0, 0, 0.15);
const bodyMat = new THREE.MeshStandardMaterial({ color, roughness: 0.8 });
const limbMat = new THREE.MeshStandardMaterial({ color, roughness: 0.8 });
const body = new THREE.Mesh(new THREE.BoxGeometry(0.6, 1.15, 0.35), bodyMat);
body.position.y = 0.575;
// Head: face texture on the +Z (forward) side; BoxGeometry order is
// +X,-X,+Y,-Y,+Z,-Z, so index 4 is the front.
const plain = () => new THREE.MeshStandardMaterial({ color: headColor, roughness: 0.7 });
const front = new THREE.MeshStandardMaterial({ map: faceTexture(peer.look.face, headColor), roughness: 0.7 });
const head = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.5),
[plain(), plain(), plain(), plain(), front, plain()]);
head.position.y = 1.45;
// Arms hang from shoulder pivots so emotes can swing them.
const mkArm = (side: number) => {
const pivot = new THREE.Group();
pivot.position.set(side * 0.38, 1.05, 0);
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.7, 0.2), limbMat);
arm.position.y = -0.35;
pivot.add(arm);
return pivot;
};
const armL = mkArm(-1), armR = mkArm(1);
peer.parts.push(body, head, armL, armR);
peer.head = head; peer.armL = armL; peer.armR = armR;
for (const o of [body, head, armL, armR]) peer.group.add(o);
// Headgear
const dark = new THREE.MeshStandardMaterial({ color: 0x191919, roughness: 0.5 });
const gear = GEAR_NAMES[peer.look.gear] ?? 'phones';
if (gear === 'phones') {
const band = new THREE.Mesh(new THREE.BoxGeometry(0.56, 0.08, 0.2), dark);
band.position.y = 1.72;
const cupL = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.24, 0.24), dark);
cupL.position.set(-0.31, 1.45, 0);
const cupR = cupL.clone();
cupR.position.x = 0.31;
peer.parts.push(band, cupL, cupR);
peer.group.add(band, cupL, cupR);
} else if (gear === 'cap') {
const capMat = new THREE.MeshStandardMaterial({ color: color.clone().offsetHSL(0.5, 0, -0.1), roughness: 0.8 });
const crown = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.16, 0.54), capMat);
crown.position.y = 1.76;
const brim = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.05, 0.28), capMat);
brim.position.set(0, 1.7, 0.36);
peer.parts.push(crown, brim);
peer.group.add(crown, brim);
} else if (gear === 'beanie') {
const bMat = new THREE.MeshStandardMaterial({ color: color.clone().offsetHSL(0.08, 0.1, -0.05), roughness: 0.95 });
const cap = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.22, 0.54), bMat);
cap.position.y = 1.78;
const bobble = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.14, 0.14), bMat);
bobble.position.y = 1.96;
peer.parts.push(cap, bobble);
peer.group.add(cap, bobble);
} else if (gear === 'crown') {
const gold = new THREE.MeshStandardMaterial({ color: 0xd4ac3c, roughness: 0.35, metalness: 0.5 });
const band = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.1, 0.52), gold);
band.position.y = 1.75;
peer.parts.push(band);
peer.group.add(band);
for (const dx of [-0.18, 0, 0.18]) {
const spike = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.14, 0.1), gold);
spike.position.set(dx, 1.86, 0);
peer.parts.push(spike);
peer.group.add(spike);
}
} // 'none': bare head
}
remove(id: number): void {
const p = this.peers.get(id);
if (!p) return;
this.disposeParts(p);
this.scene.remove(p.group);
p.nameTag.material.map?.dispose();
p.nameTag.material.dispose();
this.peers.delete(id);
}
updatePos(id: number, p: Vec3, look: Vec3): void {
const peer = this.peers.get(id);
if (!peer) return;
if (!peer.group.visible) {
peer.group.visible = true;
peer.group.position.set(p[0], p[1], p[2]);
}
peer.target.set(p[0], p[1], p[2]);
peer.targetYaw = Math.atan2(look[0], look[2]);
}
/** World position of a peer (for positional emote audio). */
positionOf(id: number): Vec3 | null {
const p = this.peers.get(id);
return p ? [p.group.position.x, p.group.position.y, p.group.position.z] : null;
}
update(dt: number): void {
const k = Math.min(1, LERP_RATE * dt);
this.nodPhase = (this.nodPhase + dt / this.beatPeriod) % 1;
const nod = Math.sin(this.nodPhase * Math.PI * 2);
for (const p of this.peers.values()) {
p.group.position.lerp(p.target, k);
let d = p.targetYaw - p.yaw;
d = ((d + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
p.yaw += d * k;
p.group.rotation.y = p.yaw;
// --- emote animation ---
if (p.emoteT > 0) p.emoteT = Math.max(0, p.emoteT - dt);
if (p.emoteT === 0) p.emote = -1;
if (p.nodT > 0) p.nodT = Math.max(0, p.nodT - dt);
const t = EMOTE_SECS - p.emoteT;
if (p.armR) {
if (p.emote === EMOTE.WAVE) {
p.armR.rotation.z = -(1.35 + 0.4 * Math.sin(t * 14));
p.armR.rotation.x = 0;
} else if (p.emote === EMOTE.POINT) {
p.armR.rotation.x = -Math.PI / 2;
p.armR.rotation.z = 0;
} else if (p.emote === EMOTE.AIRHORN) {
// hoist the horn overhead
p.armR.rotation.z = -2.5;
p.armR.rotation.x = 0;
} else {
p.armR.rotation.z *= 1 - k; p.armR.rotation.x *= 1 - k; // ease back to hanging
}
}
if (p.armL) { p.armL.rotation.z *= 1 - k; p.armL.rotation.x *= 1 - k; }
if (p.head) p.head.rotation.x = p.nodT > 0 ? nod * 0.22 : p.head.rotation.x * (1 - k);
}
}
count(): number { return this.peers.size; }
dispose(): void {
for (const u of this.unsubs) u();
this.unsubs.length = 0;
for (const id of [...this.peers.keys()]) this.remove(id);
}
}