From 05c2765bd84b32bd3eff55c0d34a8c29de864b2d Mon Sep 17 00:00:00 2001
From: jing
Date: Fri, 17 Jul 2026 02:01:42 +1000
Subject: [PATCH] SOCIAL_RESET: avatars, emotes, the reset ritual, who's-here
roster
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.claude/launch.json | 3 +-
docs/INTEGRATION_NOTES.md | 43 +++++++
docs/SOCIAL_RESET_handoff.md | 143 +++++++++++++++++++++
server/relay.mjs | 88 ++++++++++++-
src/audio/AudioEngine.ts | 57 +++++++++
src/audio/sfx.ts | 89 ++++++++++++-
src/core/events.ts | 12 ++
src/fx/FxSystem.ts | 46 ++++++-
src/interact/index.ts | 2 +-
src/interact/interaction.ts | 55 +++++++-
src/machines/quest.ts | 19 +++
src/main.ts | 92 ++++++++++++-
src/net/Avatars.ts | 242 ++++++++++++++++++++++++++++++-----
src/net/NetClient.ts | 120 ++++++++++++++++-
src/net/avatarLook.ts | 87 +++++++++++++
src/ui/Menu.ts | 175 ++++++++++++++++++++++++-
src/ui/Roster.ts | 104 +++++++++++++++
17 files changed, 1324 insertions(+), 53 deletions(-)
create mode 100644 docs/SOCIAL_RESET_handoff.md
create mode 100644 src/net/avatarLook.ts
create mode 100644 src/ui/Roster.ts
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
| Q | drop a carried wire back on the loom |
| 1–9 / SCROLL | pick a hotbar slot |
+| Z X C V | emote — wave, nod (on the beat), point, airhorn |
+| HOLD TAB | who's in the booth |
| F | fly mode (cheat — great for exploring) |
| H | this 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));
+}