TURNCRAFT/src/ui/Roster.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

105 lines
3.6 KiB
TypeScript

// Who's-here list (SOCIAL_RESET brief, Feature 4). Hold Tab: a translucent list
// of the DJs in the booth (you first), each with their avatar hue as a swatch.
// Pure view — the data comes from NetClient's roster; no network of its own.
import { bodyCss, type AvatarLook } from '../net/avatarLook';
export interface RosterEntry {
id: number;
name: string;
look: AvatarLook;
}
export class Roster {
private readonly el: HTMLDivElement;
private peers: RosterEntry[] = [];
private self: { name: string; look: AvatarLook };
private shown = false;
constructor(self: { name: string; look: AvatarLook }) {
this.self = self;
this.injectStyle();
this.el = document.createElement('div');
this.el.className = 'tcr-panel hidden';
document.body.appendChild(this.el);
window.addEventListener('keydown', this.onDown);
window.addEventListener('keyup', this.onUp);
// Tab can steal focus / fire blur — hide defensively.
window.addEventListener('blur', () => this.show(false));
}
/** Local player's name/look (the mirror can change live). */
setSelf(self: { name: string; look: AvatarLook }): void {
this.self = self;
if (this.shown) this.render();
}
setPeers(peers: RosterEntry[]): void {
this.peers = peers;
if (this.shown) this.render();
}
private onDown = (e: KeyboardEvent): void => {
if (e.code !== 'Tab') return;
e.preventDefault(); // don't tab-focus the page furniture
if (!e.repeat) this.show(true);
};
private onUp = (e: KeyboardEvent): void => {
if (e.code !== 'Tab') return;
e.preventDefault();
this.show(false);
};
private show(v: boolean): void {
if (v === this.shown) return;
this.shown = v;
if (v) this.render();
this.el.classList.toggle('hidden', !v);
}
private render(): void {
const rows: string[] = [];
const row = (name: string, css: string, you: boolean) =>
`<div class="tcr-row"><i style="background:${css}"></i>` +
`<span>${escapeHtml(name)}</span>${you ? '<b>you</b>' : ''}</div>`;
rows.push(row(this.self.name || 'you', bodyCss(this.self.look, 0), true));
for (const p of this.peers) rows.push(row(p.name, bodyCss(p.look, p.id), false));
const n = this.peers.length + 1;
this.el.innerHTML =
`<div class="tcr-head">IN THE BOOTH — ${n} DJ${n === 1 ? '' : 's'}</div>${rows.join('')}`;
}
dispose(): void {
window.removeEventListener('keydown', this.onDown);
window.removeEventListener('keyup', this.onUp);
this.el.remove();
}
private injectStyle(): void {
const css = document.createElement('style');
css.textContent = `
.tcr-panel{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:45;
min-width:240px;max-height:70vh;overflow-y:auto;background:rgba(12,10,8,0.82);
border:1px solid #4a4234;border-radius:10px;padding:12px 14px;
font:12px/1.6 ui-monospace,monospace;color:#d8cdb4;pointer-events:none;
backdrop-filter:blur(2px)}
.tcr-panel.hidden{display:none}
.tcr-head{color:#ffb028;font-size:11px;letter-spacing:.15em;margin-bottom:8px}
.tcr-row{display:flex;align-items:center;gap:9px;padding:3px 0}
.tcr-row i{width:12px;height:12px;border-radius:3px;flex:none;
border:1px solid rgba(255,255,255,0.18)}
.tcr-row b{margin-left:auto;color:#7ec46a;font-weight:400;font-size:10px;letter-spacing:.1em}`;
document.head.appendChild(css);
}
}
/** Names are server-sanitized, but this is user-controlled text in innerHTML. */
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
}