diff --git a/.claude/launch.json b/.claude/launch.json index fb5d0f6..320768d 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,7 +4,8 @@ { "name": "turncraft", "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev", "--", "--port", "5173", "--strictPort"], + "runtimeArgs": ["run", "dev"], + "autoPort": true, "port": 5173 }, { diff --git a/docs/INTEGRATION_NOTES.md b/docs/INTEGRATION_NOTES.md index 6170f0a..5b2c6a0 100644 --- a/docs/INTEGRATION_NOTES.md +++ b/docs/INTEGRATION_NOTES.md @@ -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 diff --git a/docs/SOCIAL_RESET_handoff.md b/docs/SOCIAL_RESET_handoff.md new file mode 100644 index 0000000..2d3abfa --- /dev/null +++ b/docs/SOCIAL_RESET_handoff.md @@ -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` 0–11, `gear` 0–4 (`phones|cap|beanie|crown|none`), `face` + 0–3 (`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 3–6), **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 | ✅ `!!!` → `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. diff --git a/server/relay.mjs b/server/relay.mjs index 7595f4d..7c3c2c3 100644 --- a/server/relay.mjs +++ b/server/relay.mjs @@ -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; diff --git a/src/audio/AudioEngine.ts b/src/audio/AudioEngine.ts index 25a6580..d7e9299 100644 --- a/src/audio/AudioEngine.ts +++ b/src/audio/AudioEngine.ts @@ -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); }); diff --git a/src/audio/sfx.ts b/src/audio/sfx.ts index 35f7c71..8cb0529 100644 --- a/src/audio/sfx.ts +++ b/src/audio/sfx.ts @@ -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'; diff --git a/src/core/events.ts b/src/core/events.ts index 2db531c..e2cda7c 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -18,6 +18,18 @@ export type GameEvents = { 'signal:repair': { node: SignalNode; repaired: number; total: number }; 'game:win': Record; + // 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 diff --git a/src/fx/FxSystem.ts b/src/fx/FxSystem.ts index a463ed9..a91f924 100644 --- a/src/fx/FxSystem.ts +++ b/src/fx/FxSystem.ts @@ -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 }; }); diff --git a/src/interact/index.ts b/src/interact/index.ts index 69ab402..757d3fa 100644 --- a/src/interact/index.ts +++ b/src/interact/index.ts @@ -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'; diff --git a/src/interact/interaction.ts b/src/interact/interaction.ts index f6f2d74..0a38805 100644 --- a/src/interact/interaction.ts +++ b/src/interact/interaction.ts @@ -25,6 +25,19 @@ const BREAK_TIME_BY_SOUND: Partial> = { 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; diff --git a/src/machines/quest.ts b/src/machines/quest.ts index 8e7c956..6cb0690 100644 --- a/src/machines/quest.ts +++ b/src/machines/quest.ts @@ -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; + } } diff --git a/src/main.ts b/src/main.ts index 94fcdab..2c2db60 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 = { + 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 diff --git a/src/net/Avatars.ts b/src/net/Avatars.ts index 6ff577f..9afa865 100644 --- a/src/net/Avatars.ts +++ b/src/net/Avatars.ts @@ -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(); + 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); + } } diff --git a/src/net/NetClient.ts b/src/net/NetClient.ts index 17b9e9d..9ec262b 100644 --- a/src/net/NetClient.ts +++ b/src/net/NetClient.ts @@ -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(); 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 hue→gear→face 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(); diff --git a/src/net/avatarLook.ts b/src/net/avatarLook.ts new file mode 100644 index 0000000..5238b74 --- /dev/null +++ b/src/net/avatarLook.ts @@ -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>; + 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(); +} diff --git a/src/ui/Menu.ts b/src/ui/Menu.ts index 13ac5d3..82f4660 100644 --- a/src/ui/Menu.ts +++ b/src/ui/Menu.ts @@ -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 = { @@ -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 = `

WHAT IS THIS

You are 7 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.

wire / torque a screw at the workbench Qdrop a carried wire back on the loom 1–9 / SCROLLpick a hotbar slot +Z X C Vemote — wave, nod (on the beat), point, airhorn +HOLD TABwho's in the booth Ffly mode (cheat — great for exploring) Hthis menu @@ -76,14 +93,24 @@ switch on the mixer's back edge lights green. Climb up and press it.

MULTIPLAYER

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.

+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 +Z X C V (the airhorn is rate-limited, sorry). Hold TAB to see +who's here.

TIPS

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.

`; +the gear.

+

BLOWING THE FUSE

+

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 keep mining the glass fuse: it takes a +deliberate five-second hold, and it kills the mix for everyone in the booth. +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.

`; 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 }; } 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('.tcm-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab)); this.panel.querySelectorAll('.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); } } diff --git a/src/ui/Roster.ts b/src/ui/Roster.ts new file mode 100644 index 0000000..6d027e1 --- /dev/null +++ b/src/ui/Roster.ts @@ -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) => + `
` + + `${escapeHtml(name)}${you ? 'you' : ''}
`; + + 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 = + `
IN THE BOOTH — ${n} DJ${n === 1 ? '' : 's'}
${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)); +}