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>
295 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|