// 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(); 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); } }