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>
105 lines
3.6 KiB
TypeScript
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) =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
|
}
|