SOCIAL_RESET: avatars, emotes, the reset ritual, who's-here roster
Built by the Opus session per docs/briefs/SOCIAL_RESET.md; integrated and verified by Fable (typecheck clean, relay validation reviewed, 3D avatar gear/face pixel-confirmed live — the one item the build session couldn't capture in its frozen preview pane). - Avatars: hue/gear/face enums in src/net/avatarLook.ts shared by the 3D build and the Menu AVATAR tab's 2D mirror; live sync with client-side coalescing (two-tab testing caught the relay rate limit permanently desyncing rapid editors - trailing send fixes convergence). - Emotes Z/X/C/V: wave, beat-synced nod, point, rate-limited airhorn (detuned saws + confetti), server + client caps. - Reset ritual: after a win the fuse takes a deliberate 5s hold (reuses the workshop radial meter as the warning dial) -> server-authoritative reset with 10-min cooldown; music brakes to silence, lights die over 3s, workshop resets to wires-off with a SERVER-generated random weight notch (both tabs land identical), player builds survive, quest replayable. - Roster: hold Tab for who's-in-the-booth with hue swatches. - interact/ addition (flagged friction): additive setBreakGuard hook so interaction.ts never learns what a fuse is. - launch.json: main dev config uses autoPort (5173 collisions with parallel sessions). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
036748543b
commit
05c2765bd8
@ -4,7 +4,8 @@
|
|||||||
{
|
{
|
||||||
"name": "turncraft",
|
"name": "turncraft",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev", "--", "--port", "5173", "--strictPort"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"autoPort": true,
|
||||||
"port": 5173
|
"port": 5173
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -234,6 +234,49 @@ needs to know:
|
|||||||
segmented tilting arm) + **Teleport to arm**; stand on it and watch `groundedOn`
|
segmented tilting arm) + **Teleport to arm**; stand on it and watch `groundedOn`
|
||||||
stay `seesawN` as it tilts.
|
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)
|
## Not yet done (deploy phase)
|
||||||
|
|
||||||
Run `/ship-check` before exposing anything publicly; deploy per deploy-map
|
Run `/ship-check` before exposing anything publicly; deploy per deploy-map
|
||||||
|
|||||||
143
docs/SOCIAL_RESET_handoff.md
Normal file
143
docs/SOCIAL_RESET_handoff.md
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
# Social & the Reset Ritual — Handoff
|
||||||
|
|
||||||
|
**Brief:** [docs/briefs/SOCIAL_RESET.md](briefs/SOCIAL_RESET.md)
|
||||||
|
**Status:** ✅ All four features implemented, verified live in **two tabs against a
|
||||||
|
real relay**. `npm run typecheck` clean, zero console errors across the session.
|
||||||
|
**Not deployed** — the integrator ships it (the brief says don't touch deploy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What shipped
|
||||||
|
|
||||||
|
### 1. Avatar customization
|
||||||
|
- **[src/net/avatarLook.ts](../src/net/avatarLook.ts)** (new) — the shared
|
||||||
|
vocabulary: `hue` 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 | ✅ `<script>bad</script>!!!` → `scriptbadscript` |
|
||||||
|
| Emote spam (2 valid airhorns + bad enum) | ✅ exactly **one** landed remotely |
|
||||||
|
| Relay fuzz (bad types/enums/unknown msg) | ✅ all dropped, socket stayed alive |
|
||||||
|
| Reset before win | ✅ rejected |
|
||||||
|
| Reset immediately after win | ✅ rejected (cooldown) |
|
||||||
|
| **Full reset** (cooldown expired) | ✅ **both tabs**: won→false, 5→0 repairs, blown fuse back in box, platters stopped, subtitle *"⚡ dj groove72 BLEW THE FUSE"* |
|
||||||
|
| Workshop reset pose | ✅ identical on both tabs (`weightNotch: 4` — proves the server-authoritative pose) |
|
||||||
|
| Player builds survive the reset | ✅ placed block still present |
|
||||||
|
| Replayable to a second win | ✅ both tabs |
|
||||||
|
| Late joiner post-reset | ✅ reloaded tab got the won→reset state from `hello` |
|
||||||
|
| `winAt` persistence + old-file load | ✅ saved, rewound, reloaded (`loaded state: 0 edits, 5 repairs`) |
|
||||||
|
| Tab roster + hue swatches | ✅ screenshotted |
|
||||||
|
| AVATAR tab + live mirror | ✅ screenshotted (indigo + crown + shades) |
|
||||||
|
| typecheck / console errors | ✅ clean / zero, both tabs |
|
||||||
|
|
||||||
|
### The bug two-tab testing caught
|
||||||
|
Clicking hue→gear→face quickly (the normal way to use the editor) left peers on
|
||||||
|
a **stale avatar permanently**: the relay's 1-per-2 s limit dropped messages 2
|
||||||
|
and 3, and because each message carries the *whole* look rather than a delta,
|
||||||
|
the peer never converged. `NetClient.setAvatar` now coalesces — it always
|
||||||
|
schedules the latest look for when the server's window reopens. Re-tested: rapid
|
||||||
|
lime+beanie+happy converges to `{4,2,3}`.
|
||||||
|
|
||||||
|
### Not visually confirmed
|
||||||
|
The **3D peer avatar's gear/face rendering** could not be pixel-confirmed: the
|
||||||
|
WebGL canvas in this preview pane stops repainting (verified — the frame is
|
||||||
|
byte-identical with the camera slammed 240 units up looking straight down; DOM
|
||||||
|
overlays still update). What I *did* verify programmatically: the peer's scene
|
||||||
|
graph is exactly right (tag + body + head + 2 arm groups + cap + bobble for a
|
||||||
|
beanie), `visible: true`, inside the camera frustum, zero errors — and the 2D
|
||||||
|
mirror, which shares the same `paintFace`/`bodyCss` code, renders correctly.
|
||||||
|
**Worth one human glance in a real browser before shipping.**
|
||||||
|
|
||||||
|
## Out of scope (untouched, per brief)
|
||||||
|
Text chat (deliberately never), voice, friend lists, accounts, mobile input,
|
||||||
|
deploy scripts.
|
||||||
@ -25,6 +25,13 @@ const MAX_PEERS = 24;
|
|||||||
const MAX_FRAME_BYTES = 2048;
|
const MAX_FRAME_BYTES = 2048;
|
||||||
const MAX_MSGS_PER_SEC = 40;
|
const MAX_MSGS_PER_SEC = 40;
|
||||||
const MAX_NAME_LEN = 24;
|
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
|
// 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.
|
// would be ~550MB if a bot painted the whole booth). ~400k edits ≈ 25MB.
|
||||||
const MAX_EDITS = 400_000;
|
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 } };
|
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 workshop = null; // last-write-wins Headshell Workshop assembly state (or null)
|
||||||
let dirty = false;
|
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. */
|
/** Validate + clamp a Headshell Workshop state; return a clean copy or null. */
|
||||||
function validWorkshop(s) {
|
function validWorkshop(s) {
|
||||||
@ -78,6 +113,9 @@ function loadState() {
|
|||||||
Object.assign(platters.A, s.platters?.A ?? {});
|
Object.assign(platters.A, s.platters?.A ?? {});
|
||||||
Object.assign(platters.B, s.platters?.B ?? {});
|
Object.assign(platters.B, s.platters?.B ?? {});
|
||||||
workshop = validWorkshop(s.workshop);
|
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`);
|
console.log(`[relay] loaded state: ${edits.size} edits, ${repaired.size} repairs`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[relay] state load failed, starting fresh:', e.message);
|
console.error('[relay] state load failed, starting fresh:', e.message);
|
||||||
@ -89,7 +127,7 @@ function saveState() {
|
|||||||
dirty = false;
|
dirty = false;
|
||||||
const tmp = STATE_FILE + '.tmp';
|
const tmp = STATE_FILE + '.tmp';
|
||||||
writeFileSync(tmp, JSON.stringify({
|
writeFileSync(tmp, JSON.stringify({
|
||||||
edits: [...edits.entries()], repaired: [...repaired], platters, workshop,
|
edits: [...edits.entries()], repaired: [...repaired], platters, workshop, winAt,
|
||||||
}));
|
}));
|
||||||
renameSync(tmp, STATE_FILE);
|
renameSync(tmp, STATE_FILE);
|
||||||
}
|
}
|
||||||
@ -130,12 +168,16 @@ wss.on('connection', (ws) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = nextId++;
|
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);
|
peers.set(id, peer);
|
||||||
|
|
||||||
send(ws, {
|
send(ws, {
|
||||||
t: 'hello', id,
|
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 },
|
state: { edits: [...edits.entries()], repaired: [...repaired], platters, workshop },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -151,7 +193,43 @@ wss.on('connection', (ws) => {
|
|||||||
switch (m.t) {
|
switch (m.t) {
|
||||||
case 'hi': {
|
case 'hi': {
|
||||||
peer.name = cleanName(m.name);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'pos': {
|
case 'pos': {
|
||||||
@ -172,6 +250,8 @@ wss.on('connection', (ws) => {
|
|||||||
case 'repair': {
|
case 'repair': {
|
||||||
if (!NODES.has(m.node) || repaired.has(m.node)) return;
|
if (!NODES.has(m.node) || repaired.has(m.node)) return;
|
||||||
repaired.add(m.node);
|
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;
|
dirty = true;
|
||||||
broadcast({ t: 'repair', node: m.node }, id);
|
broadcast({ t: 'repair', node: m.node }, id);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -225,6 +225,9 @@ export class AudioEngine {
|
|||||||
case 'weightDetent': SFX.weightDetent(ctx, dest, t); break;
|
case 'weightDetent': SFX.weightDetent(ctx, dest, t); break;
|
||||||
case 'bubbleLock': SFX.bubbleLock(ctx, dest, t); break;
|
case 'bubbleLock': SFX.bubbleLock(ctx, dest, t); break;
|
||||||
case 'skate': SFX.skate(ctx, dest, this.noise, 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();
|
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 ──────────────────────────────────────────────────────
|
// ── event bus wiring ──────────────────────────────────────────────────────
|
||||||
private wireBus(): void {
|
private wireBus(): void {
|
||||||
bus.on('signal:repair', (p) => {
|
bus.on('signal:repair', (p) => {
|
||||||
@ -373,6 +421,15 @@ export class AudioEngine {
|
|||||||
|
|
||||||
bus.on('game:win', () => this.runWinAudio());
|
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) => {
|
bus.on('platter:state', (p) => {
|
||||||
this.setPlaybackState(p.deck, p.playing, p.rpm);
|
this.setPlaybackState(p.deck, p.playing, p.rpm);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -316,9 +316,96 @@ export function skate(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer
|
|||||||
src.start(t); src.stop(t + 0.75);
|
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 =
|
export type SfxName =
|
||||||
| 'footstep' | 'land' | 'break' | 'place'
|
| 'footstep' | 'land' | 'break' | 'place'
|
||||||
| 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle'
|
| 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle'
|
||||||
// Headshell Workshop:
|
// Headshell Workshop:
|
||||||
| 'crimp' | 'wireOn' | 'wireOff' | 'screwTick' | 'screwClunk'
|
| 'crimp' | 'wireOn' | 'wireOff' | 'screwTick' | 'screwClunk'
|
||||||
| 'tiltCreak' | 'weightDetent' | 'bubbleLock' | 'skate';
|
| 'tiltCreak' | 'weightDetent' | 'bubbleLock' | 'skate'
|
||||||
|
// Social & Reset Ritual:
|
||||||
|
| 'airhorn' | 'powerDown';
|
||||||
|
|||||||
@ -18,6 +18,18 @@ export type GameEvents = {
|
|||||||
'signal:repair': { node: SignalNode; repaired: number; total: number };
|
'signal:repair': { node: SignalNode; repaired: number; total: number };
|
||||||
'game:win': Record<string, never>;
|
'game:win': Record<string, never>;
|
||||||
|
|
||||||
|
// The Reset Ritual (SOCIAL_RESET brief). Server-authoritative: the relay
|
||||||
|
// accepts a `reset` intent and broadcasts it; NetClient calls Quest.reset(),
|
||||||
|
// which emits this. Audio brakes to silence, FX fades the booth dark, HUD
|
||||||
|
// clears the tracker, platters spin down, workshop takes its reset pose.
|
||||||
|
// `by` is the DJ name that blew the fuse ('' for a local/offline reset).
|
||||||
|
'quest:reset': { by: string };
|
||||||
|
|
||||||
|
// Emotes (SOCIAL_RESET brief). `kind` 0..3 = wave | nod | point | airhorn.
|
||||||
|
// `self` is true for the local player's own emote (first-person: no avatar
|
||||||
|
// to animate, so audio/FX react instead). `at` is the emoter's position.
|
||||||
|
'player:emote': { kind: number; self: boolean; at: [number, number, number] };
|
||||||
|
|
||||||
// Headshell Workshop (Lane D emits; HUD/audio react). Additive per the
|
// Headshell Workshop (Lane D emits; HUD/audio react). Additive per the
|
||||||
// WORKSHOP_CARTRIDGE brief — only primitives cross the bus (no lane types).
|
// WORKSHOP_CARTRIDGE brief — only primitives cross the bus (no lane types).
|
||||||
'workshop:test': { errors: string[]; clean: boolean }; // needle-drop verdict
|
'workshop:test': { errors: string[]; clean: boolean }; // needle-drop verdict
|
||||||
|
|||||||
@ -51,6 +51,12 @@ export class FxSystem {
|
|||||||
private winClock = 0;
|
private winClock = 0;
|
||||||
private dropped = false;
|
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) {
|
constructor(opts: FxOpts) {
|
||||||
this.scene = opts.scene;
|
this.scene = opts.scene;
|
||||||
this.setBoost = opts.setEmissiveBoost;
|
this.setBoost = opts.setEmissiveBoost;
|
||||||
@ -87,7 +93,13 @@ export class FxSystem {
|
|||||||
if (this.pulse < 0.001) this.pulse = 0;
|
if (this.pulse < 0.001) this.pulse = 0;
|
||||||
|
|
||||||
let boost: number;
|
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
|
// pre-drop: booth goes dark and silent
|
||||||
this.winClock += dt;
|
this.winClock += dt;
|
||||||
boost = 0.04;
|
boost = 0.04;
|
||||||
@ -97,6 +109,7 @@ export class FxSystem {
|
|||||||
boost = this.boostBase + this.pulse;
|
boost = this.boostBase + this.pulse;
|
||||||
}
|
}
|
||||||
this.setBoost(Math.max(0, Math.min(2, boost)));
|
this.setBoost(Math.max(0, Math.min(2, boost)));
|
||||||
|
this.lastBoost = boost;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A celebratory toss of tiny brushed-steel screws (Headshell Workshop). */
|
/** A celebratory toss of tiny brushed-steel screws (Headshell Workshop). */
|
||||||
@ -179,6 +192,37 @@ export class FxSystem {
|
|||||||
this.dropped = false;
|
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) => {
|
bus.on('platter:state', (p) => {
|
||||||
this.deckSpin[p.deck] = { playing: p.playing, rpm: p.rpm };
|
this.deckSpin[p.deck] = { playing: p.playing, rpm: p.rpm };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// LANE D — interaction layer public surface.
|
// LANE D — interaction layer public surface.
|
||||||
export { Hotbar, HOTBAR_SLOTS, type HotSlot } from './hotbar';
|
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';
|
export { raycastVoxel, raycastColliders, type VoxelHit, type ColliderHit } from './raycast';
|
||||||
|
|||||||
@ -25,6 +25,19 @@ const BREAK_TIME_BY_SOUND: Partial<Record<string, number>> = {
|
|||||||
glass: 0.25,
|
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 {
|
export class Interaction {
|
||||||
private readonly world: IVoxelWorld;
|
private readonly world: IVoxelWorld;
|
||||||
private readonly player: IPlayerView;
|
private readonly player: IPlayerView;
|
||||||
@ -38,6 +51,8 @@ export class Interaction {
|
|||||||
private readonly rayColliders: KinematicCollider[] = [];
|
private readonly rayColliders: KinematicCollider[] = [];
|
||||||
|
|
||||||
private mining = false;
|
private mining = false;
|
||||||
|
private guard: BreakGuard | null = null;
|
||||||
|
private guarding = false;
|
||||||
private breakTarget: { x: number; y: number; z: number } | null = null;
|
private breakTarget: { x: number; y: number; z: number } | null = null;
|
||||||
private breakElapsed = 0;
|
private breakElapsed = 0;
|
||||||
private lastHit: RayHit = null;
|
private lastHit: RayHit = null;
|
||||||
@ -77,7 +92,18 @@ export class Interaction {
|
|||||||
|
|
||||||
// ---- Input surface (host wires DOM events to these) ----
|
// ---- Input surface (host wires DOM events to these) ----
|
||||||
startMining(): void { this.mining = true; }
|
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 {
|
place(): void {
|
||||||
const hit = this.raycast();
|
const hit = this.raycast();
|
||||||
@ -169,16 +195,35 @@ export class Interaction {
|
|||||||
|
|
||||||
private advanceMining(dt: number, hit: RayHit): void {
|
private advanceMining(dt: number, hit: RayHit): void {
|
||||||
if (!this.mining || !hit || hit.kind !== 'block' || !blockDef(hit.id).breakable) {
|
if (!this.mining || !hit || hit.kind !== 'block' || !blockDef(hit.id).breakable) {
|
||||||
|
this.endGuard();
|
||||||
this.breakTarget = null; this.breakElapsed = 0; return;
|
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) {
|
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.breakTarget = { x: hit.x, y: hit.y, z: hit.z };
|
||||||
this.breakElapsed = 0;
|
this.breakElapsed = 0;
|
||||||
|
this.endGuard();
|
||||||
}
|
}
|
||||||
this.breakElapsed += dt;
|
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) {
|
if (this.breakElapsed >= breakTime) {
|
||||||
const id: BlockId = hit.id;
|
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.hotbar.add(id, 1);
|
||||||
this.world.setBlock(hit.x, hit.y, hit.z, AIR);
|
this.world.setBlock(hit.x, hit.y, hit.z, AIR);
|
||||||
bus.emit('block:break', { x: hit.x, y: hit.y, z: hit.z, id });
|
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 {
|
private updateSelection(hit: RayHit): void {
|
||||||
if (hit && hit.kind === 'block') {
|
if (hit && hit.kind === 'block') {
|
||||||
this.selection.visible = true;
|
this.selection.visible = true;
|
||||||
|
|||||||
@ -89,4 +89,23 @@ export class Quest {
|
|||||||
}
|
}
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
92
src/main.ts
92
src/main.ts
@ -15,8 +15,11 @@ import { FxSystem } from './fx/FxSystem';
|
|||||||
import { DecalSet } from './fx/decals';
|
import { DecalSet } from './fx/decals';
|
||||||
import { Hud } from './ui/Hud';
|
import { Hud } from './ui/Hud';
|
||||||
import { Menu, QUALITY_PIXEL_RATIO } from './ui/Menu';
|
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 { NetClient, relayUrl } from './net/NetClient';
|
||||||
|
import { bus } from './core/events';
|
||||||
|
import { QUEST_ITEMS } from './machines/quest';
|
||||||
|
|
||||||
const container = document.getElementById('app')!;
|
const container = document.getElementById('app')!;
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@ -107,6 +110,11 @@ const menu = new Menu({
|
|||||||
},
|
},
|
||||||
onOpen: () => { document.exitPointerLock(); hud.setPaused(false); },
|
onOpen: () => { document.exitPointerLock(); hud.setPaused(false); },
|
||||||
onClose: () => { if (started) renderer.domElement.requestPointerLock(); },
|
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', () => {
|
document.addEventListener('pointerlockchange', () => {
|
||||||
@ -123,9 +131,13 @@ netChip.style.cssText =
|
|||||||
'position:fixed;top:10px;left:150px;z-index:40;font:11px ui-monospace,monospace;' +
|
'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';
|
'color:#8a8378;background:#0008;border:1px solid #3a352c;border-radius:6px;padding:4px 8px;display:none';
|
||||||
document.body.appendChild(netChip);
|
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({
|
const net = new NetClient({
|
||||||
world, machines, player, avatars,
|
world, machines, player, avatars,
|
||||||
name: menu.getSettings().name,
|
name: menu.getSettings().name,
|
||||||
|
avatar: menu.getSettings().avatar,
|
||||||
url: relayUrl(import.meta.env.BASE_URL),
|
url: relayUrl(import.meta.env.BASE_URL),
|
||||||
onStatus: (peerCount, connected) => {
|
onStatus: (peerCount, connected) => {
|
||||||
netChip.style.display = connected ? 'block' : 'none';
|
netChip.style.display = connected ? 'block' : 'none';
|
||||||
@ -133,6 +145,7 @@ const net = new NetClient({
|
|||||||
? 'online — booth to yourself'
|
? 'online — booth to yourself'
|
||||||
: `online — ${peerCount} other DJ${peerCount === 1 ? '' : 's'} in the booth`;
|
: `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
|
// 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 === 'ShiftLeft' || e.code === 'ShiftRight') interaction.shiftDown = true;
|
||||||
if (e.code === 'KeyE') { if (!e.repeat) interaction.useDown(e.shiftKey); } // E press (hold = crimp/torque)
|
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 (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')) {
|
else if (e.code.startsWith('Digit')) {
|
||||||
const n = parseInt(e.code.slice(5), 10);
|
const n = parseInt(e.code.slice(5), 10);
|
||||||
if (n >= 1 && n <= 9) hotbar.setActive(n - 1);
|
if (n >= 1 && n <= 9) hotbar.setActive(n - 1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── The Reset Ritual (SOCIAL_RESET Feature 3) ─────────────────────────────
|
||||||
|
// Once the mix is live, the fuse in the PCB-Depths box becomes the way to play
|
||||||
|
// the quest again — but only on a deliberate 5 s hold, and only after a
|
||||||
|
// cooldown. The relay is the authority; this is the ceremony around it.
|
||||||
|
const FUSE_HOLD_SECS = 5;
|
||||||
|
const RESET_COOLDOWN_MS = 10 * 60_000; // must match relay.mjs
|
||||||
|
let wonAt = 0;
|
||||||
|
bus.on('game:win', () => { wonAt = Date.now(); });
|
||||||
|
bus.on('quest:reset', () => { wonAt = 0; });
|
||||||
|
|
||||||
|
const fuse = QUEST_POS.fuse.box;
|
||||||
|
interaction.setBreakGuard({
|
||||||
|
timeFor: (x, y, z) => {
|
||||||
|
// Only the fuse voxel, and only while the booth is actually won.
|
||||||
|
if (!machines.quest.isWon()) return null;
|
||||||
|
if (x !== fuse[0] || y !== fuse[1] || z !== fuse[2]) return null;
|
||||||
|
return FUSE_HOLD_SECS;
|
||||||
|
},
|
||||||
|
// Reuse the workshop's radial meter as the "are you sure" dial.
|
||||||
|
progress: (p) => {
|
||||||
|
if (p < 0) hud.hideTorque();
|
||||||
|
else hud.showTorque(p, 'KEEP MINING TO KILL THE MIX — EVERYONE STARTS OVER');
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
if (Date.now() - wonAt < RESET_COOLDOWN_MS) {
|
||||||
|
// The relay would reject this anyway — say so and don't eat the fuse.
|
||||||
|
bus.emit('workshop:msg', { text: 'the fuse is still hot — give it a minute' });
|
||||||
|
return true; // veto the break
|
||||||
|
}
|
||||||
|
net.sendReset(); // relay validates + broadcasts; everyone resets together
|
||||||
|
return false; // let the fuse actually break
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The one block edit the reset writes: a fresh blackened fuse back in the box,
|
||||||
|
// so the quest is physically replayable. Player builds are untouched.
|
||||||
|
bus.on('quest:reset', ({ by }) => {
|
||||||
|
world.setBlock(fuse[0], fuse[1], fuse[2], QUEST_ITEMS.fuse);
|
||||||
|
hud.hideTorque();
|
||||||
|
hud.showSubtitle(by ? `⚡ ${by} BLEW THE FUSE` : '⚡ THE FUSE IS BLOWN');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Social: emotes (SOCIAL_RESET Feature 2) ───────────────────────────────
|
||||||
|
// Z X C V, pointer-locked only so they can't collide with the hotbar digits.
|
||||||
|
const EMOTE_KEYS: Record<string, number> = {
|
||||||
|
KeyZ: EMOTE.WAVE, KeyX: EMOTE.NOD, KeyC: EMOTE.POINT, KeyV: EMOTE.AIRHORN,
|
||||||
|
};
|
||||||
|
const EMOTE_LABEL = ['👋 wave', '🎧 nod', '👉 point', '📢 airhorn'];
|
||||||
|
const emoteToast = document.createElement('div');
|
||||||
|
emoteToast.style.cssText =
|
||||||
|
'position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:35;' +
|
||||||
|
'font:12px ui-monospace,monospace;color:#ffe9c0;background:#000a;border:1px solid #4a4234;' +
|
||||||
|
'border-radius:6px;padding:4px 10px;opacity:0;transition:opacity .18s;pointer-events:none';
|
||||||
|
document.body.appendChild(emoteToast);
|
||||||
|
let emoteToastTimer = 0;
|
||||||
|
|
||||||
|
function fireEmote(kind: number): void {
|
||||||
|
// The relay enforces the same 4 s ceiling; if our own cooldown ate it, stay
|
||||||
|
// silent locally too so what we hear matches what peers actually get.
|
||||||
|
if (!net.sendEmote(kind)) {
|
||||||
|
showEmoteToast('… still catching your breath');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// First person: you can't see your own wave, so the sound (airhorn) plus this
|
||||||
|
// toast are the "something happened". Audio + FX listen on the bus.
|
||||||
|
bus.emit('player:emote', { kind, self: true, at: [...player.position] as [number, number, number] });
|
||||||
|
showEmoteToast(EMOTE_LABEL[kind] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmoteToast(text: string): void {
|
||||||
|
emoteToast.textContent = text;
|
||||||
|
emoteToast.style.opacity = '1';
|
||||||
|
clearTimeout(emoteToastTimer);
|
||||||
|
emoteToastTimer = window.setTimeout(() => { emoteToast.style.opacity = '0'; }, 900);
|
||||||
|
}
|
||||||
window.addEventListener('keyup', (e) => {
|
window.addEventListener('keyup', (e) => {
|
||||||
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') interaction.shiftDown = false;
|
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') interaction.shiftDown = false;
|
||||||
if (e.code === 'KeyE') interaction.useUp(); // release ends any crimp/torque hold
|
if (e.code === 'KeyE') interaction.useUp(); // release ends any crimp/torque hold
|
||||||
|
|||||||
@ -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.
|
// 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 * as THREE from 'three';
|
||||||
|
import { bus } from '../core/events';
|
||||||
import type { Vec3 } from '../core/types';
|
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 {
|
interface Peer {
|
||||||
group: THREE.Group;
|
group: THREE.Group;
|
||||||
@ -12,6 +26,15 @@ interface Peer {
|
|||||||
target: THREE.Vector3;
|
target: THREE.Vector3;
|
||||||
targetYaw: number;
|
targetYaw: number;
|
||||||
yaw: 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 {
|
function nameSprite(name: string): THREE.Sprite {
|
||||||
@ -34,59 +57,175 @@ function nameSprite(name: string): THREE.Sprite {
|
|||||||
return sprite;
|
return sprite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deterministic per-id avatar hue so everyone sees the same colors. */
|
/** Head texture: base colour with the chosen face painted on the front. */
|
||||||
function peerColor(id: number): THREE.Color {
|
function faceTexture(face: number, base: THREE.Color): THREE.CanvasTexture {
|
||||||
return new THREE.Color().setHSL((id * 0.6180339887) % 1, 0.55, 0.55);
|
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 {
|
export class Avatars {
|
||||||
private readonly scene: THREE.Object3D;
|
private readonly scene: THREE.Object3D;
|
||||||
private readonly peers = new Map<number, Peer>();
|
private readonly peers = new Map<number, Peer>();
|
||||||
|
private readonly unsubs: Array<() => void> = [];
|
||||||
|
/** nod phase 0..1, advanced by dt and re-synced on every real beat */
|
||||||
|
private nodPhase = 0;
|
||||||
|
private beatPeriod = 60 / SILENT_BPM;
|
||||||
|
|
||||||
constructor(scene: THREE.Object3D) {
|
constructor(scene: THREE.Object3D) {
|
||||||
this.scene = scene;
|
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;
|
if (this.peers.has(id)) return;
|
||||||
const color = peerColor(id);
|
|
||||||
const group = new THREE.Group();
|
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);
|
const tag = nameSprite(name);
|
||||||
tag.position.y = 2.15;
|
tag.position.y = 2.15;
|
||||||
group.add(tag);
|
group.add(tag);
|
||||||
|
|
||||||
group.visible = false; // until the first pos update
|
group.visible = false; // until the first pos update
|
||||||
this.scene.add(group);
|
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 {
|
remove(id: number): void {
|
||||||
const p = this.peers.get(id);
|
const p = this.peers.get(id);
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
|
this.disposeParts(p);
|
||||||
this.scene.remove(p.group);
|
this.scene.remove(p.group);
|
||||||
p.group.traverse((o) => {
|
p.nameTag.material.map?.dispose();
|
||||||
const mesh = o as THREE.Mesh;
|
p.nameTag.material.dispose();
|
||||||
if (mesh.geometry) mesh.geometry.dispose();
|
|
||||||
const mat = (mesh as THREE.Mesh).material as THREE.Material | undefined;
|
|
||||||
if (mat) mat.dispose();
|
|
||||||
});
|
|
||||||
this.peers.delete(id);
|
this.peers.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,16 +240,55 @@ export class Avatars {
|
|||||||
peer.targetYaw = Math.atan2(look[0], look[2]);
|
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 {
|
update(dt: number): void {
|
||||||
const k = Math.min(1, LERP_RATE * dt);
|
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()) {
|
for (const p of this.peers.values()) {
|
||||||
p.group.position.lerp(p.target, k);
|
p.group.position.lerp(p.target, k);
|
||||||
let d = p.targetYaw - p.yaw;
|
let d = p.targetYaw - p.yaw;
|
||||||
d = ((d + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
|
d = ((d + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
|
||||||
p.yaw += d * k;
|
p.yaw += d * k;
|
||||||
p.group.rotation.y = p.yaw;
|
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; }
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,9 +14,15 @@ import type { SignalNode } from '../core/constants';
|
|||||||
import type { IPlayerView, IVoxelWorld, Vec3 } from '../core/types';
|
import type { IPlayerView, IVoxelWorld, Vec3 } from '../core/types';
|
||||||
import type { MachineSet, Platter } from '../machines';
|
import type { MachineSet, Platter } from '../machines';
|
||||||
import { Avatars } from './Avatars';
|
import { Avatars } from './Avatars';
|
||||||
|
import { validLook, DEFAULT_LOOK, type AvatarLook } from './avatarLook';
|
||||||
|
|
||||||
const POS_HZ = 10;
|
const POS_HZ = 10;
|
||||||
const RECONNECT_MS = 15_000;
|
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 {
|
interface NetOpts {
|
||||||
world: IVoxelWorld;
|
world: IVoxelWorld;
|
||||||
@ -24,9 +30,13 @@ interface NetOpts {
|
|||||||
player: IPlayerView;
|
player: IPlayerView;
|
||||||
avatars: Avatars;
|
avatars: Avatars;
|
||||||
name: string;
|
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" */
|
/** e.g. "wss://partly.party/turncraft/ws" or "ws://localhost:8137" */
|
||||||
url: string;
|
url: string;
|
||||||
onStatus?(peerCount: number, connected: boolean): void;
|
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 {
|
export function relayUrl(basePath: string): string {
|
||||||
@ -44,13 +54,72 @@ export class NetClient {
|
|||||||
private reconnectTimer: number | null = null;
|
private reconnectTimer: number | null = null;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
private readonly unsubs: Array<() => void> = [];
|
private readonly unsubs: Array<() => void> = [];
|
||||||
|
private avatar: AvatarLook;
|
||||||
|
private lastEmoteAt = 0;
|
||||||
|
private avatarSentAt = 0;
|
||||||
|
private avatarTimer: number | null = null;
|
||||||
|
/** who's-here roster (peers only; the local player is added by the UI). */
|
||||||
|
private readonly roster = new Map<number, { name: string; look: AvatarLook }>();
|
||||||
|
|
||||||
constructor(opts: NetOpts) {
|
constructor(opts: NetOpts) {
|
||||||
this.o = opts;
|
this.o = opts;
|
||||||
|
this.avatar = validLook(opts.avatar ?? DEFAULT_LOOK);
|
||||||
this.wireBus();
|
this.wireBus();
|
||||||
this.connect();
|
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 {
|
private platter(deck: 'A' | 'B'): Platter | undefined {
|
||||||
return this.o.machines.machines.find((m) => m.id === `platter${deck}`) as 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;
|
this.ws = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
this.send({ t: 'hi', name: this.o.name });
|
this.send({ t: 'hi', name: this.o.name, avatar: this.avatar });
|
||||||
this.startPosLoop();
|
this.startPosLoop();
|
||||||
this.o.onStatus?.(this.peerCount, true);
|
this.o.onStatus?.(this.peerCount, true);
|
||||||
};
|
};
|
||||||
@ -122,7 +191,14 @@ export class NetClient {
|
|||||||
switch (m.t) {
|
switch (m.t) {
|
||||||
case 'hello': {
|
case 'hello': {
|
||||||
this.peerCount = Array.isArray(m.peers) ? m.peers.length : 0;
|
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(() => {
|
this.applyRemote(() => {
|
||||||
for (const [key, id] of m.state?.edits ?? []) {
|
for (const [key, id] of m.state?.edits ?? []) {
|
||||||
const [x, y, z] = String(key).split(',').map(Number);
|
const [x, y, z] = String(key).split(',').map(Number);
|
||||||
@ -141,16 +217,51 @@ export class NetClient {
|
|||||||
this.o.onStatus?.(this.peerCount, true);
|
this.o.onStatus?.(this.peerCount, true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'join':
|
case 'join': {
|
||||||
this.peerCount++;
|
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);
|
this.o.onStatus?.(this.peerCount, true);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'leave':
|
case 'leave':
|
||||||
this.peerCount = Math.max(0, this.peerCount - 1);
|
this.peerCount = Math.max(0, this.peerCount - 1);
|
||||||
this.o.avatars.remove(m.id);
|
this.o.avatars.remove(m.id);
|
||||||
|
this.roster.delete(m.id);
|
||||||
|
this.pushPeers();
|
||||||
this.o.onStatus?.(this.peerCount, true);
|
this.o.onStatus?.(this.peerCount, true);
|
||||||
break;
|
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':
|
case 'pos':
|
||||||
this.o.avatars.updatePos(m.id, m.p, m.look);
|
this.o.avatars.updatePos(m.id, m.p, m.look);
|
||||||
break;
|
break;
|
||||||
@ -206,6 +317,7 @@ export class NetClient {
|
|||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
this.stopPosLoop();
|
this.stopPosLoop();
|
||||||
|
if (this.avatarTimer !== null) { clearTimeout(this.avatarTimer); this.avatarTimer = null; }
|
||||||
if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer);
|
if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer);
|
||||||
for (const u of this.unsubs) u();
|
for (const u of this.unsubs) u();
|
||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
|
|||||||
87
src/net/avatarLook.ts
Normal file
87
src/net/avatarLook.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// Avatar look: the shared vocabulary for the customization enums (SOCIAL_RESET
|
||||||
|
// brief, Feature 1). Deliberately palette-only — every option is a bounded int,
|
||||||
|
// so there is no free-form content and nothing to moderate.
|
||||||
|
//
|
||||||
|
// Used by BOTH the 3D peer avatars (src/net/Avatars.ts) and the Menu's 2D
|
||||||
|
// preview (src/ui/Menu.ts), so the mirror always matches what peers see.
|
||||||
|
|
||||||
|
export interface AvatarLook {
|
||||||
|
hue: number; // 0..11 swatch index (0 = "auto": deterministic per-peer hue)
|
||||||
|
gear: number; // 0..4 headgear
|
||||||
|
face: number; // 0..3 face
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AVATAR_RANGES = { hue: 12, gear: 5, face: 4 } as const;
|
||||||
|
|
||||||
|
export const GEAR_NAMES = ['phones', 'cap', 'beanie', 'crown', 'none'] as const;
|
||||||
|
export const FACE_NAMES = ['dots', 'shades', 'visor', 'happy'] as const;
|
||||||
|
export const HUE_NAMES = [
|
||||||
|
'auto', 'red', 'orange', 'amber', 'lime', 'green',
|
||||||
|
'teal', 'cyan', 'blue', 'indigo', 'violet', 'pink',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_LOOK: AvatarLook = { hue: 0, gear: 0, face: 0 };
|
||||||
|
|
||||||
|
/** Clamp anything to a valid look (defaults on garbage) — mirrors the relay. */
|
||||||
|
export function validLook(a: unknown): AvatarLook {
|
||||||
|
const o = (a ?? {}) as Partial<Record<keyof AvatarLook, unknown>>;
|
||||||
|
const pick = (v: unknown, n: number) =>
|
||||||
|
(Number.isInteger(v) && (v as number) >= 0 && (v as number) < n ? v as number : 0);
|
||||||
|
return {
|
||||||
|
hue: pick(o.hue, AVATAR_RANGES.hue),
|
||||||
|
gear: pick(o.gear, AVATAR_RANGES.gear),
|
||||||
|
face: pick(o.face, AVATAR_RANGES.face),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body colour as [h,s,l]. hue 0 = "auto" keeps the original golden-ratio
|
||||||
|
* per-peer hue so untouched avatars look exactly as they did before.
|
||||||
|
*/
|
||||||
|
export function bodyHSL(look: AvatarLook, peerId: number): [number, number, number] {
|
||||||
|
if (look.hue === 0) return [(peerId * 0.6180339887) % 1, 0.55, 0.55];
|
||||||
|
return [(look.hue - 1) / (AVATAR_RANGES.hue - 1), 0.6, 0.55];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS colour for the same look — used by the Menu preview + who's-here swatches. */
|
||||||
|
export function bodyCss(look: AvatarLook, peerId: number): string {
|
||||||
|
const [h, s, l] = bodyHSL(look, peerId);
|
||||||
|
return `hsl(${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paint a face onto a canvas 2D context in a 0..1 normalized box. Shared by the
|
||||||
|
* 3D head texture and the Menu preview so they can never drift apart.
|
||||||
|
*/
|
||||||
|
export function paintFace(
|
||||||
|
ctx: CanvasRenderingContext2D, face: number, x: number, y: number, w: number, h: number,
|
||||||
|
): void {
|
||||||
|
const px = (u: number, v: number, uw: number, vh: number) =>
|
||||||
|
ctx.fillRect(x + u * w, y + v * h, uw * w, vh * h);
|
||||||
|
ctx.save();
|
||||||
|
switch (FACE_NAMES[face] ?? 'dots') {
|
||||||
|
case 'dots':
|
||||||
|
ctx.fillStyle = '#141414';
|
||||||
|
px(0.24, 0.38, 0.14, 0.16); px(0.62, 0.38, 0.14, 0.16);
|
||||||
|
break;
|
||||||
|
case 'shades':
|
||||||
|
ctx.fillStyle = '#0d0d0f';
|
||||||
|
px(0.14, 0.36, 0.72, 0.2); // lens bar
|
||||||
|
ctx.fillStyle = '#3d4657';
|
||||||
|
px(0.19, 0.4, 0.24, 0.1); px(0.57, 0.4, 0.24, 0.1); // glints
|
||||||
|
break;
|
||||||
|
case 'visor':
|
||||||
|
ctx.fillStyle = '#123b2c';
|
||||||
|
px(0.1, 0.32, 0.8, 0.26);
|
||||||
|
ctx.fillStyle = '#39d98a';
|
||||||
|
px(0.14, 0.36, 0.72, 0.06); // scan line
|
||||||
|
break;
|
||||||
|
case 'happy':
|
||||||
|
ctx.fillStyle = '#141414';
|
||||||
|
px(0.24, 0.34, 0.13, 0.13); px(0.63, 0.34, 0.13, 0.13);
|
||||||
|
px(0.28, 0.62, 0.44, 0.08); // smile
|
||||||
|
px(0.24, 0.56, 0.06, 0.08); px(0.7, 0.56, 0.06, 0.08);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
175
src/ui/Menu.ts
175
src/ui/Menu.ts
@ -1,6 +1,15 @@
|
|||||||
// Help & Settings overlay (integration addition, post-playtest).
|
// Help & Settings overlay (integration addition, post-playtest).
|
||||||
// Press H (or the corner badge) to toggle. First run auto-opens the HOW TO
|
// 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.
|
// 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 {
|
export interface Settings {
|
||||||
volume: number; // 0..1 master
|
volume: number; // 0..1 master
|
||||||
@ -9,6 +18,7 @@ export interface Settings {
|
|||||||
bloom: boolean; // selective LED glow (auto-off on 'fast' quality)
|
bloom: boolean; // selective LED glow (auto-off on 'fast' quality)
|
||||||
quality: 'sharp' | 'balanced' | 'fast'; // pixelRatio cap 2 / 1.5 / 1
|
quality: 'sharp' | 'balanced' | 'fast'; // pixelRatio cap 2 / 1.5 / 1
|
||||||
name: string; // DJ name shown to other players (applies on reload)
|
name: string; // DJ name shown to other players (applies on reload)
|
||||||
|
avatar: AvatarLook; // hue/gear/face enums — applies live to peers
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QUALITY_PIXEL_RATIO: Record<Settings['quality'], number> = {
|
export const QUALITY_PIXEL_RATIO: Record<Settings['quality'], number> = {
|
||||||
@ -21,6 +31,7 @@ const randomName = () =>
|
|||||||
|
|
||||||
const DEFAULTS: Settings = {
|
const DEFAULTS: Settings = {
|
||||||
volume: 0.85, sensitivity: 1, viewBob: false, bloom: true, quality: 'sharp', name: '',
|
volume: 0.85, sensitivity: 1, viewBob: false, bloom: true, quality: 'sharp', name: '',
|
||||||
|
avatar: { ...DEFAULT_LOOK },
|
||||||
};
|
};
|
||||||
const LS_SETTINGS = 'turncraft.settings';
|
const LS_SETTINGS = 'turncraft.settings';
|
||||||
const LS_HELP_SEEN = 'turncraft.helpSeen';
|
const LS_HELP_SEEN = 'turncraft.helpSeen';
|
||||||
@ -29,8 +40,12 @@ export interface MenuOpts {
|
|||||||
onApply(s: Settings): void;
|
onApply(s: Settings): void;
|
||||||
onOpen?(): void; // host: exit pointer lock, suppress the pause overlay
|
onOpen?(): void; // host: exit pointer lock, suppress the pause overlay
|
||||||
onClose?(): void; // host: re-acquire pointer lock if the game has started
|
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 = `
|
const HELP_HTML = `
|
||||||
<h3>WHAT IS THIS</h3>
|
<h3>WHAT IS THIS</h3>
|
||||||
<p>You are 7 mm tall inside a DJ booth, and the booth is dead. Five things
|
<p>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.</p>
|
|||||||
wire / torque a screw at the workbench</td></tr>
|
wire / torque a screw at the workbench</td></tr>
|
||||||
<tr><td>Q</td><td>drop a carried wire back on the loom</td></tr>
|
<tr><td>Q</td><td>drop a carried wire back on the loom</td></tr>
|
||||||
<tr><td>1–9 / SCROLL</td><td>pick a hotbar slot</td></tr>
|
<tr><td>1–9 / SCROLL</td><td>pick a hotbar slot</td></tr>
|
||||||
|
<tr><td>Z X C V</td><td>emote — wave, nod (on the beat), point, airhorn</td></tr>
|
||||||
|
<tr><td>HOLD TAB</td><td>who's in the booth</td></tr>
|
||||||
<tr><td>F</td><td>fly mode (cheat — great for exploring)</td></tr>
|
<tr><td>F</td><td>fly mode (cheat — great for exploring)</td></tr>
|
||||||
<tr><td>H</td><td>this menu</td></tr>
|
<tr><td>H</td><td>this menu</td></tr>
|
||||||
</table>
|
</table>
|
||||||
@ -76,14 +93,24 @@ switch on the mixer's back edge lights green. Climb up and press it.</td></tr>
|
|||||||
<h3>MULTIPLAYER</h3>
|
<h3>MULTIPLAYER</h3>
|
||||||
<p>The booth is shared: other tiny DJs may be in here with you. You'll see
|
<p>The booth is shared: other tiny DJs may be in here with you. You'll see
|
||||||
them riding the records; blocks they mine or place change for everyone, and
|
them riding the records; blocks they mine or place change for everyone, and
|
||||||
quest repairs count for the whole booth. Set your DJ name in SETTINGS.</p>
|
quest repairs count for the whole booth. Set your DJ name in SETTINGS and your
|
||||||
|
look in AVATAR — colour, headgear and face, live, no reload. Say hello with
|
||||||
|
<em>Z X C V</em> (the airhorn is rate-limited, sorry). Hold <em>TAB</em> to see
|
||||||
|
who's here.</p>
|
||||||
<h3>TIPS</h3>
|
<h3>TIPS</h3>
|
||||||
<p>The records spin — stand on one and ride. Walking to the middle while it
|
<p>The records spin — stand on one and ride. Walking to the middle while it
|
||||||
turns is a real fight (tiny legs, big physics): press the START/STOP plate to
|
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
|
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
|
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
|
to get launched. Mined blocks can be placed anywhere: build bridges between
|
||||||
the gear.</p>`;
|
the gear.</p>
|
||||||
|
<h3>BLOWING THE FUSE</h3>
|
||||||
|
<p>Once the mix is live the quest is spent — so there's a way to play it again.
|
||||||
|
Go back down to the fuse box and <em>keep mining the glass fuse</em>: it takes a
|
||||||
|
deliberate five-second hold, and it kills the mix <em>for everyone in the booth</em>.
|
||||||
|
The music powers down, the lights die, and the cartridge pops its wires — but
|
||||||
|
everything anyone built stays exactly where it is. There's a cooldown after each
|
||||||
|
win, so you can't grief a room with it.</p>`;
|
||||||
|
|
||||||
export class Menu {
|
export class Menu {
|
||||||
private readonly opts: MenuOpts;
|
private readonly opts: MenuOpts;
|
||||||
@ -125,7 +152,7 @@ export class Menu {
|
|||||||
isOpen(): boolean { return this.open; }
|
isOpen(): boolean { return this.open; }
|
||||||
getSettings(): Settings { return { ...this.settings }; }
|
getSettings(): Settings { return { ...this.settings }; }
|
||||||
|
|
||||||
toggle(force?: boolean, tab?: 'help' | 'settings'): void {
|
toggle(force?: boolean, tab?: Tab): void {
|
||||||
const next = force ?? !this.open;
|
const next = force ?? !this.open;
|
||||||
if (next === this.open) { if (tab) this.showTab(tab); return; }
|
if (next === this.open) { if (tab) this.showTab(tab); return; }
|
||||||
this.open = next;
|
this.open = next;
|
||||||
@ -152,6 +179,7 @@ export class Menu {
|
|||||||
const raw = localStorage.getItem(LS_SETTINGS);
|
const raw = localStorage.getItem(LS_SETTINGS);
|
||||||
if (raw) s = { ...s, ...JSON.parse(raw) as Partial<Settings> };
|
if (raw) s = { ...s, ...JSON.parse(raw) as Partial<Settings> };
|
||||||
} catch { /* fall through to defaults */ }
|
} catch { /* fall through to defaults */ }
|
||||||
|
s.avatar = validLook(s.avatar); // stale/garbage persisted look -> defaults
|
||||||
if (!s.name) {
|
if (!s.name) {
|
||||||
s.name = randomName();
|
s.name = randomName();
|
||||||
localStorage.setItem(LS_SETTINGS, JSON.stringify(s));
|
localStorage.setItem(LS_SETTINGS, JSON.stringify(s));
|
||||||
@ -164,7 +192,7 @@ export class Menu {
|
|||||||
this.opts.onApply(this.settings);
|
this.opts.onApply(this.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
private showTab(tab: 'help' | 'settings'): void {
|
private showTab(tab: Tab): void {
|
||||||
this.panel.querySelectorAll<HTMLElement>('.tcm-tab').forEach((t) =>
|
this.panel.querySelectorAll<HTMLElement>('.tcm-tab').forEach((t) =>
|
||||||
t.classList.toggle('active', t.dataset.tab === tab));
|
t.classList.toggle('active', t.dataset.tab === tab));
|
||||||
this.panel.querySelectorAll<HTMLElement>('.tcm-page').forEach((p) =>
|
this.panel.querySelectorAll<HTMLElement>('.tcm-page').forEach((p) =>
|
||||||
@ -174,7 +202,7 @@ export class Menu {
|
|||||||
private build(): void {
|
private build(): void {
|
||||||
const tabs = document.createElement('div');
|
const tabs = document.createElement('div');
|
||||||
tabs.className = 'tcm-tabs';
|
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');
|
const t = document.createElement('button');
|
||||||
t.className = 'tcm-tab' + (key === 'help' ? ' active' : '');
|
t.className = 'tcm-tab' + (key === 'help' ? ' active' : '');
|
||||||
t.dataset.tab = key;
|
t.dataset.tab = key;
|
||||||
@ -195,6 +223,12 @@ export class Menu {
|
|||||||
help.innerHTML = HELP_HTML; // static template string, no user input
|
help.innerHTML = HELP_HTML; // static template string, no user input
|
||||||
this.panel.appendChild(help);
|
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');
|
const st = document.createElement('div');
|
||||||
st.className = 'tcm-page hidden';
|
st.className = 'tcm-page hidden';
|
||||||
st.dataset.tab = 'settings';
|
st.dataset.tab = 'settings';
|
||||||
@ -216,6 +250,123 @@ export class Menu {
|
|||||||
(v) => { this.settings.name = v.slice(0, 24) || s.name; this.save(); }));
|
(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 {
|
private text(label: string, value: string, onChange: (v: string) => void): HTMLElement {
|
||||||
const row = this.row(label);
|
const row = this.row(label);
|
||||||
const input = document.createElement('input');
|
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;
|
.tcm-row select{background:#0a0908;color:#d8cdb4;border:1px solid #3a352c;border-radius:4px;
|
||||||
font:12px ui-monospace,monospace;padding:4px 6px}
|
font:12px ui-monospace,monospace;padding:4px 6px}
|
||||||
.tcm-row input[type=text]{background:#0a0908;color:#d8cdb4;border:1px solid #3a352c;
|
.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);
|
document.head.appendChild(css);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/ui/Roster.ts
Normal file
104
src/ui/Roster.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// Who's-here list (SOCIAL_RESET brief, Feature 4). Hold Tab: a translucent list
|
||||||
|
// of the DJs in the booth (you first), each with their avatar hue as a swatch.
|
||||||
|
// Pure view — the data comes from NetClient's roster; no network of its own.
|
||||||
|
|
||||||
|
import { bodyCss, type AvatarLook } from '../net/avatarLook';
|
||||||
|
|
||||||
|
export interface RosterEntry {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
look: AvatarLook;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Roster {
|
||||||
|
private readonly el: HTMLDivElement;
|
||||||
|
private peers: RosterEntry[] = [];
|
||||||
|
private self: { name: string; look: AvatarLook };
|
||||||
|
private shown = false;
|
||||||
|
|
||||||
|
constructor(self: { name: string; look: AvatarLook }) {
|
||||||
|
this.self = self;
|
||||||
|
this.injectStyle();
|
||||||
|
this.el = document.createElement('div');
|
||||||
|
this.el.className = 'tcr-panel hidden';
|
||||||
|
document.body.appendChild(this.el);
|
||||||
|
|
||||||
|
window.addEventListener('keydown', this.onDown);
|
||||||
|
window.addEventListener('keyup', this.onUp);
|
||||||
|
// Tab can steal focus / fire blur — hide defensively.
|
||||||
|
window.addEventListener('blur', () => this.show(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local player's name/look (the mirror can change live). */
|
||||||
|
setSelf(self: { name: string; look: AvatarLook }): void {
|
||||||
|
this.self = self;
|
||||||
|
if (this.shown) this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPeers(peers: RosterEntry[]): void {
|
||||||
|
this.peers = peers;
|
||||||
|
if (this.shown) this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onDown = (e: KeyboardEvent): void => {
|
||||||
|
if (e.code !== 'Tab') return;
|
||||||
|
e.preventDefault(); // don't tab-focus the page furniture
|
||||||
|
if (!e.repeat) this.show(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onUp = (e: KeyboardEvent): void => {
|
||||||
|
if (e.code !== 'Tab') return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.show(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
private show(v: boolean): void {
|
||||||
|
if (v === this.shown) return;
|
||||||
|
this.shown = v;
|
||||||
|
if (v) this.render();
|
||||||
|
this.el.classList.toggle('hidden', !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(): void {
|
||||||
|
const rows: string[] = [];
|
||||||
|
const row = (name: string, css: string, you: boolean) =>
|
||||||
|
`<div class="tcr-row"><i style="background:${css}"></i>` +
|
||||||
|
`<span>${escapeHtml(name)}</span>${you ? '<b>you</b>' : ''}</div>`;
|
||||||
|
|
||||||
|
rows.push(row(this.self.name || 'you', bodyCss(this.self.look, 0), true));
|
||||||
|
for (const p of this.peers) rows.push(row(p.name, bodyCss(p.look, p.id), false));
|
||||||
|
|
||||||
|
const n = this.peers.length + 1;
|
||||||
|
this.el.innerHTML =
|
||||||
|
`<div class="tcr-head">IN THE BOOTH — ${n} DJ${n === 1 ? '' : 's'}</div>${rows.join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
window.removeEventListener('keydown', this.onDown);
|
||||||
|
window.removeEventListener('keyup', this.onUp);
|
||||||
|
this.el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private injectStyle(): void {
|
||||||
|
const css = document.createElement('style');
|
||||||
|
css.textContent = `
|
||||||
|
.tcr-panel{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:45;
|
||||||
|
min-width:240px;max-height:70vh;overflow-y:auto;background:rgba(12,10,8,0.82);
|
||||||
|
border:1px solid #4a4234;border-radius:10px;padding:12px 14px;
|
||||||
|
font:12px/1.6 ui-monospace,monospace;color:#d8cdb4;pointer-events:none;
|
||||||
|
backdrop-filter:blur(2px)}
|
||||||
|
.tcr-panel.hidden{display:none}
|
||||||
|
.tcr-head{color:#ffb028;font-size:11px;letter-spacing:.15em;margin-bottom:8px}
|
||||||
|
.tcr-row{display:flex;align-items:center;gap:9px;padding:3px 0}
|
||||||
|
.tcr-row i{width:12px;height:12px;border-radius:3px;flex:none;
|
||||||
|
border:1px solid rgba(255,255,255,0.18)}
|
||||||
|
.tcr-row b{margin-left:auto;color:#7ec46a;font-weight:400;font-size:10px;letter-spacing:.1em}`;
|
||||||
|
document.head.appendChild(css);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names are server-sanitized, but this is user-controlled text in innerHTML. */
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/[&<>"']/g, (c) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user