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>
This commit is contained in:
jing 2026-07-17 02:01:42 +10:00
parent 036748543b
commit 05c2765bd8
17 changed files with 1324 additions and 53 deletions

View File

@ -4,7 +4,8 @@
{
"name": "turncraft",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5173", "--strictPort"],
"runtimeArgs": ["run", "dev"],
"autoPort": true,
"port": 5173
},
{

View File

@ -234,6 +234,49 @@ needs to know:
segmented tilting arm) + **Teleport to arm**; stand on it and watch `groundedOn`
stay `seesawN` as it tilts.
## Social & the Reset Ritual (SOCIAL_RESET.md)
Relay protocol v2 — **the relay and client ship together** (the server is the
authority for avatars, emote rate limits and reset):
- **New messages.** `hi` gains `avatar`; new `avatar`, `emote`, `reset`
(client→server intent) and `reset` (server→clients authoritative).
`hello.peers[]` and `join` now carry `avatar`. Every field is a bounded int —
garbage defaults rather than rejects (`validAvatar`), names stay sanitized.
- **Rate limits (server-side, drop-never-queue):** emote 1/4 s/peer, avatar
1/2 s/peer, reset once per 10-min global cooldown.
- **`booth-state.json` gains `winAt`** (ms epoch of the win, gates the reset
cooldown). Old state files without it load as `winAt = 0` = "won long ago" =
cooldown already expired, so an existing prod state file is safe to keep.
- **Reset is server-authoritative:** it validates win + cooldown, clears
`repaired`, stops both platters, sets the workshop to a reset pose, **keeps
every block edit** (player builds survive), persists, and broadcasts to
*everyone including the breaker*. The workshop pose rides along in the
`reset` message so live clients land on exactly the state a late joiner gets
from `hello` — the pose contains a random `weightNotch`, so deriving it
client-side would desync (verified: both tabs landed on notch 4).
Client glue:
- **`Quest.reset(by)`** + bus event **`quest:reset { by }`** (new, additive) —
AudioEngine brakes the transport to silence (power-down, not a mute),
FxSystem eases the emissive boost back to 0.35 over ~3 s, HUD clears, the
platters stop, and `main.ts` writes the one block the reset owns: the blown
fuse back into the box.
- **`player:emote { kind, self, at }`** (new, additive) — audio plays the
airhorn (positional for peers), FX throws confetti.
- **`Interaction.setBreakGuard(g)`** (new, additive, `src/interact/`): lets a
caller demand a deliberate hold on ONE voxel without interaction.ts knowing
what a fuse is. With no guard installed mining is byte-for-byte unchanged.
main.ts uses it for the 5 s fuse hold and reuses the workshop's radial meter
as the "KEEP MINING TO KILL THE MIX" dial.
*(`src/interact/**` is not in SOCIAL_RESET's ownership list — this was the
minimum additive hook needed for the ritual; flagged for the integrator.)*
- **`NetClient.setAvatar` coalesces.** The relay drops avatar churn > 1/2 s and
we send the whole look (not a delta), so a naive drop left peers on a stale
avatar forever (found in two-tab testing: hue landed, gear/face never did).
It now schedules the LATEST look for when the server window reopens.
## Not yet done (deploy phase)
Run `/ship-check` before exposing anything publicly; deploy per deploy-map

View File

@ -0,0 +1,143 @@
# Social & the Reset Ritual — Handoff
**Brief:** [docs/briefs/SOCIAL_RESET.md](briefs/SOCIAL_RESET.md)
**Status:** ✅ All four features implemented, verified live in **two tabs against a
real relay**. `npm run typecheck` clean, zero console errors across the session.
**Not deployed** — the integrator ships it (the brief says don't touch deploy).
---
## What shipped
### 1. Avatar customization
- **[src/net/avatarLook.ts](../src/net/avatarLook.ts)** (new) — the shared
vocabulary: `hue` 011, `gear` 04 (`phones|cap|beanie|crown|none`), `face`
03 (`dots|shades|visor|happy`). All bounded ints — no free-form content,
nothing to moderate. `validLook()` mirrors the relay's clamp; `bodyHSL`/
`bodyCss`/`paintFace` are shared by the 3D avatar AND the 2D mirror so they
cannot drift apart.
- `hue: 0` = **"auto"** — keeps the original golden-ratio per-peer hue, so
every existing avatar looks exactly as it did before.
- **[Avatars.ts](../src/net/Avatars.ts)** renders the look, plus arms (needed
for emotes). `updateAvatar()` rebuilds that peer's meshes (they're tiny).
- **[Menu.ts](../src/ui/Menu.ts)** — third tab **AVATAR**: swatch/chip rows and
a 2D canvas mirror ("you're first person — so here's the mirror"). Applies
**live**, persists in the existing Settings object.
### 2. Emotes
- `Z` wave · `X` nod (on the beat; free-runs at 118 BPM when the booth is
silent; auto-off after 16 bars) · `C` point · `V` airhorn.
Pointer-locked only, so no hotbar collision.
- **Airhorn** ([sfx.ts](../src/audio/sfx.ts) `airhorn()`): three detuned saws →
resonant bandpass → tanh grit, with the signature pitch droop. Positional at
the emoter; confetti puff from FxSystem.
- **Rate limited client AND server** (1 per 4 s per peer, drop-never-queue).
- Self-view: first person can't see its own wave, so you get the sound plus a
small toast. **Deviation:** the brief asked for a "tiny camera nudge" — that
needs a Lane B API on `PlayerController` (not in this brief's ownership), so
the toast stands in. Trivial to swap if Lane B adds a nudge.
### 3. The Reset Ritual
- Fuse voxel takes a **deliberate 5 s hold** once the booth is won, with the
workshop's radial meter reused as the *"KEEP MINING TO KILL THE MIX —
EVERYONE STARTS OVER"* dial.
- **Server-authoritative**: validates win + **10-min anti-grief cooldown**,
clears repairs, stops platters, sets the workshop reset pose
(**assembled-but-undone**: cart seated + square, all four wires popped off,
screws 0.5, weight a random notch 36), **keeps all block edits**, persists
`winAt`, broadcasts to everyone including the breaker.
- Client: `Quest.reset(by)``quest:reset` → music **brakes to silence**
(transport rate → 0, lowpass collapse, contactor thunk + dying mains hum —
a power-down, not a mute), FX eases emissive back to 0.35 over ~3 s, tracker
clears, platters stop, and the blown fuse reappears in the box.
- Cooldown rejection shows *"the fuse is still hot — give it a minute"* and
**vetoes the break**, so you don't lose the fuse to a refused reset.
### 4. Who's here
- **[src/ui/Roster.ts](../src/ui/Roster.ts)** (new) — hold **Tab**: translucent
list, you first, each with their avatar hue as a swatch. HTML-escaped (names
are user-controlled even though the relay sanitizes them).
---
## Files touched
| file | what |
|---|---|
| `server/relay.mjs` | protocol v2: avatar/emote/reset, rate limits, `winAt`, reset pose |
| `src/core/events.ts` | **+`quest:reset`**, **+`player:emote`** (additive, primitives only) |
| `src/machines/quest.ts` | **+`Quest.reset(by)`** (idempotent) |
| `src/net/avatarLook.ts` | **new** — shared look vocabulary |
| `src/net/Avatars.ts` | look rendering, arms, emote animation, `positionOf`, dispose |
| `src/net/NetClient.ts` | avatar/emote/reset wiring, roster, **avatar coalescing** |
| `src/ui/Menu.ts` | AVATAR tab + mirror, `avatar` setting, help text (emotes, Tab, fuse) |
| `src/ui/Roster.ts` | **new** — who's-here |
| `src/audio/sfx.ts` | **+`airhorn`**, **+`powerDown`** |
| `src/audio/AudioEngine.ts` | `runResetAudio()` brake-to-silence; emote/reset bus wiring |
| `src/fx/FxSystem.ts` | reset boost ease-down + spark cough; airhorn confetti |
| `src/interact/interaction.ts` | **+`setBreakGuard()`** (additive hook — see friction) |
| `src/main.ts` | emote keys, roster, avatar live-apply, fuse guard, blown-fuse restore |
| `docs/INTEGRATION_NOTES.md` | protocol v2 section |
**Merge points:** `src/main.ts`, `src/ui/Menu.ts` — both kept additive.
---
## Contract friction (for the integrator)
1. **`src/interact/**` isn't in this brief's ownership list**, but the ritual is
defined as a mining behaviour and the break time lives in
`interaction.ts:179`. I added the smallest possible additive hook
(`setBreakGuard`) rather than special-casing "fuse" inside interact. With no
guard installed, mining is unchanged. Flagging rather than assuming.
2. **The camera-nudge self-view** was dropped (needs a Lane B API) — see above.
3. **Pre-existing, not introduced:** a peer who never *moves* is invisible to a
late joiner, because `hello` carries no positions and avatars stay hidden
until the first `pos`. Worth a one-line fix upstream (send a pos on join, or
include positions in `hello`) if it ever bites.
---
## Verification (two tabs, real relay, real browser)
| check | result |
|---|---|
| Avatar change → peer sees it, no reload | ✅ propagates in well under 1 s |
| **Rapid** hue→gear→face | ✅ converges to the full look (**bug found + fixed**, below) |
| Garbage avatar from console | ✅ `{hue:999,gear:-3,face:'x'}``{0,0,0}` |
| Name fuzz | ✅ `<script>bad</script>!!!``scriptbadscript` |
| Emote spam (2 valid airhorns + bad enum) | ✅ exactly **one** landed remotely |
| Relay fuzz (bad types/enums/unknown msg) | ✅ all dropped, socket stayed alive |
| Reset before win | ✅ rejected |
| Reset immediately after win | ✅ rejected (cooldown) |
| **Full reset** (cooldown expired) | ✅ **both tabs**: won→false, 5→0 repairs, blown fuse back in box, platters stopped, subtitle *"⚡ dj groove72 BLEW THE FUSE"* |
| Workshop reset pose | ✅ identical on both tabs (`weightNotch: 4` — proves the server-authoritative pose) |
| Player builds survive the reset | ✅ placed block still present |
| Replayable to a second win | ✅ both tabs |
| Late joiner post-reset | ✅ reloaded tab got the won→reset state from `hello` |
| `winAt` persistence + old-file load | ✅ saved, rewound, reloaded (`loaded state: 0 edits, 5 repairs`) |
| Tab roster + hue swatches | ✅ screenshotted |
| AVATAR tab + live mirror | ✅ screenshotted (indigo + crown + shades) |
| typecheck / console errors | ✅ clean / zero, both tabs |
### The bug two-tab testing caught
Clicking hue→gear→face quickly (the normal way to use the editor) left peers on
a **stale avatar permanently**: the relay's 1-per-2 s limit dropped messages 2
and 3, and because each message carries the *whole* look rather than a delta,
the peer never converged. `NetClient.setAvatar` now coalesces — it always
schedules the latest look for when the server's window reopens. Re-tested: rapid
lime+beanie+happy converges to `{4,2,3}`.
### Not visually confirmed
The **3D peer avatar's gear/face rendering** could not be pixel-confirmed: the
WebGL canvas in this preview pane stops repainting (verified — the frame is
byte-identical with the camera slammed 240 units up looking straight down; DOM
overlays still update). What I *did* verify programmatically: the peer's scene
graph is exactly right (tag + body + head + 2 arm groups + cap + bobble for a
beanie), `visible: true`, inside the camera frustum, zero errors — and the 2D
mirror, which shares the same `paintFace`/`bodyCss` code, renders correctly.
**Worth one human glance in a real browser before shipping.**
## Out of scope (untouched, per brief)
Text chat (deliberately never), voice, friend lists, accounts, mobile input,
deploy scripts.

View File

@ -25,6 +25,13 @@ const MAX_PEERS = 24;
const MAX_FRAME_BYTES = 2048;
const MAX_MSGS_PER_SEC = 40;
const MAX_NAME_LEN = 24;
// Social & Reset Ritual limits: emotes and avatar churn are per-peer rate
// limited (drop, never queue) or the booth becomes hell; reset is global.
const EMOTE_COOLDOWN_MS = 4000; // 1 airhorn/wave per 4 s per peer
const AVATAR_COOLDOWN_MS = 2000; // 1 avatar change per 2 s per peer
const RESET_COOLDOWN_MS = 10 * 60_000; // anti-grief: 10 min since the win
const AVATAR_RANGES = { hue: 12, gear: 5, face: 4 }; // enum sizes (0..n-1)
const EMOTE_KINDS = 4; // 0..3 = wave | nod | point | airhorn
// Grief ceiling: the diff map must stay bounded (18M voxels × ~30B/entry
// would be ~550MB if a bot painted the whole booth). ~400k edits ≈ 25MB.
const MAX_EDITS = 400_000;
@ -43,6 +50,34 @@ const repaired = new Set();
const platters = { A: { playing: false, rpm: 33 }, B: { playing: false, rpm: 33 } };
let workshop = null; // last-write-wins Headshell Workshop assembly state (or null)
let dirty = false;
/** ms epoch of the last win — gates the reset cooldown. 0 = "long ago". */
let winAt = 0;
/** Clamp an avatar to bounded ints; returns a clean copy (defaults on garbage). */
function validAvatar(a) {
const pick = (v, n) => (Number.isInteger(v) && v >= 0 && v < n ? v : 0);
if (!a || typeof a !== 'object') return { hue: 0, gear: 0, face: 0 };
return {
hue: pick(a.hue, AVATAR_RANGES.hue),
gear: pick(a.gear, AVATAR_RANGES.gear),
face: pick(a.face, AVATAR_RANGES.face),
};
}
/**
* The workshop's post-reset pose: assembled-but-undone. The fun parts replay
* (wires, screws, balance); the tedium (finding + seating the cart) doesn't.
*/
function resetWorkshopPose() {
return {
cartSeated: true,
cartRotation: 0, // already square — re-seating isn't the fun part
wires: [null, null, null, null], // all four popped off: re-crimp them
screws: [0.5, 0.5],
weightNotch: 3 + Math.floor(Math.random() * 4), // random 3..6 — re-balance it
tested: false,
};
}
/** Validate + clamp a Headshell Workshop state; return a clean copy or null. */
function validWorkshop(s) {
@ -78,6 +113,9 @@ function loadState() {
Object.assign(platters.A, s.platters?.A ?? {});
Object.assign(platters.B, s.platters?.B ?? {});
workshop = validWorkshop(s.workshop);
// Old state files predate winAt: treat as "won long ago" so the reset
// cooldown is already expired rather than blocking forever.
winAt = Number.isFinite(s.winAt) ? s.winAt : 0;
console.log(`[relay] loaded state: ${edits.size} edits, ${repaired.size} repairs`);
} catch (e) {
console.error('[relay] state load failed, starting fresh:', e.message);
@ -89,7 +127,7 @@ function saveState() {
dirty = false;
const tmp = STATE_FILE + '.tmp';
writeFileSync(tmp, JSON.stringify({
edits: [...edits.entries()], repaired: [...repaired], platters, workshop,
edits: [...edits.entries()], repaired: [...repaired], platters, workshop, winAt,
}));
renameSync(tmp, STATE_FILE);
}
@ -130,12 +168,16 @@ wss.on('connection', (ws) => {
return;
}
const id = nextId++;
const peer = { ws, name: 'lil dj', alive: true, budget: MAX_MSGS_PER_SEC };
const peer = {
ws, name: 'lil dj', alive: true, budget: MAX_MSGS_PER_SEC,
avatar: validAvatar(null), lastEmote: 0, lastAvatar: 0,
};
peers.set(id, peer);
send(ws, {
t: 'hello', id,
peers: [...peers.entries()].filter(([pid]) => pid !== id).map(([pid, p]) => ({ id: pid, name: p.name })),
peers: [...peers.entries()].filter(([pid]) => pid !== id)
.map(([pid, p]) => ({ id: pid, name: p.name, avatar: p.avatar })),
state: { edits: [...edits.entries()], repaired: [...repaired], platters, workshop },
});
@ -151,7 +193,43 @@ wss.on('connection', (ws) => {
switch (m.t) {
case 'hi': {
peer.name = cleanName(m.name);
broadcast({ t: 'join', id, name: peer.name }, id);
peer.avatar = validAvatar(m.avatar); // absent/garbage -> defaults
broadcast({ t: 'join', id, name: peer.name, avatar: peer.avatar }, id);
break;
}
case 'avatar': {
const now = Date.now();
if (now - peer.lastAvatar < AVATAR_COOLDOWN_MS) return; // drop, don't queue
peer.lastAvatar = now;
peer.avatar = validAvatar(m.avatar);
broadcast({ t: 'avatar', id, avatar: peer.avatar }, id);
break;
}
case 'emote': {
if (!isInt(m.kind) || m.kind < 0 || m.kind >= EMOTE_KINDS) return;
const now = Date.now();
if (now - peer.lastEmote < EMOTE_COOLDOWN_MS) return; // spam ceiling
peer.lastEmote = now;
broadcast({ t: 'emote', id, kind: m.kind }, id);
break;
}
case 'reset': {
// Server-authoritative. Only a won booth resets, and only after the
// anti-grief cooldown. Rejections are silent-ish: the client shows
// "the fuse is still hot" off its own read of the state.
if (repaired.size < NODES.size) return;
if (Date.now() - winAt < RESET_COOLDOWN_MS) return;
repaired.clear();
platters.A = { playing: false, rpm: 33 };
platters.B = { playing: false, rpm: 33 };
workshop = resetWorkshopPose();
winAt = 0;
dirty = true;
// NOTE: `edits` is deliberately untouched — player builds survive.
// Carry the workshop pose so live clients land on the same state a late
// joiner would get from `hello.state.workshop`.
broadcast({ t: 'reset', by: peer.name, workshop }); // everyone incl. the breaker
saveState();
break;
}
case 'pos': {
@ -172,6 +250,8 @@ wss.on('connection', (ws) => {
case 'repair': {
if (!NODES.has(m.node) || repaired.has(m.node)) return;
repaired.add(m.node);
// The moment the booth comes alive, start the reset cooldown clock.
if (repaired.size === NODES.size) winAt = Date.now();
dirty = true;
broadcast({ t: 'repair', node: m.node }, id);
break;

View File

@ -225,6 +225,9 @@ export class AudioEngine {
case 'weightDetent': SFX.weightDetent(ctx, dest, t); break;
case 'bubbleLock': SFX.bubbleLock(ctx, dest, t); break;
case 'skate': SFX.skate(ctx, dest, this.noise, t); break;
// Social & Reset Ritual
case 'airhorn': SFX.airhorn(ctx, dest, t); break;
case 'powerDown': SFX.powerDown(ctx, dest, this.noise, t); break;
}
}
@ -357,6 +360,51 @@ export class AudioEngine {
this.updateCrackle();
}
/**
* The Reset Ritual (SOCIAL_RESET): someone blew the fuse. The mix doesn't
* mute the power dies. The transport brakes to a halt (the groove sags and
* detunes as it stops), the master lowpass collapses, and a contactor thunk +
* dying mains hum lands on top. Once it's silent we reset the chain so the
* quest is cleanly replayable. Idempotent: a reset with no win is harmless.
*/
private runResetAudio(): void {
this.won = false;
this.deck.A = { playing: false, rpm: PLATTER.rpm33 };
this.deck.B = { playing: false, rpm: PLATTER.rpm33 };
if (!this.ctx) { this.stemCount = 0; return; } // pre-gesture: nothing to brake
const ctx = this.ctx, now = ctx.currentTime;
this.transport.setRateTarget(0); // tempo + pitch sag to a stop
const lp = this.masterLP.frequency;
lp.cancelScheduledValues(now);
lp.setValueAtTime(Math.max(200, lp.value), now);
lp.exponentialRampToValueAtTime(110, now + 1.4); // top end drains away
const mg = this.musicGain.gain;
mg.cancelScheduledValues(now);
mg.setValueAtTime(Math.max(0.0001, mg.value), now);
mg.exponentialRampToValueAtTime(0.0001, now + 1.5);
SFX.powerDown(ctx, this.sfxGain, this.noise, now);
this.fadeGate(this.panGate.A, 0, now);
this.fadeGate(this.panGate.B, 0, now);
this.updateCrackle();
// After the power-down, restore a transparent chain at zero stems so the
// second run sounds exactly like the first.
window.setTimeout(() => {
if (!this.ctx) return;
const t = this.ctx.currentTime;
this.setStemCount(0);
this.transport.setRateImmediate(0);
this.musicGain.gain.cancelScheduledValues(t);
this.musicGain.gain.setValueAtTime(1, t);
this.masterLP.frequency.cancelScheduledValues(t);
this.masterLP.frequency.setValueAtTime(20000, t);
this.updateCrackle();
}, 1700);
}
// ── event bus wiring ──────────────────────────────────────────────────────
private wireBus(): void {
bus.on('signal:repair', (p) => {
@ -373,6 +421,15 @@ export class AudioEngine {
bus.on('game:win', () => this.runWinAudio());
bus.on('quest:reset', () => this.runResetAudio());
// Emotes: the airhorn is the only one with a voice. Remote horns are
// positional at the emoter; your own plays dry (you're at the listener).
bus.on('player:emote', (p) => {
if (p.kind !== 3) return; // 0..2 = wave / nod / point (silent)
this.playSfx('airhorn', p.self ? undefined : p.at);
});
bus.on('platter:state', (p) => {
this.setPlaybackState(p.deck, p.playing, p.rpm);
});

View File

@ -316,9 +316,96 @@ export function skate(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer
src.start(t); src.stop(t + 0.75);
}
/**
* The classic rave airhorn (SOCIAL_RESET emote V): three detuned saws through a
* resonant bandpass, with the signature pitch droop on the tail. Rate-limited by
* the caller 1 per 4 s per peer, client AND relay or the booth becomes hell.
*/
export function airhorn(ctx: BaseAudioContext, dest: AudioNode, t: number): void {
const dur = 0.85;
const out = ctx.createGain();
out.gain.setValueAtTime(0.0001, t);
out.gain.exponentialRampToValueAtTime(0.22, t + 0.03); // fast attack
out.gain.setValueAtTime(0.22, t + dur - 0.22);
out.gain.exponentialRampToValueAtTime(0.0001, t + dur);
// Resonant horn body
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = 1400; bp.Q.value = 3.5;
const shaper = ctx.createWaveShaper();
const curve = new Float32Array(257);
for (let i = 0; i < 257; i++) {
const x = (i / 128) - 1;
curve[i] = Math.tanh(x * 2.6); // a bit of grit
}
shaper.curve = curve;
bp.connect(shaper); shaper.connect(out); out.connect(dest);
// Three saws, detuned; each droops ~12% at the end (the "hoooonk-eugh")
for (const [mult, det, gain] of [[1, 0, 0.5], [1, 7, 0.34], [2, -5, 0.2]] as const) {
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.detune.value = det;
const f0 = 233 * mult; // ~Bb3
osc.frequency.setValueAtTime(f0, t);
osc.frequency.setValueAtTime(f0, t + dur - 0.3);
osc.frequency.exponentialRampToValueAtTime(f0 * 0.86, t + dur); // droop
const g = ctx.createGain();
g.gain.value = gain;
osc.connect(g); g.connect(bp);
osc.start(t); osc.stop(t + dur + 0.02);
}
}
/**
* Blow-the-fuse power-down (SOCIAL_RESET reset ritual): the mains-thunk half of
* the moment. The stems' pitch brake lives in AudioEngine (it owns the
* transport); this is the transformer clunk + dying hum underneath it.
*/
export function powerDown(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
// Contactor thunk
const thunk = ctx.createOscillator();
thunk.type = 'sine';
thunk.frequency.setValueAtTime(90, t);
thunk.frequency.exponentialRampToValueAtTime(28, t + 0.18);
const tg = ctx.createGain();
tg.gain.setValueAtTime(0.5, t);
tg.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
thunk.connect(tg); tg.connect(dest);
thunk.start(t); thunk.stop(t + 0.32);
// 50 Hz hum sagging away as the rails collapse
const hum = ctx.createOscillator();
hum.type = 'sawtooth';
hum.frequency.setValueAtTime(50, t);
hum.frequency.exponentialRampToValueAtTime(14, t + 1.6);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.setValueAtTime(1200, t);
lp.frequency.exponentialRampToValueAtTime(90, t + 1.6);
const hg = ctx.createGain();
hg.gain.setValueAtTime(0.14, t);
hg.gain.exponentialRampToValueAtTime(0.0001, t + 1.7);
hum.connect(lp); lp.connect(hg); hg.connect(dest);
hum.start(t); hum.stop(t + 1.72);
// Spark crackle off the blown fuse
const src = ctx.createBufferSource();
src.buffer = noise; src.loop = true;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = 3200; bp.Q.value = 6;
const ng = ctx.createGain();
ng.gain.setValueAtTime(0.2, t);
ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.25);
src.connect(bp); bp.connect(ng); ng.connect(dest);
src.start(t); src.stop(t + 0.27);
}
export type SfxName =
| 'footstep' | 'land' | 'break' | 'place'
| 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle'
// Headshell Workshop:
| 'crimp' | 'wireOn' | 'wireOff' | 'screwTick' | 'screwClunk'
| 'tiltCreak' | 'weightDetent' | 'bubbleLock' | 'skate';
| 'tiltCreak' | 'weightDetent' | 'bubbleLock' | 'skate'
// Social & Reset Ritual:
| 'airhorn' | 'powerDown';

View File

@ -18,6 +18,18 @@ export type GameEvents = {
'signal:repair': { node: SignalNode; repaired: number; total: number };
'game:win': Record<string, never>;
// The Reset Ritual (SOCIAL_RESET brief). Server-authoritative: the relay
// accepts a `reset` intent and broadcasts it; NetClient calls Quest.reset(),
// which emits this. Audio brakes to silence, FX fades the booth dark, HUD
// clears the tracker, platters spin down, workshop takes its reset pose.
// `by` is the DJ name that blew the fuse ('' for a local/offline reset).
'quest:reset': { by: string };
// Emotes (SOCIAL_RESET brief). `kind` 0..3 = wave | nod | point | airhorn.
// `self` is true for the local player's own emote (first-person: no avatar
// to animate, so audio/FX react instead). `at` is the emoter's position.
'player:emote': { kind: number; self: boolean; at: [number, number, number] };
// Headshell Workshop (Lane D emits; HUD/audio react). Additive per the
// WORKSHOP_CARTRIDGE brief — only primitives cross the bus (no lane types).
'workshop:test': { errors: string[]; clean: boolean }; // needle-drop verdict

View File

@ -51,6 +51,12 @@ export class FxSystem {
private winClock = 0;
private dropped = false;
// reset ritual: seconds left of the "booth drains dark" ease-down, and the
// boost we were showing when the fuse blew (so it falls from there, not 0).
private fading = 0;
private fadeFrom = 0;
private lastBoost = 0.35;
constructor(opts: FxOpts) {
this.scene = opts.scene;
this.setBoost = opts.setEmissiveBoost;
@ -87,7 +93,13 @@ export class FxSystem {
if (this.pulse < 0.001) this.pulse = 0;
let boost: number;
if (this.won && this.winClock < 1.8) {
if (this.fading > 0) {
// reset ritual: the booth drains dark over ~3 s (blowing the fuse should
// look like the power leaving the room, not a light switch)
this.fading = Math.max(0, this.fading - dt);
const k = this.fading / 3.0; // 1 -> 0
boost = this.boostBase + (this.fadeFrom - this.boostBase) * (k * k);
} else if (this.won && this.winClock < 1.8) {
// pre-drop: booth goes dark and silent
this.winClock += dt;
boost = 0.04;
@ -97,6 +109,7 @@ export class FxSystem {
boost = this.boostBase + this.pulse;
}
this.setBoost(Math.max(0, Math.min(2, boost)));
this.lastBoost = boost;
}
/** A celebratory toss of tiny brushed-steel screws (Headshell Workshop). */
@ -179,6 +192,37 @@ export class FxSystem {
this.dropped = false;
});
// The Reset Ritual (SOCIAL_RESET): someone blew the fuse. The booth visibly
// dies — this moment should feel HUGE. Cancel the win timeline and let the
// emissive boost fall back to the dead-booth base; update() eases it there
// (~3 s) instead of snapping, so the lights drain rather than switch off.
bus.on('quest:reset', () => {
this.won = false;
this.dropped = false;
this.winClock = 0;
this.repairs = 0;
this.fadeFrom = this.lastBoost; // fall from whatever the booth was showing
this.boostBase = 0.35; // dead booth
this.pulse = 0;
this.fading = 3.0; // seconds of ease-down (see update())
this.patch.setLevel(0);
// a last cough of sparks out of the fuse box
const c = SIGNAL_PATH[0];
this.puff.spawn(40, c[0], c[1], c[2], {
spread: 3, speed: 7, up: 5, life: 0.9, jitter: 3,
r: 1.0, g: 0.72, b: 0.25,
});
});
// Airhorn (emote V) throws a confetti puff at the emoter.
bus.on('player:emote', (p) => {
if (p.kind !== 3) return;
this.confetti.spawn(60, p.at[0], p.at[1] + 1.6, p.at[2], {
spread: 1.2, speed: 9, up: 7, life: 1.8, jitter: 4,
r: 1.0, g: 0.85, b: 0.4,
});
});
bus.on('platter:state', (p) => {
this.deckSpin[p.deck] = { playing: p.playing, rpm: p.rpm };
});

View File

@ -1,4 +1,4 @@
// LANE D — interaction layer public surface.
export { Hotbar, HOTBAR_SLOTS, type HotSlot } from './hotbar';
export { Interaction } from './interaction';
export { Interaction, type BreakGuard } from './interaction';
export { raycastVoxel, raycastColliders, type VoxelHit, type ColliderHit } from './raycast';

View File

@ -25,6 +25,19 @@ const BREAK_TIME_BY_SOUND: Partial<Record<string, number>> = {
glass: 0.25,
};
/**
* Optional per-voxel mining override (SOCIAL_RESET). Lets a caller demand a
* deliberate hold on one block without interaction.ts knowing what a "fuse" is.
*/
export interface BreakGuard {
/** Seconds to break this voxel, or null to use the normal material time. */
timeFor(x: number, y: number, z: number, id: BlockId): number | null;
/** Hold progress 0..1 while mining a guarded voxel; -1 when the hold ends. */
progress?(p: number): void;
/** Completion hook. Return true to VETO the break (nothing is mined). */
onComplete?(x: number, y: number, z: number, id: BlockId): boolean;
}
export class Interaction {
private readonly world: IVoxelWorld;
private readonly player: IPlayerView;
@ -38,6 +51,8 @@ export class Interaction {
private readonly rayColliders: KinematicCollider[] = [];
private mining = false;
private guard: BreakGuard | null = null;
private guarding = false;
private breakTarget: { x: number; y: number; z: number } | null = null;
private breakElapsed = 0;
private lastHit: RayHit = null;
@ -77,7 +92,18 @@ export class Interaction {
// ---- Input surface (host wires DOM events to these) ----
startMining(): void { this.mining = true; }
stopMining(): void { this.mining = false; this.breakTarget = null; this.breakElapsed = 0; }
stopMining(): void {
this.mining = false; this.breakTarget = null; this.breakElapsed = 0;
this.endGuard();
}
/**
* Install an optional break guard (SOCIAL_RESET reset ritual). Additive hook:
* with no guard installed, mining behaves exactly as before. The guard can
* lengthen the hold for one voxel, watch its progress (for a HUD warning),
* and veto the break on completion.
*/
setBreakGuard(g: BreakGuard | null): void { this.guard = g; this.endGuard(); }
place(): void {
const hit = this.raycast();
@ -169,16 +195,35 @@ export class Interaction {
private advanceMining(dt: number, hit: RayHit): void {
if (!this.mining || !hit || hit.kind !== 'block' || !blockDef(hit.id).breakable) {
this.endGuard();
this.breakTarget = null; this.breakElapsed = 0; return;
}
if (!this.breakTarget || this.breakTarget.x !== hit.x || this.breakTarget.y !== hit.y || this.breakTarget.z !== hit.z) {
this.breakTarget = { x: hit.x, y: hit.y, z: hit.z };
this.breakElapsed = 0;
this.endGuard();
}
this.breakElapsed += dt;
const breakTime = BREAK_TIME_BY_SOUND[blockDef(hit.id).sound] ?? BREAK_TIME_DEFAULT;
// A guard may demand a longer, deliberate hold for one specific voxel
// (SOCIAL_RESET: the fuse takes 5 s once the booth is won). Null = normal.
const override = this.guard ? this.guard.timeFor(hit.x, hit.y, hit.z, hit.id) : null;
const breakTime = override ?? BREAK_TIME_BY_SOUND[blockDef(hit.id).sound] ?? BREAK_TIME_DEFAULT;
if (override !== null) {
this.guarding = true;
this.guard!.progress?.(Math.min(1, this.breakElapsed / breakTime));
} else {
this.endGuard();
}
if (this.breakElapsed >= breakTime) {
const id: BlockId = hit.id;
// The guard gets to veto the break (e.g. "the fuse is still hot").
if (override !== null && this.guard?.onComplete?.(hit.x, hit.y, hit.z, id)) {
this.endGuard();
this.breakTarget = null; this.breakElapsed = 0; return;
}
this.endGuard();
this.hotbar.add(id, 1);
this.world.setBlock(hit.x, hit.y, hit.z, AIR);
bus.emit('block:break', { x: hit.x, y: hit.y, z: hit.z, id });
@ -186,6 +231,12 @@ export class Interaction {
}
}
private endGuard(): void {
if (!this.guarding) return;
this.guarding = false;
this.guard?.progress?.(-1); // -1 = hold ended, hide the HUD warning
}
private updateSelection(hit: RayHit): void {
if (hit && hit.kind === 'block') {
this.selection.visible = true;

View File

@ -89,4 +89,23 @@ export class Quest {
}
return true;
}
/**
* The Reset Ritual (SOCIAL_RESET brief): un-break the booth so a group can
* play the quest again. Authority lives on the relay it validates the win
* and the anti-grief cooldown and broadcasts; NetClient calls this on every
* client (including the one that blew the fuse). Idempotent: a reset with
* nothing repaired is a no-op, so a replayed broadcast can't double-fire.
*
* Emits `quest:reset` so audio brakes to silence, FX fades the booth dark,
* the HUD tracker clears and the platters spin down. It does NOT touch
* blocks the caller restores the blown fuse (player builds must survive).
*/
reset(by = ''): boolean {
if (this.repaired.size === 0 && !this.won) return false;
this.repaired.clear();
this.won = false;
bus.emit('quest:reset', { by });
return true;
}
}

View File

@ -15,8 +15,11 @@ import { FxSystem } from './fx/FxSystem';
import { DecalSet } from './fx/decals';
import { Hud } from './ui/Hud';
import { Menu, QUALITY_PIXEL_RATIO } from './ui/Menu';
import { Avatars } from './net/Avatars';
import { Roster } from './ui/Roster';
import { Avatars, EMOTE } from './net/Avatars';
import { NetClient, relayUrl } from './net/NetClient';
import { bus } from './core/events';
import { QUEST_ITEMS } from './machines/quest';
const container = document.getElementById('app')!;
container.innerHTML = '';
@ -107,6 +110,11 @@ const menu = new Menu({
},
onOpen: () => { document.exitPointerLock(); hud.setPaused(false); },
onClose: () => { if (started) renderer.domElement.requestPointerLock(); },
// Avatar edits apply live: push to the relay and update our own roster row.
onAvatar: (look) => {
net?.setAvatar(look);
roster?.setSelf({ name: menu.getSettings().name, look });
},
});
document.addEventListener('pointerlockchange', () => {
@ -123,9 +131,13 @@ netChip.style.cssText =
'position:fixed;top:10px;left:150px;z-index:40;font:11px ui-monospace,monospace;' +
'color:#8a8378;background:#0008;border:1px solid #3a352c;border-radius:6px;padding:4px 8px;display:none';
document.body.appendChild(netChip);
// Who's-here list (hold Tab). Seeded with the local DJ; peers arrive from net.
const roster = new Roster({ name: menu.getSettings().name, look: menu.getSettings().avatar });
const net = new NetClient({
world, machines, player, avatars,
name: menu.getSettings().name,
avatar: menu.getSettings().avatar,
url: relayUrl(import.meta.env.BASE_URL),
onStatus: (peerCount, connected) => {
netChip.style.display = connected ? 'block' : 'none';
@ -133,6 +145,7 @@ const net = new NetClient({
? 'online — booth to yourself'
: `online — ${peerCount} other DJ${peerCount === 1 ? '' : 's'} in the booth`;
},
onPeers: (peers) => roster.setPeers(peers),
});
// Interaction input (mouse buttons + E + hotbar keys), gated on pointer lock
@ -150,11 +163,88 @@ window.addEventListener('keydown', (e) => {
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') interaction.shiftDown = true;
if (e.code === 'KeyE') { if (!e.repeat) interaction.useDown(e.shiftKey); } // E press (hold = crimp/torque)
else if (e.code === 'KeyQ') interaction.dropCarry(); // drop a carried wire
else if (EMOTE_KEYS[e.code] !== undefined) { if (!e.repeat) fireEmote(EMOTE_KEYS[e.code]); }
else if (e.code.startsWith('Digit')) {
const n = parseInt(e.code.slice(5), 10);
if (n >= 1 && n <= 9) hotbar.setActive(n - 1);
}
});
// ── The Reset Ritual (SOCIAL_RESET Feature 3) ─────────────────────────────
// Once the mix is live, the fuse in the PCB-Depths box becomes the way to play
// the quest again — but only on a deliberate 5 s hold, and only after a
// cooldown. The relay is the authority; this is the ceremony around it.
const FUSE_HOLD_SECS = 5;
const RESET_COOLDOWN_MS = 10 * 60_000; // must match relay.mjs
let wonAt = 0;
bus.on('game:win', () => { wonAt = Date.now(); });
bus.on('quest:reset', () => { wonAt = 0; });
const fuse = QUEST_POS.fuse.box;
interaction.setBreakGuard({
timeFor: (x, y, z) => {
// Only the fuse voxel, and only while the booth is actually won.
if (!machines.quest.isWon()) return null;
if (x !== fuse[0] || y !== fuse[1] || z !== fuse[2]) return null;
return FUSE_HOLD_SECS;
},
// Reuse the workshop's radial meter as the "are you sure" dial.
progress: (p) => {
if (p < 0) hud.hideTorque();
else hud.showTorque(p, 'KEEP MINING TO KILL THE MIX — EVERYONE STARTS OVER');
},
onComplete: () => {
if (Date.now() - wonAt < RESET_COOLDOWN_MS) {
// The relay would reject this anyway — say so and don't eat the fuse.
bus.emit('workshop:msg', { text: 'the fuse is still hot — give it a minute' });
return true; // veto the break
}
net.sendReset(); // relay validates + broadcasts; everyone resets together
return false; // let the fuse actually break
},
});
// The one block edit the reset writes: a fresh blackened fuse back in the box,
// so the quest is physically replayable. Player builds are untouched.
bus.on('quest:reset', ({ by }) => {
world.setBlock(fuse[0], fuse[1], fuse[2], QUEST_ITEMS.fuse);
hud.hideTorque();
hud.showSubtitle(by ? `${by} BLEW THE FUSE` : '⚡ THE FUSE IS BLOWN');
});
// ── Social: emotes (SOCIAL_RESET Feature 2) ───────────────────────────────
// Z X C V, pointer-locked only so they can't collide with the hotbar digits.
const EMOTE_KEYS: Record<string, number> = {
KeyZ: EMOTE.WAVE, KeyX: EMOTE.NOD, KeyC: EMOTE.POINT, KeyV: EMOTE.AIRHORN,
};
const EMOTE_LABEL = ['👋 wave', '🎧 nod', '👉 point', '📢 airhorn'];
const emoteToast = document.createElement('div');
emoteToast.style.cssText =
'position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:35;' +
'font:12px ui-monospace,monospace;color:#ffe9c0;background:#000a;border:1px solid #4a4234;' +
'border-radius:6px;padding:4px 10px;opacity:0;transition:opacity .18s;pointer-events:none';
document.body.appendChild(emoteToast);
let emoteToastTimer = 0;
function fireEmote(kind: number): void {
// The relay enforces the same 4 s ceiling; if our own cooldown ate it, stay
// silent locally too so what we hear matches what peers actually get.
if (!net.sendEmote(kind)) {
showEmoteToast('… still catching your breath');
return;
}
// First person: you can't see your own wave, so the sound (airhorn) plus this
// toast are the "something happened". Audio + FX listen on the bus.
bus.emit('player:emote', { kind, self: true, at: [...player.position] as [number, number, number] });
showEmoteToast(EMOTE_LABEL[kind] ?? '');
}
function showEmoteToast(text: string): void {
emoteToast.textContent = text;
emoteToast.style.opacity = '1';
clearTimeout(emoteToastTimer);
emoteToastTimer = window.setTimeout(() => { emoteToast.style.opacity = '0'; }, 900);
}
window.addEventListener('keyup', (e) => {
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') interaction.shiftDown = false;
if (e.code === 'KeyE') interaction.useUp(); // release ends any crimp/torque hold

View File

@ -1,10 +1,24 @@
// Remote-player avatars: a chunky voxel-person (body + head) per peer with a
// 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 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;
@ -12,6 +26,15 @@ interface Peer {
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 {
@ -34,59 +57,175 @@ function nameSprite(name: string): THREE.Sprite {
return sprite;
}
/** Deterministic per-id avatar hue so everyone sees the same colors. */
function peerColor(id: number): THREE.Color {
return new THREE.Color().setHSL((id * 0.6180339887) % 1, 0.55, 0.55);
/** 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): void {
add(id: number, name: string, look: unknown = DEFAULT_LOOK): void {
if (this.peers.has(id)) return;
const color = peerColor(id);
const group = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color, roughness: 0.8 });
const headMat = new THREE.MeshStandardMaterial({ color: color.clone().offsetHSL(0, 0, 0.15), roughness: 0.7 });
const body = new THREE.Mesh(new THREE.BoxGeometry(0.6, 1.15, 0.35), bodyMat);
body.position.y = 0.575;
const head = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.5), headMat);
head.position.y = 1.45;
// Headphones: every tiny DJ wears them.
const bandMat = new THREE.MeshStandardMaterial({ color: 0x191919, roughness: 0.5 });
const band = new THREE.Mesh(new THREE.BoxGeometry(0.56, 0.08, 0.2), bandMat);
band.position.y = 1.72;
const cupL = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.24, 0.24), bandMat);
cupL.position.set(-0.31, 1.45, 0);
const cupR = cupL.clone();
cupR.position.x = 0.31;
group.add(body, head, band, cupL, cupR);
const tag = nameSprite(name);
tag.position.y = 2.15;
group.add(tag);
group.visible = false; // until the first pos update
this.scene.add(group);
this.peers.set(id, { group, nameTag: tag, target: new THREE.Vector3(), targetYaw: 0, yaw: 0 });
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.group.traverse((o) => {
const mesh = o as THREE.Mesh;
if (mesh.geometry) mesh.geometry.dispose();
const mat = (mesh as THREE.Mesh).material as THREE.Material | undefined;
if (mat) mat.dispose();
});
p.nameTag.material.map?.dispose();
p.nameTag.material.dispose();
this.peers.delete(id);
}
@ -101,16 +240,55 @@ export class Avatars {
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);
}
}

View File

@ -14,9 +14,15 @@ import type { SignalNode } from '../core/constants';
import type { IPlayerView, IVoxelWorld, Vec3 } from '../core/types';
import type { MachineSet, Platter } from '../machines';
import { Avatars } from './Avatars';
import { validLook, DEFAULT_LOOK, type AvatarLook } from './avatarLook';
const POS_HZ = 10;
const RECONNECT_MS = 15_000;
/** Client-side emote ceiling. The relay enforces its own (1/4 s) this just
* stops us shouting into the wire and keeps the local echo honest. */
const EMOTE_COOLDOWN_MS = 4000;
/** Must match relay.mjs AVATAR_COOLDOWN_MS — we coalesce against it. */
const AVATAR_COOLDOWN_MS = 2000;
interface NetOpts {
world: IVoxelWorld;
@ -24,9 +30,13 @@ interface NetOpts {
player: IPlayerView;
avatars: Avatars;
name: string;
/** This player's avatar look (bounded enums; see avatarLook.ts). */
avatar?: AvatarLook;
/** e.g. "wss://partly.party/turncraft/ws" or "ws://localhost:8137" */
url: string;
onStatus?(peerCount: number, connected: boolean): void;
/** Roster changes (who's-here list): id -> {name, look}. */
onPeers?(peers: Array<{ id: number; name: string; look: AvatarLook }>): void;
}
export function relayUrl(basePath: string): string {
@ -44,13 +54,72 @@ export class NetClient {
private reconnectTimer: number | null = null;
private disposed = false;
private readonly unsubs: Array<() => void> = [];
private avatar: AvatarLook;
private lastEmoteAt = 0;
private avatarSentAt = 0;
private avatarTimer: number | null = null;
/** who's-here roster (peers only; the local player is added by the UI). */
private readonly roster = new Map<number, { name: string; look: AvatarLook }>();
constructor(opts: NetOpts) {
this.o = opts;
this.avatar = validLook(opts.avatar ?? DEFAULT_LOOK);
this.wireBus();
this.connect();
}
/**
* Live avatar change from the Menu rebroadcast so peers re-render us.
*
* COALESCED, not dropped: the relay rate-limits avatar churn to 1/2 s, and we
* send the whole look (not a delta), so a naively-dropped message would leave
* peers stuck on a stale avatar forever. Clicking huegearface quickly is the
* normal way to use the editor, so we always schedule the LATEST look to go
* out when the server's window reopens. Worst case peers converge in ~2 s.
*/
setAvatar(look: AvatarLook): void {
this.avatar = validLook(look);
this.flushAvatar();
}
private flushAvatar(): void {
const wait = AVATAR_COOLDOWN_MS - (Date.now() - this.avatarSentAt);
if (wait <= 0) {
this.avatarSentAt = Date.now();
this.send({ t: 'avatar', avatar: this.avatar });
return;
}
if (this.avatarTimer === null) {
this.avatarTimer = window.setTimeout(() => {
this.avatarTimer = null;
this.flushAvatar(); // sends whatever the latest look is by then
}, wait + 40); // +40ms so we land just outside the server's window
}
}
/**
* Fire an emote. Returns false if the local cooldown swallowed it (the relay
* enforces the same 4 s ceiling server-side this keeps the local echo in
* step with what peers will actually see).
*/
sendEmote(kind: number): boolean {
const now = Date.now();
if (now - this.lastEmoteAt < EMOTE_COOLDOWN_MS) return false;
this.lastEmoteAt = now;
this.send({ t: 'emote', kind });
return true;
}
/** Reset intent — the relay validates win + cooldown and broadcasts. */
sendReset(): void { this.send({ t: 'reset' }); }
/** Current peers for the who's-here list. */
peerList(): Array<{ id: number; name: string; look: AvatarLook }> {
return [...this.roster.entries()].map(([id, p]) => ({ id, name: p.name, look: p.look }));
}
private pushPeers(): void { this.o.onPeers?.(this.peerList()); }
private platter(deck: 'A' | 'B'): Platter | undefined {
return this.o.machines.machines.find((m) => m.id === `platter${deck}`) as Platter | undefined;
}
@ -92,7 +161,7 @@ export class NetClient {
this.ws = ws;
ws.onopen = () => {
this.send({ t: 'hi', name: this.o.name });
this.send({ t: 'hi', name: this.o.name, avatar: this.avatar });
this.startPosLoop();
this.o.onStatus?.(this.peerCount, true);
};
@ -122,7 +191,14 @@ export class NetClient {
switch (m.t) {
case 'hello': {
this.peerCount = Array.isArray(m.peers) ? m.peers.length : 0;
for (const p of m.peers ?? []) this.o.avatars.add(p.id, String(p.name ?? 'lil dj'));
this.roster.clear();
for (const p of m.peers ?? []) {
const look = validLook(p.avatar);
const name = String(p.name ?? 'lil dj');
this.o.avatars.add(p.id, name, look);
this.roster.set(p.id, { name, look });
}
this.pushPeers();
this.applyRemote(() => {
for (const [key, id] of m.state?.edits ?? []) {
const [x, y, z] = String(key).split(',').map(Number);
@ -141,16 +217,51 @@ export class NetClient {
this.o.onStatus?.(this.peerCount, true);
break;
}
case 'join':
case 'join': {
this.peerCount++;
this.o.avatars.add(m.id, String(m.name ?? 'lil dj'));
const look = validLook(m.avatar);
const name = String(m.name ?? 'lil dj');
this.o.avatars.add(m.id, name, look);
this.roster.set(m.id, { name, look });
this.pushPeers();
this.o.onStatus?.(this.peerCount, true);
break;
}
case 'leave':
this.peerCount = Math.max(0, this.peerCount - 1);
this.o.avatars.remove(m.id);
this.roster.delete(m.id);
this.pushPeers();
this.o.onStatus?.(this.peerCount, true);
break;
case 'avatar': {
const look = validLook(m.avatar);
this.o.avatars.updateAvatar(m.id, look);
const e = this.roster.get(m.id);
if (e) { e.look = look; this.pushPeers(); }
break;
}
case 'emote': {
if (!Number.isInteger(m.kind)) return;
this.o.avatars.emote(m.id, m.kind);
const at = this.o.avatars.positionOf(m.id);
if (at) bus.emit('player:emote', { kind: m.kind, self: false, at });
break;
}
case 'reset': {
// Server-authoritative: it already validated win + cooldown. Unwind the
// quest locally; `by` names the DJ who blew the fuse. The workshop pose
// rides along so we land exactly where a late joiner would.
this.applyRemote(() => {
this.o.machines.quest.reset(String(m.by ?? ''));
if (m.workshop) this.o.machines.workshop.setState(m.workshop);
for (const deck of ['A', 'B'] as const) {
const pl = this.platter(deck);
if (pl) { pl.setSpeed(33); pl.setPlaying(false); }
}
});
break;
}
case 'pos':
this.o.avatars.updatePos(m.id, m.p, m.look);
break;
@ -206,6 +317,7 @@ export class NetClient {
dispose(): void {
this.disposed = true;
this.stopPosLoop();
if (this.avatarTimer !== null) { clearTimeout(this.avatarTimer); this.avatarTimer = null; }
if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer);
for (const u of this.unsubs) u();
this.ws?.close();

87
src/net/avatarLook.ts Normal file
View File

@ -0,0 +1,87 @@
// Avatar look: the shared vocabulary for the customization enums (SOCIAL_RESET
// brief, Feature 1). Deliberately palette-only — every option is a bounded int,
// so there is no free-form content and nothing to moderate.
//
// Used by BOTH the 3D peer avatars (src/net/Avatars.ts) and the Menu's 2D
// preview (src/ui/Menu.ts), so the mirror always matches what peers see.
export interface AvatarLook {
hue: number; // 0..11 swatch index (0 = "auto": deterministic per-peer hue)
gear: number; // 0..4 headgear
face: number; // 0..3 face
}
export const AVATAR_RANGES = { hue: 12, gear: 5, face: 4 } as const;
export const GEAR_NAMES = ['phones', 'cap', 'beanie', 'crown', 'none'] as const;
export const FACE_NAMES = ['dots', 'shades', 'visor', 'happy'] as const;
export const HUE_NAMES = [
'auto', 'red', 'orange', 'amber', 'lime', 'green',
'teal', 'cyan', 'blue', 'indigo', 'violet', 'pink',
] as const;
export const DEFAULT_LOOK: AvatarLook = { hue: 0, gear: 0, face: 0 };
/** Clamp anything to a valid look (defaults on garbage) — mirrors the relay. */
export function validLook(a: unknown): AvatarLook {
const o = (a ?? {}) as Partial<Record<keyof AvatarLook, unknown>>;
const pick = (v: unknown, n: number) =>
(Number.isInteger(v) && (v as number) >= 0 && (v as number) < n ? v as number : 0);
return {
hue: pick(o.hue, AVATAR_RANGES.hue),
gear: pick(o.gear, AVATAR_RANGES.gear),
face: pick(o.face, AVATAR_RANGES.face),
};
}
/**
* Body colour as [h,s,l]. hue 0 = "auto" keeps the original golden-ratio
* per-peer hue so untouched avatars look exactly as they did before.
*/
export function bodyHSL(look: AvatarLook, peerId: number): [number, number, number] {
if (look.hue === 0) return [(peerId * 0.6180339887) % 1, 0.55, 0.55];
return [(look.hue - 1) / (AVATAR_RANGES.hue - 1), 0.6, 0.55];
}
/** CSS colour for the same look — used by the Menu preview + who's-here swatches. */
export function bodyCss(look: AvatarLook, peerId: number): string {
const [h, s, l] = bodyHSL(look, peerId);
return `hsl(${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%)`;
}
/**
* Paint a face onto a canvas 2D context in a 0..1 normalized box. Shared by the
* 3D head texture and the Menu preview so they can never drift apart.
*/
export function paintFace(
ctx: CanvasRenderingContext2D, face: number, x: number, y: number, w: number, h: number,
): void {
const px = (u: number, v: number, uw: number, vh: number) =>
ctx.fillRect(x + u * w, y + v * h, uw * w, vh * h);
ctx.save();
switch (FACE_NAMES[face] ?? 'dots') {
case 'dots':
ctx.fillStyle = '#141414';
px(0.24, 0.38, 0.14, 0.16); px(0.62, 0.38, 0.14, 0.16);
break;
case 'shades':
ctx.fillStyle = '#0d0d0f';
px(0.14, 0.36, 0.72, 0.2); // lens bar
ctx.fillStyle = '#3d4657';
px(0.19, 0.4, 0.24, 0.1); px(0.57, 0.4, 0.24, 0.1); // glints
break;
case 'visor':
ctx.fillStyle = '#123b2c';
px(0.1, 0.32, 0.8, 0.26);
ctx.fillStyle = '#39d98a';
px(0.14, 0.36, 0.72, 0.06); // scan line
break;
case 'happy':
ctx.fillStyle = '#141414';
px(0.24, 0.34, 0.13, 0.13); px(0.63, 0.34, 0.13, 0.13);
px(0.28, 0.62, 0.44, 0.08); // smile
px(0.24, 0.56, 0.06, 0.08); px(0.7, 0.56, 0.06, 0.08);
break;
}
ctx.restore();
}

View File

@ -1,6 +1,15 @@
// Help & Settings overlay (integration addition, post-playtest).
// Press H (or the corner badge) to toggle. First run auto-opens the HOW TO
// PLAY tab. Settings persist to localStorage and apply live via onApply.
//
// SOCIAL_RESET adds an AVATAR tab: palette-only customization (hue/gear/face)
// with a 2D canvas mirror — you're first-person, so the preview is the only
// way to see yourself. Changes apply live via onAvatar (no reload).
import {
AVATAR_RANGES, DEFAULT_LOOK, FACE_NAMES, GEAR_NAMES, HUE_NAMES,
bodyCss, paintFace, validLook, type AvatarLook,
} from '../net/avatarLook';
export interface Settings {
volume: number; // 0..1 master
@ -9,6 +18,7 @@ export interface Settings {
bloom: boolean; // selective LED glow (auto-off on 'fast' quality)
quality: 'sharp' | 'balanced' | 'fast'; // pixelRatio cap 2 / 1.5 / 1
name: string; // DJ name shown to other players (applies on reload)
avatar: AvatarLook; // hue/gear/face enums — applies live to peers
}
export const QUALITY_PIXEL_RATIO: Record<Settings['quality'], number> = {
@ -21,6 +31,7 @@ const randomName = () =>
const DEFAULTS: Settings = {
volume: 0.85, sensitivity: 1, viewBob: false, bloom: true, quality: 'sharp', name: '',
avatar: { ...DEFAULT_LOOK },
};
const LS_SETTINGS = 'turncraft.settings';
const LS_HELP_SEEN = 'turncraft.helpSeen';
@ -29,8 +40,12 @@ export interface MenuOpts {
onApply(s: Settings): void;
onOpen?(): void; // host: exit pointer lock, suppress the pause overlay
onClose?(): void; // host: re-acquire pointer lock if the game has started
/** Avatar changed — push it to the relay live (no reload). */
onAvatar?(look: AvatarLook): void;
}
type Tab = 'help' | 'settings' | 'avatar';
const HELP_HTML = `
<h3>WHAT IS THIS</h3>
<p>You are 7&nbsp;mm tall inside a DJ booth, and the booth is dead. Five things
@ -48,6 +63,8 @@ mix comes back to life — one layer of music per repair.</p>
wire / torque a screw at the workbench</td></tr>
<tr><td>Q</td><td>drop a carried wire back on the loom</td></tr>
<tr><td>19 / SCROLL</td><td>pick a hotbar slot</td></tr>
<tr><td>Z X C V</td><td>emote wave, nod (on the beat), point, airhorn</td></tr>
<tr><td>HOLD TAB</td><td>who's in the booth</td></tr>
<tr><td>F</td><td>fly mode (cheat great for exploring)</td></tr>
<tr><td>H</td><td>this menu</td></tr>
</table>
@ -76,14 +93,24 @@ switch on the mixer's back edge lights green. Climb up and press it.</td></tr>
<h3>MULTIPLAYER</h3>
<p>The booth is shared: other tiny DJs may be in here with you. You'll see
them riding the records; blocks they mine or place change for everyone, and
quest repairs count for the whole booth. Set your DJ name in SETTINGS.</p>
quest repairs count for the whole booth. Set your DJ name in SETTINGS and your
look in AVATAR colour, headgear and face, live, no reload. Say hello with
<em>Z X C V</em> (the airhorn is rate-limited, sorry). Hold <em>TAB</em> to see
who's here.</p>
<h3>TIPS</h3>
<p>The records spin stand on one and ride. Walking to the middle while it
turns is a real fight (tiny legs, big physics): press the START/STOP plate to
stop the deck, or embrace the spiral. The little terraces step up as you walk
in. At 45 speed the rim moves faster than you can sprint jump near the edge
to get launched. Mined blocks can be placed anywhere: build bridges between
the gear.</p>`;
the gear.</p>
<h3>BLOWING THE FUSE</h3>
<p>Once the mix is live the quest is spent so there's a way to play it again.
Go back down to the fuse box and <em>keep mining the glass fuse</em>: it takes a
deliberate five-second hold, and it kills the mix <em>for everyone in the booth</em>.
The music powers down, the lights die, and the cartridge pops its wires but
everything anyone built stays exactly where it is. There's a cooldown after each
win, so you can't grief a room with it.</p>`;
export class Menu {
private readonly opts: MenuOpts;
@ -125,7 +152,7 @@ export class Menu {
isOpen(): boolean { return this.open; }
getSettings(): Settings { return { ...this.settings }; }
toggle(force?: boolean, tab?: 'help' | 'settings'): void {
toggle(force?: boolean, tab?: Tab): void {
const next = force ?? !this.open;
if (next === this.open) { if (tab) this.showTab(tab); return; }
this.open = next;
@ -152,6 +179,7 @@ export class Menu {
const raw = localStorage.getItem(LS_SETTINGS);
if (raw) s = { ...s, ...JSON.parse(raw) as Partial<Settings> };
} catch { /* fall through to defaults */ }
s.avatar = validLook(s.avatar); // stale/garbage persisted look -> defaults
if (!s.name) {
s.name = randomName();
localStorage.setItem(LS_SETTINGS, JSON.stringify(s));
@ -164,7 +192,7 @@ export class Menu {
this.opts.onApply(this.settings);
}
private showTab(tab: 'help' | 'settings'): void {
private showTab(tab: Tab): void {
this.panel.querySelectorAll<HTMLElement>('.tcm-tab').forEach((t) =>
t.classList.toggle('active', t.dataset.tab === tab));
this.panel.querySelectorAll<HTMLElement>('.tcm-page').forEach((p) =>
@ -174,7 +202,7 @@ export class Menu {
private build(): void {
const tabs = document.createElement('div');
tabs.className = 'tcm-tabs';
for (const [key, label] of [['help', 'HOW TO PLAY'], ['settings', 'SETTINGS']] as const) {
for (const [key, label] of [['help', 'HOW TO PLAY'], ['avatar', 'AVATAR'], ['settings', 'SETTINGS']] as const) {
const t = document.createElement('button');
t.className = 'tcm-tab' + (key === 'help' ? ' active' : '');
t.dataset.tab = key;
@ -195,6 +223,12 @@ export class Menu {
help.innerHTML = HELP_HTML; // static template string, no user input
this.panel.appendChild(help);
const av = document.createElement('div');
av.className = 'tcm-page hidden';
av.dataset.tab = 'avatar';
this.panel.appendChild(av);
this.buildAvatarPage(av);
const st = document.createElement('div');
st.className = 'tcm-page hidden';
st.dataset.tab = 'settings';
@ -216,6 +250,123 @@ export class Menu {
(v) => { this.settings.name = v.slice(0, 24) || s.name; this.save(); }));
}
/**
* AVATAR tab: palette-only swatch rows + a 2D mirror. You're first-person, so
* this preview is the only way to see yourself; it shares paintFace/bodyCss
* with the 3D avatar so it can't drift from what peers actually see.
*/
private buildAvatarPage(root: HTMLElement): void {
const intro = document.createElement('p');
intro.textContent =
"Other DJs see this. You don't (you're first person) — so here's the mirror.";
root.appendChild(intro);
const wrap = document.createElement('div');
wrap.className = 'tcm-avwrap';
const canvas = document.createElement('canvas');
canvas.width = 132; canvas.height = 168;
canvas.className = 'tcm-avpreview';
const controls = document.createElement('div');
controls.className = 'tcm-avcontrols';
wrap.appendChild(canvas);
wrap.appendChild(controls);
root.appendChild(wrap);
const draw = () => this.drawAvatarPreview(canvas);
const pick = (label: string, count: number, names: readonly string[],
get: () => number, set: (i: number) => void, swatch: boolean) => {
const row = document.createElement('div');
row.className = 'tcm-avrow';
const h = document.createElement('span');
h.className = 'tcm-avlabel';
h.textContent = label;
row.appendChild(h);
const chips = document.createElement('div');
chips.className = 'tcm-chips';
for (let i = 0; i < count; i++) {
const b = document.createElement('button');
b.className = 'tcm-chip' + (get() === i ? ' on' : '');
b.title = names[i] ?? String(i);
if (swatch) {
b.style.background = i === 0 ? 'linear-gradient(135deg,#888,#ccc)'
: bodyCss({ ...this.settings.avatar, hue: i }, 0);
} else {
b.textContent = names[i] ?? String(i);
}
b.addEventListener('click', () => {
set(i);
chips.querySelectorAll('.tcm-chip').forEach((c, ci) => c.classList.toggle('on', ci === i));
this.settings.avatar = validLook(this.settings.avatar);
this.save();
this.opts.onAvatar?.(this.settings.avatar); // live — no reload
draw();
});
chips.appendChild(b);
}
row.appendChild(chips);
controls.appendChild(row);
};
const a = () => this.settings.avatar;
pick('COLOUR', AVATAR_RANGES.hue, HUE_NAMES, () => a().hue,
(i) => { a().hue = i; }, true);
pick('HEADGEAR', AVATAR_RANGES.gear, GEAR_NAMES, () => a().gear,
(i) => { a().gear = i; }, false);
pick('FACE', AVATAR_RANGES.face, FACE_NAMES, () => a().face,
(i) => { a().face = i; }, false);
draw();
}
/** Front-view mock-up of the voxel DJ — cheap 2D, same palette as the 3D one. */
private drawAvatarPreview(canvas: HTMLCanvasElement): void {
const ctx = canvas.getContext('2d')!;
const look = this.settings.avatar;
const body = bodyCss(look, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#0c0a08';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const cx = canvas.width / 2;
// body + arms
ctx.fillStyle = body;
ctx.fillRect(cx - 27, 82, 54, 56); // torso
ctx.fillRect(cx - 41, 84, 11, 42); // arm L
ctx.fillRect(cx + 30, 84, 11, 42); // arm R
// head (slightly lighter, like the 3D headMat offsetHSL(+0.15 l))
const head = bodyCss(look, 0).replace(/(\d+)%\)$/, (_m, l) => `${Math.min(100, +l + 15)}%)`);
ctx.fillStyle = head;
ctx.fillRect(cx - 22, 34, 44, 44);
paintFace(ctx, look.face, cx - 22, 34, 44, 44);
// headgear
const gear = GEAR_NAMES[look.gear] ?? 'phones';
if (gear === 'phones') {
ctx.fillStyle = '#191919';
ctx.fillRect(cx - 25, 28, 50, 7); // band
ctx.fillRect(cx - 30, 44, 9, 22); // cup L
ctx.fillRect(cx + 21, 44, 9, 22); // cup R
} else if (gear === 'cap') {
ctx.fillStyle = '#2f6f8f';
ctx.fillRect(cx - 24, 22, 48, 14);
ctx.fillRect(cx - 24, 34, 48, 5); // brim (front view)
} else if (gear === 'beanie') {
ctx.fillStyle = '#8a5a3a';
ctx.fillRect(cx - 24, 20, 48, 18);
ctx.fillRect(cx - 6, 12, 12, 10); // bobble
} else if (gear === 'crown') {
ctx.fillStyle = '#d4ac3c';
ctx.fillRect(cx - 23, 26, 46, 9);
for (const dx of [-16, -4, 8]) ctx.fillRect(cx + dx, 16, 9, 11);
}
ctx.fillStyle = '#6a6252';
ctx.font = '10px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(this.settings.name || 'you', cx, 158);
}
private text(label: string, value: string, onChange: (v: string) => void): HTMLElement {
const row = this.row(label);
const input = document.createElement('input');
@ -303,7 +454,19 @@ export class Menu {
.tcm-row select{background:#0a0908;color:#d8cdb4;border:1px solid #3a352c;border-radius:4px;
font:12px ui-monospace,monospace;padding:4px 6px}
.tcm-row input[type=text]{background:#0a0908;color:#d8cdb4;border:1px solid #3a352c;
border-radius:4px;font:12px ui-monospace,monospace;padding:4px 8px;width:160px}`;
border-radius:4px;font:12px ui-monospace,monospace;padding:4px 8px;width:160px}
.tcm-avwrap{display:flex;gap:18px;align-items:flex-start;margin-top:10px}
.tcm-avpreview{border:1px solid #3a352c;border-radius:6px;image-rendering:pixelated;
width:132px;height:168px;flex:none}
.tcm-avcontrols{flex:1;display:flex;flex-direction:column;gap:12px}
.tcm-avrow{display:flex;flex-direction:column;gap:5px}
.tcm-avlabel{color:#8a8378;font-size:11px;letter-spacing:.14em}
.tcm-chips{display:flex;flex-wrap:wrap;gap:5px}
.tcm-chip{width:26px;height:26px;border:1px solid #3a352c;border-radius:5px;cursor:pointer;
background:#0a0908;color:#d8cdb4;font:9px ui-monospace,monospace;padding:0;overflow:hidden}
.tcm-chip:not([title=""]){width:auto;min-width:26px;padding:0 7px}
.tcm-chip.on{border-color:#ffb028;box-shadow:0 0 0 1px #ffb028}
.tcm-chip:hover{border-color:#6a6252}`;
document.head.appendChild(css);
}
}

104
src/ui/Roster.ts Normal file
View File

@ -0,0 +1,104 @@
// 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));
}