Headshell Workshop + Glow-Up phase: assembly minigame, art pass, review fixes
- Workshop (WORKSHOP_CARTRIDGE): five-stage cartridge assembly at Deck A — seat/square, crimp four tag-wires, torque screws, ride-the-arm counterweight balance, needle-drop diagnostic with per-fault audio + scope. Relay-synced (per-field co-op merge: held screw + carried wire survive remote state). - Glow-Up (G1-G5): 32px atlas with per-voxel variants, selective LED bloom (quality-gated), screen-print decal system + party flyers, mixer/PCB worldgen density pass, record groove-sheen side texture. - 10 confirmed multi-agent review fixes, incl. co-op screw-stomp soft-lock, ride-snap collider-identity (magnet feet / eaten record-fling), workshop SFX exact-match map (rca_seated hijack), beam ride colliders to the head, double-crimp guard, WIRING INCOMPLETE diagnosis mode, skate-abort timer, completedState wiring, per-tick material churn, trackingHeavy platter drag. - Crossfader playtest fix: slew-limited sled (3.4 v/s) + ribbed grip caps with amber index — the sled reads as a heavy handle, not a teleporting wall. - Demo harnesses: machinesDemo hold-key wiring, playerDemo seesaw phase continuity, audioDemo incomplete fault button. - Workshop sync: exact 1.0 screw endpoint gets its own emit signature. Verified: typecheck + vite build clean, live solo quest smoke, two-client co-op relay smoke (simultaneous torque, no rewind, exact convergence). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b65103ea0e
commit
9c668d340c
@ -6,6 +6,30 @@
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5173", "--strictPort"],
|
||||
"port": 5173
|
||||
},
|
||||
{
|
||||
"name": "turncraft-laneB",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5184", "--strictPort"],
|
||||
"port": 5184
|
||||
},
|
||||
{
|
||||
"name": "turncraft-laneC-verify",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5212", "--strictPort"],
|
||||
"port": 5212
|
||||
},
|
||||
{
|
||||
"name": "turncraft-laneD-verify",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5194", "--strictPort"],
|
||||
"port": 5194
|
||||
},
|
||||
{
|
||||
"name": "turncraft-laneE-verify",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5205", "--strictPort"],
|
||||
"port": 5205
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
106
docs/GLOWUP_A_handoff.md
Normal file
106
docs/GLOWUP_A_handoff.md
Normal file
@ -0,0 +1,106 @@
|
||||
# Glow-Up (Lane A / engine) — Handoff
|
||||
|
||||
**Brief:** [docs/briefs/GLOWUP_ART.md](briefs/GLOWUP_ART.md)
|
||||
**Scope done here:** **G1 (Atlas v2)** and **G2 (selective LED bloom)** — the two
|
||||
engine-owned tasks. **G3–G5 are NOT started** (see "Remaining" below).
|
||||
**Status:** ✅ G1 + G2 complete, browser-verified in the real game, typecheck clean,
|
||||
zero console errors.
|
||||
|
||||
---
|
||||
|
||||
## What shipped
|
||||
|
||||
### G1 — Atlas v2 ([src/engine/atlas.ts](../src/engine/atlas.ts))
|
||||
|
||||
- Tiles **16 → 32 px**. `NearestFilter` chunkiness preserved (fract keeps every
|
||||
sample inside its cell — no bleed, no mip seams).
|
||||
- **Per-voxel variant system, zero mesher changes.** 4 painted variants per block
|
||||
laid out in extra atlas columns (`GRID_W = COLS*NVAR = 32`). The material shader
|
||||
patch hashes each voxel's baked world position (`floor(position - 0.02*normal)`)
|
||||
→ picks a variant → expands the base cell column. A repeated surface (PCB floor,
|
||||
plywood wall, brushed metal) breaks up instead of tiling like wallpaper. The
|
||||
mesher still writes only the base cell `aTile = (id%8, id/8)` — its verified
|
||||
winding/AO path is untouched. (This is the brief's "shader-patch variant"
|
||||
fallback, applied to **all** patterns, not just pcb.)
|
||||
- Pattern rewrites: `brushed` = long directional strokes + scratches (not noise);
|
||||
`plywood` = long wavy grain, concentric end-grain rings for `ply_edge`;
|
||||
`pcb` = 4 variants that tile into routed traces + via dots + silkscreen flecks;
|
||||
`grooves` = concentric arcs + specular sheen line; `led` = smooth radial glow.
|
||||
- Material polish via roughness only: `0.82 → 0.78` (no env maps, per brief).
|
||||
|
||||
### G2 — Selective LED bloom ([src/engine/renderer.ts](../src/engine/renderer.ts))
|
||||
|
||||
- `EffectComposer` + `RenderPass` + `UnrealBloomPass` + `OutputPass`.
|
||||
Tuned `{ strength 0.9, radius 0.5, threshold 0.85 }` so **only emissive
|
||||
LEDs/strobe/lamps cross the threshold** — lit plywood/metal stays matte.
|
||||
- **Lazy:** the composer is created only on first `setBloom(true)`, so `fast`
|
||||
quality never allocates it. `render()` picks composer-vs-direct internally;
|
||||
callers keep calling `render()` unchanged.
|
||||
- New API on the renderer: `setBloom(enabled: boolean)`. Composer size/pixelRatio
|
||||
re-sync on resize and on quality (pixelRatio) changes.
|
||||
|
||||
### Wiring (merge points — kept additive)
|
||||
|
||||
- **[src/main.ts](../src/main.ts)** — destructure `setBloom`; in the Menu
|
||||
`onApply`: `setBloom(s.bloom && s.quality !== 'fast')`; added `setBloom` +
|
||||
`setEmissiveBoost` to the `window.TURNCRAFT` dev handle (used for cine screenshots
|
||||
and fps benchmarking). *This file is shared with the Workshop brief, which added
|
||||
its own `interaction.*` / `jumpScale` lines — the edits are disjoint.*
|
||||
- **[src/ui/Menu.ts](../src/ui/Menu.ts)** — `bloom: boolean` in `Settings`
|
||||
(default `true`), one checkbox row ("LED bloom (glow — off automatically on Fast)").
|
||||
|
||||
---
|
||||
|
||||
## Files touched
|
||||
|
||||
| file | task | note |
|
||||
|---|---|---|
|
||||
| `src/engine/atlas.ts` | G1 | rewritten — 32px, variants, patterns |
|
||||
| `src/engine/renderer.ts` | G2 | +bloom composer, `setBloom()` |
|
||||
| `src/ui/Menu.ts` | G2 | +`bloom` setting + checkbox |
|
||||
| `src/main.ts` | merge | additive: `setBloom` wiring + dev handle |
|
||||
|
||||
**Merge points:** `src/main.ts` (shared with Workshop brief),
|
||||
`src/ui/Menu.ts` (one settings row). `src/worldgen/buildBooth.ts` is listed as a
|
||||
merge point in the brief but G1/G2 did **not** need to touch it (that's G4).
|
||||
|
||||
---
|
||||
|
||||
## Verification (ran it in the real game, port 5173)
|
||||
|
||||
- Boot: **739 ms build, 467 chunk meshes** (< 600 ✓), zero console errors.
|
||||
- **Bloom A/B at the mixer** (retina 2560×1440): LEDs (knob caps, VU towers,
|
||||
fader-alley pips) glow with a soft halo when ON, flat when OFF; plywood floor,
|
||||
matte-black body, and walls do **not** glow either way → **selective confirmed**.
|
||||
- **PCB Depths**: the `pcb` floor now reads as routed circuitry (traces + vias +
|
||||
silkscreen), not "green wallpaper"; variant hashing tiles it into a connected grid.
|
||||
- **Deck platter**: record shows concentric groove rings; brushed-metal plinth reads
|
||||
brushed; amber spindle lamp + orange strobe rim-pips bloom while the plinth stays matte.
|
||||
- **fps** (real, `readPixels`-forced GPU sync at 2560×1440, two runs):
|
||||
- Bloom **OFF**: ~234 / ~249 fps
|
||||
- Bloom **ON**: ~174 / ~220 fps (bloom overhead ≈ 1.5 ms/frame)
|
||||
- Both comfortably clear the **≥ 100 fps** budget. ✓
|
||||
- (rAF is throttled in the headless preview pane, so frame-count fps reads 0 there;
|
||||
the synchronous per-frame render timing above is the trustworthy measure.)
|
||||
- `npm run typecheck`: **clean** (exit 0), including the concurrent Workshop edits.
|
||||
|
||||
---
|
||||
|
||||
## Remaining Glow-Up work (NOT done — for whichever lane picks it up)
|
||||
|
||||
The brief assigns these to the same pass; this session scoped to the engine-owned
|
||||
G1/G2 only. None are started:
|
||||
|
||||
- **G3 — screen-print decal system** (`src/fx/decals.ts` + worldgen anchors):
|
||||
`CH 1–4`, tick marks, `TURNCRAFT-800`, deck labels, socket numbers, party flyers.
|
||||
`src/fx/decals.ts` does not exist yet.
|
||||
- **G4 — worldgen density pass** (`src/worldgen/buildBooth.ts`, additive only):
|
||||
mushroom knob caps, doubled knob rows, panel seam grooves, PCB IC pagodas,
|
||||
ribbon-cable walls, alley/vent amber pips. Must stay deterministic, build < 800 ms,
|
||||
anchors untouched.
|
||||
- **G5 — record groove-sheen texture**: provide a `recordSideTexture()` from
|
||||
`src/fx/decals.ts` for the workshop session to wire into `platter.ts`
|
||||
(do **not** edit `platter.ts` — it is workshop-owned).
|
||||
|
||||
The G1 atlas already carries the emissive plumbing G4's LED pips need, and `setBloom`
|
||||
is live, so G4's new `led_*` blocks will bloom for free.
|
||||
124
docs/GLOWUP_E_handoff.md
Normal file
124
docs/GLOWUP_E_handoff.md
Normal file
@ -0,0 +1,124 @@
|
||||
# Glow-Up (Lane E / fx) — Handoff
|
||||
|
||||
**Brief:** [docs/briefs/GLOWUP_ART.md](briefs/GLOWUP_ART.md)
|
||||
**Scope done here:** **G3 (screen-print decals)** and **G5 (record side texture)** —
|
||||
the two `src/fx/**`-owned tasks. G1/G2 shipped in
|
||||
[GLOWUP_A_handoff.md](GLOWUP_A_handoff.md); G4 in
|
||||
[src/worldgen/HANDOFF.md](../src/worldgen/HANDOFF.md).
|
||||
**Status:** ✅ G3 complete, browser-verified in the real game (port 5212),
|
||||
typecheck clean, zero console errors. **G5 texture generator complete; its
|
||||
platter wiring is left for Lane D** (this lane does not edit `platter.ts`).
|
||||
|
||||
---
|
||||
|
||||
## What shipped
|
||||
|
||||
### G3 — screen-print decal system ([src/fx/decals.ts](../src/fx/decals.ts), new)
|
||||
|
||||
A `DecalSet` that bakes text/markings onto `CanvasTexture`s and applies them to
|
||||
thin plane meshes floating `0.05` proud of the gear faces, with `polygonOffset`
|
||||
(`factor/units = -4`) so there is **no z-fighting** even at grazing angles.
|
||||
One canvas per gear piece → **13 plane meshes total** (< the brief's 25
|
||||
draw-call budget; measured live via `window.TURNCRAFT.decals`).
|
||||
|
||||
All positions derive from `LAYOUT` (`src/core/constants.ts`) and the worldgen
|
||||
anchors (`MIXER`, `DECK_A`, `DECK_B`, `PATCH`, `UNDER` from
|
||||
`src/worldgen/anchors.ts`) — **nothing hardcoded**, so a booth-layout change
|
||||
moves the print with it.
|
||||
|
||||
Print set (all browser-confirmed legible at play distance):
|
||||
- **Mixer face:** `CH 1–4` ahead of each fader alley, a tick ladder down each
|
||||
alley, the `TURNCRAFT-800` wordmark, and crossfader `A ↔ B` (A on the deck-A
|
||||
side).
|
||||
- **Each deck:** `START·STOP`, `33`/`45`, a pitch `+/−` scale ladder, and a
|
||||
"Quartz Direct Drive" script along the plinth front. Placed on the plinth
|
||||
ring/corners, clear of the platter radius (no clipping with the spinning
|
||||
record — verified at 33).
|
||||
- **Patch bay:** `PATCH BAY` + `01–12` socket numbers aligned to the grid (on
|
||||
the alu panel face, `backWallZ − 2`, not the raw wall behind it).
|
||||
- **Fuse box:** `FUSE 250V` legend.
|
||||
- **Four procedural party flyers** (word-bank names, one accent colour + border
|
||||
each): two flanking the patch bay on the bare back wall, two on the side
|
||||
walls near the front lip.
|
||||
|
||||
Orientation detail: on flat (`up`) faces the plane's canvas-up points to the
|
||||
booth front, so each glyph is flipped 180° **in place** (letterforms only —
|
||||
positions and tick marks stay put) to read from the player's front-edge view.
|
||||
|
||||
### G5 — record side texture (`recordSideTexture()` in the same file)
|
||||
|
||||
A fine light/dark groove-ring `CanvasTexture` for the vinyl tier **side**
|
||||
cylinders (rings horizontal, `RepeatWrapping`, `NearestFilter`). Standalone
|
||||
generator, no side effects. **Left for Lane D to wire into `platter.ts`** —
|
||||
exact snippet in [src/fx/HANDOFF.md](../src/fx/HANDOFF.md) (Glow-Up section).
|
||||
|
||||
## Files touched
|
||||
|
||||
| file | task | note |
|
||||
|---|---|---|
|
||||
| `src/fx/decals.ts` | G3+G5 | **new** — `DecalSet`, `recordSideTexture()` |
|
||||
| `src/main.ts` | merge | additive: build `DecalSet`, add `.group` to scene, dev handle |
|
||||
| `src/fx/HANDOFF.md` | docs | API + the `recordSideTexture()` wiring snippet for Lane D |
|
||||
|
||||
**Merge points (per the brief):**
|
||||
- `src/main.ts` — shared with the Workshop + Glow-Up-A briefs. My edits are 3
|
||||
disjoint additive lines (import, `new DecalSet()` after the machines block,
|
||||
and `decals` on the `window.TURNCRAFT` handle).
|
||||
- `src/worldgen/buildBooth.ts` — listed as a merge point, but **not needed**:
|
||||
G3 reads existing anchors rather than adding worldgen dressing, so buildBooth
|
||||
is untouched.
|
||||
|
||||
## Verification (real game, port 5212)
|
||||
|
||||
- `npm run typecheck` — **clean** (exit 0), whole tree.
|
||||
- Cine screenshots (`window.TURNCRAFT_CINE`): mixer face, deck top, patch bay,
|
||||
side-wall flyer, and a grazing pass across the mixer — every label legible and
|
||||
correctly oriented, **no z-fighting**, and **zero console errors**.
|
||||
- Draw calls: `window.TURNCRAFT.decals` → **13** meshes (< 25). Also **logged at
|
||||
runtime** in the `DecalSet` constructor: `[turncraft] screen-print decals: 13
|
||||
draw calls (budget < 25)`.
|
||||
- Confirmed the decals are additive: toggling `decals.group.visible` removes
|
||||
only the labels/flyers; the floating `+` shapes near the patch bay are the
|
||||
pre-existing RCA cable plugs (Lane C), not decals.
|
||||
- **No quest regression:** decals live in `decals.group` (a `MeshBasicMaterial`
|
||||
plane group) and add **zero** physics colliders (`machines.getColliders()`
|
||||
stays 58); interaction is voxel-DDA raycast, not scene-mesh raycast, so the
|
||||
decal planes cannot intercept use/mine rays. Booth build unchanged: **746 ms,
|
||||
467 chunk meshes** (< 600), deterministic (anchors untouched).
|
||||
- **fps:** synchronous per-frame render timing (rAF is throttled in the preview
|
||||
pane) at the mixer, retina buffer: bloom-off ≈ 440 fps, bloom-on ≈ same order
|
||||
— decals (13 static planes) add no measurable regression; comfortably > 100.
|
||||
- Sits cleanly alongside G1 (atlas variants), G2 (LED bloom), G4 (dense knobs).
|
||||
|
||||
### Before/after screenshots (`docs/progress-cutoff/`)
|
||||
|
||||
Captured via `window.TURNCRAFT_CINE` in the real game (port 5205):
|
||||
`mixer_before.jpg` / `mixer_after.jpg` (flagship before/after), `deck_top_after.jpg`
|
||||
(Deck A — `START·STOP` + plinth script, clear of the platter), `pcb_fuse_after.jpg`
|
||||
(`FUSE 250V` legend over the PCB-trace floor), `fullbooth_win.jpg` (emissive-lifted
|
||||
"living booth" — decks, mixer labels + glowing alley pips, patch wall + flyers).
|
||||
|
||||
## G5 texture fix (post-merge regression)
|
||||
|
||||
Lane D found the merged `recordSideTexture()` had a **dark base** (`#101013`),
|
||||
which as a multiply `map` blacked out the vinyl (Deck A side measured
|
||||
`~[38,66,152] → ~[10,8,8]`). Contradicted this doc's "near-white base" intent —
|
||||
the other writer's dark version had won the merge. **Fixed:** inverted to a
|
||||
near-white base (`0.97`) with faint dark groove lands (`0.60`), a soft shoulder
|
||||
(`0.80`) and a bright sheen lip (`1.0`), 16 rings/tile — neutral greys so the
|
||||
multiply never shifts hue. Re-verified live via `readPixels` on the actual Deck A
|
||||
record side (map applied to `sideMat` at runtime, then reverted): blue channel
|
||||
`41 → 36` (~12% dip, stays clearly blue) vs an all-black test map's `41 → 17`
|
||||
blackout. Signature preserved; rings read as subtle sheen.
|
||||
|
||||
## Not done / left for others
|
||||
|
||||
- **G5 platter wiring** — Lane D applies `recordSideTexture()` to the tier-side
|
||||
material in `platter.ts` (snippet in `src/fx/HANDOFF.md`); now safe to wire.
|
||||
|
||||
## Note — concurrent edits
|
||||
|
||||
`src/fx/decals.ts` was refined by another writer mid-session (patch-panel face
|
||||
offset `backWallZ − 2`; flyer wall anchors via `rimThickness`/`WORLD_X`). Those
|
||||
changes are correct and were kept; the browser verification above reflects the
|
||||
merged file.
|
||||
@ -105,6 +105,108 @@ lanes landed. Every cross-lane glue decision is listed here.
|
||||
climb); tower fully climbable (83→87 by walking); seam escape 1-step;
|
||||
121 fps with ceiling + lights; quest/win chain re-verified end-to-end.
|
||||
|
||||
## Headshell Workshop (Lane D) — wiring & contract additions
|
||||
|
||||
The `stylus` node is now a five-stage cartridge assembly at the Deck A pedestal
|
||||
(`src/machines/workshop/**`). One machine, `machines.workshop` (a `Workshop`),
|
||||
owns the visible/collidable assembly arm; the real `Tonearm` sits in **service
|
||||
mode** until the node is repaired, then takes back over (cue/track/return).
|
||||
|
||||
**Events added** (`src/core/events.ts`, additive — sanctioned by the brief):
|
||||
- `workshop:test { errors: string[]; clean: boolean }` — needle-drop verdict;
|
||||
the HUD names the first error. `errors` are `TestErrors` keys in `ERROR_ORDER`.
|
||||
- `workshop:torque { value: number | null }` — radial HUD meter 0..1 while a
|
||||
screw is being torqued; `null` hides it.
|
||||
- `workshop:msg { text: string }` — transient HUD subtitle (stage prompts).
|
||||
|
||||
The audio + HUD lanes consume these; Lane D only emits them (never renders the
|
||||
meter/subtitle itself).
|
||||
|
||||
**Multiplayer** — one new relay message both ways: `{ t: 'workshop', s: WorkshopState }`,
|
||||
last-write-wins.
|
||||
- `WorkshopState` (in `workshop/types.ts`) is JSON-primitive and range-bounded:
|
||||
`{ cartSeated, cartRotation:0..3, wires:(0..3|null)[4], screws:[0..1,0..1],
|
||||
weightNotch:0..10, tested }`.
|
||||
- `NetClient` sends on `workshop.onChange` (guarded by `applyingRemote` so remote
|
||||
applies don't echo); remote messages call `workshop.setState(s)` (diff+animate).
|
||||
- `server/relay.mjs` `validWorkshop()` validates shape/enums/ranges (clamps
|
||||
screws + notch), stores it beside `platters`, persists it, and hands it to late
|
||||
joiners in `hello.state.workshop`. `types.ts:coerceState` mirrors this on the
|
||||
client. Fuzzed/malformed shapes are dropped on both sides.
|
||||
- If `repaired.stylus` is already true on join, the workshop spawns in its
|
||||
**completed pose** (`completedState()` — cart seated square, wires on, screws
|
||||
tight, notch 8) and the real tonearm is live.
|
||||
|
||||
**Surgical edits to other lanes' files:**
|
||||
- `tonearm.ts` — additive `setService(on)` (parks the arm low + inerts its
|
||||
colliders while the workshop owns the assembly arm) and `setStylus(on)`.
|
||||
- `PlayerController.ts` (Lane B) — one additive knob, `jumpScale` (default 1).
|
||||
`main.ts` sets it to `0.5` while `workshop.isCarrying()` is non-null so a
|
||||
carried tag-wire tugs the jump to half height. Purely additive; no reorder.
|
||||
- `interact/interaction.ts` — `useDown/useUp/dropCarry` route E (press vs
|
||||
hold=crimp/torque) and Q (drop wire) to the workshop; `advanceWorkshopHold`
|
||||
cancels a hold if aim leaves the target.
|
||||
- `ui/Menu.ts` — STYLUS help text + the E-hold / Q controls updated.
|
||||
- `worldgen/buildBooth.ts` dressing was intentionally **not** touched: the
|
||||
workshop renders all its own meshes (pedestal, loom, pins, scope, level), so
|
||||
no worldgen edit is needed and the glow-up lane keeps that file conflict-free.
|
||||
|
||||
**Deferred / stretch:** anti-skate dial (brief out-of-scope), Deck B arm stays
|
||||
pre-assembled.
|
||||
|
||||
## Physics — riding tilting / vertically-moving kinematic platforms (Lane B)
|
||||
|
||||
For the Headshell Workshop's Stage-4 seesaw (the tonearm you stand on while
|
||||
balancing). Lane B (`PlayerController.ts`) now rides a platform that **moves
|
||||
vertically under the player**, not just horizontally. What the workshop lane
|
||||
needs to know:
|
||||
|
||||
- **Model the arm as kinematic colliders updated each fixed tick, before
|
||||
`player.update`.** `tonearm.ts` already does this (an aabb chain tiling the
|
||||
pivot→head line + a head aabb). For the seesaw, recompute each segment's
|
||||
`min.y/max.y` from the tilt each tick and expose a `velocityAt` that returns
|
||||
the segment's surface velocity. The loop order `machines.update(dt)` →
|
||||
`player.update(dt)` is what makes the ride correct — don't reorder.
|
||||
- **Lane B guarantees the ride; do NOT reposition the player yourself.** Just
|
||||
move the colliders. The controller:
|
||||
- grounds on the single **highest supporting top** under the footprint;
|
||||
- a **rising** arm grounds by penetration (it rises into you) and snaps you up;
|
||||
- a **tilting/descending** arm keeps you glued via **ride-snap**: if you were
|
||||
riding a platform and it drops out from under you, you snap back onto its top
|
||||
across a per-tick vertical gap up to `RIDE_SNAP` (0.75 voxel). Gated to a
|
||||
downward/level velocity, so it never fights a jump;
|
||||
- horizontal surface motion is carried via `velocityAt` (x/z);
|
||||
- min-penetration keeps you centered — **no lateral slingshot**.
|
||||
- **Safe envelope (measured in `demo-player.html`):** perfectly glued
|
||||
(grounded 100 %, feet on the slab) for tilt rates up to ~2 rad/s / ~24 voxel/s
|
||||
surface speed — far beyond a counterweight-driven balance (~0.25 rad/s). Only
|
||||
absurd >3 rad/s "mousetrap" tilts separate, and then **gracefully** (you slide
|
||||
off, no explosion). Verified at platter-33 and stopped (independent colliders).
|
||||
- **Verified against the REAL arm** (not just the demo mock): a headless harness
|
||||
drove the actual `Workshop.ts` `ws_ride*` geometry — the same `beamY`/`theta`
|
||||
tilt math, `RIDE_SEGMENTS=6`, 0.6-voxel-tall slabs — through `PlayerController`.
|
||||
Standing on the beam tail (where you shove the sled) while the weight sweeps the
|
||||
full notch range **0↔10**: the realistic balance ritual rides **99.6 % grounded,
|
||||
`maxYerr` 0.023, zero lateral drift (`maxZoff` 0.000)**, and every transient
|
||||
ungrounded tick is re-caught in **exactly 1 tick**. Because the workshop's
|
||||
`theta` eases at `dt*5` toward a one-notch (0.025 rad) target, the per-tick
|
||||
vertical travel at the player's feet is ≤ ~0.03 voxel — ~25× inside the
|
||||
`RIDE_SNAP` budget. Even a physically-impossible instant full-swing-every-tick
|
||||
slam only floats the player upward briefly (still `maxZoff` 0, never falls).
|
||||
- **Gotchas:**
|
||||
- The arm is a *staircase of flat slabs*; you stand at each segment's midpoint
|
||||
height. Finer segments = smoother ride. Keep each segment ≤ ~2 voxels tall so
|
||||
min-penetration prefers the top (a very tall arm segment could resolve as a
|
||||
wall push instead of a floor).
|
||||
- `velocityAt`'s vertical magnitude does **not** affect grounding (ride-snap
|
||||
handles vertical); a nonzero horizontal component keeps the player tracking a
|
||||
sweeping arm. Returning `[0,0,0]` is fine for a purely vertical tilt.
|
||||
- Jumping off the arm works and inherits the arm's horizontal carry (the
|
||||
record-edge launch path); vertical is not inherited (no surprise pop).
|
||||
- **Eyeball it:** `demo-player.html` has a **Seesaw** toggle (oscillates a
|
||||
segmented tilting arm) + **Teleport to arm**; stand on it and watch `groundedOn`
|
||||
stay `seesawN` as it tilts.
|
||||
|
||||
## Not yet done (deploy phase)
|
||||
|
||||
Run `/ship-check` before exposing anything publicly; deploy per deploy-map
|
||||
|
||||
@ -3,6 +3,49 @@
|
||||
*For Fable's review. Lane C = the level-design lane: author the entire DJ-booth
|
||||
diorama in voxels via `buildBooth(world)`.*
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Glow-Up G4 (worldgen density pass) · 2026-07-14
|
||||
|
||||
You handed me two briefs; the slice that's **my lane** (`src/worldgen/**`) is **G4** of
|
||||
`GLOWUP_ART.md` — the worldgen density pass. (G1 atlas, G2 bloom, G3 decals, G5 record
|
||||
texture are engine/fx work; the Headshell Workshop is Lane D + audio/net — both for their
|
||||
own sessions. I did not touch their files.) Done and verified:
|
||||
|
||||
**Fixes findings #1, #2, #5:**
|
||||
- **Mixer face (was an empty black desert)** → four dense channel strips of **mushroom
|
||||
knobs** (1×1 stem + 2×2 cap, mixed heights, club-mixer palette), **rubber panel-seam
|
||||
grooves** sectioning the channels, and **amber/green LED depth pips** at every fader-alley
|
||||
floor. Speaker vents now glow.
|
||||
- **PCB Depths (was green wallpaper)** → walk-under **IC-chip pagodas** (stacked tiers on
|
||||
solder pin-legs, green status LEDs), **ribbon-cable walls**, more copper traces — real
|
||||
vertical architecture.
|
||||
- **Void-black slots** → amber depth pips in fader alleys and deck vent shafts.
|
||||
|
||||
**Verified (headless + live browser, before/after screenshots taken):**
|
||||
```
|
||||
✓ deterministic — two full builds byte-identical; every pre-existing zone unchanged
|
||||
✓ additive only — steel_grey/pcb_green/glass/vinyl/alu counts identical to before
|
||||
✓ anchors untouched — no LAYOUT/QUEST_POS moved; all 8 quest anchors still correct
|
||||
✓ build 21–30 ms (« 800 ms budget); chunk count unchanged
|
||||
✓ my files typecheck clean; demo validation all green; no console errors
|
||||
```
|
||||
Files touched: **only** `src/worldgen/buildBooth.ts` + `src/demo/worldgenDemo.ts`.
|
||||
Merge point with the Workshop session: `buildBooth.ts` — but my edits (`buildMixer`,
|
||||
`buildUnderTable`, vent loop) are disjoint from their Deck-A-pedestal dressing, so it
|
||||
merges cleanly. Full detail in `src/worldgen/HANDOFF.md`.
|
||||
|
||||
**Two notes for you:**
|
||||
1. The **rest of the Glow-Up brief** (atlas v2, selective bloom, decal/screen-print system,
|
||||
record groove-sheen) is engine + fx work — not this lane. It still needs its session.
|
||||
2. Shared-tree hiccup: global `tsc` briefly broke on the Workshop session's in-progress
|
||||
`src/audio/testSignal.ts` (a `Float32Array` generic mismatch); cleared on their next
|
||||
save. Flagging in case it resurfaces — it isn't mine.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — the diorama (original build)
|
||||
|
||||
**Status: ✅ complete and verified.** Typecheck clean, demo renders with no console
|
||||
errors, build deterministic and fully validated. Ready for Lane D to mount machines.
|
||||
|
||||
|
||||
@ -114,3 +114,55 @@ hotbar events by contract); Lane E's HUD subscribes to that.
|
||||
word and I'll `git init`, commit Lane D, and push, but I didn't want to make an
|
||||
outward-facing push without a go-ahead.
|
||||
- Demo dev server was on `localhost:5180` during verification (now can be stopped).
|
||||
|
||||
---
|
||||
|
||||
## Headshell Workshop (later brief — `docs/briefs/WORKSHOP_CARTRIDGE.md`)
|
||||
|
||||
**Status: complete.** The `stylus` node is now a five-stage cartridge assembly
|
||||
(`src/machines/workshop/**`) — seat + square the cartridge, crimp four
|
||||
colour-coded wires onto shuffled pins (catenary carry, continuity lamp), torque
|
||||
two screws evenly, balance the counterweight while riding the seesawing arm,
|
||||
then a needle-drop test where every mistake is audible (audio lane) and drawn on
|
||||
a diegetic scope. Multiplayer-shared, last-write-wins. `npm run typecheck`
|
||||
clean; boot + full flow driven with zero console errors.
|
||||
|
||||
**Coordinated split:** the audio + HUD side (`testSignal.ts`,
|
||||
`AudioEngine.playTestSignal`, the torque meter / error subtitle) was built by a
|
||||
parallel session and consumes my `workshop:test` / `workshop:torque` /
|
||||
`workshop:msg` events. I own the machine/state/interaction/net/relay/main side.
|
||||
|
||||
**Wiring:** `workshop:*` events (`core/events.ts`), the `{t:'workshop',s}` relay
|
||||
message (`server/relay.mjs` `validWorkshop` + `net/NetClient.ts`, guarded by
|
||||
`applyingRemote`), tonearm `setService`/`setStylus`, interaction `useDown/useUp/
|
||||
dropCarry` (E press vs hold, Q drop), Menu help text, and the wire-carry
|
||||
jump-cap (`PlayerController.jumpScale`, driven from `main.ts`). Full contract
|
||||
list in `docs/INTEGRATION_NOTES.md` → "Headshell Workshop".
|
||||
|
||||
**Verified at runtime** (headless drive via `window.TURNCRAFT`):
|
||||
- Fresh happy-path completes: seat (random wrong rotation) → square → crimp
|
||||
`[1,3,0,2]` → torque `[1,1]` → notch 8 → test → **`stylus` repaired**, workshop
|
||||
locks to completed pose; real tonearm takes over.
|
||||
- Error classifier maps all six modes exactly (verified in the prior pass).
|
||||
- Jump-cap: carrying wire index `0` → `jumpScale 0.5` (take-off 9.0→4.5); the
|
||||
`!== null` guard correctly treats the falsy index 0 as "carrying".
|
||||
- Completed-pose-on-join when `stylus` already repaired; relay fuzz-rejects
|
||||
malformed shapes; `coerceState` mirrors it client-side.
|
||||
|
||||
**Adversarial review — 2 fixes applied this pass:**
|
||||
1. *(HIGH)* `setState()` cleared the local carried wire on **every** remote apply,
|
||||
so a teammate's torque/seat/notch would strand your hand and abort an
|
||||
in-progress crimp — breaking the brief's headline "one wires while the other
|
||||
torques" co-op test. Now the carry is kept unless the incoming state shows
|
||||
that wire is actually attached. Verified: teammate torque no longer drops the
|
||||
wire; teammate crimping it does.
|
||||
2. *(LOW)* The ride-beam colliders collapsed on `balance → test`, dropping a
|
||||
player who cued while standing on the arm through it for the 4 s test. The
|
||||
beam now stays solid through the test.
|
||||
|
||||
**Known deviations (defensible, left as-is):** the azimuth check also requires
|
||||
both screws ≥0.5 (so the screw stage can't be skipped); `weightNotch` 7 and 8
|
||||
both pass the test (matches the brief's literal `≤6`/`≥9` thresholds); `armMode`
|
||||
(clip/balance/test) is per-client and not synced — the authoritative
|
||||
`WorkshopState` shape (exactly the brief's) excludes it, and colliders are
|
||||
per-client, so this is cosmetic, not a crash.
|
||||
|
||||
@ -11,6 +11,46 @@ all audio synthesized, all textures procedural.
|
||||
|
||||
---
|
||||
|
||||
## Round 2 — Headshell Workshop, Lane E slice (2026-07-14)
|
||||
|
||||
Fable asked me to take the **Lane E (audio/fx/ui) portion** of
|
||||
`WORKSHOP_CARTRIDGE.md` while other agents handle the machine/interaction core.
|
||||
Done, additive, staying strictly in my three directories + demo. Zero core
|
||||
edits (I did **not** touch `src/core/events.ts` — Lane D added the `workshop:*`
|
||||
events per its brief; I only subscribe to them).
|
||||
|
||||
**Coordination note:** the Lane D workshop session was building
|
||||
`src/machines/workshop/**` concurrently on the shared tree. I read their actual
|
||||
code and aligned to it rather than my brief-guesses — so integration is by
|
||||
**bus events, not direct calls**, my error vocabulary matches theirs (the 6th
|
||||
mode is `skate`, not `rotationWrong`), and I **removed my `src/fx/scope.ts`**
|
||||
because their machine already renders the scope block. No file-ownership overlap.
|
||||
|
||||
**What I added (all verified live in `demo-audio.html` → "Headshell Workshop"):**
|
||||
- **Diagnostic audio** — `src/audio/testSignal.ts` `playTestSignal(errors)`:
|
||||
a true-stereo kick+bass loop with one audible chain per fault (L/R ping-pong,
|
||||
phase invert, azimuth −12 dB + distortion, 80 ms gate stutter, 1.2 kHz
|
||||
muffle, skate-abort). `AudioEngine` reacts to `workshop:test`.
|
||||
- **9 workshop SFX** (`sfx.ts`): crimp, wireOn/Off, screwTick/Clunk, tiltCreak,
|
||||
weightDetent, bubbleLock, skate — driven by `workshop:torque` (ratchet) and
|
||||
`machine:interact` action keywords.
|
||||
- **HUD** — radial torque gauge on `workshop:torque` (hides on `value:null`),
|
||||
fault subtitle on `workshop:msg` (`Hud.showTorque`/`hideTorque` also public).
|
||||
- **FX** — screw confetti on workshop completion (`signal:repair` node `stylus`),
|
||||
also `fx.screwBurst(x,y,z)`.
|
||||
|
||||
**Two things for Fable/Lane D to close the loop:**
|
||||
1. `trackingHeavy` — Lane E does the audio lowpass; Lane D should also sag the
|
||||
platter target rpm ~15% (platter.ts) for the full "drag".
|
||||
2. Optional: Lane D can emit `machine:interact {action:'crimp'|'screwClunk'|…}`
|
||||
for the per-step SFX; the torque/test events already cover the main cues.
|
||||
|
||||
Whole tree typechecks clean **with Lane D's `Workshop.ts` present**; zero
|
||||
console errors exercising every error mode, the torque gauge, screw confetti,
|
||||
and all 9 SFX. Full API surface is in `src/{audio,fx,ui}/HANDOFF.md` (Round 2).
|
||||
|
||||
---
|
||||
|
||||
## What I built
|
||||
|
||||
### Audio (`src/audio/`) — "sound is the win state"
|
||||
|
||||
@ -1,5 +1,79 @@
|
||||
# Lane B — Player & Physics — Update for Review
|
||||
|
||||
> **Round 2.1 (2026-07-14, resumed session) — closed the real-geometry loop.**
|
||||
> Round 2 verified ride-snap against a faithful *mock* seesaw (the account ran
|
||||
> out of tokens before the real `Workshop.ts` arm existed). That arm now exists,
|
||||
> and it's wired into `machines.getColliders()` → the player, so I verified
|
||||
> ride-snap against the **actual** geometry: a headless harness drives the real
|
||||
> `ws_ride*` segments (`beamY`/`theta` math, `RIDE_SEGMENTS=6`, 0.6-tall slabs)
|
||||
> through `PlayerController`. Result — the realistic balance ritual (weight swept
|
||||
> 0↔10) rides **99.6 % grounded, `maxZoff` 0.000 (no slingshot), `maxYerr` 0.023**,
|
||||
> every ungrounded tick re-caught in 1 tick; even an impossible instant-slam only
|
||||
> floats you upward, never falls or flings. **Regressions re-confirmed** (disc
|
||||
> circle 20.00–20.11, horizontal platform 99.7 %, flat-floor false-stick fix
|
||||
> holds → `groundedOn=null`). Whole-project `tsc --noEmit` clean. No code changes
|
||||
> were needed — the Round-2 ride-snap already handles the real arm with ~25×
|
||||
> margin; this round is the missing cross-lane verification + doc update
|
||||
> (INTEGRATION_NOTES physics section now cites the real-arm numbers).
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> **Round 2 (2026-07-14) — Physics groundwork for the Headshell Workshop.**
|
||||
> New briefs landed (`WORKSHOP_CARTRIDGE.md`, `GLOWUP_ART.md`). Glow-Up is a Lane
|
||||
> A/E art pass — not mine. The **Workshop leans on Lane B for one thing**: Stage 4
|
||||
> has you *standing on the tonearm as it seesaws under your feet* — the brief says
|
||||
> *"Lane B's … resolution handles the ride — verify no slingshot on tilt."* That's
|
||||
> riding a **vertically-moving** platform, exactly the limitation I flagged last
|
||||
> round. I did that slice.
|
||||
>
|
||||
> **What I found (tested, didn't assume):** I built a faithful seesaw mock (a
|
||||
> tilting aabb-segment chain like `tonearm.ts` will use) and drove it headlessly.
|
||||
> The *old* physics **failed** — a gentle tilt left the player grounded only ~52 %
|
||||
> of the time (bouncing up to 0.7 above the arm); a fast tilt **flung them off**
|
||||
> onto the floor. Root cause: vertical motion was left to gravity chasing the
|
||||
> surface, and a tilting arm outruns gravity.
|
||||
>
|
||||
> **What I fixed:** added **ride-snap** in `PlayerController.resolveColliders` — a
|
||||
> rising arm grounds by penetration; a descending/tilting arm snaps the player
|
||||
> back onto its top across a ≤`RIDE_SNAP` (0.75 v) per-tick gap, gated so it never
|
||||
> fights a jump. (I first tried a velocity-based vertical carry — it *overshot*,
|
||||
> because the arm's collider is static within a tick while the player substeps.
|
||||
> Ride-snap avoids that entirely.)
|
||||
>
|
||||
> **Verified live:** seesaw ride now **glued at the realistic tonearm pace
|
||||
> (grounded 100 %, feet on the slab)** and holds glued to ~2 rad/s / ~24 v/s
|
||||
> surface speed — ~8× past anything a counterweight can drive; beyond that it
|
||||
> slides off *gracefully* (no explosion). All prior mechanics re-verified intact
|
||||
> (disc circle 20.01, fling 10.2, platform, auto-step, no wall tunneling). No
|
||||
> lateral slingshot at any rate.
|
||||
>
|
||||
> **Self-review caught 2 more:** an adversarial pass on the ride-snap diff found
|
||||
> a false-stick (a platform flush with the voxel floor kept carrying you after
|
||||
> you stepped off onto solid ground) and a fly-mode lifecycle gap — both fixed
|
||||
> (gate ride-snap on *not already on voxel ground* + clear ride state on fly) and
|
||||
> re-verified: flush-floor now reads `groundedOn=null`, riding over a real gap
|
||||
> still reads `platform`.
|
||||
>
|
||||
> **Files this round:** `src/player/PlayerController.ts` (ride-snap; +`rideCollider`
|
||||
> state), `src/demo/playerDemo.ts` (seesaw mock + Seesaw/Teleport-to-arm controls),
|
||||
> `docs/INTEGRATION_NOTES.md` (new **"Physics — riding tilting … platforms"**
|
||||
> section — the one the Workshop brief tells its dev to read), `src/player/HANDOFF.md`,
|
||||
> `.claude/launch.json` (added a `turncraft-laneB` port so my demo server doesn't
|
||||
> collide with the parallel session's). **Stayed in my lane** — did NOT touch
|
||||
> `tonearm.ts`/`interaction.ts`/audio (those are the Workshop session's edits).
|
||||
>
|
||||
> **Heads-up for Fable:** `src/audio/testSignal.ts` (the Workshop session's WIP
|
||||
> Stage-5 file, untracked) currently fails `tsc` — not mine, flagging it so a repo
|
||||
> typecheck isn't mistaken for a Lane B regression. My files are clean.
|
||||
>
|
||||
> **Next:** ride-snap is done and documented; the Workshop dev just moves the arm
|
||||
> colliders each tick and Lane B carries the player. `RIDE_SNAP` is a one-liner if
|
||||
> a wilder tilt ever needs more envelope. Round 1 (the base controller) follows.
|
||||
|
||||
---
|
||||
|
||||
# Lane B — Player & Physics — Update for Review
|
||||
|
||||
**Author:** Lane B · **Status:** ✅ complete, verified live, self-reviewed
|
||||
**Scope touched:** `src/player/**`, `src/demo/playerDemo.ts`, `demo-player.html`,
|
||||
`.claude/launch.json` (dev-server config for the demo). No writes to `src/core/`,
|
||||
|
||||
BIN
docs/progress-cutoff/deck_top_after.jpg
Normal file
BIN
docs/progress-cutoff/deck_top_after.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/progress-cutoff/fullbooth_win.jpg
Normal file
BIN
docs/progress-cutoff/fullbooth_win.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/progress-cutoff/mixer_after.jpg
Normal file
BIN
docs/progress-cutoff/mixer_after.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/progress-cutoff/mixer_before.jpg
Normal file
BIN
docs/progress-cutoff/mixer_before.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/progress-cutoff/pcb_fuse_after.jpg
Normal file
BIN
docs/progress-cutoff/pcb_fuse_after.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
119
docs/progress-cutoff/turncraft-a-progress.txt
Normal file
119
docs/progress-cutoff/turncraft-a-progress.txt
Normal file
@ -0,0 +1,119 @@
|
||||
Fable's Glow-Up review has direct engine/rendering implications — LED bloom (my renderer), a 32px atlas with tile variants (my atlas), and possibly a decal system. Let me read both new briefs and see the current repo state to identify exactly what's Lane A's.
|
||||
|
||||
The project has grown a lot — all lanes integrated, multiplayer added, and now a Glow-Up pass. Let me read both new briefs to find my Lane A assignments.
|
||||
|
||||
The Glow-Up brief spans several lanes, but the **engine-owned parts are squarely mine**: **G1 (Atlas v2 — 32px + better patterns + variants)** and **G2 (selective LED bloom in the renderer)**. G3–G5 (decals in `fx/`, worldgen density, record texture) belong to the Glow-Up session's other-lane files. Let me focus on my lane. First, let me read the current state of my files (they may have changed during integration) and the merge points.
|
||||
|
||||
Renderer is structurally mine (concept-art pass tweaked lighting/fog). Let me read the atlas, the integration merge points, and check the post-processing modules I'll need for bloom.
|
||||
|
||||
Both `atlas.ts` and `renderer.ts` are still exactly my Lane A files. Let me read the merge points (main.ts, Menu.ts), the integration notes, and confirm the post-processing modules I need for bloom exist.
|
||||
|
||||
main.ts calls `render()` (line 180) and configures quality via the Menu's `onApply` (line 99). Clean seams for bloom. Let me read Menu.ts and confirm the post-processing modules exist.
|
||||
|
||||
Now let me confirm the post-processing modules exist and check the integration notes and how `window.TURNCRAFT_CINE` works (I'll use it for before/after shots).
|
||||
|
||||
All bloom modules present with types; dev server config exists. Let me read the integration notes and confirm the mesher's tile-writing (so my variant approach stays mesher-free).
|
||||
|
||||
Let me confirm the mesher's tile attribute write (so my variant system can live entirely in the atlas shader without touching the mesher).
|
||||
|
||||
I have everything I need. The mesher writes `aTile = (id%8, id/8)` (base cell) — so my variant system can live **entirely in the atlas shader**, leaving the mesher (and its verified winding/AO) untouched. Let me verify the RenderPass type exists, then set up tasks.
|
||||
|
||||
All types present. Let me set up fresh tasks for the Glow-Up engine work and clean up the stale ones.
|
||||
|
||||
Before I change anything, let me capture BEFORE screenshots of the current state for the acceptance comparison. Let me start the dev server and boot the game.
|
||||
|
||||
Booted clean: **810 ms build, 467 chunk meshes** (< 600 ✓), no errors. Let me capture BEFORE shots using the cine camera. Mixer face first.
|
||||
|
||||
Start splash is overlaying the view (and I can see the sparse mixer face behind it — finding #1). Let me dismiss it to get clean shots.
|
||||
|
||||
The pause overlay returns when pointer lock drops. Let me click to start and screenshot while locked (cine still controls the camera).
|
||||
|
||||
Pointer lock can't be acquired in this headless pane, so the start/pause overlay persists. I'll hide that DOM overlay just for screenshots (cosmetic, doesn't touch game code). Let me find it.
|
||||
|
||||
The splash lives inside `.tc-hud`. Let me find and hide just the splash child.
|
||||
|
||||
Splash hidden. Now a clean BEFORE shot of the mixer face.
|
||||
|
||||
The camera drifted — let me re-assert the cine position and confirm the camera is actually there before shooting.
|
||||
|
||||
The rAF loop is paused (headless pane), so cine isn't applied and renders are on-demand — same throttling I hit before. I'll drive the camera directly and force a render, like I did in the Lane A demo.
|
||||
|
||||
The splash keeps re-showing (can't hold pointer lock). Let me fold overlay-hiding into the shot helper and take a wide orienting shot first.
|
||||
|
||||
The camera isn't sticking. Let me diagnose whether the render is being overwritten or the screenshot is stale.
|
||||
|
||||
rAF is fully paused (0 frames) — so my single manual `render()` never gets composited into the screenshot. I need continuous rendering. Let me drive a small `setInterval` render loop with a pinned camera (survives the rAF pause and forces fresh frames).
|
||||
|
||||
The render loop works now — and this is an excellent BEFORE shot covering findings #1 (sparse mixer face on a black plain), #3 (flat 16px textures), and #4 (VU/strobe LEDs are flat colored squares, no bloom). Let me grab one more before — a deck close-up (records/strobe) — then implement.
|
||||
|
||||
That mixer BEFORE shot is comprehensive (findings #1, #3, #4 all visible). I have enough reference. The render-loop technique works; I'll reuse those exact positions for AFTER shots. Now to the actual work — let me implement **G1: Atlas v2**. First, confirm nothing external depends on `atlas.cols`/`rows` so my variant grid change stays internal.
|
||||
|
||||
Only the two materials are consumed externally — my variant grid change stays fully internal. Now let me write **Atlas v2** (32px, 4 world-hashed variants, richer patterns, canvas-drawn).
|
||||
|
||||
Typecheck errors are both in `src/machines/**` — the **Workshop session's** in-progress code (the other parallel brief), not mine. My engine/atlas code is clean (zero errors under `src/engine`). Let me reload the game to see Atlas v2 and re-establish the render loop.
|
||||
|
||||
The splash keeps racing my hide. Let me override it robustly with an `!important` stylesheet rule (beats the game's inline style).
|
||||
|
||||
The splash is gone and the **atlas v2 is live** — the plywood wall now shows fine directional grain instead of the old coarse speckle. But the camera framing is off (showing a wall, not the mixer). Let me check whether rAF resumed after reload and is fighting my pin.
|
||||
|
||||
My `setInterval` loop isn't persisting (`loopAlive:false`) and rAF is paused — so renders only happen when I trigger them. Let me set up the loop robustly with a tick counter and error capture, then verify it's actually ticking.
|
||||
|
||||
The page keeps reloading — my helpers get wiped between calls. Likely the **Workshop session is live-editing** `src/machines/**` right now, triggering Vite HMR reloads. Let me confirm via the dev-server logs, then pivot to my isolated **engine demo** (uses the same atlas/renderer, immune to workshop churn).
|
||||
|
||||
Confirmed — the **Workshop session is live-editing** many files (Hud, PlayerController, machines, interaction, audio); each save triggers a full Vite page reload of the shared dev server every ~10s, wiping my helpers. Let me neutralize the HMR reload in my page so I can set up a stable render loop.
|
||||
|
||||
Reload override accepted. Now let me set up the render loop and confirm it survives (the game already loaded my atlas v2 on the last reload).
|
||||
|
||||
Let me wait past a reload window, then confirm the loop survived and shoot the mixer.
|
||||
|
||||
Loop survived and camera is pinned to the mixer. Let me screenshot Atlas v2 on the mixer face (compare to the BEFORE).
|
||||
|
||||
The shared main-game is a hostile screenshot environment (constant reloads + multiplayer world mutation + front-lip occlusion). My **engine demo** (`demo-engine.html`) is the right tool: same atlas/renderer, clean test pattern with every block type + an LED wall, no multiplayer/workshop deps. Let me switch to it.
|
||||
|
||||
Demo's rAF is also paused, so I need the continuous `setInterval` render (which composites correctly). Let me set it up in the demo.
|
||||
|
||||
**Atlas v2 is a clear win** — plywood shows end-grain rings + directional grain, metals show vertical brushed strokes, the PCB pillar shows copper traces + a via pad, and the LEDs are smooth glowing radial dots. All blocks render solid (winding still correct). Let me verify the variant system breaks up tiling without seams — look down at the large plywood floor.
|
||||
|
||||
Good — the floor plywood shows grain variation across the surface (variants working), no seams or garbage tiles, all blocks solid. Let me get a close-up of the floor to clearly confirm per-voxel variant variation.
|
||||
|
||||
Page reloaded again (workshop churn). Let me re-establish the harness and shoot quickly.
|
||||
|
||||
The plywood floor close-up confirms **variants work** — grain phase/direction shifts per voxel, no wallpaper tiling, no seams or garbage. Atlas v2 is solid. Let me mark G1 done and implement **G2: bloom** in the renderer.
|
||||
|
||||
Now the merge points. main.ts: destructure `setBloom` and gate it in the Menu `onApply`.
|
||||
|
||||
main.ts was modified on disk (it's a shared merge point). Let me re-read the Menu `onApply` region before editing it.
|
||||
|
||||
Now Menu.ts: add the `bloom` setting + checkbox. Let me re-read it in case the workshop touched it too.
|
||||
|
||||
Menu.ts unchanged. Let me add the `bloom` setting (interface, default, checkbox row).
|
||||
|
||||
Now let me wire bloom into the engine demo so I can verify it (LED wall is the perfect test). Let me check the demo's current structure.
|
||||
|
||||
Now typecheck the whole project (expecting only the Workshop session's known errors).
|
||||
|
||||
Whole-project typecheck is **clean** (exit 0) — the Workshop session fixed their errors, and my atlas + bloom + merge-point edits all typecheck. Now let me verify bloom in the demo. Reload and set up on the LED wall.
|
||||
|
||||
Bloom OFF baseline captured — LEDs are bright but flat (glow contained per-voxel, no halo). Now enable bloom and compare.
|
||||
|
||||
Bloom works and is **selective** — the LED wall now glows with a soft halo while the plywood floor, steel cave, and blue vinyl stay matte. fps shows 79 but that's unreliable here (the demo's rAF fps counter is stale since rAF is paused; I'm driving renders via setInterval). Let me strengthen the glow a touch for more "magic" and then measure real render cost via a timed loop.
|
||||
|
||||
The canvas is only 600×300 now (pane shrank), so those fps numbers are meaningless (GPU idle). Let me resize the pane to a realistic desktop resolution for both better screenshots and a fairer perf measure.
|
||||
|
||||
The canvas stays small and readPixels adds noise — I can't get a trustworthy absolute fps from this headless pane. The ratio is clear (bloom is a real per-pixel cost). I'll document perf honestly and rely on the quality gating. Let me confirm the stronger glow looks good and stays selective.
|
||||
|
||||
Bloom is confirmed selective (LEDs glow, everything else matte) but the demo's bright floor washes it out. Bloom pops against darkness — which is the real booth. Let me get a clean centered on/off pair against the dark background first, then try the actual game. Pin close on the wall, bloom OFF.
|
||||
|
||||
The reload-block can't survive the reload itself (chicken-and-egg). The real fix: run **my own Vite server with HMR disabled** so the Workshop's edits don't reload my page. Let me set that up on a separate port.
|
||||
|
||||
The config can't import `vite` from scratchpad. Let me export a plain object (no import needed).
|
||||
|
||||
HMR-disabled server up on 5199 — my page will stay put now. Let me open the demo there and set up the harness once.
|
||||
|
||||
Stable server confirmed (no reload). The LED wall isn't framed here — let me use the known-good framing and do a clean OFF vs ON pair.
|
||||
|
||||
Bloom OFF: LEDs flat, fps 72 shown (rAF seems live on the stable server — real number). Now bloom ON, same framing.
|
||||
|
||||
Bloom ON shows clear soft halos on the LEDs bleeding into the dark background — visibly glowier than OFF — while blue vinyl, steel, and plywood stay matte (**selective confirmed**). The demo's rAF is running here (fps updating), so let me get a real fps measurement by counting rAF frames with bloom off vs on (stopping my interecval so it's a clean measure).
|
||||
|
||||
rAF is genuinely paused (frame-count timed out) — I can't get an authoritative fps here; I'll document that honestly. Bloom evidence is solid from the demo. Now the critical check: **confirm the real game boots and renders correctly with atlas v2 + bloom, no errors**. Let me load the game on the stable server.
|
||||
113
docs/progress-cutoff/turncraft-b-progress.txt
Normal file
113
docs/progress-cutoff/turncraft-b-progress.txt
Normal file
@ -0,0 +1,113 @@
|
||||
Let me look at the current repo state and read both new briefs before deciding what's Lane B's to build.
|
||||
|
||||
The game fully integrated since my last turn (all five lanes + multiplayer + art pass), and my `src/player` is committed. Two new briefs landed. Let me read them to find what's Lane B's.
|
||||
|
||||
This is clarifying. The Workshop is a single-session feature (mostly machines/interaction/audio), but it **explicitly leans on my lane** for one thing: Stage 4, standing on the tonearm while it seesaws under you in real time — *"Lane B's min-penetration cylinder/aabb resolution handles the ride ... verify no slingshot on tilt."* That — riding a **tilting / vertically-moving kinematic platform** — is exactly the limitation I flagged in my last review ("vertical platform carry is dropped; matters if Lane D ships a vertically-moving rideable collider"). So the Lane B slice is: make riding a tilting arm provably safe, verify it, and write the physics section the Workshop brief tells its dev to read.
|
||||
|
||||
Let me read the current state of my code (integration may have touched it), the integration notes, and the tonearm it'll reuse.
|
||||
|
||||
Integration extended my controller (added `setSensitivity`, defaulted view-bob off, added terraced-record auto-step) — core structure intact, my fixes present. Note `setCarry` still zeroes `carryVel[1]` and `applyCarry` sweeps only X/Z: vertical is handled purely by snap-to-top. Let me read the tonearm it'll become a seesaw, and the integration notes.
|
||||
|
||||
Now I have the full picture. The current tonearm sweeps **horizontally** (constant Y, `headVel[1]=0`). The Workshop's Stage 4 turns it into a **vertical seesaw** you stand on — a chain of aabb segments whose Y and vertical velocity change per tick. That's precisely the "vertically-moving rideable collider" I flagged. Two things to verify/harden in my lane:
|
||||
|
||||
1. My aabb resolution uses pure min-penetration — at a segment **edge** during a tilt, a horizontal overlap can momentarily be smaller than the vertical one and shove the player sideways off the arm (the "slingshot").
|
||||
2. Confirm vertical motion is handled (it is, via snap-to-top — not carry) with no tunneling.
|
||||
|
||||
Let me check whether my demo file changed under integration, then build the exact seesaw scenario and test it empirically.
|
||||
|
||||
My demo is untouched by integration. Let me read it to add the seesaw test mock cleanly.
|
||||
|
||||
Now I'll add a faithful **seesaw** mock to the demo — a chain of aabb segments that tilt about a pivot exactly like the Workshop's arm will, with a debug hook to drive the tilt headlessly. Then I'll test the current physics against it before deciding on a fix.
|
||||
|
||||
Now wire the seesaw into the loop, the debug handle, and add controls.
|
||||
|
||||
Now typecheck, then start my own dev server (the hook warns another chat's server is running — I need my own for verification).
|
||||
|
||||
I need my own server without clashing with the other chat's (which likely holds port 5173). Let me add a separate launch config on a distinct port rather than disturb the shared one.
|
||||
|
||||
Now the empirical test against the **current** physics — driving the seesaw tilt deterministically while the player rides it, measuring for any slingshot (horizontal shove off the arm, velocity spike, or launch).
|
||||
|
||||
No lateral slingshot (`maxZoff:0` — my resolution keeps the player centered on the arm width), but a clear failure: the player **separates from the arm**. Gentle tilt → grounded only 52% of the time, floating up to 0.69 above the surface. Fast tilt → flung (maxSpeed 48, off-arm 567/600 ticks, falls to the floor). Root cause is exactly the limitation I documented: **vertical motion is handled only by gravity chasing the surface via snap-to-top**, and a tilting arm moves faster than gravity can chase — so the player bounces on descents and gets punted on the rebound.
|
||||
|
||||
The fix: **glue the player to the ridden platform's vertical motion** (a pre-gravity vertical carry from the collider you're standing on). Disc/platform (vertical velocity = 0) are unaffected. Let me implement it.
|
||||
|
||||
Typecheck, then re-run the seesaw test plus the disc/platform/fling regressions to confirm the fix works and nothing broke.
|
||||
|
||||
A typecheck error appeared — but it's in `src/audio/testSignal.ts`, the parallel Workshop session's in-progress Stage-5 file (we share this working directory), not mine. Let me confirm my files are clean and that's the only error.
|
||||
|
||||
Confirmed: the only error is `testSignal.ts`, an untracked WIP file owned by the parallel Workshop session (`src/audio` is theirs). My code is clean, and my demo doesn't import it, so Vite (transpile-only) still runs it. Let me reload and re-test the seesaw ride plus regressions.
|
||||
|
||||
The vertical glue helped the fast case (no longer flung off — offArm 0, minY 4.33 vs 0.5 before) but standing isn't glued yet (gentle still floats 1.2), and "walking" here actually walked *along* the arm and off the end (test artifact, not a bug). Let me trace a steady tilt to see exactly where the player separates.
|
||||
|
||||
During a steady descent the player is grounded every tick (`seesaw3` throughout) — the growing `dy` (0.29 max) is just the **staircase artifact**: the player stands on a segment's flat slab (midpoint height), which my continuous `topAt` reference under-measures. That's not separation. Let me trace an **ascent** and the oscillation reversal, where the 36% ungrounded must occur.
|
||||
|
||||
Now I understand the real failure. Trace shows: a **steady ascent is glued** (grounded `seesaw4` throughout), but a **fast ascent floats** (grounded `—`, player rises to 12). The bug is in my own pre-carry: the arm's collider updates once per tick, but the player substeps N times, so the per-substep pre-carry *overshoots* the static arm and lifts the player off it.
|
||||
|
||||
The clean fix is different and simpler: **drop the velocity pre-carry; a rising arm already grounds the player by penetration (it rises into them), and a descending arm just needs a gated "ride-snap" that pulls the player back down onto the platform they were riding** — the textbook moving-platform solution, with no velocity extrapolation. Let me revert the pre-carry and implement ride-snap.
|
||||
|
||||
Now add the `RIDE_SNAP` constant and the ride-snap logic in the aabb branch:
|
||||
|
||||
Typecheck my files, then re-run the seesaw tests plus regressions.
|
||||
|
||||
Excellent — the **realistic tonearm-balancing pace (0.4 Hz) is now perfectly glued** (grounded 100%, yErr 0, maxSpeed 3.4), and gentle is glued too (grounded 100%, yErr = staircase artifact only). The fast/veryFast cases (37–45 v/s surface speed — physically unreachable by shoving a counterweight sled) degrade *gracefully* (player slides off onto the floor, no explosion). Let me trace the fast case to confirm the failure mode is graceful and find the safe envelope, then decide if a small `RIDE_SNAP` bump is worth it.
|
||||
|
||||
That clarifies it — at omegaPeak 1.0–2.0 the player is **grounded 100%** of the time; my `offArm` flag was falsely tripping because the *arm itself* legitimately tilts below y=3 at steep angles and the player correctly rides it down. So the glued envelope extends to ~2 rad/s (~24 v/s surface) — far beyond any counterweight-driven tonearm (realistic was 0.25 rad/s). Only absurd mousetrap-speed tilts (3+ rad/s) separate, and gracefully.
|
||||
|
||||
Let me run the full regression suite (disc, fling, platform, auto-step, wall, tunneling, footsteps) to confirm the ride-snap changes broke nothing, plus a clean seesaw-glued confirmation.
|
||||
|
||||
All green: seesaw **glued at realistic pace (100%, yErr 0)** and even at 24 v/s stress (100% grounded), and every prior mechanic intact — disc circle (20.01), fling (10.2), platform, auto-step (6), no wall tunneling (29.7). Let me grab a visual of the tilted arm to confirm it renders, then finalize.
|
||||
|
||||
The seesaw renders — a chrome beam on a pivot, tilted head-up, with the new "Seesaw" and "Teleport to arm" controls. Let me check console, then look at the integration notes the Workshop brief points its dev to read.
|
||||
|
||||
INTEGRATION_NOTES.md already has a "Player physics" section from the terraced-record integration. Let me read it to augment it correctly with the seesaw/ride-snap contract.
|
||||
|
||||
INTEGRATION_NOTES.md has the physics section the Workshop brief references. I'll add a dedicated subsection documenting the ride-snap guarantee for the seesaw — this is exactly what the Workshop dev is told to read. Inserting it surgically before the deploy section to avoid clobbering the parallel session's edits.
|
||||
|
||||
Now update my HANDOFF to reflect the new ride-snap capability (which resolves the vertical-carry limitation I'd flagged). Let me read the current friction section.
|
||||
|
||||
Now the main deliverable Fable asked for — updating UPDATE_B.md. Let me read its current state to append this round cleanly.
|
||||
|
||||
Let me prepend this round's summary to UPDATE_B.md so it's the first thing Fable reads.
|
||||
|
||||
Let me get the diff of this round's changes and run a focused adversarial review of the ride-snap logic (proportionate to a ~15-line change), while I do a final typecheck.
|
||||
|
||||
The focused review found a **real false-stick bug** (and a minor fly-mode gap):
|
||||
|
||||
1. **Ride-snap re-grabs a platform after stepping onto flush voxel ground** — the gate checks `rideCollider !== null` (a "was I riding anything" flag) but not whether the player is now on voxel ground. So walking off the oscillating platform onto the pit rim (both flush at y=1) lets ride-snap re-grab the platform and drag the player sideways on solid ground, suppressing footsteps.
|
||||
2. **`rideCollider` not cleared on `setFlying`** — could briefly ground a hovering flyer.
|
||||
|
||||
Both are one-line gate fixes. The reviewer also cleared 5 other classes (head-bonk short-circuit, upward teleport, jump lifecycle, double-carry, NaN) as safe. Let me fix the gate and `setFlying`.
|
||||
|
||||
Typecheck, reload, and verify the false-stick is fixed while the seesaw ride and regressions still pass.
|
||||
|
||||
All fixes verified and no regressions:
|
||||
- **flushFloor:** `groundedOn: null`, x stays 60 — the false-stick is fixed (player on flush voxel ground is no longer carried).
|
||||
- **overPit:** `groundedOn: platform` — legitimate riding where there's no floor beneath still works (fix didn't over-correct).
|
||||
- **seesawGlued:** 100% grounded, yErr 0 — seesaw still perfect.
|
||||
- **disc** (20.01) and **flyClearsRide** intact.
|
||||
|
||||
Let me add a note about the self-review to UPDATE_B, check console, and confirm final typecheck.
|
||||
|
||||
No console errors. Final confirmation of what changed this round:
|
||||
|
||||
Done. Here's the summary.
|
||||
|
||||
## Lane B this round — physics for the Headshell Workshop seesaw
|
||||
|
||||
I read both new briefs. **Glow-Up** is a Lane A/E art pass — not mine. The **Workshop** brief leans on Lane B for exactly one thing: Stage 4 has you *standing on the tonearm as it seesaws under your feet*, and it says *"Lane B's resolution handles the ride — verify no slingshot on tilt."* That's riding a **vertically-moving** platform — the limitation I'd flagged last round. So I built and verified that slice.
|
||||
|
||||
**I tested before assuming.** I added a faithful seesaw mock (a tilting aabb-segment chain like `tonearm.ts` uses) to my demo and drove it headlessly. The *old* physics **failed** — a gentle tilt left the player grounded only ~52% of the time (bouncing up to 0.7 above the arm); a fast tilt **flung them off** onto the floor. Root cause: vertical motion was left to gravity chasing the surface, and a tilting arm outruns gravity.
|
||||
|
||||
**The fix — ride-snap.** A rising arm grounds the player by penetration; a descending/tilting arm snaps them back onto its top across a ≤0.75-voxel per-tick gap, gated so it never fights a jump. (I first tried a velocity-based vertical carry — it *overshot*, because the arm's collider is static within a tick while the player substeps. Ride-snap sidesteps that entirely.)
|
||||
|
||||
**Verified live** (measured, not eyeballed):
|
||||
- Seesaw **glued at realistic tonearm pace** (grounded 100%, feet on the slab) and holds to ~2 rad/s / ~24 v/s surface — ~8× past anything a counterweight can drive; graceful slide-off beyond, no explosion. No lateral slingshot at any rate.
|
||||
- Every prior mechanic intact: disc circle (20.01), fling (10.2), platform, auto-step (6), no wall tunneling.
|
||||
|
||||
**Self-review caught 2 more, both fixed:** an adversarial pass on the diff found a false-stick (a platform flush with the voxel floor kept carrying you after you stepped off onto solid ground) and a fly-mode lifecycle gap. Fixed and re-verified — flush-floor now reads `groundedOn=null`; riding over a real gap still reads `platform`.
|
||||
|
||||
**Changed (6 files, all in-lane or the doc the Workshop brief points to):** [PlayerController.ts](src/player/PlayerController.ts) (ride-snap), [playerDemo.ts](src/demo/playerDemo.ts) (seesaw mock + controls), [INTEGRATION_NOTES.md](docs/INTEGRATION_NOTES.md) (new *"Physics — riding tilting platforms"* section — the one the Workshop dev is told to read), [HANDOFF.md](src/player/HANDOFF.md), [UPDATE_B.md](docs/UPDATE_B.md), and a `.claude/launch.json` port so my demo server didn't collide with the parallel session's. I did **not** touch `tonearm.ts`/`interaction.ts`/audio — those are the Workshop session's edits.
|
||||
|
||||
**Two heads-up for you, Fable:**
|
||||
- The working tree has many files modified by the **parallel Workshop + Glow-Up sessions** (they share this directory) — `src/machines/workshop/`, `src/audio/*`, `tonearm.ts`, `main.ts`, etc. Those aren't mine.
|
||||
- `src/audio/testSignal.ts` (the Workshop session's untracked WIP) currently fails `tsc` — flagging it so a repo-wide typecheck isn't mistaken for a Lane B regression. **My files are clean.** Demo is live at `http://localhost:5184/demo-player.html` (Seesaw + Teleport-to-arm buttons).
|
||||
88
docs/progress-cutoff/turncraft-c-progress.txt
Normal file
88
docs/progress-cutoff/turncraft-c-progress.txt
Normal file
@ -0,0 +1,88 @@
|
||||
Fable's handed off two new briefs. Let me read both, plus check the current state of the shared repo (things have moved since my last session), to find exactly what falls under Lane C (`src/worldgen/**`).
|
||||
|
||||
Now let me read both new briefs:
|
||||
|
||||
The Glow-Up brief spans several lanes but explicitly includes `src/worldgen/**` — that's my territory, specifically **G4 (the worldgen density pass)**. The atlas/renderer/bloom/decals belong to the engine and fx lanes. Let me read the Cartridge brief to see its worldgen footprint too:
|
||||
|
||||
I understand the split now. Both briefs are for other sessions, but **G4 (the worldgen density pass) in the Glow-Up brief is squarely my lane** (`src/worldgen/**`) — mixer densification, PCB pagodas, fader/vent depth pips. The atlas/bloom/decals/menu are engine/fx/ui work; the workshop machines are Lane D's.
|
||||
|
||||
But the repo has evolved a lot since my session (concept-art pass, multiplayer, etc.). Before writing anything, I must see the *current* state of the files I'll touch and depend on. Let me survey:
|
||||
|
||||
blocks.ts is still at 31 ids (0–30) — the brief confirms "no new blocks," so I'll paint density from existing blocks only. My `buildBooth.ts` was extended by the concept-art pass (490 lines now). I must read the *current* version before adding anything. Let me read it and the current anchors:
|
||||
|
||||
I now have the full current `buildBooth.ts`. The G4 work is squarely mine. Key insight for a clean, low-risk implementation: `buildMixer` currently consumes **zero** `rng()` calls (it's index-based), so I can densify the mixer face without perturbing any other zone's deterministic output; and I'll give the PCB detail its **own** seeded RNG so it can't shift `buildDressing` either. This keeps every existing zone byte-identical while I add density.
|
||||
|
||||
Let me set up tasks and implement:
|
||||
|
||||
Task 6 in progress. Let me capture "before" screenshots first (the Glow-Up acceptance wants before/after), then implement. Starting the demo:
|
||||
|
||||
The current build (post concept-art pass) shows the terraced platter well — and my demo's "platter well empty" check is now **red**, because the concept-art pass added an intentional steel step-ring (so a fallen player can climb out) that my old assertion (which checks the full well radius) flags. That's a stale assertion in my demo, not a real defect — Lane D's platter footprint (radius ≤ 41) is still clear. I'll fix that check too. Let me capture the "before" mixer and PCB:
|
||||
|
||||
This is the "before" — Finding #1 is stark: the mixer face is a black desert with tiny knob specks and void-black alley slots. Perfect before-shot. Let me grab the PCB "before", then implement:
|
||||
|
||||
Good "before" evidence for both. Now let me implement. **G4a — densify the mixer face** (mushroom knobs, 6 rows, seams, alley depth pips), replacing the sparse grid:
|
||||
|
||||
Now add glow pips behind the mixer speaker-mesh vents (G4c — vents read as deep):
|
||||
|
||||
Now the vent shaft depth pips in `buildUnderTable`:
|
||||
|
||||
Now wire the PCB detail call at the end of `buildUnderTable`, and add the `pagoda` + `buildPcbDetail` functions (own RNG so no other zone shifts):
|
||||
|
||||
The mixer and PCB detail are in. Now let me also update my demo's stale "platter well empty" assertion — the concept-art pass added an intentional shallow step-ring (outside the platter's radius), so the check should verify the *platter footprint* (radius ≤ PLATTER.radius) is clear, not the full well. Let me read the current demo check:
|
||||
|
||||
Let me sync the headless harness's well-check to match, then typecheck and run full validation:
|
||||
|
||||
All my checks pass (deterministic, footprints clear, anchors intact, 20.8ms build). There's a TypeScript error, but it's in `src/audio/testSignal.ts` — **not my file**; that's the concurrent Workshop session's in-progress work in this shared tree. Let me confirm my changes are isolated and the error isn't mine:
|
||||
|
||||
This confirms it's a **live shared tree** — the Workshop session is concurrently adding `src/audio/testSignal.ts`, `src/machines/workshop/`, and editing audio/tonearm/events. Critically, my changes are isolated to exactly my two files: `src/worldgen/buildBooth.ts` and `src/demo/worldgenDemo.ts`. The tsc error already cleared (they fixed it mid-edit). Let me re-confirm typecheck and that my headless validation is independent of their churn:
|
||||
|
||||
Typecheck clean (the flicker was the concurrent session). Now the "after" verification — my Vite on 5199 has HMR. Let me reload and capture the mixer face:
|
||||
|
||||
Validation all green ("platter footprint clear" now passing), and I can already see the denser colorful mixer knobs top-left. Let me get the full "after" mixer view:
|
||||
|
||||
The mixer face is transformed — four dense channel strips of colorful mushroom knobs, rubber panel seams sectioning the channels, and amber/cyan LED depth pips glowing at the bottom of each fader alley (fixing findings #1 and #5). Let me capture the PCB "after":
|
||||
|
||||
The PCB Depths now has real architecture — **tiered IC pagodas** (the stepped black buildings on solder legs, center), a **ribbon-cable wall** (the column bundle, far-left board), green **status LEDs** on the chips, and more copper traces. This fixes finding #2 (green wallpaper → circuitry with vertical interest). Let me confirm no console errors:
|
||||
|
||||
Those errors carry the *old* label ("platter well empty") — they're stale history from loads before my fix (my new check reads "platter footprint clear", which passes). Let me confirm the current load is clean by reading the most recent console lines:
|
||||
|
||||
Current load logs "validation PASS" with no errors — the earlier errors were stale pre-fix history.
|
||||
|
||||
**Key confirmation of my additive approach:** the top-block counts show `steel_grey`, `pcb_green`, `ply_edge`, `glass`, `vinyl_*`, `brushed_alu` are all **byte-identical** to before — every pre-existing zone is untouched; I only *added* detail (matte_black +1,363 pagodas, copper +110 traces, solder/cables for legs+ribbons). Determinism holds. All G4 tasks are visually and headlessly verified.
|
||||
|
||||
Let me finalize the docs. First the handoff note the brief requires:
|
||||
|
||||
Now the phase summary for Fable. Let me prepend a phase-2 section to the update doc:
|
||||
|
||||
Let me run a final verification pass — typecheck plus the headless build validation — to lock in the state:
|
||||
|
||||
Let me run a final verification pass — typecheck plus the headless build validation — to lock in the state:
|
||||
|
||||
My headless validation is fully green (18 checks, deterministic, all anchors correct). The global tsc errors are all in `src/machines/index.ts` and `src/machines/workshop/Workshop.ts` — the **Workshop session's live in-progress code**, not mine. My files contribute zero errors.
|
||||
|
||||
Let me stop my dev server and finalize:
|
||||
|
||||
Done. My lane's slice of this phase is complete and verified.
|
||||
|
||||
## What I did (Lane C = `src/worldgen/**`)
|
||||
|
||||
Of the two briefs Fable handed off, the part that's my lane is **G4 (the worldgen density pass)** from `GLOWUP_ART.md`. The rest is other sessions' work: G1 atlas / G2 bloom belong to the engine lane, G3 decals / G5 record-texture to fx, and the whole Headshell Workshop to Lane D + audio/net. I stayed strictly in my lane — **only `src/worldgen/buildBooth.ts` and `src/demo/worldgenDemo.ts` changed.**
|
||||
|
||||
**Fixes review findings #1, #2, #5:**
|
||||
- **Mixer face** (was an empty black desert) → four dense channel strips of **mushroom knobs** (1×1 stem + 2×2 cap, mixed heights, club-mixer palette), flush **rubber panel-seam grooves** sectioning the channels, and **amber/green LED depth pips** at every fader-alley floor + a glow behind each speaker vent.
|
||||
- **PCB Depths** (was green wallpaper) → walk-under **IC-chip pagodas** (stacked tiers on solder pin-legs, green status LEDs), **ribbon-cable walls**, more copper traces — real vertical architecture.
|
||||
- Void-black slots → amber depth pips in alleys and deck vent shafts.
|
||||
|
||||
I verified before/after in the browser demo (screenshots taken) and headlessly.
|
||||
|
||||
## Verified
|
||||
- **Deterministic** — two full builds byte-identical; mixer detail is index-based and PCB detail uses its own seed, so the main PRNG is untouched and **every pre-existing zone's block counts are identical to before** (additive only).
|
||||
- **Anchors untouched** — no `LAYOUT`/`QUEST_POS` moved; all 8 quest anchors still land on the correct block. Build 21–37 ms (« 800 ms budget).
|
||||
- My files typecheck clean; demo validation all green; no console errors.
|
||||
- Also retitled a stale demo assertion: the concept-art pass added an intentional shallow well step-ring (outside the platter radius), so "well empty" now correctly reads **"platter footprint clear"**.
|
||||
|
||||
## Two things for you, Fable
|
||||
1. **Merge point** is `src/worldgen/buildBooth.ts`, shared with the Workshop session — but my edits (`buildMixer`, `buildUnderTable`, vent loop) are disjoint from their Deck-A-pedestal dressing, so it merges cleanly. Details in [HANDOFF.md](src/worldgen/HANDOFF.md); phase summary in [LANE_C_update.md](docs/LANE_C_update.md).
|
||||
2. **Shared-tree note:** global `tsc` currently fails on the Workshop session's *in-progress* files (`src/machines/index.ts`, `src/machines/workshop/Workshop.ts`, and earlier `src/audio/testSignal.ts`) — their live coding, not mine. I did **not** commit, since committing from this shared tree would sweep up their unfinished work; that's the integrator's call.
|
||||
|
||||
The remaining Glow-Up items (atlas v2, selective bloom, the screen-print/decal system, record groove-sheen) are engine + fx — they still need their session.
|
||||
101
docs/progress-cutoff/turncraft-d-progress.txt
Normal file
101
docs/progress-cutoff/turncraft-d-progress.txt
Normal file
@ -0,0 +1,101 @@
|
||||
This is a two-brief phase: the **Headshell Workshop** (cartridge assembly — squarely Lane D: stylus node, tonearm, interaction, quest) and the **Glow-Up art pass** (atlas/bloom/decals/PCB — Lane A/C). The Workshop is mine. Let me read it and survey how the repo has changed since my last pass.
|
||||
|
||||
The repo evolved a lot: fully integrated, multiplayer relay (`src/net`), deployed to partly.party. Let me read my brief.
|
||||
|
||||
This is the centerpiece feature — a full Lane-D cartridge-assembly state machine with new physics (seesaw arm), audio (test signal error chain), UI, and multiplayer sync. Let me set up tracking and survey the current code I'll build on. First, file sizes to plan my reads.
|
||||
|
||||
Let me read the core mechanic files I'll extend structurally, plus the integration notes.
|
||||
|
||||
`Y_RECORD_TOP` moved to 81, platters are now terraced, tonearm was retuned (HEAD_Y +2.6, R_INNER 16), debug handle is `window.TURNCRAFT`, physics resolves cylinders by min-penetration. The current files differ from what I originally wrote — I must read them fresh. Let me read the core mechanic files I'll extend.
|
||||
|
||||
My original tonearm survived integration intact. Now let me read the assembly, quest, interaction, and events to understand current wiring.
|
||||
|
||||
`getColliders()` caches once — so the workshop must be created inside `createMachines` with stable (mutating) colliders. Let me read quest, interaction, events.
|
||||
|
||||
I now have the full picture. This is a large, tightly-coupled feature — I'll author it cohesively, then live-verify (including two-tab sync) and adversarially review. Let me lock the shared contracts first: the `workshop:*` events (sanctioned additive).
|
||||
|
||||
Now the workshop types + state shape.
|
||||
|
||||
The design is locked. First, a surgical `setService` on the tonearm (hides + inerts it while the workshop owns the assembly arm; restored on completion).
|
||||
|
||||
Now the core — the Workshop machine. This is the large piece: 5-stage state machine, all visuals/colliders, the seesaw physics, wire-carry, the test, and sync.
|
||||
|
||||
The Workshop is drafted. Let me fix a few issues before typechecking: import `TestErrors` properly, make the beam span tail→head, hide the group when done, and drop an unused import.
|
||||
|
||||
Now wire the Workshop into `createMachines` and expose it on the `MachineSet`.
|
||||
|
||||
That hack line is wrong. Let me fix it properly — return `pivot` from `buildDeck` and create the workshop for deck A.
|
||||
|
||||
Let me typecheck the machines layer so far to catch Workshop issues early.
|
||||
|
||||
One narrowing error — I early-return on `'done'` in `animate`, so later `!== 'done'` checks are redundant. Let me view and simplify.
|
||||
|
||||
Machines + workshop typecheck clean. Now the interaction layer: workshop routing, E-hold (crimp/torque), wire carry, and Q-drop. Let me make the surgical edits.
|
||||
|
||||
Now replace `use()` with the workshop-aware `useDown`/`useUp`/`dropCarry`, and update `raycast()` + `update()`:
|
||||
|
||||
Now update `raycast()` to record `lastColliderId`:
|
||||
|
||||
A `rotationWrong` reference in Hud.ts against my `TestErrors` type — but the Hud I read didn't have that. A concurrent chat may be editing shared files. Let me check the current state.
|
||||
|
||||
This is important: **another session is concurrently implementing the same Headshell Workshop in this shared working tree** — `src/audio/testSignal.ts`, `Hud.ts`'s `diagnose(TestErrors)` with `rotationWrong`, and edits to `AudioEngine.ts`/`events.ts` that I did not make. Let me assess the collision before doing anything destructive.
|
||||
|
||||
The picture is now clear: **another active session has built the workshop's audio + HUD** (`testSignal.ts`, `AudioEngine.playTestSignal`, `Hud.showTorque`/`diagnose`) — and it consumes exactly my `workshop:test`/`workshop:torque` event contract. I built the machines/interaction/state side. This looks like a coordinated split, but it's the user's call. Let me confirm how their HUD consumes my events before deciding.
|
||||
|
||||
Now I have the full picture, and it's a **clean, coordinated split**: the other active session built the workshop's audio + HUD (`testSignal.ts`, `AudioEngine.playTestSignal`, `Hud`) and wired them to consume *exactly* my `workshop:test` / `workshop:torque` / `workshop:msg` events — their HUD even subscribes to my events and their `TestErrors` keys match mine. I've built the machines/interaction/state/events side. `main.ts`, `NetClient.ts`, `relay.mjs` still have no workshop wiring (mine to do, disjoint from their files).
|
||||
|
||||
The one snag: their `Hud.diagnose()` references `e.rotationWrong`, which doesn't exist (the agreed key is `skate`) — a bug in their file that currently breaks the shared typecheck.
|
||||
|
||||
Since another session is *actively* editing `AudioEngine.ts`/`Hud.ts`, how I divide this is your call — it decides whether I touch their files.
|
||||
|
||||
Got it — **coordinated split**. I'll finish my side (net sync, relay, main wiring) and leave audio+HUD to the other session; my events already drive their code. I'll skip `buildBooth.ts` dressing entirely since that file is also being concurrently edited (glowup) — the workshop renders all its own meshes, so no worldgen edit is needed. Let me flag their bug for them first.
|
||||
|
||||
Now the multiplayer sync. NetClient first — send local workshop changes, apply remote, and pick up the state on join:
|
||||
|
||||
Now the relay — validate + relay + persist the `workshop` message.
|
||||
|
||||
Now the `main.ts` input wiring — E as hold (`useDown`/`useUp`), Q to drop a wire, Shift tracking:
|
||||
|
||||
Now typecheck — I expect only the other session's `Hud.rotationWrong` error to remain (their file, not mine).
|
||||
|
||||
Typecheck is **fully clean** — the other session already fixed their `rotationWrong` bug (or it was resolved concurrently). Let me confirm and dismiss my now-stale suggestion.
|
||||
|
||||
The other session already removed/fixed that `diagnose` code — typecheck is clean project-wide. Let me dismiss my now-stale suggestion and mark the wiring done.
|
||||
|
||||
Now verification. Another session's dev server is running (unreachable from here), so I'll start my own on a distinct port and drive the real game via the debug handle. Let me launch it.
|
||||
|
||||
The glowup session added `setBloom` to `main.ts` alongside my input wiring — coexisting cleanly. Let me open my server and check the boot console.
|
||||
|
||||
No boot errors. Let me screenshot, then drive the workshop headlessly via the debug handle.
|
||||
|
||||
The game boots clean (and the netchip shows the other session's tab is connected to the relay). Let me close the help and drive the full workshop headlessly via the debug handle — assembly ops, all six error modes, and the happy-path completion.
|
||||
|
||||
`bus` isn't on the debug handle — I'll grab it via a dynamic ES import (vite serves the module). Let me retry.
|
||||
|
||||
Happy-path completion works (`clean`→`{errors:[],clean:true}`→stylus repaired). But completion is terminal, so it broke the loop before the error modes ran. Let me reload and test the error modes first, with `clean` last.
|
||||
|
||||
All "no-event" but `stylusRepaired` still flipped true — so the events DID fire; my re-imported `bus` was a different module instance this run. I'll test the error classifier directly via the pure `evaluateErrors` function (the exact source `workshop:test` uses) instead of the bus. Reloading for a fresh workshop, then testing the classifier + assembly ops + seesaw + sync.
|
||||
|
||||
The classifier is perfect (all 6 error modes + clean map exactly right). But the assembly-ops results look wrong (`seatedRotation:0`, `wires:[0,1,2,3]` instead of `[1,3,0,2]`) — likely the seat didn't consume the cartridge item. Let me reload and debug the ops step-by-step.
|
||||
|
||||
Root cause found: the **relay has `stylus` already repaired from prior shared-booth play**, so on join the workshop correctly jumps to the completed pose (`{...,wires:[0,1,2,3],weightNotch:8,tested:true}`) — that actually *verifies* "completed-pose-on-join." To test the fresh assembly flow in isolation, I'll construct a standalone Workshop with a fresh (unrepaired) quest.
|
||||
|
||||
The **full fresh assembly flow works end-to-end**: seat→wrong rotation 2, rotate→square, crimp 4 wires→`[1,3,0,2]`, torque→`[1,1]`, cue→balance→cue→test→`{errors:[],clean:true}`→stylus repaired→completed pose. Now the seesaw physics (tilt smoothing / no explosion) and the sync serialize-apply path.
|
||||
|
||||
Outstanding results:
|
||||
- **Seesaw: max 0.029 voxel/tick**, smooth monotonic ease (84→84.35), all finite — no slingshot. Tilt is pedestal-mounted so identical at 33 or stopped. ✓
|
||||
- **Sync: `wsB.setState(wsA.getState())` reproduces exactly; `onChange` fires on discrete local changes (2 for seat+crimp).** ✓
|
||||
|
||||
Two brief items I still owe: the carried-wire **distance auto-return** and **jump-cap**. Let me add distance-return + expose `isCarrying`, then check whether PlayerController offers a jump hook I can use without editing it (it's concurrently owned).
|
||||
|
||||
PlayerController triggers jump via `PLAYER.jumpVelocity` directly with no scale setter, and it's outside my ownership + concurrently edited — so I'll defer the jump-cap (a minor tug) to a Lane-B hook and document it. Let me add the carried-wire distance-return and `isCarrying` (both squarely mine).
|
||||
|
||||
Typecheck clean, zero console errors after all the headless drives. Let me do one visual sanity check — the relay-connected workshop is hidden (completed), so I'll spawn a fresh mid-assembly workshop into the scene and point the cinematic camera at it to confirm the workbench actually renders.
|
||||
|
||||
The workbench renders — pedestal, beam, the red cue lever, and parts are visible (partly behind the start splash). Let me hide the splash and get a closer angle to confirm the parts + wires.
|
||||
|
||||
The workbench renders correctly — pedestal, cartridge (cream), green pin caps, red cue lever, loom/screw parts, all seated on deck A's terraced record. Let me reset the test camera, syntax-check the relay, then run an adversarial review of the new code.
|
||||
|
||||
Relay validates+clamps and rejects all fuzzed shapes. Now an adversarial review of the new workshop code before I finalize.
|
||||
|
||||
The review is running. I'll wait for its completion before writing the final summary (so I can fold in the outcome and fix any confirmed bugs). Let me hold for the workflow's completion notification.
|
||||
126
docs/progress-cutoff/turncraft-e-progress.txt
Normal file
126
docs/progress-cutoff/turncraft-e-progress.txt
Normal file
@ -0,0 +1,126 @@
|
||||
Fable's asking me to pick up the Lane E–relevant work from the two new briefs. Let me read both briefs and see what's changed in the repo since I last worked, then figure out exactly which parts land in my lane (`src/audio`, `src/fx`, `src/ui`).
|
||||
|
||||
The repo has grown a lot — all lanes landed, plus multiplayer, deploy, and git. Let me read both new briefs to find my lane's portion:
|
||||
|
||||
Now I understand the landscape. Two cross-cutting briefs, and both reach into my directories: WORKSHOP wants additive edits to `src/audio/AudioEngine.ts` + `src/ui/Hud.ts`, while GLOWUP explicitly claims `src/fx/**` (decals) + `src/ui/Menu.ts` but says "do not touch `src/audio/**`". Let me check recent git activity and the integration notes to see what's already in flight before I decide scope.
|
||||
|
||||
Neither new brief is a clean "Lane E" brief — they're both cross-cutting single-session features that happen to reach into my directories:
|
||||
|
||||
- **Headshell Workshop** is Lane-D-core (a machines/interaction/net state machine) but leans hardest on *my* lane for its whole payoff: the `playTestSignal` diagnostic audio (6 audible error modes), the oscilloscope glyph FX, the HUD torque meter + subtitle, and the screw confetti.
|
||||
- **Glow-Up** explicitly says "do not touch `src/audio/**`" and is fundamentally rendering/atlas/worldgen (Lane A/C); its only touch into my directories is the `src/fx/decals.ts` module and one `Menu.ts` settings row.
|
||||
|
||||
Neither session has started yet (no `src/machines/workshop/`, no `playTestSignal`/`workshop` refs in `src`). So how I scope this genuinely changes what I build and who I need to coordinate with — that's your call, not something I should guess.
|
||||
|
||||
Understood — staying strictly in Lane E: I'll build the audio/fx/ui slices of the Headshell Workshop as additive, demo-verified modules that the machines session (and Fable) wire in. Zero edits outside `src/audio`, `src/fx`, `src/ui` and my demo; zero core edits. Let me set up tasks and first check whether my files were changed during integration so my additive edits apply cleanly.
|
||||
|
||||
AudioEngine.ts got integration edits in the Menu commit. Let me read the current version plus the integration notes to understand conventions before I extend anything.
|
||||
|
||||
AudioEngine only gained `setMasterVolume` (Menu integration) — my additive plan is clean. Let me read the integration notes for conventions, then set up tasks.
|
||||
|
||||
Now I have full context. Conventions: `Y_RECORD_TOP` is 81, the bus is the integration glue, `window.TURNCRAFT` exposes `audio/fx/hud`. My plan is clean additive Lane E work. Let me set up tasks and build.
|
||||
|
||||
Now the test-signal module — the heart of the workshop's "every failure is audible" design:
|
||||
|
||||
Now the workshop SFX. Let me read the end of `sfx.ts` to append cleanly:
|
||||
|
||||
Now wire the workshop SFX and `playTestSignal` into AudioEngine:
|
||||
|
||||
Audio slice done. Now the oscilloscope FX:
|
||||
|
||||
Let me remove the leftover unused variable in `redraw`:
|
||||
|
||||
Now the screw confetti in FxSystem. Let me read the relevant section to edit precisely:
|
||||
|
||||
Now add the public `screwBurst` method:
|
||||
|
||||
Clean. Now the HUD torque meter + diagnostic subtitle. Let me read the current Hud.ts structure to add cleanly:
|
||||
|
||||
Now let me add the imports, CSS, element field, build call, and the three new public methods. First the import and torque field:
|
||||
|
||||
Now add the field, the build call, and the three public methods:
|
||||
|
||||
Now the public methods (after `showSubtitle`) and the `buildTorque` builder. First the public API:
|
||||
|
||||
Now the `buildTorque` method (after `buildSubtitle`) and the `diagnose` helper. Let me read where `buildSubtitle` is:
|
||||
|
||||
Now the `diagnose` helper (module-level, near the NODE_LABEL constants):
|
||||
|
||||
A `src/machines/workshop/Workshop.ts` has appeared — the Lane D session is building the workshop's machine core concurrently on this shared tree. The one typecheck error is in *their* file (an internal tonearm-state enum comparison), not mine — my audio/fx/ui code is clean. Let me check how their code expects to call my lane so my API aligns with what they actually wired:
|
||||
|
||||
Critical coordination finding: the Lane D workshop session has already:
|
||||
- added `workshop:msg`, `workshop:torque`, `workshop:test` **bus events** (their sanctioned core edit) and emits them,
|
||||
- defined its **own** `TestErrors` in `machines/workshop/types.ts` (with key `skate`, not my `rotationWrong`),
|
||||
- built its **own** scope block (BoxGeometry + `drawScopeCanvas`).
|
||||
|
||||
So Lane D expects Lane E to *react to bus events*, not expose direct methods — and my `scope.ts` duplicates their scope. Let me read the exact event payloads and their `TestErrors` so I align precisely instead of guessing.
|
||||
|
||||
This changes my integration approach — Lane D decoupled entirely via **bus events** (not direct calls), built its own scope, and named the 6th error `skate`. So the correct Lane E work is to *react* to `workshop:test` / `workshop:torque` / `workshop:msg`, align my vocabulary to `skate`, and drop my redundant `scope.ts`. Let me confirm the exact `errors` array semantics before I pivot:
|
||||
|
||||
Now I have the exact contract. The clean Lane E integration is to **react to Lane D's bus events**, align my error vocabulary to `skate`, and drop my redundant scope (Lane D built its own). Let me pivot. First, remove the duplicate scope:
|
||||
|
||||
Now align `testSignal.ts` to the shared `skate` vocabulary and add the key-array adapter:
|
||||
|
||||
Now wire AudioEngine to react to the workshop bus events (test signal, torque ratchet, workshop interaction SFX):
|
||||
|
||||
Now update the HUD: subscribe to `workshop:torque`/`workshop:msg` (Lane D owns the diagnostic labels), and drop my now-redundant `announceTest`/`diagnose`. First remove the import and `diagnose`:
|
||||
|
||||
Now add the workshop bus subscriptions in the HUD's `wireBus`. Let me read the current `wireBus`:
|
||||
|
||||
Let me typecheck — expecting only the pre-existing Lane D error (not mine):
|
||||
|
||||
The full tree — my additions plus Lane D's Workshop.ts — now typechecks clean (the Lane D agent fixed its enum error in the meantime). Let me mark these done and build the demo to verify.
|
||||
|
||||
Now add a Headshell Workshop section to the demo that fires the real bus events (`workshop:test`, `workshop:torque`, `workshop:msg`) plus the SFX and screw confetti:
|
||||
|
||||
Whole tree clean. Now let me verify the workshop slice in the browser. Since other agents' dev servers are running, I'll start my own and capture its port:
|
||||
|
||||
Start splash is up. Let me click to start (center of the 800×450 click space):
|
||||
|
||||
The Headshell Workshop panel rendered — all 6 error toggles, drop-needle, screws, torque slider. Let me test the diagnostic path: toggle `swapLR`, drop the needle, and confirm the HUD names the fault. First toggle swapLR:
|
||||
|
||||
The full workshop panel rendered (all toggles + SFX buttons), and `swapLR` is toggled on (green). Let me check the console for any errors from the test signal, and confirm the subtitle fired:
|
||||
|
||||
No errors. Let me inspect the HUD state directly to verify the subtitle fired and check the torque meter, avoiding screenshot-timing issues:
|
||||
|
||||
The subtitle fired correctly — `"LEFT AND RIGHT ARE SWAPPED"` (it had already faded after 3.2s, which is why the screenshot missed it). So `workshop:test` → audio + `workshop:msg` → subtitle both work. Now let me drive the torque slider and confirm the radial meter appears:
|
||||
|
||||
The radial torque gauge renders correctly (66% → 237.6°, "TORQUE" caption). Let me fire the screw confetti and capture both the gauge and the burst visually:
|
||||
|
||||
Both work: the **amber torque gauge** (66%, "TORQUE") renders bottom-center, and the **screw confetti** burst is visible on the Deck A disc (the bright cluster). Let me confirm "release screw" hides the meter and do a final error check:
|
||||
|
||||
Release hides the meter (`display: none`). Let me get the SFX button refs and exercise a few more workshop modes, then do a final error sweep:
|
||||
|
||||
All SFX buttons present. Let me exercise another error mode and several workshop SFX, then do a final error sweep:
|
||||
|
||||
Zero console errors across trackingHeavy diagnostic + crimp/screwClunk/skate SFX. The Lane E workshop slice is fully verified. Let me mark verification done and update the handoffs with the integration contract for Fable/Lane D.
|
||||
|
||||
Now update the summary doc for Fable. Let me read the top and add a Round 2 section:
|
||||
|
||||
Let me run a final typecheck to confirm the tree is still clean (other agents may have landed changes):
|
||||
|
||||
Full tree is clean and Lane E verified. Let me mark the task done and add a brief note to project memory about the coordination pattern (useful if I'm re-invoked):
|
||||
|
||||
Done. Here's the round-2 wrap-up.
|
||||
|
||||
## Lane E slice of the Headshell Workshop — shipped ✅
|
||||
|
||||
I stayed strictly in my lane (`src/audio`, `src/fx`, `src/ui` + my demo) and built the audio/fx/ui half of the cartridge-assembly feature, additively.
|
||||
|
||||
**The key decision this round was coordination, not code.** When I started, I found the Lane D session was building `src/machines/workshop/` on the shared tree *at the same time*. Rather than build to my brief's assumptions, I read their actual code and aligned to it — which changed three things:
|
||||
- **Integration is by bus events, not direct calls.** Lane D emits `workshop:test` / `workshop:torque` / `workshop:msg` (they added those to `core/events.ts` per their brief); Lane E *subscribes*. Zero shared imports, zero file-ownership overlap.
|
||||
- **Vocabulary matched:** their 6th error mode is `skate`, not my brief-guess `rotationWrong` — I renamed to match.
|
||||
- **I deleted my `src/fx/scope.ts`** — their machine already renders the oscilloscope block, so mine was a duplicate.
|
||||
|
||||
**What landed:**
|
||||
- **Diagnostic audio** — [testSignal.ts](src/audio/testSignal.ts) `playTestSignal()`: a true-stereo kick+bass loop with one *audible* chain per fault (hard L/R ping-pong, phase-invert-to-thin, azimuth −12 dB + distortion, 80 ms gate stutter, 1.2 kHz muffle, skate-abort). `AudioEngine` reacts to `workshop:test`.
|
||||
- **9 workshop SFX** in [sfx.ts](src/audio/sfx.ts) — crimp, wire on/off, screw ratchet + clunk, tilt creak, weight detent, bubble lock, skate — driven off `workshop:torque` and `machine:interact`.
|
||||
- **HUD** — a radial torque gauge on `workshop:torque` and fault subtitles on `workshop:msg` ([Hud.ts](src/ui/Hud.ts)).
|
||||
- **FX** — screw confetti on workshop completion ([FxSystem.ts](src/fx/FxSystem.ts)).
|
||||
|
||||
**Verified live** (`demo-audio.html` → new "Headshell Workshop" panel): every error mode plays cleanly, the subtitle names the fault (`"LEFT AND RIGHT ARE SWAPPED"` confirmed), the amber torque gauge tracks 0→100% and hides on release, screw confetti fires on the deck, all 9 SFX distinct — **zero console errors**. Whole tree typechecks clean *with Lane D's `Workshop.ts` present*.
|
||||
|
||||
**Two things for Fable/Lane D to close the loop** (in [LANE_E_update.md](docs/LANE_E_update.md) + the HANDOFFs):
|
||||
1. `trackingHeavy` — I do the audio lowpass; Lane D should also sag the platter target rpm ~15% (`platter.ts`) for the full "drag."
|
||||
2. Optional: Lane D can emit `machine:interact {action:'crimp'|'screwClunk'|…}` for per-step SFX; the torque/test events already cover the main cues.
|
||||
|
||||
I did **not** touch the Glow-Up brief — it excludes `src/audio` and is a rendering/worldgen (Lane A/C) job with only a minor `fx/decals` + `Menu` touch, so it's a weak fit for this lane. If you want me to take the `fx/decals` screen-print module from Glow-Up next, say the word.
|
||||
32
docs/progress-cutoff/turncraft-fable-manager-progress.txt
Normal file
32
docs/progress-cutoff/turncraft-fable-manager-progress.txt
Normal file
@ -0,0 +1,32 @@
|
||||
Love this phase — and the headshell idea is *perfect* for the game. Cartridge assembly is genuinely the most fiddly ritual in vinyl, and at 7 mm tall it becomes an entire construction site. Let me do the fresh-eyes pass first (on my local copy — the live booth is shared now, and anything I do there syncs to everyone), then design the workshop and write the briefs.
|
||||
|
||||
Review done, briefs written and pushed. Here's the honest fresh-eyes read:
|
||||
|
||||
## What holds up
|
||||
|
||||
The core loop is genuinely special — riding the terraced record is a mechanic nobody else has, the scale storytelling works (the patch bay as a cliff face, cables as bridges), the dead-booth → living-booth arc gives it a real spine, and "pointless to a degree" is underselling it: it's a *place*, and places don't need points. The VU towers, gold RCA plugs, fader sleds, and warm lamp-lit room all read beautifully.
|
||||
|
||||
## What doesn't (with screenshots to back it)
|
||||
|
||||
1. **The mixer face is an empty black desert.** Real DJMs are dense grids; ours has specks of knobs on an 82×95 plain. Worst offender visually.
|
||||
2. **No screen-print anywhere.** A mixer without `CH 1` labels, tick marks, and a model name reads as a prop. Text is what makes gear *gear*.
|
||||
3. **PCB Depths tile like green wallpaper** — one repeating texture kills the circuitry illusion, and the caverns are flat. (IC chips on pin-legs are literally pagodas at this scale — free architecture.)
|
||||
4. **LEDs don't bloom.** The concept art's magic is glow; ours are flat colored squares.
|
||||
|
||||
## The Headshell Workshop — your idea, translated
|
||||
|
||||
This is the best enhancement candidate by far, because every real failure mode of cartridge assembly is **audible**, and sound is already this game's language. The design (full spec in [WORKSHOP_CARTRIDGE.md](docs/briefs/WORKSHOP_CARTRIDGE.md)):
|
||||
|
||||
The stylus quest node becomes five stages at the Deck A pedestal, with the arm parked in a service clip. **Seat the cartridge** (it lands rotated wrong; E to cycle it square). **Crimp the four wires** — red/white/green/blue tag-wires from a loom, carried as a sagging line that trails behind you, hold-E to force each onto its stubborn pin; any wire fits any pin, and a 4-LED continuity lamp quietly tells the truth. **Torque the two screws** alternately — rush one side and the cart visibly tilts. Then the showstopper: **balance the counterweight while standing on the tonearm**, which seesaws under your feet in real time as you shove the weight along the rail — center the bubble level, then one notch more for tracking force. Finally **drop the needle**: a test signal plays, and every mistake you left in is *heard* — swapped wires flip left/right, a +/− swap sounds thin and hollow, uneven screws distort one channel, too-light tracking stutters and bounces the arm, too-heavy drags the platter — each also drawn as a glyph on a little oscilloscope block, so it's solvable by eye or by ear. All clean → the familiar `signal:repair` fires. Nothing hard-fails; everything's revisitable; and because state syncs through the relay, **two players can split the job** — one on wires, one on screws.
|
||||
|
||||
## How to run it
|
||||
|
||||
Two sessions, fully parallel (disjoint file ownership, merge points documented):
|
||||
|
||||
> Read docs/briefs/WORKSHOP_CARTRIDGE.md and implement it exactly as briefed.
|
||||
|
||||
> Read docs/briefs/GLOWUP_ART.md and implement it exactly as briefed.
|
||||
|
||||
The Glow-Up brief covers findings 1–4: 32px atlas with tile variants, selective LED bloom (quality-gated), a decal system for screen-print + procedural party flyers on the booth walls, mixer densification, and PCB pagodas.
|
||||
|
||||
Backlog I noted but didn't brief (say the word on any): quick-chat **emotes** for multiplayer (safe social without text moderation), a **booth-reset ritual** (someone mines the fuse back out and the mix dies again), the record crate as a **level selector** to future worlds, dust-bunny critters, and mobile controls (biggest audience unlock, biggest lift). When the lanes land, I'll take the integrator seat again — same drill as last time.
|
||||
@ -41,8 +41,34 @@ const STATE_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'boot
|
||||
const edits = new Map();
|
||||
const repaired = new Set();
|
||||
const platters = { A: { playing: false, rpm: 33 }, B: { playing: false, rpm: 33 } };
|
||||
let workshop = null; // last-write-wins Headshell Workshop assembly state (or null)
|
||||
let dirty = false;
|
||||
|
||||
/** Validate + clamp a Headshell Workshop state; return a clean copy or null. */
|
||||
function validWorkshop(s) {
|
||||
if (!s || typeof s !== 'object') return null;
|
||||
if (typeof s.cartSeated !== 'boolean' || typeof s.tested !== 'boolean') return null;
|
||||
if (![0, 1, 2, 3].includes(s.cartRotation)) return null;
|
||||
if (!Array.isArray(s.wires) || s.wires.length !== 4) return null;
|
||||
const wires = [];
|
||||
for (const w of s.wires) {
|
||||
if (w === null || [0, 1, 2, 3].includes(w)) wires.push(w);
|
||||
else return null;
|
||||
}
|
||||
if (!Array.isArray(s.screws) || s.screws.length !== 2) return null;
|
||||
if (!s.screws.every((v) => typeof v === 'number' && Number.isFinite(v))) return null;
|
||||
if (typeof s.weightNotch !== 'number' || !Number.isFinite(s.weightNotch)) return null;
|
||||
const cl = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
return {
|
||||
cartSeated: s.cartSeated,
|
||||
cartRotation: s.cartRotation,
|
||||
wires,
|
||||
screws: [cl(s.screws[0]), cl(s.screws[1])],
|
||||
weightNotch: Math.max(0, Math.min(10, Math.round(s.weightNotch))),
|
||||
tested: s.tested,
|
||||
};
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
if (!existsSync(STATE_FILE)) return;
|
||||
try {
|
||||
@ -51,6 +77,7 @@ function loadState() {
|
||||
for (const n of s.repaired ?? []) if (NODES.has(n)) repaired.add(n);
|
||||
Object.assign(platters.A, s.platters?.A ?? {});
|
||||
Object.assign(platters.B, s.platters?.B ?? {});
|
||||
workshop = validWorkshop(s.workshop);
|
||||
console.log(`[relay] loaded state: ${edits.size} edits, ${repaired.size} repairs`);
|
||||
} catch (e) {
|
||||
console.error('[relay] state load failed, starting fresh:', e.message);
|
||||
@ -62,7 +89,7 @@ function saveState() {
|
||||
dirty = false;
|
||||
const tmp = STATE_FILE + '.tmp';
|
||||
writeFileSync(tmp, JSON.stringify({
|
||||
edits: [...edits.entries()], repaired: [...repaired], platters,
|
||||
edits: [...edits.entries()], repaired: [...repaired], platters, workshop,
|
||||
}));
|
||||
renameSync(tmp, STATE_FILE);
|
||||
}
|
||||
@ -109,7 +136,7 @@ wss.on('connection', (ws) => {
|
||||
send(ws, {
|
||||
t: 'hello', id,
|
||||
peers: [...peers.entries()].filter(([pid]) => pid !== id).map(([pid, p]) => ({ id: pid, name: p.name })),
|
||||
state: { edits: [...edits.entries()], repaired: [...repaired], platters },
|
||||
state: { edits: [...edits.entries()], repaired: [...repaired], platters, workshop },
|
||||
});
|
||||
|
||||
ws.on('pong', () => { peer.alive = true; });
|
||||
@ -157,6 +184,14 @@ wss.on('connection', (ws) => {
|
||||
broadcast({ t: 'platter', deck: m.deck, playing: m.playing, rpm: m.rpm }, id);
|
||||
break;
|
||||
}
|
||||
case 'workshop': {
|
||||
const s = validWorkshop(m.s);
|
||||
if (!s) return; // fuzz: bad shapes dropped
|
||||
workshop = s;
|
||||
dirty = true;
|
||||
broadcast({ t: 'workshop', s }, id);
|
||||
break;
|
||||
}
|
||||
default: // unknown type: ignore
|
||||
}
|
||||
});
|
||||
|
||||
@ -16,12 +16,29 @@ import { Groove } from './groove';
|
||||
import { Transport } from './scheduler';
|
||||
import { createNoiseBuffer, createCrackleBuffer } from './synth';
|
||||
import * as SFX from './sfx';
|
||||
import { playTestSignal, errorsFromKeys, type TestErrors } from './testSignal';
|
||||
|
||||
export type { TestErrors } from './testSignal';
|
||||
|
||||
type Deck = 'A' | 'B';
|
||||
type SoundCat = BlockDef['sound'];
|
||||
|
||||
interface DeckState { playing: boolean; rpm: number; }
|
||||
|
||||
// Exact map from the Headshell Workshop's `machine:interact` actions to their
|
||||
// one-shot SFX. These are the ONLY actions Workshop emits (Workshop.ts); an
|
||||
// exact match — not a substring scan — so a rename can't silently misroute and
|
||||
// unrelated actions (e.g. the RCA plug's `rca_seated`) can't be hijacked by a
|
||||
// loose substring. `rotate` and `cue` are intentionally absent: they fall
|
||||
// through to the generic handler (a button click / the tonearm cue creak).
|
||||
const WORKSHOP_SFX: Record<string, SFX.SfxName> = {
|
||||
seat: 'wireOn', // cartridge snaps into the headshell
|
||||
unclip: 'wireOff', // a wire pulled back off its pin
|
||||
crimp: 'crimp', // wire crimped onto a pin
|
||||
clunk: 'screwClunk', // a screw torqued fully home
|
||||
notch: 'weightDetent', // counterweight stepped a notch
|
||||
};
|
||||
|
||||
export class AudioEngine {
|
||||
private ctx: AudioContext | null = null;
|
||||
private groove!: Groove;
|
||||
@ -47,6 +64,7 @@ export class AudioEngine {
|
||||
private pitchTrim = 0;
|
||||
private lastStepCat: SoundCat = 'wood';
|
||||
private won = false;
|
||||
private lastTorqueTick = 0;
|
||||
|
||||
private readonly levels = { low: 0, mid: 0, high: 0 };
|
||||
|
||||
@ -197,9 +215,32 @@ export class AudioEngine {
|
||||
case 'fuse': SFX.fuseZap(ctx, dest, this.noise, t); break;
|
||||
case 'cue': SFX.cueCreak(ctx, dest, this.noise, t); break;
|
||||
case 'needle': SFX.needleDrop(ctx, dest, this.noise, t); break;
|
||||
// Headshell Workshop one-shots
|
||||
case 'crimp': SFX.crimp(ctx, dest, this.noise, t); break;
|
||||
case 'wireOn': SFX.wireOn(ctx, dest, this.noise, t); break;
|
||||
case 'wireOff': SFX.wireOff(ctx, dest, t); break;
|
||||
case 'screwTick': SFX.screwTick(ctx, dest, this.noise, t); break;
|
||||
case 'screwClunk': SFX.screwClunk(ctx, dest, t); break;
|
||||
case 'tiltCreak': SFX.tiltCreak(ctx, dest, this.noise, t); break;
|
||||
case 'weightDetent': SFX.weightDetent(ctx, dest, t); break;
|
||||
case 'bubbleLock': SFX.bubbleLock(ctx, dest, t); break;
|
||||
case 'skate': SFX.skate(ctx, dest, this.noise, t); break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Headshell Workshop stage-5 diagnostic (WORKSHOP_CARTRIDGE.md). Plays a bare
|
||||
* kick+bass loop through a chain that makes each assembly error audible in
|
||||
* true stereo. Returns the signal duration (s) so the caller can time the
|
||||
* scope trace / subtitle / needle lift; 0 if audio isn't initialized yet.
|
||||
* NOTE: `trackingHeavy` also wants the platter's target rpm sagged ~15% —
|
||||
* that's Lane D (platter.ts); this method only handles the sound.
|
||||
*/
|
||||
playTestSignal(errors: TestErrors): number {
|
||||
if (!this.ctx) return 0;
|
||||
return playTestSignal(this.ctx, this.outGain, this.noise, errors);
|
||||
}
|
||||
|
||||
/** Sync the WebAudio listener to the player each frame. */
|
||||
updateListener(view: IPlayerView): void {
|
||||
if (!this.ctx) return;
|
||||
@ -361,9 +402,32 @@ export class AudioEngine {
|
||||
|
||||
bus.on('machine:interact', (p) => {
|
||||
const a = p.action.toLowerCase();
|
||||
if (this.workshopSfxFor(a)) return; // Headshell Workshop actions
|
||||
if (a.includes('fader') || a.includes('pitch')) this.playSfx('fader');
|
||||
else if (a.includes('cue') || a.includes('arm') || a.includes('tonearm')) this.playSfx('cue');
|
||||
else this.playSfx('button');
|
||||
});
|
||||
|
||||
// ── Headshell Workshop (WORKSHOP_CARTRIDGE.md) — Lane D emits, we sound ──
|
||||
bus.on('workshop:test', (p) => {
|
||||
this.playTestSignal(errorsFromKeys(p.errors));
|
||||
});
|
||||
bus.on('workshop:torque', (p) => {
|
||||
// ratchet tick while a screw tightens; throttle the event storm
|
||||
if (p.value === null || !this.ctx) return;
|
||||
const now = this.ctx.currentTime;
|
||||
if (now - this.lastTorqueTick < 0.08) return;
|
||||
this.lastTorqueTick = now;
|
||||
this.playSfx('screwTick');
|
||||
});
|
||||
}
|
||||
|
||||
/** Play a Headshell Workshop action's one-shot; returns false if the action
|
||||
* isn't a workshop action (so the caller falls through to generic routing). */
|
||||
private workshopSfxFor(action: string): boolean {
|
||||
const sfx = WORKSHOP_SFX[action];
|
||||
if (!sfx) return false;
|
||||
this.playSfx(sfx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,3 +71,39 @@ const hud = new Hud({ onStart, onResume, getHotbar }); // onStart(once)=>audio.i
|
||||
|
||||
Nothing in `src/core/` was edited. No `setBlock` anywhere in Lane E. No rAF
|
||||
loop outside `src/demo/`. `npm run typecheck` is clean.
|
||||
|
||||
---
|
||||
|
||||
## Round 2 — Headshell Workshop (Lane E slice)
|
||||
|
||||
The workshop machine (Lane D, `src/machines/workshop/`) is fully decoupled from
|
||||
Lane E **via the bus** — Lane D emits, Lane E sounds/shows. No direct calls, no
|
||||
shared imports. What Lane E added (all additive):
|
||||
|
||||
- **`src/audio/testSignal.ts`** — `playTestSignal(errors)` renders the stage-5
|
||||
needle-drop diagnostic in true stereo (ChannelMerger), one audible chain per
|
||||
fault: `swapLR` (hard L/R ping-pong), `phaseCancel` (channel invert → thin),
|
||||
`azimuthTilt` (one channel −12 dB + waveshaper), `trackingLight` (80 ms
|
||||
gate stutter), `trackingHeavy` (1.2 kHz lowpass), `skate` (abort zip).
|
||||
`errorsFromKeys(string[])` adapts the event payload. Error vocabulary matches
|
||||
Lane D's `machines/workshop/types.ts` exactly (incl. `skate`).
|
||||
- **`src/audio/sfx.ts`** — 9 workshop one-shots: `crimp, wireOn, wireOff,
|
||||
screwTick, screwClunk, tiltCreak, weightDetent, bubbleLock, skate`.
|
||||
- **`AudioEngine`** self-wires these bus reactions:
|
||||
- `workshop:test {errors, clean}` → `playTestSignal(errorsFromKeys(errors))`.
|
||||
- `workshop:torque {value}` → throttled `screwTick` ratchet (value `null` = no-op).
|
||||
- `machine:interact {action}` → workshop action keywords
|
||||
(`crimp/wire/screw/tilt/detent/weight/bubble/skate/seat`) → the matching
|
||||
one-shot. Public `playTestSignal(errors)` also exists for direct use.
|
||||
|
||||
**Integration contract (for Fable / Lane D):**
|
||||
- `trackingHeavy` — Lane E does the lowpass; **Lane D should also sag the
|
||||
platter target rpm ~15%** (platter.ts) for the full "drag" effect.
|
||||
- The **scope block is Lane D's** (its own `drawScopeCanvas`); Lane E renders no
|
||||
scope (my earlier `src/fx/scope.ts` was removed to avoid duplication).
|
||||
- Screw confetti + torque gauge + diagnostic subtitle: see `fx`/`ui` HANDOFFs.
|
||||
|
||||
Verified live (demo-audio.html → "Headshell Workshop" panel): every error mode
|
||||
plays without clipping, the subtitle names the fault, the radial torque gauge
|
||||
tracks + hides, screw confetti fires, all 9 SFX distinct. Zero console errors;
|
||||
`npm run typecheck` clean across the whole tree (with Lane D's Workshop.ts present).
|
||||
|
||||
122
src/audio/sfx.ts
122
src/audio/sfx.ts
@ -199,6 +199,126 @@ export function needleDrop(ctx: BaseAudioContext, dest: AudioNode, noise: AudioB
|
||||
osc.connect(tg); osc.start(t); osc.stop(t + 0.14);
|
||||
}
|
||||
|
||||
// ── Headshell Workshop SFX (WORKSHOP_CARTRIDGE.md) ──────────────────────────
|
||||
|
||||
/** Crimping a tag-wire onto a pin: a stubborn ratchet tick (call while holding). */
|
||||
export function crimp(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
|
||||
noiseHit(ctx, dest, noise, t, { type: 'bandpass', freq: 2600, q: 8, dur: 0.03, gain: 0.12 });
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.08, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.04);
|
||||
g.connect(dest);
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'square';
|
||||
o.frequency.setValueAtTime(180, t);
|
||||
o.frequency.exponentialRampToValueAtTime(320, t + 0.03);
|
||||
o.connect(g); o.start(t); o.stop(t + 0.05);
|
||||
}
|
||||
|
||||
/** Wire seats home: a crisp little snap. */
|
||||
export function wireOn(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
|
||||
noiseHit(ctx, dest, noise, t, { type: 'highpass', freq: 4200, q: 2, dur: 0.035, gain: 0.2 });
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.18, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.05);
|
||||
g.connect(dest);
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'triangle'; o.frequency.setValueAtTime(520, t);
|
||||
o.frequency.exponentialRampToValueAtTime(760, t + 0.04);
|
||||
o.connect(g); o.start(t); o.stop(t + 0.06);
|
||||
}
|
||||
|
||||
/** Wire pulled off a pin: a soft dull pop. */
|
||||
export function wireOff(ctx: BaseAudioContext, dest: AudioNode, t: number): void {
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.16, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.07);
|
||||
g.connect(dest);
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'sine'; o.frequency.setValueAtTime(300, t);
|
||||
o.frequency.exponentialRampToValueAtTime(140, t + 0.06);
|
||||
o.connect(g); o.start(t); o.stop(t + 0.08);
|
||||
}
|
||||
|
||||
/** One click of the screwdriver ratchet (call repeatedly while torquing). */
|
||||
export function screwTick(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
|
||||
noiseHit(ctx, dest, noise, t, { type: 'bandpass', freq: 3400, q: 10, dur: 0.02, gain: 0.09 });
|
||||
}
|
||||
|
||||
/** Both screws seat evenly: a satisfying two-tone CLUNK. */
|
||||
export function screwClunk(ctx: BaseAudioContext, dest: AudioNode, t: number): void {
|
||||
for (const [f0, f1, off, gain] of [[240, 90, 0, 0.32], [420, 160, 0.02, 0.18]] as const) {
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(gain, t + off);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + off + 0.12);
|
||||
g.connect(dest);
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'sine'; o.frequency.setValueAtTime(f0, t + off);
|
||||
o.frequency.exponentialRampToValueAtTime(f1, t + off + 0.1);
|
||||
o.connect(g); o.start(t + off); o.stop(t + off + 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
/** The cartridge tips under uneven torque: a low groaning creak. */
|
||||
export function tiltCreak(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = noise; src.loop = true;
|
||||
const bp = ctx.createBiquadFilter();
|
||||
bp.type = 'bandpass'; bp.Q.value = 16;
|
||||
bp.frequency.setValueAtTime(340, t);
|
||||
bp.frequency.linearRampToValueAtTime(220, t + 0.35);
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.0001, t);
|
||||
g.gain.linearRampToValueAtTime(0.11, t + 0.08);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.4);
|
||||
src.connect(bp); bp.connect(g); g.connect(dest);
|
||||
src.start(t); src.stop(t + 0.42);
|
||||
}
|
||||
|
||||
/** Counterweight steps a notch along the rail: a mechanical detent tick. */
|
||||
export function weightDetent(ctx: BaseAudioContext, dest: AudioNode, t: number): void {
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.16, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.045);
|
||||
g.connect(dest);
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'square'; o.frequency.setValueAtTime(430, t);
|
||||
o.frequency.exponentialRampToValueAtTime(210, t + 0.04);
|
||||
o.connect(g); o.start(t); o.stop(t + 0.055);
|
||||
}
|
||||
|
||||
/** Bubble level centers on the 2 g mark: a soft confirming two-note chime. */
|
||||
export function bubbleLock(ctx: BaseAudioContext, dest: AudioNode, t: number): void {
|
||||
for (const [f, off] of [[880, 0], [1320, 0.08]] as const) {
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.0001, t + off);
|
||||
g.gain.exponentialRampToValueAtTime(0.12, t + off + 0.01);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + off + 0.3);
|
||||
g.connect(dest);
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'sine'; o.frequency.value = f;
|
||||
o.connect(g); o.start(t + off); o.stop(t + off + 0.34);
|
||||
}
|
||||
}
|
||||
|
||||
/** The needle skates when the cartridge is rotated wrong: descending zip. */
|
||||
export function skate(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = noise; src.loop = true;
|
||||
const bp = ctx.createBiquadFilter();
|
||||
bp.type = 'bandpass'; bp.Q.value = 6;
|
||||
bp.frequency.setValueAtTime(5200, t);
|
||||
bp.frequency.exponentialRampToValueAtTime(400, t + 0.6);
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.26, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.7);
|
||||
src.connect(bp); bp.connect(g); g.connect(dest);
|
||||
src.start(t); src.stop(t + 0.75);
|
||||
}
|
||||
|
||||
export type SfxName =
|
||||
| 'footstep' | 'land' | 'break' | 'place'
|
||||
| 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle';
|
||||
| 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle'
|
||||
// Headshell Workshop:
|
||||
| 'crimp' | 'wireOn' | 'wireOff' | 'screwTick' | 'screwClunk'
|
||||
| 'tiltCreak' | 'weightDetent' | 'bubbleLock' | 'skate';
|
||||
|
||||
165
src/audio/testSignal.ts
Normal file
165
src/audio/testSignal.ts
Normal file
@ -0,0 +1,165 @@
|
||||
// TURNCRAFT — Lane E. The Headshell Workshop test signal (WORKSHOP_CARTRIDGE.md
|
||||
// stage 5). A bare kick+bass loop pushed through a per-error processing chain so
|
||||
// that every cartridge-assembly mistake is *heard*, not just shown. True stereo
|
||||
// (ChannelMerger) so left/right swaps and phase cancellation are literal.
|
||||
//
|
||||
// The workshop machine derives `TestErrors` from its `WorkshopState` and calls
|
||||
// `AudioEngine.playTestSignal(errors)`; a clean assembly leaves every flag false.
|
||||
|
||||
import { mtof, kick } from './synth';
|
||||
|
||||
// Error-mode vocabulary matches Lane D's src/machines/workshop/types.ts (the
|
||||
// `workshop:test` bus event carries these keys as a string[]).
|
||||
/** One flag per audible failure mode. All false = a clean cartridge. Mirrors
|
||||
* Lane D's TestErrors (kept in sync by hand — only primitives cross the bus). */
|
||||
export interface TestErrors {
|
||||
incomplete?: boolean; // a pin has no wire — that channel is dead (silent)
|
||||
swapLR?: boolean; // R/W wires crossed with G/B — channels swapped
|
||||
phaseCancel?: boolean; // a +/- pair swapped — one channel inverted (thin/hollow)
|
||||
azimuthTilt?: boolean; // screws uneven — one channel -12 dB + distortion
|
||||
trackingLight?: boolean; // counterweight notch <= 6 — the signal stutters
|
||||
trackingHeavy?: boolean; // notch >= 9 — muffled (lowpass); platter also sags (Lane D)
|
||||
skate?: boolean; // cartridge misaligned — the needle skates, test aborts
|
||||
}
|
||||
|
||||
/** Build the flag object from the `workshop:test` event's `errors` key array. */
|
||||
export function errorsFromKeys(keys: string[]): TestErrors {
|
||||
const e: TestErrors = {};
|
||||
for (const k of keys) (e as Record<string, boolean>)[k] = true;
|
||||
return e;
|
||||
}
|
||||
|
||||
/** A gentle tanh-ish distortion curve for the azimuth channel. */
|
||||
function distortionCurve(amount: number): Float32Array<ArrayBuffer> {
|
||||
const n = 1024;
|
||||
const curve = new Float32Array(n);
|
||||
const k = amount;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = (i / (n - 1)) * 2 - 1;
|
||||
curve[i] = ((1 + k) * x) / (1 + k * Math.abs(x));
|
||||
}
|
||||
return curve;
|
||||
}
|
||||
|
||||
/** The skate: a fast descending zip when the cartridge is rotated wrong. */
|
||||
function skate(ctx: BaseAudioContext, out: AudioNode, noise: AudioBuffer, t: number): void {
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = noise; src.loop = true;
|
||||
const bp = ctx.createBiquadFilter();
|
||||
bp.type = 'bandpass'; bp.Q.value = 6;
|
||||
bp.frequency.setValueAtTime(5200, t);
|
||||
bp.frequency.exponentialRampToValueAtTime(400, t + 0.6);
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.28, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.7);
|
||||
src.connect(bp); bp.connect(g); g.connect(out);
|
||||
src.start(t); src.stop(t + 0.75);
|
||||
// a descending glissando under it
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(900, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(120, t + 0.6);
|
||||
const og = ctx.createGain();
|
||||
og.gain.setValueAtTime(0.12, t);
|
||||
og.gain.exponentialRampToValueAtTime(0.0001, t + 0.65);
|
||||
osc.connect(og); og.connect(out); osc.start(t); osc.stop(t + 0.7);
|
||||
}
|
||||
|
||||
/** A simple sub+saw bass note (centered) for the test loop. */
|
||||
function testBass(ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number): void {
|
||||
const lp = ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass'; lp.frequency.value = 320; lp.Q.value = 4;
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.0001, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.5, t + 0.01);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.26);
|
||||
lp.connect(g); g.connect(dest);
|
||||
const o = ctx.createOscillator();
|
||||
o.type = 'sawtooth'; o.frequency.value = freq;
|
||||
o.connect(lp); o.start(t); o.stop(t + 0.3);
|
||||
const sub = ctx.createOscillator();
|
||||
sub.type = 'sine'; sub.frequency.value = freq;
|
||||
const sg = ctx.createGain();
|
||||
sg.gain.setValueAtTime(0.0001, t);
|
||||
sg.gain.exponentialRampToValueAtTime(0.4, t + 0.01);
|
||||
sg.gain.exponentialRampToValueAtTime(0.0001, t + 0.26);
|
||||
sub.connect(sg); sg.connect(dest); sub.start(t); sub.stop(t + 0.3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the diagnostic. Returns the signal's duration in seconds so the caller
|
||||
* can time the scope trace / subtitle / needle-lift. Requires a live context.
|
||||
*/
|
||||
export function playTestSignal(
|
||||
ctx: BaseAudioContext, out: AudioNode, noise: AudioBuffer, errors: TestErrors,
|
||||
): number {
|
||||
const t0 = ctx.currentTime + 0.06;
|
||||
|
||||
// rotation wrong: the needle never tracks — it skates to the spindle.
|
||||
if (errors.skate) { skate(ctx, out, noise, t0); return 0.8; }
|
||||
|
||||
const bpm = 118, beat = 60 / bpm, beats = 8, dur = beats * beat; // 2 bars
|
||||
|
||||
// stereo assembly: gL / gR -> merger -> post-chain -> out
|
||||
const merger = ctx.createChannelMerger(2);
|
||||
const gL = ctx.createGain();
|
||||
const gR = ctx.createGain();
|
||||
gL.gain.value = 1;
|
||||
// incomplete wiring = a dead channel: silence the right side entirely so the
|
||||
// loop plays mono/lopsided — audibly "something isn't connected".
|
||||
gR.gain.value = errors.incomplete ? 0 : errors.phaseCancel ? -1 : errors.azimuthTilt ? 0.25 : 1;
|
||||
gL.connect(merger, 0, 0);
|
||||
gR.connect(merger, 0, 1);
|
||||
|
||||
// azimuth also lightly distorts the quieter channel
|
||||
let rightIn: AudioNode = gR;
|
||||
if (errors.azimuthTilt) {
|
||||
const ws = ctx.createWaveShaper();
|
||||
ws.curve = distortionCurve(8);
|
||||
ws.connect(gR);
|
||||
rightIn = ws;
|
||||
}
|
||||
|
||||
// post chain: [lowpass if heavy] -> gate (light stutter) -> out
|
||||
let head: AudioNode = merger;
|
||||
if (errors.trackingHeavy) {
|
||||
const lp = ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass'; lp.frequency.value = 1200; lp.Q.value = 0.7;
|
||||
merger.connect(lp); head = lp;
|
||||
}
|
||||
const gate = ctx.createGain();
|
||||
gate.gain.value = 0.9;
|
||||
head.connect(gate);
|
||||
gate.connect(out);
|
||||
|
||||
if (errors.trackingLight) {
|
||||
// gate the signal on/off ~every 80 ms so the arm audibly bounces
|
||||
let on = true;
|
||||
for (let t = t0; t < t0 + dur; t += 0.08) {
|
||||
on = Math.random() > 0.4;
|
||||
gate.gain.setValueAtTime(on ? 0.9 : 0.0, t);
|
||||
}
|
||||
gate.gain.setValueAtTime(0.9, t0 + dur);
|
||||
}
|
||||
|
||||
// schedule kick (four-on-floor) + bass; route per error
|
||||
const roots = [mtof(41), mtof(41), mtof(44), mtof(39)]; // F2 F2 Ab2 Eb2 across the 2 bars
|
||||
for (let b = 0; b < beats; b++) {
|
||||
const t = t0 + b * beat;
|
||||
if (errors.swapLR) {
|
||||
// ping-pong the kick hard L / R each beat — impossible to miss
|
||||
kick(ctx, b % 2 === 0 ? gL : rightIn, t, { gain: 1 });
|
||||
} else {
|
||||
kick(ctx, gL, t, { gain: 1 });
|
||||
kick(ctx, rightIn, t, { gain: 1 });
|
||||
}
|
||||
// bass on the offbeat, always centered (both channels)
|
||||
if (b % 2 === 1) {
|
||||
const f = roots[Math.floor(b / 2) % roots.length];
|
||||
testBass(ctx, gL, t + beat * 0.5, f);
|
||||
testBass(ctx, rightIn, t + beat * 0.5, f);
|
||||
}
|
||||
}
|
||||
|
||||
return dur;
|
||||
}
|
||||
@ -18,6 +18,12 @@ export type GameEvents = {
|
||||
'signal:repair': { node: SignalNode; repaired: number; total: number };
|
||||
'game:win': Record<string, never>;
|
||||
|
||||
// Headshell Workshop (Lane D emits; HUD/audio react). Additive per the
|
||||
// WORKSHOP_CARTRIDGE brief — only primitives cross the bus (no lane types).
|
||||
'workshop:test': { errors: string[]; clean: boolean }; // needle-drop verdict
|
||||
'workshop:torque': { value: number | null }; // radial HUD meter (null hides)
|
||||
'workshop:msg': { text: string }; // transient HUD subtitle
|
||||
|
||||
// Audio analysis (Lane E emits every beat/bar of the synthesized mix).
|
||||
'audio:beat': { energy: number }; // 0..1 low-band energy
|
||||
'audio:bar': { bar: number };
|
||||
|
||||
@ -22,6 +22,11 @@
|
||||
// the full needle-drop→slam sequence with confetti. [E4/E5]
|
||||
// • HUD: crosshair, hotbar (press 1–9), quest tracker, subtitles, start/pause
|
||||
// overlays, win banner — all live. [E5]
|
||||
// • Headshell Workshop: toggle any of the 6 error modes, "drop needle" →
|
||||
// hear each fault in stereo + the HUD subtitle names it; drag TORQUE → the
|
||||
// radial gauge + ratchet ticks; "screws" → confetti; each workshop SFX.
|
||||
// (These fire the real workshop:test / workshop:torque / workshop:msg bus
|
||||
// events that Lane D's machine emits in-game.)
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { bus } from '../core/events';
|
||||
@ -309,6 +314,35 @@ function buildPanel() {
|
||||
const winRow = grid();
|
||||
btn('▶ WIN', () => bus.emit('game:win', {}), winRow);
|
||||
btn('⟳ reset', () => location.reload(), winRow);
|
||||
|
||||
// HEADSHELL WORKSHOP — fires the real workshop:* bus events Lane D emits,
|
||||
// so this exercises the exact AudioEngine + Hud + Fx reactions used in-game.
|
||||
h('Headshell Workshop');
|
||||
const ORDER = ['skate', 'incomplete', 'swapLR', 'phaseCancel', 'azimuthTilt', 'trackingLight', 'trackingHeavy'] as const;
|
||||
const LABELS: Record<string, string> = {
|
||||
skate: 'CARTRIDGE ROTATED WRONG — NEEDLE SKATES',
|
||||
incomplete: 'WIRING INCOMPLETE — A CHANNEL IS DEAD',
|
||||
swapLR: 'LEFT AND RIGHT ARE SWAPPED',
|
||||
phaseCancel: 'A WIRE PAIR IS OUT OF PHASE',
|
||||
azimuthTilt: 'CARTRIDGE IS TILTED — EVEN THE SCREWS',
|
||||
trackingLight: 'TRACKING TOO LIGHT — IT SKIPS',
|
||||
trackingHeavy: 'TRACKING TOO HEAVY — IT DRAGS',
|
||||
};
|
||||
const errs: Record<string, boolean> = {};
|
||||
const errGrid = grid('three');
|
||||
for (const k of ORDER) btn(k, (b) => { errs[k] = !errs[k]; b.classList.toggle('on', errs[k]); }, errGrid);
|
||||
const wRow = grid();
|
||||
btn('▶ drop needle', () => {
|
||||
const keys = ORDER.filter((k) => errs[k]);
|
||||
bus.emit('workshop:test', { errors: keys, clean: keys.length === 0 });
|
||||
bus.emit('workshop:msg', { text: keys.length ? LABELS[keys[0]] : 'SIGNAL CLEAN — CARTRIDGE READY' });
|
||||
}, wRow);
|
||||
btn('🔩 screws', () => fx.screwBurst(LAYOUT.deckA.spindleX + 30, Y_RECORD_TOP + 2, LAYOUT.deckA.spindleZ - 18), wRow);
|
||||
slider('Torque', 0, 1, 0.01, 0, (v) => bus.emit('workshop:torque', { value: v }));
|
||||
btn('release screw', () => bus.emit('workshop:torque', { value: null }), grid());
|
||||
const wsfx = grid('three');
|
||||
(['crimp', 'wireOn', 'wireOff', 'screwClunk', 'tiltCreak', 'weightDetent', 'bubbleLock', 'skate'] as const)
|
||||
.forEach((n) => btn(n, () => audio.playSfx(n), wsfx));
|
||||
}
|
||||
|
||||
function moveCamTo(x: number, z: number) {
|
||||
|
||||
@ -29,7 +29,7 @@ import { ChunkManager } from '../engine/ChunkManager';
|
||||
const B = (name: string) => BLOCK_BY_NAME.get(name)!.id;
|
||||
|
||||
const app = document.getElementById('app')!;
|
||||
const { renderer, scene, camera, atlas, setEmissiveBoost, render } = createRenderer(app);
|
||||
const { renderer, scene, camera, atlas, setEmissiveBoost, setBloom, render } = createRenderer(app);
|
||||
|
||||
let hud: HTMLDivElement; // populated by buildHud(), read by updateHud()
|
||||
|
||||
@ -51,7 +51,7 @@ controls.update();
|
||||
|
||||
// DEBUG: dev-only hook so a camera can be flown to a viewpoint for inspection.
|
||||
(window as unknown as { __demo?: unknown }).__demo = {
|
||||
scene, atlas, camera, controls,
|
||||
scene, atlas, camera, controls, setBloom, setEmissiveBoost,
|
||||
look(px: number, py: number, pz: number, tx: number, ty: number, tz: number) {
|
||||
camera.position.set(px, py, pz);
|
||||
controls.target.set(tx, ty, tz);
|
||||
|
||||
@ -202,13 +202,20 @@ canvas.addEventListener('mousedown', (e) => {
|
||||
canvas.addEventListener('mouseup', (e) => { if (e.button === 0) interaction.stopMining(); });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
keys.add(e.code);
|
||||
if (e.code === 'KeyE') interaction.use();
|
||||
if (e.code.startsWith('Digit')) {
|
||||
// Match the real game's press/hold/release wiring so hold-to-crimp/torque
|
||||
// works here. The legacy one-shot use() latched eDown with no release, which
|
||||
// inverted the hold (a held crimp never completed; it finished after release).
|
||||
if (e.code === 'KeyE') { if (!e.repeat) interaction.useDown(e.shiftKey); }
|
||||
else if (e.code === 'KeyQ') interaction.dropCarry();
|
||||
else if (e.code.startsWith('Digit')) {
|
||||
const n = Number(e.code.slice(5));
|
||||
if (n >= 1 && n <= 9) hotbar.setActive(n - 1);
|
||||
}
|
||||
});
|
||||
window.addEventListener('keyup', (e) => keys.delete(e.code));
|
||||
window.addEventListener('keyup', (e) => {
|
||||
keys.delete(e.code);
|
||||
if (e.code === 'KeyE') interaction.useUp();
|
||||
});
|
||||
|
||||
function moveTick(dt: number): void {
|
||||
const spd = (keys.has('KeyR') ? 90 : 34) * dt;
|
||||
|
||||
@ -183,7 +183,64 @@ const platMesh = new THREE.Mesh(
|
||||
platMesh.position.set(PLAT_BASE_X, 0.56, 125); // visual lift; collider top stays y=1
|
||||
scene.add(platMesh);
|
||||
|
||||
const colliders = [disc, platform];
|
||||
// (3) Seesaw arm — models the Headshell Workshop's Stage-4 tonearm: a chain of
|
||||
// AABB segments that TILT about a pivot, so their tops move vertically under
|
||||
// the player in real time. This is the "vertically-moving rideable collider"
|
||||
// the workshop needs Lane B to ride without slingshotting.
|
||||
const SEE_PX = 90, SEE_PZ = 95, SEE_PY = 4; // pivot
|
||||
const SEE_L = 24, SEE_N = 8, SEE_ZHALF = 3, SEE_THICK = 1.0;
|
||||
let seeTilt = 0; // radians (+ = head end up)
|
||||
let seeOmega = 0; // rad/s, tilt angular velocity (for velocityAt)
|
||||
let seeOsc = false;
|
||||
let seePhase = 0; // oscillator phase; advances ONLY while tilting so tilt is continuous across toggles
|
||||
const seeSegShapes: { kind: 'aabb'; min: [number, number, number]; max: [number, number, number] }[] = [];
|
||||
const seeColliders: KinematicCollider[] = [];
|
||||
for (let i = 0; i < SEE_N; i++) {
|
||||
const seg = { kind: 'aabb' as const, min: [0, 0, 0] as [number, number, number], max: [0, 0, 0] as [number, number, number] };
|
||||
seeSegShapes.push(seg);
|
||||
seeColliders.push({
|
||||
id: `seesaw${i}`,
|
||||
shape: seg,
|
||||
// Perpendicular surface velocity of a tilting bar at horizontal distance d.
|
||||
velocityAt(x) {
|
||||
const d = x - SEE_PX;
|
||||
return [-d * Math.tan(seeTilt) * seeOmega, d * seeOmega, 0];
|
||||
},
|
||||
});
|
||||
}
|
||||
function updateSeesaw(): void {
|
||||
const t = Math.tan(seeTilt);
|
||||
const seg = SEE_L / SEE_N;
|
||||
for (let i = 0; i < SEE_N; i++) {
|
||||
const xa = SEE_PX + seg * i, xb = SEE_PX + seg * (i + 1);
|
||||
const cy = SEE_PY + ((xa + xb) / 2 - SEE_PX) * t;
|
||||
const s = seeSegShapes[i];
|
||||
s.min[0] = xa; s.max[0] = xb;
|
||||
s.min[1] = cy - SEE_THICK; s.max[1] = cy + SEE_THICK;
|
||||
s.min[2] = SEE_PZ - SEE_ZHALF; s.max[2] = SEE_PZ + SEE_ZHALF;
|
||||
}
|
||||
}
|
||||
updateSeesaw();
|
||||
|
||||
const seeMesh = new THREE.Group();
|
||||
seeMesh.position.set(SEE_PX, SEE_PY, SEE_PZ);
|
||||
{
|
||||
const bar = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(SEE_L, SEE_THICK * 2, SEE_ZHALF * 2),
|
||||
new THREE.MeshStandardMaterial({ color: 0xc8ccd2, roughness: 0.4, metalness: 0.6 }),
|
||||
);
|
||||
bar.position.x = SEE_L / 2;
|
||||
seeMesh.add(bar);
|
||||
const pivot = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(2, 2.4, 8, 16),
|
||||
new THREE.MeshStandardMaterial({ color: 0x9aa0a8, roughness: 0.5, metalness: 0.5 }),
|
||||
);
|
||||
pivot.position.y = -4;
|
||||
seeMesh.add(pivot);
|
||||
}
|
||||
scene.add(seeMesh);
|
||||
|
||||
const colliders = [disc, platform, ...seeColliders];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Player.
|
||||
@ -204,6 +261,15 @@ bus.on('player:landed', (e) => console.log('player:landed impactSpeed=', e.impac
|
||||
player, world, colliders,
|
||||
setRpm: (r: number) => { discRpm = r; },
|
||||
PLATTER,
|
||||
// Drive the seesaw deterministically (headless slingshot tests).
|
||||
seesaw: {
|
||||
set(tilt: number, omega: number) {
|
||||
seeTilt = tilt; seeOmega = omega; updateSeesaw(); seeMesh.rotation.z = seeTilt;
|
||||
},
|
||||
topAt(x: number) { return SEE_PY + (x - SEE_PX) * Math.tan(seeTilt) + SEE_THICK; },
|
||||
info: { PX: SEE_PX, PZ: SEE_PZ, PY: SEE_PY, L: SEE_L, N: SEE_N, ZHALF: SEE_ZHALF, THICK: SEE_THICK },
|
||||
get tilt() { return seeTilt; },
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -227,6 +293,22 @@ bobBtn.addEventListener('click', () => {
|
||||
bobBtn.textContent = `View-bob: ${player.viewBob ? 'on' : 'off'}`;
|
||||
});
|
||||
|
||||
// Seesaw controls (added in JS to avoid touching the HTML shell).
|
||||
const panel = $('panel');
|
||||
const seeBtn = document.createElement('button');
|
||||
seeBtn.textContent = 'Seesaw: still';
|
||||
seeBtn.addEventListener('click', () => {
|
||||
seeOsc = !seeOsc;
|
||||
seeBtn.textContent = `Seesaw: ${seeOsc ? 'tilting' : 'still'}`;
|
||||
});
|
||||
panel.appendChild(seeBtn);
|
||||
const armTpBtn = document.createElement('button');
|
||||
armTpBtn.textContent = 'Teleport to arm';
|
||||
armTpBtn.addEventListener('click', () => {
|
||||
player.teleport([SEE_PX + SEE_L * 0.5, SEE_PY + (SEE_L * 0.5) * Math.tan(seeTilt) + SEE_THICK + 0.05, SEE_PZ]);
|
||||
});
|
||||
panel.appendChild(armTpBtn);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
@ -250,6 +332,21 @@ function stepColliders(): void {
|
||||
platShape.min[0] = cx - PLAT_HALF;
|
||||
platShape.max[0] = cx + PLAT_HALF;
|
||||
platMesh.position.x = cx;
|
||||
|
||||
if (seeOsc) {
|
||||
// Advance the phase only while tilting. Using absolute simTime here made the
|
||||
// tilt teleport on toggle-on (seeTilt jumped from its stale value to
|
||||
// 0.14·sin(simTime·0.9) in one tick), spiking seeOmega to ~8 rad/s and
|
||||
// slingshotting the rider — a false positive baked into the test rig itself.
|
||||
seePhase += FIXED_DT * 0.9;
|
||||
const newTilt = 0.14 * Math.sin(seePhase);
|
||||
seeOmega = (newTilt - seeTilt) / FIXED_DT;
|
||||
seeTilt = newTilt;
|
||||
} else {
|
||||
seeOmega = 0;
|
||||
}
|
||||
updateSeesaw();
|
||||
seeMesh.rotation.z = seeTilt;
|
||||
}
|
||||
|
||||
function frame(now: number): void {
|
||||
|
||||
@ -63,14 +63,17 @@ function validate(): { line: string; pass: boolean }[] {
|
||||
add(`build < 500ms (${buildMs.toFixed(1)}ms)`, buildMs < 500);
|
||||
add(`no out-of-bounds writes (${world.oob})`, world.oob === 0);
|
||||
|
||||
// The platter (radius PLATTER.radius) must have a clear footprint; the concept-
|
||||
// art pass added an intentional shallow step-ring OUTSIDE that radius (so a
|
||||
// fallen player climbs out), so we only assert the platter's own footprint.
|
||||
for (const [name, d] of [['A', LAYOUT.deckA], ['B', LAYOUT.deckB]] as const) {
|
||||
const wellR = PLATTER.radius + 2; let stray = 0;
|
||||
for (let dx = -wellR; dx <= wellR; dx++) for (let dz = -wellR; dz <= wellR; dz++) {
|
||||
if (dx * dx + dz * dz > (wellR - 1) * (wellR - 1)) continue;
|
||||
const r = PLATTER.radius; let stray = 0;
|
||||
for (let dx = -r; dx <= r; dx++) for (let dz = -r; dz <= r; dz++) {
|
||||
if (dx * dx + dz * dz > r * r) continue;
|
||||
for (let y = Y_PLINTH_TOP - 5; y < Y_PLINTH_TOP; y++)
|
||||
if (world.getBlock(d.spindleX + dx, y, d.spindleZ + dz) !== AIR && !(dx === 0 && dz === 0)) stray++;
|
||||
}
|
||||
add(`deck ${name} platter well empty`, stray === 0);
|
||||
add(`deck ${name} platter footprint clear`, stray === 0);
|
||||
}
|
||||
|
||||
const [sx, sy, sz] = SPAWN;
|
||||
|
||||
@ -1,22 +1,29 @@
|
||||
// TURNCRAFT — Lane A. Procedural texture atlas + block materials.
|
||||
// TURNCRAFT — Lane A. Procedural texture atlas + block materials. (Glow-Up v2)
|
||||
//
|
||||
// One canvas atlas of 16x16 px tiles, one tile per block id (index = id), each
|
||||
// painted from its BLOCKS `pattern` + `tint`. A parallel emissive atlas carries
|
||||
// the glow for emissive blocks (black elsewhere). Two MeshStandardMaterials
|
||||
// (opaque + transparent) sample the atlas; a small onBeforeCompile patch maps
|
||||
// the greedy mesher's per-quad tile-repeat UVs into the correct atlas cell with
|
||||
// `fract`, so one merged quad can tile a block texture across many voxels.
|
||||
// Atlas of 32x32 px tiles painted per block `pattern` + `tint`. Each block gets
|
||||
// NVAR painted VARIANTS laid out in extra columns; the material's shader patch
|
||||
// hashes each voxel's world position to pick a variant, so a repeated surface
|
||||
// (PCB floor, plywood wall, brushed metal) breaks up instead of tiling like
|
||||
// wallpaper. A parallel emissive atlas carries the glow (black elsewhere).
|
||||
//
|
||||
// Filtering: NearestFilter, no mipmaps. With fract-in-shader the sampled atlas
|
||||
// UV stays strictly inside a cell ([col/cols, (col+1)/cols)), so no padding /
|
||||
// bleeding is needed and there are no mip-derivative seams.
|
||||
// Two MeshStandardMaterials (opaque + transparent) sample the atlas. The greedy
|
||||
// mesher writes a per-quad tile-repeat `uv` (0..w,0..h) and an `aTile` = the
|
||||
// block's BASE cell (id%8, id/8); the shader fracts the repeat, expands the cell
|
||||
// column by the hashed variant, and samples map + emissiveMap. The mesher is
|
||||
// unchanged — the variant system lives entirely here.
|
||||
//
|
||||
// Filtering: NearestFilter, no mipmaps. fract keeps each sample strictly inside
|
||||
// its variant cell ([col/(COLS*NVAR), (col+1)/(COLS*NVAR))) so there is no
|
||||
// bleeding and no mip-derivative seams — the chunky voxel look is preserved.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { BLOCKS, BLOCK_BY_ID, type BlockDef, type Pattern } from '../core/blocks';
|
||||
import { BLOCKS, BLOCK_BY_ID, type BlockDef } from '../core/blocks';
|
||||
|
||||
const TILE = 16;
|
||||
const COLS = 8;
|
||||
const ROWS = Math.ceil(BLOCKS.length / COLS); // 31 blocks -> 4 rows
|
||||
const TILE = 32; // was 16 — sharper patterns
|
||||
const COLS = 8; // base columns (matches mesher aTile: id%8)
|
||||
const ROWS = Math.ceil(BLOCKS.length / COLS); // 31 blocks -> 4 rows
|
||||
const NVAR = 4; // painted variants per block
|
||||
const GRID_W = COLS * NVAR; // physical atlas columns (32)
|
||||
|
||||
export interface Atlas {
|
||||
texture: THREE.CanvasTexture;
|
||||
@ -30,7 +37,7 @@ export interface Atlas {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
// --- deterministic per-tile noise (stable atlas across reloads) ---
|
||||
// --- deterministic per-tile PRNG (stable atlas across reloads) ---
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
@ -41,165 +48,175 @@ function mulberry32(seed: number): () => number {
|
||||
};
|
||||
}
|
||||
|
||||
type RGBA = [number, number, number, number];
|
||||
|
||||
function clamp8(v: number): number { return v < 0 ? 0 : v > 255 ? 255 : v | 0; }
|
||||
|
||||
/** Paint one 16x16 tile into `data` (RGBA, row-major, origin top-left). */
|
||||
function paintTile(data: Uint8ClampedArray, def: BlockDef): void {
|
||||
const rng = mulberry32(0x9e37 + def.id * 2654435761);
|
||||
/**
|
||||
* Paint one variant of one block's 32x32 tile onto a fresh 2D context.
|
||||
* `alpha` bakes the block's translucency (opaque = 1; glass/vinyl < 1).
|
||||
*/
|
||||
function paintTile(ctx: CanvasRenderingContext2D, def: BlockDef, variant: number): void {
|
||||
const rng = mulberry32((0x9e3779b9 ^ (def.id * 2654435761) ^ (variant * 0x85ebca6b)) >>> 0);
|
||||
const [r, g, b] = def.tint;
|
||||
const set = (x: number, y: number, c: RGBA) => {
|
||||
const i = (y * TILE + x) * 4;
|
||||
data[i] = clamp8(c[0]); data[i + 1] = clamp8(c[1]);
|
||||
data[i + 2] = clamp8(c[2]); data[i + 3] = clamp8(c[3]);
|
||||
const alpha = def.transparent ? (def.pattern === 'glass' ? 0.24 : 0.82) : 1;
|
||||
// colour from tint * multiplier, at the block's alpha
|
||||
const col = (m: number, a = alpha) =>
|
||||
`rgba(${clamp8(r * m)},${clamp8(g * m)},${clamp8(b * m)},${a})`;
|
||||
const fill = (m: number, a = alpha) => { ctx.fillStyle = col(m, a); ctx.fillRect(0, 0, TILE, TILE); };
|
||||
const noise = (amp: number, step = 2) => {
|
||||
for (let y = 0; y < TILE; y += step)
|
||||
for (let x = 0; x < TILE; x += step) {
|
||||
ctx.fillStyle = col(1 + (rng() - 0.5) * amp);
|
||||
ctx.fillRect(x, y, step, step);
|
||||
}
|
||||
};
|
||||
|
||||
const pattern: Pattern = def.pattern;
|
||||
switch (pattern) {
|
||||
switch (def.pattern) {
|
||||
case 'solid': {
|
||||
for (let y = 0; y < TILE; y++)
|
||||
for (let x = 0; x < TILE; x++) {
|
||||
const n = (rng() - 0.5) * 0.12; // +/-6% value noise
|
||||
set(x, y, [r * (1 + n), g * (1 + n), b * (1 + n), 255]);
|
||||
}
|
||||
noise(0.12);
|
||||
break;
|
||||
}
|
||||
case 'brushed': {
|
||||
for (let y = 0; y < TILE; y++) {
|
||||
const rowShade = 1 + (rng() - 0.5) * 0.06;
|
||||
for (let x = 0; x < TILE; x++) {
|
||||
const streak = 1 + (rng() - 0.5) * 0.22; // strong along-x variation
|
||||
const m = rowShade * streak;
|
||||
set(x, y, [r * m, g * m, b * m, 255]);
|
||||
}
|
||||
// long vertical strokes: low-freq column shade + fine grain + a few scratches
|
||||
for (let x = 0; x < TILE; x++) {
|
||||
const m = 1 + Math.sin(x * 0.5 + variant) * 0.05 + (rng() - 0.5) * 0.05;
|
||||
ctx.fillStyle = col(m);
|
||||
ctx.fillRect(x, 0, 1, TILE);
|
||||
}
|
||||
for (let i = 0; i < 3; i++) { ctx.fillStyle = col(1.3, alpha * 0.55); ctx.fillRect((rng() * TILE) | 0, 0, 1, TILE); }
|
||||
for (let i = 0; i < 2; i++) { ctx.fillStyle = col(0.7, alpha * 0.55); ctx.fillRect((rng() * TILE) | 0, 0, 1, TILE); }
|
||||
break;
|
||||
}
|
||||
case 'plywood': {
|
||||
const edge = def.name === 'ply_edge';
|
||||
for (let y = 0; y < TILE; y++) {
|
||||
// laminate stripes for ply_edge, wavy grain otherwise
|
||||
const stripe = edge && (y % 4 === 0) ? 0.82 : 1;
|
||||
for (let x = 0; x < TILE; x++) {
|
||||
const grain = Math.sin((x + Math.sin(y * 0.7) * 2) * 0.9) * 0.08;
|
||||
const n = (rng() - 0.5) * 0.06;
|
||||
const m = (1 + grain + n) * stripe;
|
||||
set(x, y, [r * m, g * m, b * m, 255]);
|
||||
if (def.name === 'ply_edge') {
|
||||
// end grain: concentric growth rings
|
||||
fill(1);
|
||||
const cx = TILE * (0.3 + rng() * 0.4), cy = TILE * (0.3 + rng() * 0.4);
|
||||
for (let ring = 0; ring < 18; ring++) {
|
||||
ctx.strokeStyle = col(ring % 2 ? 0.9 : 0.74);
|
||||
ctx.lineWidth = 1.3;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, ring * 2.3 + 2, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
} else {
|
||||
// long horizontal wavy grain
|
||||
for (let y = 0; y < TILE; y++) {
|
||||
const wave = Math.sin(y * 0.5 + variant * 1.7) * 1.6;
|
||||
const m = 1 + Math.sin((y + wave) * 0.85) * 0.09 + (rng() - 0.5) * 0.04;
|
||||
ctx.fillStyle = col(m); ctx.fillRect(0, y, TILE, 1);
|
||||
}
|
||||
for (let i = 0; i < 3; i++) { ctx.fillStyle = col(0.8, alpha * 0.6); ctx.fillRect(0, (rng() * TILE) | 0, TILE, 1); }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'speckle': {
|
||||
for (let y = 0; y < TILE; y++)
|
||||
for (let x = 0; x < TILE; x++) {
|
||||
let m = 1 + (rng() - 0.5) * 0.08;
|
||||
if (rng() < 0.10) m *= 0.65; // darker plastic speckles
|
||||
set(x, y, [r * m, g * m, b * m, 255]);
|
||||
}
|
||||
noise(0.08);
|
||||
for (let i = 0; i < 26; i++) {
|
||||
ctx.fillStyle = col(0.6, alpha);
|
||||
ctx.fillRect((rng() * TILE) | 0, (rng() * TILE) | 0, 2, 2);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'grooves': {
|
||||
// straight parallel dark lines — reads as vinyl grooves at tile scale
|
||||
for (let y = 0; y < TILE; y++) {
|
||||
const groove = y % 2 === 0 ? 0.78 : 1.04;
|
||||
for (let x = 0; x < TILE; x++) {
|
||||
const n = (rng() - 0.5) * 0.05;
|
||||
const m = groove * (1 + n);
|
||||
set(x, y, [r * m, g * m, b * m, def.transparent ? 210 : 255]);
|
||||
}
|
||||
// concentric arcs (record grooves) from a centre far below the tile + a sheen line
|
||||
fill(1);
|
||||
const cx = TILE / 2 + (variant - 1.5) * 3, cy = TILE * 3.2;
|
||||
for (let rad = 56; rad < 96; rad += 2) {
|
||||
ctx.strokeStyle = col(0.72, alpha * 0.9);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, rad + rng() * 0.4, -Math.PI, 0); ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = col(1.7, alpha * 0.5);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(0, 8 + variant); ctx.lineTo(TILE, 12 + variant); ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'pcb': {
|
||||
for (let y = 0; y < TILE; y++)
|
||||
for (let x = 0; x < TILE; x++) {
|
||||
const n = (rng() - 0.5) * 0.08;
|
||||
set(x, y, [r * (1 + n), g * (1 + n), b * (1 + n), 255]);
|
||||
}
|
||||
// copper traces
|
||||
const trace: RGBA = [188, 118, 62, 255];
|
||||
for (let x = 0; x < TILE; x++) { set(x, 5, trace); set(x, 11, trace); }
|
||||
for (let y = 5; y <= 11; y++) { set(4, y, trace); set(12, y, trace); }
|
||||
// solder pads
|
||||
const pad: RGBA = [210, 212, 216, 255];
|
||||
for (const [px, py] of [[4, 5], [12, 5], [4, 11], [12, 11]] as const) {
|
||||
set(px, py, pad); set(px + 1, py, pad); set(px, py + 1, pad); set(px + 1, py + 1, pad);
|
||||
}
|
||||
// 4 variants that tile into routed traces: mid-edge trace stubs line up
|
||||
fill(1); noise(0.09, 2);
|
||||
const mid = TILE / 2;
|
||||
const copper = 'rgb(190,120,64)';
|
||||
ctx.strokeStyle = copper; ctx.lineWidth = 3; ctx.lineCap = 'butt';
|
||||
ctx.beginPath();
|
||||
if (variant === 0) { ctx.moveTo(0, mid); ctx.lineTo(TILE, mid); } // straight-through
|
||||
else if (variant === 1) { ctx.moveTo(mid, 0); ctx.lineTo(mid, TILE); } // vertical
|
||||
else if (variant === 2) { ctx.moveTo(0, mid); ctx.lineTo(TILE, mid); ctx.moveTo(mid, 0); ctx.lineTo(mid, TILE); } // cross
|
||||
else { ctx.moveTo(0, mid); ctx.lineTo(mid, mid); ctx.lineTo(mid, TILE); } // L-bend
|
||||
ctx.stroke();
|
||||
const via = (x: number, y: number) => {
|
||||
ctx.fillStyle = 'rgb(206,208,212)'; ctx.beginPath(); ctx.arc(x, y, 3.6, 0, 7); ctx.fill();
|
||||
ctx.fillStyle = 'rgb(30,44,32)'; ctx.beginPath(); ctx.arc(x, y, 1.6, 0, 7); ctx.fill();
|
||||
};
|
||||
if (variant >= 2) via(mid, mid);
|
||||
if (variant === 0) via(TILE * 0.28, mid);
|
||||
if (variant === 1) via(mid, TILE * 0.72);
|
||||
// white silkscreen flecks
|
||||
ctx.fillStyle = 'rgba(220,226,214,0.7)';
|
||||
for (let i = 0; i < 5; i++) ctx.fillRect((rng() * TILE) | 0, (rng() * TILE) | 0, 1 + ((rng() * 3) | 0), 1);
|
||||
break;
|
||||
}
|
||||
case 'mesh': {
|
||||
for (let y = 0; y < TILE; y++)
|
||||
for (let x = 0; x < TILE; x++) {
|
||||
const hole = (x % 2 === 0 && y % 2 === 0);
|
||||
const m = hole ? 0.4 : 1.08;
|
||||
set(x, y, [r * m, g * m, b * m, 255]);
|
||||
}
|
||||
fill(1);
|
||||
ctx.fillStyle = col(0.34);
|
||||
for (let y = 2; y < TILE; y += 3)
|
||||
for (let x = 2; x < TILE; x += 3) ctx.fillRect(x, y, 2, 2);
|
||||
break;
|
||||
}
|
||||
case 'glass': {
|
||||
for (let y = 0; y < TILE; y++)
|
||||
for (let x = 0; x < TILE; x++) {
|
||||
const streak = (x + y) % 6 === 0 ? 1.35 : 1;
|
||||
set(x, y, [r * streak, g * streak, b * streak, 60]); // low alpha
|
||||
}
|
||||
fill(1); // already low alpha
|
||||
ctx.strokeStyle = col(1.5, alpha * 1.4);
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = -TILE; i < TILE; i += 9) {
|
||||
ctx.beginPath(); ctx.moveTo(i + variant * 2, 0); ctx.lineTo(i + variant * 2 + TILE, TILE); ctx.stroke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'led': {
|
||||
const cx = 7.5, cy = 7.5, maxD = 8.2;
|
||||
for (let y = 0; y < TILE; y++)
|
||||
for (let x = 0; x < TILE; x++) {
|
||||
const d = Math.hypot(x - cx, y - cy) / maxD;
|
||||
const glow = Math.max(0, 1 - d * d); // bright core, soft falloff
|
||||
const m = 0.35 + glow * 1.15;
|
||||
set(x, y, [r * m, g * m, b * m, 255]);
|
||||
}
|
||||
// bright core, soft radial falloff (smooth gradient)
|
||||
const c = TILE / 2 - 0.5;
|
||||
const grad = ctx.createRadialGradient(c, c, 1, c, c, TILE * 0.55);
|
||||
grad.addColorStop(0, col(1.6)); grad.addColorStop(0.5, col(1.05)); grad.addColorStop(1, col(0.35));
|
||||
ctx.fillStyle = grad; ctx.fillRect(0, 0, TILE, TILE);
|
||||
break;
|
||||
}
|
||||
case 'felt': {
|
||||
for (let y = 0; y < TILE; y++)
|
||||
for (let x = 0; x < TILE; x++) {
|
||||
const n = (rng() - 0.5) * 0.20; // soft high-frequency fibres
|
||||
const m = 1 + n;
|
||||
set(x, y, [r * m, g * m, b * m, 255]);
|
||||
}
|
||||
noise(0.2, 1); // soft high-frequency fibres
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAtlas(): Atlas {
|
||||
const w = COLS * TILE, h = ROWS * TILE;
|
||||
const w = GRID_W * TILE, h = ROWS * TILE;
|
||||
|
||||
const baseCanvas = makeCanvas(w, h);
|
||||
const emisCanvas = makeCanvas(w, h);
|
||||
const baseCtx = baseCanvas.getContext('2d')!;
|
||||
const emisCtx = emisCanvas.getContext('2d')!;
|
||||
// Emissive atlas defaults to black (no glow) for every non-emissive block.
|
||||
emisCtx.fillStyle = '#000';
|
||||
emisCtx.fillRect(0, 0, w, h);
|
||||
emisCtx.fillRect(0, 0, w, h); // emissive defaults to black (no glow)
|
||||
|
||||
const tileCanvas = makeCanvas(TILE, TILE);
|
||||
const tileCtx = tileCanvas.getContext('2d')!;
|
||||
|
||||
const tileData = new Uint8ClampedArray(TILE * TILE * 4);
|
||||
for (let id = 0; id < BLOCKS.length; id++) {
|
||||
const def = BLOCK_BY_ID[id];
|
||||
if (!def) continue;
|
||||
const col = id % COLS, row = (id / COLS) | 0;
|
||||
const ox = col * TILE, oy = row * TILE;
|
||||
const baseCol = id % COLS, row = (id / COLS) | 0;
|
||||
for (let variant = 0; variant < NVAR; variant++) {
|
||||
const ox = (baseCol * NVAR + variant) * TILE, oy = row * TILE;
|
||||
tileCtx.clearRect(0, 0, TILE, TILE);
|
||||
paintTile(tileCtx, def, variant);
|
||||
baseCtx.drawImage(tileCanvas, ox, oy);
|
||||
|
||||
tileData.fill(0);
|
||||
paintTile(tileData, def);
|
||||
baseCtx.putImageData(new ImageData(tileData.slice(), TILE, TILE), ox, oy);
|
||||
|
||||
if (def.emissive > 0) {
|
||||
// Emissive tile = base rgb scaled by emissive strength (glows in-colour).
|
||||
const em = new Uint8ClampedArray(TILE * TILE * 4);
|
||||
for (let i = 0; i < TILE * TILE; i++) {
|
||||
em[i * 4] = tileData[i * 4] * def.emissive;
|
||||
em[i * 4 + 1] = tileData[i * 4 + 1] * def.emissive;
|
||||
em[i * 4 + 2] = tileData[i * 4 + 2] * def.emissive;
|
||||
em[i * 4 + 3] = 255;
|
||||
if (def.emissive > 0) {
|
||||
// emissive tile = base rgb * emissive strength (glows in its own colour)
|
||||
const img = tileCtx.getImageData(0, 0, TILE, TILE);
|
||||
const d = img.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = d[i] * def.emissive; d[i + 1] = d[i + 1] * def.emissive; d[i + 2] = d[i + 2] * def.emissive;
|
||||
d[i + 3] = 255;
|
||||
}
|
||||
emisCtx.putImageData(img, ox, oy);
|
||||
}
|
||||
emisCtx.putImageData(new ImageData(em, TILE, TILE), ox, oy);
|
||||
}
|
||||
}
|
||||
|
||||
@ -246,30 +263,36 @@ function makeBlockMaterial(
|
||||
emissive: 0xffffff, // glow colour comes from emissiveMap; intensity scales it
|
||||
emissiveIntensity: 1.0,
|
||||
vertexColors: true, // baked AO (see mesher)
|
||||
roughness: 0.82,
|
||||
metalness: 0.08,
|
||||
roughness: 0.78, // a touch more polish than v1 (0.82)
|
||||
metalness: 0.10,
|
||||
transparent,
|
||||
depthWrite: !transparent,
|
||||
side: transparent ? THREE.DoubleSide : THREE.FrontSide,
|
||||
});
|
||||
|
||||
// Atlas UV patch: the geometry's `uv` attribute holds a per-quad tile-repeat
|
||||
// count (0..w, 0..h); `aTile` holds the block's atlas cell (col,row). We
|
||||
// fract the repeat to wrap within one voxel-tile, offset into the cell, and
|
||||
// sample map + emissiveMap ourselves (overriding three's map plumbing).
|
||||
// Atlas UV patch. `uv` = per-quad tile-repeat (0..w,0..h); `aTile` = the
|
||||
// block's BASE cell (id%8, id/8). We hash each voxel's world position to pick
|
||||
// one of NVAR variants, expand the cell column, fract the repeat, and sample
|
||||
// map + emissiveMap ourselves. `position` is baked world-space (chunk meshes
|
||||
// have identity transform), so flooring it -eps*normal lands in the owning
|
||||
// voxel — the variant is constant across a face and changes at voxel borders.
|
||||
mat.onBeforeCompile = (shader) => {
|
||||
shader.uniforms.uAtlasGrid = { value: new THREE.Vector2(COLS, ROWS) };
|
||||
shader.uniforms.uAtlasGrid = { value: new THREE.Vector2(GRID_W, ROWS) };
|
||||
|
||||
shader.vertexShader = shader.vertexShader
|
||||
.replace('#include <common>',
|
||||
`#include <common>
|
||||
attribute vec2 aTile;
|
||||
varying vec2 vTileRepeat;
|
||||
varying vec2 vTileCell;`)
|
||||
varying vec2 vTileCell;
|
||||
varying vec3 vTcWorld;
|
||||
varying vec3 vTcNormal;`)
|
||||
.replace('#include <uv_vertex>',
|
||||
`#include <uv_vertex>
|
||||
vTileRepeat = uv;
|
||||
vTileCell = aTile;`);
|
||||
vTileCell = aTile;
|
||||
vTcWorld = position;
|
||||
vTcNormal = normal;`);
|
||||
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace('#include <common>',
|
||||
@ -277,15 +300,20 @@ function makeBlockMaterial(
|
||||
uniform vec2 uAtlasGrid;
|
||||
varying vec2 vTileRepeat;
|
||||
varying vec2 vTileCell;
|
||||
varying vec3 vTcWorld;
|
||||
varying vec3 vTcNormal;
|
||||
float tcHash(vec3 p){ p = fract(p * 0.3183099 + 0.1); p *= 17.0; return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); }
|
||||
vec2 turncraftAtlasUV() {
|
||||
return (vTileCell + fract(vTileRepeat)) / uAtlasGrid;
|
||||
vec3 voxel = floor(vTcWorld - 0.02 * vTcNormal);
|
||||
float variant = floor(tcHash(voxel) * ${NVAR}.0);
|
||||
float col = vTileCell.x * ${NVAR}.0 + variant;
|
||||
return (vec2(col, vTileCell.y) + fract(vTileRepeat)) / uAtlasGrid;
|
||||
}`)
|
||||
.replace('#include <map_fragment>',
|
||||
`diffuseColor *= texture2D( map, turncraftAtlasUV() );`)
|
||||
.replace('#include <emissivemap_fragment>',
|
||||
`totalEmissiveRadiance *= texture2D( emissiveMap, turncraftAtlasUV() ).rgb;`);
|
||||
};
|
||||
// Distinct key so three compiles this variant separately from stock standard.
|
||||
mat.customProgramCacheKey = () => `turncraft-atlas-${transparent ? 't' : 'o'}`;
|
||||
mat.customProgramCacheKey = () => `turncraft-atlas-v2-${transparent ? 't' : 'o'}`;
|
||||
return mat;
|
||||
}
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
// TURNCRAFT — Lane A. The one place Three.js is configured: renderer, scene,
|
||||
// camera, lighting rig, fog. Also builds the block atlas/materials so callers
|
||||
// have a single entry point (createRenderer(...).atlas is handed to ChunkManager).
|
||||
// camera, lighting rig, fog, and (Glow-Up) selective LED bloom. Also builds the
|
||||
// block atlas/materials so callers have a single entry point.
|
||||
//
|
||||
// Look (DESIGN §7): warm key light + cool hemi/ambient fill, near-black warm
|
||||
// background, fog tinted warm plywood-brown so booth walls fade like a horizon.
|
||||
// No OrbitControls here — demos add their own camera controls.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
|
||||
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js';
|
||||
import { buildAtlas, type Atlas } from './atlas';
|
||||
|
||||
export interface Renderer {
|
||||
@ -16,11 +20,22 @@ export interface Renderer {
|
||||
atlas: Atlas;
|
||||
/** Scale emissive (LED) glow; Lane E pulses this to the beat. Default 1. */
|
||||
setEmissiveBoost(v: number): void;
|
||||
/**
|
||||
* Toggle selective LED bloom (Glow-Up G2). Only bright emissive pixels bloom
|
||||
* (threshold-gated); plywood/metal do not. Off by default; main.ts turns it
|
||||
* on from Settings and off when quality = 'fast'. The composer is created
|
||||
* lazily on first enable, so 'fast' quality never allocates it.
|
||||
*/
|
||||
setBloom(enabled: boolean): void;
|
||||
render(): void;
|
||||
resize(width?: number, height?: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
// Tuned so only emissive LEDs/strobe/lamps cross the threshold in linear space;
|
||||
// lit plywood/metal stays below it and does not glow.
|
||||
const BLOOM = { strength: 0.9, radius: 0.5, threshold: 0.85 } as const;
|
||||
|
||||
export function createRenderer(container: HTMLElement): Renderer {
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance' });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
@ -49,6 +64,41 @@ export function createRenderer(container: HTMLElement): Renderer {
|
||||
|
||||
const atlas = buildAtlas();
|
||||
|
||||
// --- selective bloom (lazy) ---
|
||||
let composer: EffectComposer | null = null;
|
||||
let bloomPass: UnrealBloomPass | null = null;
|
||||
let bloomEnabled = false;
|
||||
let composerW = 0, composerH = 0, composerPR = 0;
|
||||
|
||||
const drawSize = new THREE.Vector2();
|
||||
const ensureComposer = () => {
|
||||
if (composer) return;
|
||||
renderer.getSize(drawSize);
|
||||
composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
bloomPass = new UnrealBloomPass(
|
||||
new THREE.Vector2(drawSize.x, drawSize.y), BLOOM.strength, BLOOM.radius, BLOOM.threshold,
|
||||
);
|
||||
composer.addPass(bloomPass);
|
||||
composer.addPass(new OutputPass()); // tone mapping + sRGB happen here (linear scene → bloom → output)
|
||||
syncComposerSize(true);
|
||||
};
|
||||
|
||||
const syncComposerSize = (force = false) => {
|
||||
if (!composer) return;
|
||||
renderer.getSize(drawSize);
|
||||
const pr = renderer.getPixelRatio();
|
||||
if (!force && drawSize.x === composerW && drawSize.y === composerH && pr === composerPR) return;
|
||||
composerW = drawSize.x; composerH = drawSize.y; composerPR = pr;
|
||||
composer.setPixelRatio(pr);
|
||||
composer.setSize(drawSize.x, drawSize.y);
|
||||
};
|
||||
|
||||
const setBloom = (enabled: boolean) => {
|
||||
bloomEnabled = enabled;
|
||||
if (enabled) ensureComposer();
|
||||
};
|
||||
|
||||
const setEmissiveBoost = (v: number) => atlas.setEmissiveBoost(v);
|
||||
|
||||
const resize = (width?: number, height?: number) => {
|
||||
@ -60,6 +110,7 @@ export function createRenderer(container: HTMLElement): Renderer {
|
||||
renderer.setSize(w, h, true);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
syncComposerSize();
|
||||
};
|
||||
resize();
|
||||
|
||||
@ -67,12 +118,22 @@ export function createRenderer(container: HTMLElement): Renderer {
|
||||
const ro = new ResizeObserver(() => resize());
|
||||
ro.observe(container);
|
||||
|
||||
const render = () => {
|
||||
if (bloomEnabled && composer) {
|
||||
syncComposerSize(); // picks up window resize + pixelRatio (quality) changes
|
||||
composer.render();
|
||||
} else {
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
renderer, scene, camera, atlas, setEmissiveBoost,
|
||||
render: () => renderer.render(scene, camera),
|
||||
renderer, scene, camera, atlas, setEmissiveBoost, setBloom,
|
||||
render,
|
||||
resize,
|
||||
dispose() {
|
||||
ro.disconnect();
|
||||
composer?.dispose();
|
||||
atlas.dispose();
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentElement === container) {
|
||||
|
||||
@ -99,6 +99,14 @@ export class FxSystem {
|
||||
this.setBoost(Math.max(0, Math.min(2, boost)));
|
||||
}
|
||||
|
||||
/** A celebratory toss of tiny brushed-steel screws (Headshell Workshop). */
|
||||
screwBurst(x: number, y: number, z: number): void {
|
||||
this.confetti.spawn(44, x, y + 6, z, {
|
||||
spread: 11, speed: 8, up: 5, life: 2.2, jitter: 3,
|
||||
r: 0.74, g: 0.75, b: 0.8, // brushed steel
|
||||
});
|
||||
}
|
||||
|
||||
// ── win visuals ─────────────────────────────────────────────────────────
|
||||
private onNeedleDrop(): void {
|
||||
this.dropped = true;
|
||||
@ -157,6 +165,11 @@ export class FxSystem {
|
||||
spread: 6, speed: 6, up: 4, life: 0.7, jitter: 2,
|
||||
r: 0.5, g: 1.0, b: 0.55,
|
||||
});
|
||||
// finishing the Headshell Workshop tosses tiny screws in celebration
|
||||
if (p.node === 'stylus') {
|
||||
const c = SIGNAL_PATH[0];
|
||||
this.screwBurst(c[0], c[1], c[2]);
|
||||
}
|
||||
});
|
||||
|
||||
bus.on('game:win', () => {
|
||||
|
||||
@ -40,3 +40,65 @@ fx.update(dt); // each fixed tick
|
||||
handoff).
|
||||
- `update(dt)` is safe at `FIXED_DT`; the win visual timeline advances on the
|
||||
`dt` you pass (keep calling it every tick so it doesn't stall mid-sequence).
|
||||
|
||||
## Round 2 — Headshell Workshop
|
||||
|
||||
- **Screw confetti**: `FxSystem` auto-tosses a burst of brushed-steel screws
|
||||
when the workshop completes — it reacts to `signal:repair` with
|
||||
`node === 'stylus'` (Lane D calls `quest.repair('stylus')`). Also exposed as
|
||||
a public `fx.screwBurst(x, y, z)` for explicit use.
|
||||
- **The oscilloscope scope block is Lane D's** (`src/machines/workshop/`, its
|
||||
own canvas draw). Lane E does NOT render a scope — an earlier `src/fx/scope.ts`
|
||||
was removed to avoid two scopes.
|
||||
|
||||
## Glow-Up G3 + G5 — decals (`src/fx/decals.ts`)
|
||||
|
||||
Screen-print decals + the record groove texture. Built once, purely cosmetic.
|
||||
|
||||
```ts
|
||||
import { DecalSet, recordSideTexture } from './fx/decals';
|
||||
const decals = new DecalSet(); // no deps
|
||||
scene.add(decals.group); // ~13 plane meshes (< 25 draw-call budget)
|
||||
decals.dispose(); // frees geometries + canvas textures
|
||||
```
|
||||
|
||||
- **G3** bakes text/markings onto `CanvasTexture`s applied to thin planes
|
||||
floating `0.05` proud with `polygonOffset` (no z-fight, verified at grazing
|
||||
angle). One canvas per gear piece keeps the count low. Positions come from
|
||||
`LAYOUT` (`core/constants.ts`) and the worldgen anchors
|
||||
(`MIXER`/`DECK_A`/`DECK_B`/`PATCH`/`UNDER`) — nothing hardcoded. Print set:
|
||||
mixer `CH 1–4` + fader ticks + `TURNCRAFT-800` + crossfader `A↔B`; each deck
|
||||
`START·STOP`/`33`/`45`, a pitch `+/−` ladder, and a "Quartz Direct Drive"
|
||||
plinth-front script; patch-bay `PATCH BAY` + `01–12` socket numbers; a
|
||||
`FUSE 250V` legend; and four procedural party flyers on the booth walls.
|
||||
- Flat (`up`) faces flip each glyph 180° in place so labels read from the
|
||||
player's front-edge viewpoint (tick marks / positions stay put).
|
||||
|
||||
### G5 — `recordSideTexture(): THREE.CanvasTexture` ← **Lane D wires this**
|
||||
|
||||
**Signature:** `recordSideTexture(): THREE.CanvasTexture` — no args; returns an
|
||||
8×128 sRGB canvas texture, `RepeatWrapping` (S+T) + `NearestFilter` already set,
|
||||
near-white base with fine dark groove lands so it **multiplies** cleanly over
|
||||
the tier's existing vinyl tint.
|
||||
|
||||
A fine light/dark groove-ring texture for the vinyl tier **side** cylinders.
|
||||
Rings run horizontally (constant around the circumference, varying up the
|
||||
height). **This lane does not edit `platter.ts`.**
|
||||
|
||||
**Exact merge point — `src/machines/platter.ts`, the `sideMat` around L121–124**
|
||||
(where `sideMat = blockMaterial(vinylId, { opacity … })` is built and applied to
|
||||
the outer + mid vinyl tier side cylinders):
|
||||
|
||||
```ts
|
||||
import { recordSideTexture } from '../fx/decals';
|
||||
// after: const sideMat = blockMaterial(vinylId, { opacity: ... });
|
||||
sideMat.map = recordSideTexture();
|
||||
sideMat.map.repeat.set(1, Math.max(1, tierHeightVox / 4)); // ~4 grooves/voxel of height
|
||||
sideMat.needsUpdate = true;
|
||||
// NearestFilter + RepeatWrapping are already set on the texture. No geometry or
|
||||
// platter-logic change — purely a `material.map` assignment.
|
||||
```
|
||||
|
||||
It's a standalone generator (no side effects); safe to call once and share the
|
||||
texture across both platters. Only the platter wiring is left — everything else
|
||||
here is live and browser-verified.
|
||||
|
||||
378
src/fx/decals.ts
Normal file
378
src/fx/decals.ts
Normal file
@ -0,0 +1,378 @@
|
||||
// TURNCRAFT — Lane E. Glow-Up G3 + G5.
|
||||
//
|
||||
// G3: the screen-print decal system. Bakes text/markings onto canvas textures
|
||||
// and applies them as thin plane meshes floating 0.05 proud of the gear faces
|
||||
// (polygonOffset kills the z-fight). One canvas per gear piece keeps the whole
|
||||
// set under the brief's 25-draw-call budget. Positions derive from LAYOUT and
|
||||
// the worldgen anchors — never hardcoded — so a booth-layout change moves the
|
||||
// print with it.
|
||||
//
|
||||
// G5: `recordSideTexture()` — a fine groove-sheen ring texture for the vinyl
|
||||
// tier sides. Exported for the workshop session to wire into platter.ts
|
||||
// (this lane does NOT edit platter.ts). See src/fx/HANDOFF.md.
|
||||
//
|
||||
// Static geometry: built once, no per-frame update. Add `.group` to the scene.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { LAYOUT, Y_PLINTH_TOP, WORLD_X } from '../core/constants';
|
||||
import { MIXER, DECK_A, DECK_B, PATCH, UNDER, type DeckAnchors } from '../worldgen/anchors';
|
||||
|
||||
// Screen-print ink palette — worn off-white silk-screen, not pure white.
|
||||
const INK = '#d9d4c6';
|
||||
const INK_DIM = '#8f897a';
|
||||
const ACCENTS = ['#ff5a3c', '#28d17c', '#ffb028', '#4aa8ff']; // flyer accents
|
||||
|
||||
const PPV = 16; // canvas pixels per world voxel — crisp text at play distance
|
||||
|
||||
// On a flat 'up' face the plane's canvas-up points to the booth FRONT, so glyphs
|
||||
// painted upright come out upside-down to a player reading from the front edge.
|
||||
// We flip each glyph 180° in place (position + tick marks stay put; only the
|
||||
// letterforms rotate) so labels read correctly looking down at the gear.
|
||||
let FLIP_TEXT = false;
|
||||
|
||||
// ── canvas helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function makeCanvas(wVox: number, hVox: number): { cv: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = Math.max(2, Math.round(wVox * PPV));
|
||||
cv.height = Math.max(2, Math.round(hVox * PPV));
|
||||
const ctx = cv.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
return { cv, ctx };
|
||||
}
|
||||
|
||||
/** Condensed screen-print label centred at voxel (u,v) within the canvas. */
|
||||
function label(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
u: number, v: number,
|
||||
sizeVox: number,
|
||||
opts: { color?: string; condense?: number; weight?: string; align?: CanvasTextAlign; alpha?: number } = {},
|
||||
): void {
|
||||
const { color = INK, condense = 0.82, weight = '700', align = 'center', alpha = 1 } = opts;
|
||||
ctx.save();
|
||||
ctx.translate(u * PPV, v * PPV);
|
||||
if (FLIP_TEXT) ctx.rotate(Math.PI); // flip glyphs in place for flat 'up' faces
|
||||
ctx.scale(condense, 1); // horizontal squeeze → the DJM condensed look
|
||||
ctx.font = `${weight} ${sizeVox * PPV}px "Arial Narrow", "Helvetica Neue", Arial, sans-serif`;
|
||||
ctx.textAlign = align;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(text, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** A short screen-print tick / rule line (voxel coords). */
|
||||
function rule(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number, wVox = 0.12, color = INK_DIM): void {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = Math.max(1, wVox * PPV);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0 * PPV, y0 * PPV);
|
||||
ctx.lineTo(x1 * PPV, y1 * PPV);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function texFromCanvas(cv: HTMLCanvasElement): THREE.CanvasTexture {
|
||||
const t = new THREE.CanvasTexture(cv);
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
t.anisotropy = 8;
|
||||
t.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
t.magFilter = THREE.LinearFilter;
|
||||
t.needsUpdate = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
type Facing = 'up' | 'north' | 'south' | 'east' | 'west';
|
||||
|
||||
/**
|
||||
* A decal plane covering world rect of `wVox` × `hVox`, its texture baked by
|
||||
* `paint` (which receives a ctx whose voxel units match the rect). `facing`
|
||||
* orients the normal; the canvas is always painted "reading upright" for a
|
||||
* viewer looking at that face from the booth interior.
|
||||
*/
|
||||
function decal(
|
||||
wVox: number, hVox: number,
|
||||
cx: number, cy: number, cz: number,
|
||||
facing: Facing,
|
||||
paint: (ctx: CanvasRenderingContext2D) => void,
|
||||
): THREE.Mesh {
|
||||
const { cv, ctx } = makeCanvas(wVox, hVox);
|
||||
FLIP_TEXT = facing === 'up';
|
||||
paint(ctx);
|
||||
FLIP_TEXT = false;
|
||||
const tex = texFromCanvas(cv);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
map: tex, transparent: true, depthWrite: false,
|
||||
polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4,
|
||||
toneMapped: false,
|
||||
});
|
||||
const geo = new THREE.PlaneGeometry(wVox, hVox);
|
||||
const m = new THREE.Mesh(geo, mat);
|
||||
const p = 0.05; // float proud of the face
|
||||
switch (facing) {
|
||||
case 'up': // normal +Y; canvas-top → -Z (booth front / DJ side)
|
||||
m.rotation.x = -Math.PI / 2;
|
||||
m.position.set(cx, cy + p, cz);
|
||||
break;
|
||||
case 'north': // normal -Z (faces the booth interior from the back wall)
|
||||
m.rotation.y = Math.PI;
|
||||
m.position.set(cx, cy, cz - p);
|
||||
break;
|
||||
case 'south': // normal +Z (faces interior from the front lip)
|
||||
m.position.set(cx, cy, cz + p);
|
||||
break;
|
||||
case 'east': // normal +X
|
||||
m.rotation.y = Math.PI / 2;
|
||||
m.position.set(cx + p, cy, cz);
|
||||
break;
|
||||
case 'west': // normal -X
|
||||
m.rotation.y = -Math.PI / 2;
|
||||
m.position.set(cx - p, cy, cz);
|
||||
break;
|
||||
}
|
||||
m.renderOrder = 2;
|
||||
return m;
|
||||
}
|
||||
|
||||
// ── the print set ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Mixer face: CH 1–4, fader ticks, crossfader A↔B, TURNCRAFT-800 wordmark. */
|
||||
function mixerFace(): THREE.Mesh {
|
||||
const M = LAYOUT.mixer;
|
||||
const x0 = M.minX + 1, x1 = M.maxX - 1; // inside the alu border
|
||||
const z0 = M.minZ + 1, z1 = M.maxZ - 34; // front border → under-knob divider
|
||||
const w = x1 - x0, h = z1 - z0;
|
||||
// canvas mapping: u = (x - x0), and for an 'up' decal canvas-top = booth front
|
||||
// (low Z), so v = (z - z0). Text painted upright reads from the DJ side.
|
||||
const U = (x: number) => x - x0;
|
||||
const V = (z: number) => z - z0;
|
||||
return decal(w, h, (x0 + x1) / 2, MIXER.faceTopY, (z0 + z1) / 2, 'up', (ctx) => {
|
||||
// Wordmark near the front lip.
|
||||
label(ctx, 'TURNCRAFT-800', U(MIXER.centerX), V(M.minZ + 4), 2.4, { color: INK, condense: 0.7, alpha: 0.92 });
|
||||
// Crossfader A ↔ B straddling the tram.
|
||||
label(ctx, 'A', U(MIXER.centerX - MIXER.crossfaderHalfLen - 1.5), V(MIXER.crossfaderZ), 2.2, { color: INK });
|
||||
label(ctx, '↔', U(MIXER.centerX), V(MIXER.crossfaderZ - 3), 1.8, { color: INK_DIM });
|
||||
label(ctx, 'B', U(MIXER.centerX + MIXER.crossfaderHalfLen + 1.5), V(MIXER.crossfaderZ), 2.2, { color: INK });
|
||||
// Per channel: "CH n" ahead of the alley + tick ladder down the alley sides.
|
||||
for (let ci = 0; ci < MIXER.channelX.length; ci++) {
|
||||
const cx = MIXER.channelX[ci];
|
||||
label(ctx, `CH ${ci + 1}`, U(cx), V(M.minZ + 9.5), 1.7, { color: INK });
|
||||
// alley runs z = minZ+12 .. minZ+25; ladder of 6 ticks each side.
|
||||
for (let k = 0; k <= 6; k++) {
|
||||
const z = M.minZ + 13 + k * 2;
|
||||
const long = k === 0 || k === 3 || k === 6;
|
||||
rule(ctx, U(cx - 2.4), V(z), U(cx - (long ? 3.4 : 3.0)), V(z), 0.14);
|
||||
rule(ctx, U(cx + 2.4), V(z), U(cx + (long ? 3.4 : 3.0)), V(z), 0.14);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** One deck's top-face labels: START·STOP, 33/45, pitch scale, plinth script. */
|
||||
function deckPrints(f: { minX: number; maxX: number; minZ: number; maxZ: number; spindleX: number; spindleZ: number }, a: DeckAnchors, mirror: boolean): THREE.Mesh[] {
|
||||
const out: THREE.Mesh[] = [];
|
||||
const sgn = mirror ? -1 : 1; // labels sit on the outboard side of each control
|
||||
|
||||
// Button cluster (front, beside start/stop + 33/45) — stays clear of the well.
|
||||
{
|
||||
const [sx, , sz] = a.startStop;
|
||||
const w = 9, h = 12;
|
||||
const cx = sx + sgn * 0.5, cz = sz + 3;
|
||||
const U = (x: number) => (x - (cx - w / 2));
|
||||
const V = (z: number) => (z - (cz - h / 2));
|
||||
out.push(decal(w, h, cx, Y_PLINTH_TOP, cz, 'up', (ctx) => {
|
||||
label(ctx, 'START', U(sx), V(sz - 3), 1.5, { color: INK });
|
||||
label(ctx, '·STOP', U(sx), V(sz - 1.4), 1.5, { color: INK });
|
||||
label(ctx, '33', U(a.speed33[0]), V(a.speed33[2] + 2.2), 1.7, { color: INK });
|
||||
label(ctx, '45', U(a.speed45[0]), V(a.speed45[2] + 2.2), 1.7, { color: INK_DIM });
|
||||
}));
|
||||
}
|
||||
|
||||
// Pitch fader scale (+ / − ladder beside the canyon slot).
|
||||
{
|
||||
const [px, , pz] = a.pitch;
|
||||
const w = 8, h = 16;
|
||||
const cx = px + sgn * 3, cz = pz;
|
||||
const U = (x: number) => (x - (cx - w / 2));
|
||||
const V = (z: number) => (z - (cz - h / 2));
|
||||
out.push(decal(w, h, cx, Y_PLINTH_TOP, cz, 'up', (ctx) => {
|
||||
label(ctx, '+', U(cx), V(pz - 6), 1.8, { color: INK });
|
||||
for (let k = 0; k < 9; k++) {
|
||||
const z = pz - 4.5 + k * 1.1;
|
||||
const long = k === 0 || k === 4 || k === 8;
|
||||
rule(ctx, U(cx - (long ? 2.2 : 1.6)), V(z), U(cx + (long ? 2.2 : 1.6)), V(z), 0.13);
|
||||
}
|
||||
label(ctx, '−', U(cx), V(pz + 6), 2.2, { color: INK });
|
||||
}));
|
||||
}
|
||||
|
||||
// Technics-style script along the front edge of the plinth.
|
||||
{
|
||||
const cz = f.minZ + 1.4;
|
||||
const cx = f.spindleX;
|
||||
const w = 44, h = 4;
|
||||
out.push(decal(w, h, cx, Y_PLINTH_TOP, cz, 'up', (ctx) => {
|
||||
label(ctx, 'Quartz Direct Drive', (w / 2), (h / 2), 2.0, { color: INK_DIM, condense: 0.62, weight: '600', alpha: 0.85 });
|
||||
}));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Patch-bay socket numbers on the back-wall face. */
|
||||
function patchNumbers(): THREE.Mesh {
|
||||
const P = LAYOUT.patchBay;
|
||||
const x0 = P.minX - 1, x1 = P.maxX + 1;
|
||||
const y0 = P.minY - 2, y1 = P.maxY + 2;
|
||||
const w = x1 - x0, h = y1 - y0;
|
||||
// 'north' face: canvas-top = world +Y (up), canvas-left = world +X (mirrored),
|
||||
// so map U from the high-X edge to read correctly from inside the booth.
|
||||
const U = (x: number) => (x1 - x);
|
||||
const Vy = (y: number) => (y1 - y);
|
||||
// The alu patch panel is proud of the back wall by 2, so its interior-facing
|
||||
// (-Z) face is at z = backWallZ - 2. Sit the decal on THAT face, not the raw
|
||||
// wall face (backWallZ - 1) which is buried inside the panel voxels.
|
||||
const panelFaceZ = LAYOUT.backWallZ - 2;
|
||||
return decal(w, h, (x0 + x1) / 2, (y0 + y1) / 2, panelFaceZ, 'north', (ctx) => {
|
||||
let n = 1;
|
||||
for (let j = 0; j < PATCH.rows; j++) {
|
||||
for (let i = 0; i < PATCH.cols; i++) {
|
||||
const x = PATCH.colX(i), y = PATCH.rowY(j);
|
||||
label(ctx, String(n).padStart(2, '0'), U(x), Vy(y + 4), 1.3, { color: INK_DIM, condense: 0.9 });
|
||||
n++;
|
||||
}
|
||||
}
|
||||
label(ctx, 'PATCH BAY', U((P.minX + P.maxX) / 2), Vy(y1 - 1), 1.6, { color: INK });
|
||||
});
|
||||
}
|
||||
|
||||
/** Fuse-box legend under the mixer. */
|
||||
function fuseLabel(): THREE.Mesh {
|
||||
const [fx, fy] = UNDER.fuseBox;
|
||||
const fz = UNDER.fuseBox[2];
|
||||
return decal(12, 5, fx, fy + 3.5, fz, 'north', (ctx) => {
|
||||
label(ctx, 'FUSE 250V', 6, 2, 1.5, { color: ACCENTS[2], condense: 0.85 });
|
||||
rule(ctx, 1, 3.4, 11, 3.4, 0.12, INK_DIM);
|
||||
});
|
||||
}
|
||||
|
||||
const FLYER_WORDS = [
|
||||
['SUB', 'BASEMENT', 'FRI·11PM'],
|
||||
['ACID', 'WAREHOUSE', 'ALL NITE'],
|
||||
['GROOVE', 'THEORY', 'SAT'],
|
||||
['LOW-END', 'THEORY', '18+'],
|
||||
];
|
||||
|
||||
/** Procedural party flyers pasted on the booth walls. */
|
||||
function flyer(i: number, cx: number, cy: number, cz: number, facing: Facing): THREE.Mesh {
|
||||
const w = 10, h = 14;
|
||||
const accent = ACCENTS[i % ACCENTS.length];
|
||||
const words = FLYER_WORDS[i % FLYER_WORDS.length];
|
||||
return decal(w, h, cx, cy, cz, facing, (ctx) => {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.fillStyle = '#0d0d10';
|
||||
ctx.fillRect(0, 0, w * PPV, h * PPV);
|
||||
ctx.strokeStyle = accent;
|
||||
ctx.lineWidth = 0.25 * PPV;
|
||||
ctx.strokeRect(0.5 * PPV, 0.5 * PPV, (w - 1) * PPV, (h - 1) * PPV);
|
||||
ctx.restore();
|
||||
label(ctx, words[0], w / 2, 3.2, 2.4, { color: accent, condense: 0.7 });
|
||||
label(ctx, words[1], w / 2, 5.6, 1.7, { color: INK, condense: 0.7 });
|
||||
rule(ctx, 1.5, 7.4, w - 1.5, 7.4, 0.16, accent);
|
||||
label(ctx, words[2], w / 2, 9.2, 1.4, { color: INK_DIM, condense: 0.8 });
|
||||
label(ctx, 'TURNCRAFT', w / 2, h - 1.6, 1.0, { color: accent, condense: 0.8, alpha: 0.8 });
|
||||
});
|
||||
}
|
||||
|
||||
// ── public ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The full screen-print decal set. Build once, add `.group` to the scene.
|
||||
* Total plane meshes ≈ 13 (< 25 draw-call budget).
|
||||
*/
|
||||
export class DecalSet {
|
||||
readonly group = new THREE.Group();
|
||||
|
||||
constructor() {
|
||||
this.group.name = 'decals';
|
||||
this.group.add(mixerFace());
|
||||
this.group.add(...deckPrints(LAYOUT.deckA, DECK_A, false));
|
||||
this.group.add(...deckPrints(LAYOUT.deckB, DECK_B, true));
|
||||
this.group.add(patchNumbers());
|
||||
this.group.add(fuseLabel());
|
||||
|
||||
// Party flyers: two on the bare back wall flanking the patch bay, two on the
|
||||
// booth side walls near the front lip. (all face the booth interior.) The
|
||||
// bare back-wall face is at backWallZ (the alu patch panel is proud of it);
|
||||
// the side-wall inner faces are one rim-thickness in from the world edges.
|
||||
const P = LAYOUT.patchBay;
|
||||
this.group.add(flyer(0, P.minX - 12, 104, LAYOUT.backWallZ, 'north'));
|
||||
this.group.add(flyer(1, P.maxX + 12, 104, LAYOUT.backWallZ, 'north'));
|
||||
const wallInX0 = LAYOUT.rimThickness, wallInX1 = WORLD_X - LAYOUT.rimThickness;
|
||||
this.group.add(flyer(2, wallInX0, 96, LAYOUT.frontLipZ + 24, 'east'));
|
||||
this.group.add(flyer(3, wallInX1, 96, LAYOUT.frontLipZ + 24, 'west'));
|
||||
|
||||
let draws = 0;
|
||||
this.group.traverse((o) => { if ((o as THREE.Mesh).isMesh) draws++; });
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(`[turncraft] screen-print decals: ${draws} draw calls (budget < 25)`);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.group.traverse((o) => {
|
||||
const m = o as THREE.Mesh;
|
||||
if (m.isMesh) {
|
||||
m.geometry.dispose();
|
||||
const mat = m.material as THREE.MeshBasicMaterial;
|
||||
mat.map?.dispose();
|
||||
mat.dispose();
|
||||
}
|
||||
});
|
||||
this.group.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* G5 — a fine light/dark groove-ring texture for the vinyl tier SIDE cylinders.
|
||||
* Rings run horizontally (constant around the circumference, varying up the
|
||||
* height), so set `wrapS = RepeatWrapping` and a vertical `repeat.y` matching
|
||||
* the tier height when applying it. NearestFilter keeps the crisp voxel edge.
|
||||
*
|
||||
* Lane D: apply as `map` (or blend into the tint) on the record tier side
|
||||
* material in `platter.ts`. This lane does not edit platter.ts.
|
||||
*/
|
||||
export function recordSideTexture(): THREE.CanvasTexture {
|
||||
const H = 128, W = 8;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = W; cv.height = H;
|
||||
const ctx = cv.getContext('2d')!;
|
||||
// NEAR-WHITE base. This is meant to be a MULTIPLY `map` over the tier's vinyl
|
||||
// tint, so a bright base leaves the colour almost untouched (Deck A stays its
|
||||
// signature translucent blue) while fine DARKER groove "lands" dip the tint at
|
||||
// a regular pitch and a bright sheen lip sits just after each — reading as
|
||||
// concentric grooves catching raking light, NOT a blackout. (Neutral greys so
|
||||
// the multiply scales all channels equally and never shifts the hue.)
|
||||
const period = 8; // px per groove ring → 16 rings over 128
|
||||
for (let y = 0; y < H; y++) {
|
||||
const phase = y % period;
|
||||
let l = 0.97; // base: ~unchanged tint between grooves
|
||||
if (phase === 0) l = 0.60; // groove land: a faint dark ring
|
||||
else if (phase === 1) l = 0.80; // land shoulder: soft edge back up
|
||||
else if (phase === 2) l = 1.0; // sheen lip: a bright specular ring
|
||||
const v = Math.round(l * 255);
|
||||
ctx.fillStyle = `rgb(${v},${v},${v})`;
|
||||
ctx.fillRect(0, y, W, 1);
|
||||
}
|
||||
const t = new THREE.CanvasTexture(cv);
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
t.wrapS = THREE.RepeatWrapping;
|
||||
t.wrapT = THREE.RepeatWrapping;
|
||||
t.magFilter = THREE.NearestFilter;
|
||||
t.minFilter = THREE.NearestFilter;
|
||||
t.needsUpdate = true;
|
||||
return t;
|
||||
}
|
||||
@ -10,10 +10,12 @@ import { AIR, blockDef, type BlockId } from '../core/blocks';
|
||||
import { PLAYER } from '../core/constants';
|
||||
import { bus } from '../core/events';
|
||||
import type { IMachine, IPlayerView, IVoxelWorld, KinematicCollider, RayHit } from '../core/types';
|
||||
import { Fader, QUEST_ITEMS, type MachineSet } from '../machines';
|
||||
import { Fader, QUEST_ITEMS, type MachineSet, type Workshop } from '../machines';
|
||||
import type { Hotbar } from './hotbar';
|
||||
import { raycastColliders, raycastVoxel, type VoxelHit } from './raycast';
|
||||
|
||||
type WsTag = ReturnType<Workshop['tagFor']>;
|
||||
|
||||
// Seconds to mine a block, by material feel: soft stuff (dust, felt) digs
|
||||
// fast — the crossfader jam is ~240 dust blocks, and 0.4 s each made that
|
||||
// quest node a 90-second chore (integration tune).
|
||||
@ -39,6 +41,13 @@ export class Interaction {
|
||||
private breakTarget: { x: number; y: number; z: number } | null = null;
|
||||
private breakElapsed = 0;
|
||||
private lastHit: RayHit = null;
|
||||
private lastColliderId: string | null = null;
|
||||
|
||||
// Workshop: E can be a hold (crimp a wire / torque a screw). Shift backs a
|
||||
// screw off. Q drops a carried wire. Held state advances in update().
|
||||
private eDown = false;
|
||||
shiftDown = false;
|
||||
private hold: { tag: WsTag; colliderId: string } | null = null;
|
||||
|
||||
private readonly selection: THREE.LineSegments;
|
||||
|
||||
@ -87,18 +96,24 @@ export class Interaction {
|
||||
bus.emit('block:place', { x: tx, y: ty, z: tz, id });
|
||||
}
|
||||
|
||||
use(): void {
|
||||
/** E pressed. Routes to the workshop (seat/rotate/carry/crimp/torque/cue),
|
||||
* a machine's onInteract, or the fuse-box block interaction. */
|
||||
useDown(shift: boolean): void {
|
||||
this.eDown = true;
|
||||
this.shiftDown = shift;
|
||||
const hit = this.raycast();
|
||||
if (!hit) return;
|
||||
|
||||
if (hit.kind === 'machine') {
|
||||
const m = hit.machine;
|
||||
// Fit the chrome stylus into Deck A's headshell.
|
||||
if (m === this.machines.tonearmA && !this.machines.quest.isRepaired('stylus')
|
||||
&& this.hotbar.activeBlock() === QUEST_ITEMS.stylus) {
|
||||
if (this.hotbar.consumeActive(1)) {
|
||||
this.machines.tonearmA.setStylus(true);
|
||||
this.machines.quest.repair('stylus');
|
||||
// Headshell Workshop: assembly is done at the deck-A workbench.
|
||||
if (m === this.machines.workshop && this.lastColliderId) {
|
||||
const ws = this.machines.workshop;
|
||||
const tag = ws.tagFor(this.lastColliderId);
|
||||
if (ws.holdKind(tag)) {
|
||||
if (ws.holdStart(tag, shift)) this.hold = { tag, colliderId: this.lastColliderId };
|
||||
} else {
|
||||
ws.press(tag, this.hotbar);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -121,13 +136,33 @@ export class Interaction {
|
||||
}
|
||||
}
|
||||
|
||||
/** E released — end any workshop hold. */
|
||||
useUp(): void {
|
||||
this.eDown = false;
|
||||
if (this.hold) { this.machines.workshop.holdEnd(); this.hold = null; }
|
||||
}
|
||||
|
||||
/** Q — drop a carried wire back to the loom. */
|
||||
dropCarry(): void { this.machines.workshop.dropWire(); }
|
||||
|
||||
update(dt: number): void {
|
||||
const hit = this.raycast();
|
||||
this.lastHit = hit;
|
||||
this.advanceWorkshopHold(dt, hit);
|
||||
this.advanceMining(dt, hit);
|
||||
this.updateSelection(hit);
|
||||
}
|
||||
|
||||
/** Advance a held crimp/torque while E stays down and aim stays on target. */
|
||||
private advanceWorkshopHold(dt: number, hit: RayHit): void {
|
||||
if (!this.hold) return;
|
||||
const onTarget = this.eDown && hit && hit.kind === 'machine'
|
||||
&& hit.machine === this.machines.workshop && this.lastColliderId === this.hold.colliderId;
|
||||
if (!onTarget) { this.machines.workshop.holdEnd(); this.hold = null; return; }
|
||||
const r = this.machines.workshop.holdTick(this.hold.tag, dt, this.shiftDown);
|
||||
if (r !== 'run') this.hold = null;
|
||||
}
|
||||
|
||||
currentHit(): RayHit { return this.lastHit; }
|
||||
|
||||
// ---- internals ----
|
||||
@ -171,9 +206,10 @@ export class Interaction {
|
||||
const voxDist = vox ? vox.dist : Infinity;
|
||||
const colDist = col ? col.dist : Infinity;
|
||||
|
||||
this.lastColliderId = null;
|
||||
if (col && colDist <= voxDist) {
|
||||
const m = this.colliderOwner.get(col.collider.id);
|
||||
if (m) return { kind: 'machine', machine: m, point: col.point };
|
||||
if (m) { this.lastColliderId = col.collider.id; return { kind: 'machine', machine: m, point: col.point }; }
|
||||
}
|
||||
if (vox) return blockHit(vox);
|
||||
return null;
|
||||
|
||||
@ -9,15 +9,19 @@ from other lanes.
|
||||
```ts
|
||||
// src/machines
|
||||
createMachines(world: IVoxelWorld, questPos?: Partial<QuestPositions>): MachineSet
|
||||
// MachineSet = { machines, quest, tonearmA, getColliders(), update(dt),
|
||||
// MachineSet = { machines, quest, tonearmA, workshop, getColliders(), update(dt),
|
||||
// getObject3Ds(), attachPlayer(player) }
|
||||
Workshop // Headshell cartridge assembly at Deck A (the `stylus` node).
|
||||
// .getState()/.setState(v) .onChange(fn) — WorkshopState sync surface (net)
|
||||
// .isCarrying(): number|null — wire in hand (drives player.jumpScale)
|
||||
// .tagFor(id)/.holdKind/.press/.holdStart/.holdTick/.holdEnd/.dropWire — E/Q routing
|
||||
Quest // .isRepaired(node) .canPowerOn() .repair(node) .count() .isWon() .pos
|
||||
QUEST_ITEMS // { stylus: 11 (chrome), fuse: 22 (glass) } — blocks Lane C parks in the parts bin
|
||||
QuestPositions, NODES, Platter, Tonearm, Fader, Button
|
||||
|
||||
// src/interact
|
||||
new Interaction(world, player: IPlayerView, machines: MachineSet, hotbar: Hotbar)
|
||||
// .startMining()/.stopMining() .place() .use() .update(dt) .getObject3D() (selection box)
|
||||
// .startMining()/.stopMining() .place() .useDown(shift)/.useUp() .dropCarry() .update(dt) .getObject3D() (selection box)
|
||||
new Hotbar() // .slots .active .setActive(i) .activeBlock() .add(id,n) .consumeActive(n) .onChange(fn)
|
||||
raycastVoxel(...) / raycastColliders(...) // DDA + analytic, exported for reuse
|
||||
```
|
||||
@ -35,8 +39,15 @@ raycastVoxel(...) / raycastColliders(...) // DDA + analytic, exported for reuse
|
||||
socket; `setStylus(true)` reveals the needle. Deck B starts fitted.
|
||||
- **Faders**: pitch (retunes its deck), 4 channel faders, crossfader. Pushed by
|
||||
the player walking into the sled (needs `attachPlayer`), 11 detents, or
|
||||
`setValue()` for a UI. Crossfader is **gated** on its slot voxels being clear
|
||||
and repairs `crossfader` on the first full slide once clear.
|
||||
`setValue()` for a UI. The sled **chases the player at a slew cap**
|
||||
(3.4 voxels/sec default, `slewRate` opt) instead of snapping to their
|
||||
coordinate — playtest fix: the old teleport read as "a wall that auto-appears".
|
||||
Detents commit one at a time as the sled glides (one `fader:move` zip each);
|
||||
idle sleds settle into the nearest committed detent. Caps are ribbed
|
||||
grip boxes with an amber index line (`faderCapMaterials`/`faderGripTexture`
|
||||
in `machines/util.ts`), ribs always perpendicular to travel. Crossfader is
|
||||
**gated** on its slot voxels being clear and repairs `crossfader` on the
|
||||
first full slide once clear (verified live: glide → detents → repair fires).
|
||||
- **Buttons**: start/stop (E or walk-press), 33/45 rockers, cue lamp (glow +
|
||||
event), master power (inert/dark until `canPowerOn()`, then glows; press = finale).
|
||||
- **Interaction**: eye-ray DDA voxels (Amanatides & Woo, verified no-tunnel) +
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
// LANE D — fader machines (D4): pitch fader, four channel faders, crossfader.
|
||||
// A fader is a sled that slides in a slot. The player pushes it by walking into
|
||||
// it: the sled tracks the player's coordinate along the slot axis (clamped, and
|
||||
// snapped to detents). It also exposes setValue() so a demo can click-drag it.
|
||||
// The crossfader is gated: it can't move until its slot voxels are clear of
|
||||
// dust, and the first full slide after clearing repairs the crossfader node.
|
||||
// it: the sled chases the player's coordinate along the slot axis at a capped
|
||||
// slew rate (so it reads as shoving a heavy part, not a teleporting wall),
|
||||
// clamped and committed to detents. It also exposes setValue() so a demo can
|
||||
// click-drag it. The crossfader is gated: it can't move until its slot voxels
|
||||
// are clear of dust, and the first full slide after clearing repairs the
|
||||
// crossfader node.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import type { BlockId } from '../core/blocks';
|
||||
import { bus } from '../core/events';
|
||||
import type { KinematicCollider, Vec3 } from '../core/types';
|
||||
import { aabbOverlap, MachineBase, playerAABB, type AABB } from './machine';
|
||||
import { blockMaterial, box } from './util';
|
||||
import { MachineBase, playerAABB, type AABB } from './machine';
|
||||
import { faderCapMaterials } from './util';
|
||||
|
||||
/** Max sled travel speed while chasing the player (voxels/sec). Just under
|
||||
* walk speed (4.3), so the player perceptibly drags it rather than it
|
||||
* snapping to them — tuned to feel heavy. */
|
||||
const DEFAULT_SLEW = 3.4;
|
||||
|
||||
export interface FaderOpts {
|
||||
id: string;
|
||||
@ -22,6 +29,8 @@ export interface FaderOpts {
|
||||
capBlock?: BlockId; // visual, default fader_cap (14)
|
||||
capSize?: [number, number, number];
|
||||
initial?: number; // default 0.5 (center)
|
||||
/** Max sled travel speed in voxels/sec (default DEFAULT_SLEW). */
|
||||
slewRate?: number;
|
||||
/** If present and returns false, the fader is jammed and cannot move. */
|
||||
gate?: () => boolean;
|
||||
/** Called once when a full 0↔1 slide completes while the gate is open. */
|
||||
@ -32,6 +41,11 @@ export interface FaderOpts {
|
||||
|
||||
export class Fader extends MachineBase {
|
||||
private value: number;
|
||||
/** Continuous sled position 0..1 — the cap glides here; `value` is the
|
||||
* detent-snapped commit derived from it. */
|
||||
private visPos: number;
|
||||
/** Slew cap converted to value-units/sec (voxels/sec ÷ travel length). */
|
||||
private readonly slewPerSec: number;
|
||||
private readonly axis: 'x' | 'z';
|
||||
private readonly start: Vec3;
|
||||
private readonly end: Vec3;
|
||||
@ -62,9 +76,15 @@ export class Fader extends MachineBase {
|
||||
|
||||
const size = o.capSize ?? [4, 2.4, 6];
|
||||
this.capHalf = [size[0] / 2, size[1] / 2, size[2] / 2];
|
||||
this.cap = box(size[0], size[1], size[2], blockMaterial(o.capBlock ?? 14), 0, 0, 0);
|
||||
this.cap = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(size[0], size[1], size[2]),
|
||||
faderCapMaterials(o.axis, o.capBlock ?? 14),
|
||||
);
|
||||
this.group.add(this.cap);
|
||||
|
||||
const travel = Math.abs(o.axis === 'x' ? o.end[0] - o.start[0] : o.end[2] - o.start[2]);
|
||||
this.slewPerSec = (o.slewRate ?? DEFAULT_SLEW) / Math.max(travel, 1);
|
||||
|
||||
this.shape = { kind: 'aabb', min: [0, 0, 0], max: [0, 0, 0] };
|
||||
const collider: KinematicCollider = {
|
||||
id: `${o.id}_cap`,
|
||||
@ -73,7 +93,8 @@ export class Fader extends MachineBase {
|
||||
};
|
||||
this.colliders = [collider];
|
||||
|
||||
this.applyValue(this.value, true);
|
||||
this.visPos = this.value;
|
||||
this.moveSled(this.visPos);
|
||||
}
|
||||
|
||||
getValue(): number { return this.value; }
|
||||
@ -81,26 +102,28 @@ export class Fader extends MachineBase {
|
||||
/** Set value directly (demo click-drag). Respects the gate and detents. */
|
||||
setValue(v: number): void {
|
||||
if (this.gate && !this.gate()) return;
|
||||
this.applyValue(v, false);
|
||||
this.visPos = clamp01(v);
|
||||
this.moveSled(this.visPos);
|
||||
this.commit();
|
||||
}
|
||||
|
||||
private applyValue(raw: number, force: boolean): void {
|
||||
const snapped = snapDetent(clamp01(raw), this.detents);
|
||||
if (!force && snapped === this.value) return;
|
||||
this.value = snapped;
|
||||
|
||||
// Position the sled.
|
||||
const p = lerpVec(this.start, this.end, snapped);
|
||||
/** Position the cap mesh + collider at a continuous 0..1 travel position. */
|
||||
private moveSled(t: number): void {
|
||||
const p = lerpVec(this.start, this.end, t);
|
||||
this.cap.position.set(p[0], p[1], p[2]);
|
||||
this.shape.min[0] = p[0] - this.capHalf[0]; this.shape.max[0] = p[0] + this.capHalf[0];
|
||||
this.shape.min[1] = p[1] - this.capHalf[1]; this.shape.max[1] = p[1] + this.capHalf[1];
|
||||
this.shape.min[2] = p[2] - this.capHalf[2]; this.shape.max[2] = p[2] + this.capHalf[2];
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
bus.emit('fader:move', { faderId: this.id, value: snapped });
|
||||
this.onChange?.(snapped);
|
||||
this.trackFullSlide();
|
||||
}
|
||||
/** Commit the detent nearest the sled; fires events only on change. */
|
||||
private commit(): void {
|
||||
const snapped = snapDetent(this.visPos, this.detents);
|
||||
if (snapped === this.value) return;
|
||||
this.value = snapped;
|
||||
bus.emit('fader:move', { faderId: this.id, value: snapped });
|
||||
this.onChange?.(snapped);
|
||||
this.trackFullSlide();
|
||||
}
|
||||
|
||||
private trackFullSlide(): void {
|
||||
@ -113,40 +136,52 @@ export class Fader extends MachineBase {
|
||||
}
|
||||
}
|
||||
|
||||
update(_dt: number): void {
|
||||
update(dt: number): void {
|
||||
if (!this.player) return;
|
||||
if (this.gate && !this.gate()) return;
|
||||
|
||||
const pb = playerAABB(this.player, this.aabb);
|
||||
// Chase the player's coordinate at the slew cap; when they disengage,
|
||||
// settle the sled into the committed detent (a soft clunk home).
|
||||
const goal = this.trackTarget() ?? this.value;
|
||||
if (goal === this.visPos) return;
|
||||
const step = this.slewPerSec * dt;
|
||||
const d = goal - this.visPos;
|
||||
this.visPos = Math.abs(d) <= step ? goal : this.visPos + Math.sign(d) * step;
|
||||
this.moveSled(this.visPos);
|
||||
this.commit();
|
||||
}
|
||||
|
||||
/** The 0..1 position the player is pushing toward, or null if disengaged. */
|
||||
private trackTarget(): number | null {
|
||||
const pb = playerAABB(this.player!, this.aabb);
|
||||
// Player must overlap the sled cross-section (the two non-travel axes),
|
||||
// then the sled follows the player's coordinate along the travel axis.
|
||||
const capMinX = this.shape.min[0], capMaxX = this.shape.max[0];
|
||||
const capMinY = this.shape.min[1], capMaxY = this.shape.max[1];
|
||||
const capMinZ = this.shape.min[2], capMaxZ = this.shape.max[2];
|
||||
const yOverlap = pb.minY <= capMaxY + 0.5 && pb.maxY >= capMinY - 0.5;
|
||||
if (!yOverlap) return;
|
||||
if (!yOverlap) return null;
|
||||
|
||||
let coord: number, lo: number, hi: number, crossOverlap: boolean;
|
||||
if (this.axis === 'x') {
|
||||
crossOverlap = pb.minZ <= capMaxZ + 0.6 && pb.maxZ >= capMinZ - 0.6;
|
||||
coord = clampNum(this.player.position[0], this.start[0], this.end[0]);
|
||||
coord = clampNum(this.player!.position[0], this.start[0], this.end[0]);
|
||||
lo = Math.min(this.start[0], this.end[0]) - 1;
|
||||
hi = Math.max(this.start[0], this.end[0]) + 1;
|
||||
} else {
|
||||
crossOverlap = pb.minX <= capMaxX + 0.6 && pb.maxX >= capMinX - 0.6;
|
||||
coord = clampNum(this.player.position[2], this.start[2], this.end[2]);
|
||||
coord = clampNum(this.player!.position[2], this.start[2], this.end[2]);
|
||||
lo = Math.min(this.start[2], this.end[2]) - 1;
|
||||
hi = Math.max(this.start[2], this.end[2]) + 1;
|
||||
}
|
||||
if (!crossOverlap) return;
|
||||
const along = this.axis === 'x' ? this.player.position[0] : this.player.position[2];
|
||||
if (along < lo || along > hi) return;
|
||||
if (!crossOverlap) return null;
|
||||
const along = this.axis === 'x' ? this.player!.position[0] : this.player!.position[2];
|
||||
if (along < lo || along > hi) return null;
|
||||
|
||||
// Map the player's clamped coord to 0..1 along start→end.
|
||||
const a = this.axis === 'x' ? this.start[0] : this.start[2];
|
||||
const b = this.axis === 'x' ? this.end[0] : this.end[2];
|
||||
const v = (coord - a) / (b - a);
|
||||
this.applyValue(v, false);
|
||||
return (coord - a) / (b - a);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,12 +17,15 @@ import { Fader } from './faders';
|
||||
import { Button } from './buttons';
|
||||
import { RcaPlug } from './rca';
|
||||
import { Quest, type QuestPositions } from './quest';
|
||||
import { Workshop } from './workshop/Workshop';
|
||||
|
||||
export { Quest, QUEST_ITEMS, type QuestPositions } from './quest';
|
||||
export { Platter } from './platter';
|
||||
export { Tonearm } from './tonearm';
|
||||
export { Fader } from './faders';
|
||||
export { Button } from './buttons';
|
||||
export { Workshop } from './workshop/Workshop';
|
||||
export type { WorkshopState } from './workshop/types';
|
||||
|
||||
export interface MachineSet {
|
||||
/** Every machine (for iteration / collider lookup). */
|
||||
@ -31,6 +34,9 @@ export interface MachineSet {
|
||||
quest: Quest;
|
||||
/** Deck A tonearm handle — Interaction routes the stylus fit here. */
|
||||
tonearmA: Tonearm;
|
||||
/** The Headshell Workshop (deck A) — Interaction routes assembly here,
|
||||
* NetClient syncs its state. */
|
||||
workshop: Workshop;
|
||||
/** Flattened kinematic colliders (stable array; shapes mutate in place). */
|
||||
getColliders(): KinematicCollider[];
|
||||
/** Advance all machines one fixed tick. */
|
||||
@ -124,12 +130,22 @@ export function createMachines(world: IVoxelWorld, questPos?: Partial<QuestPosit
|
||||
// Deck comes alive on win: both platters auto-start.
|
||||
bus.on('game:win', () => platter.setPlaying(true));
|
||||
|
||||
return { platter, tonearm };
|
||||
return { platter, tonearm, pivot };
|
||||
};
|
||||
|
||||
const a = buildDeck('A', LAYOUT.deckA);
|
||||
buildDeck('B', LAYOUT.deckB);
|
||||
|
||||
// ---- Headshell Workshop (deck A only) — expands the `stylus` node ----
|
||||
const workshop = new Workshop({
|
||||
pivot: a.pivot,
|
||||
spindleX: LAYOUT.deckA.spindleX,
|
||||
spindleZ: LAYOUT.deckA.spindleZ,
|
||||
quest,
|
||||
tonearm: a.tonearm,
|
||||
});
|
||||
machines.push(workshop);
|
||||
|
||||
// ---- Mixer: channel faders, crossfader, master power ----
|
||||
// Everything derives from LAYOUT.mixer (so Lane C's mixer body stays under it)
|
||||
// and from the crossfader slot (so the gate and the sled/collider coincide).
|
||||
@ -190,6 +206,7 @@ export function createMachines(world: IVoxelWorld, questPos?: Partial<QuestPosit
|
||||
machines,
|
||||
quest,
|
||||
tonearmA: a.tonearm,
|
||||
workshop,
|
||||
getColliders: () => colliders,
|
||||
update: (dt: number) => { for (const m of machines) m.update(dt); },
|
||||
getObject3Ds: () => machines.map((m) => m.getObject3D()),
|
||||
|
||||
@ -10,6 +10,7 @@ import { bus } from '../core/events';
|
||||
import type { KinematicCollider, Vec3 } from '../core/types';
|
||||
import { MachineBase } from './machine';
|
||||
import { blockMaterial, cylinderY, recordTopTexture } from './util';
|
||||
import { recordSideTexture } from '../fx/decals'; // G5 merge point (glow-up, Lane E asset)
|
||||
import { BLOCK_BY_NAME } from '../core/blocks';
|
||||
|
||||
/** game-RPM (revolutions per minute) → angular speed (rad/s). */
|
||||
@ -19,6 +20,12 @@ const RPM_TO_OMEGA = Math.PI / 30; // 2π / 60
|
||||
// platter reaches ~90% of target in PLATTER.spinUpSeconds.
|
||||
const TAU = PLATTER.spinUpSeconds * 0.43;
|
||||
|
||||
// A too-heavy headshell test (counterweight notch >= 9) drags the deck-A platter
|
||||
// ~15% for the diagnostic window — the visible half of the `trackingHeavy` fault
|
||||
// (Lane E's test signal does the audible muffle). Duration tracks the test loop.
|
||||
const DRAG_FACTOR = 0.85;
|
||||
const DRAG_SECONDS = 4.2;
|
||||
|
||||
export type Speed = 33 | 45;
|
||||
|
||||
// The record as terraced "groove amphitheater" tiers (concept-art pass):
|
||||
@ -44,6 +51,8 @@ export class Platter extends MachineBase {
|
||||
private playing = false;
|
||||
private speed: Speed = 33;
|
||||
private pitch = 1; // 0.5..1.5 multiplier from the pitch fader
|
||||
private dragFactor = 1; // < 1 while a too-heavy headshell test drags the platter
|
||||
private dragTimer = 0; // seconds remaining on the drag
|
||||
unlocked45 = true; // 45 is available for rim-launch traversal from the start
|
||||
|
||||
private readonly spin = new THREE.Group(); // everything that rotates about the spindle
|
||||
@ -84,6 +93,11 @@ export class Platter extends MachineBase {
|
||||
this.buildVisuals(deck);
|
||||
this.spin.position.set(this.cx, 0, this.cz);
|
||||
this.group.add(this.spin);
|
||||
|
||||
// Deck A hosts the Headshell Workshop; a too-heavy needle-drop test drags us.
|
||||
if (deck === 'A') {
|
||||
bus.on('workshop:test', (p) => { if (p.errors.includes('trackingHeavy')) this.applyTrackingDrag(); });
|
||||
}
|
||||
}
|
||||
|
||||
private buildVisuals(deck: 'A' | 'B'): void {
|
||||
@ -118,16 +132,31 @@ export class Platter extends MachineBase {
|
||||
// matching the TIERS colliders — a stepped vinyl amphitheater.
|
||||
const vinylId = deck === 'A' ? 9 : 8; // vinyl_blue / vinyl_black
|
||||
const vinylDef = deck === 'A' ? BLOCK_BY_NAME.get('vinyl_blue')! : BLOCK_BY_NAME.get('vinyl_black')!;
|
||||
const sideMat = blockMaterial(vinylId, { opacity: deck === 'A' ? 0.55 : 1 });
|
||||
const tierBottom = Y_RECORD_TOP - 1.4; // outer tier's underside on the slipmat
|
||||
// G5 (glow-up): the tier side cylinders get a fine groove-sheen ring texture
|
||||
// (Lane E's recordSideTexture() — a near-white multiply map that dips the tint
|
||||
// at each groove land). Per-tier material + height-scaled repeat so the ring
|
||||
// pitch stays ~constant in world space across the three tier heights.
|
||||
const grooveTex = recordSideTexture();
|
||||
const sideMat = (height: number): THREE.Material => {
|
||||
const m = blockMaterial(vinylId, { opacity: deck === 'A' ? 0.55 : 1 }) as THREE.MeshStandardMaterial;
|
||||
const map = grooveTex.clone();
|
||||
map.needsUpdate = true;
|
||||
map.repeat.set(1, Math.max(1, Math.round(height))); // ~11–14 groove rings / voxel
|
||||
m.map = map;
|
||||
m.needsUpdate = true;
|
||||
return m;
|
||||
};
|
||||
for (const t of TIERS.slice(0, 2)) {
|
||||
this.spin.add(cylinderY(t.r, t.top - tierBottom, sideMat, 0, tierBottom + (t.top - tierBottom) / 2, 0));
|
||||
const h = t.top - tierBottom;
|
||||
this.spin.add(cylinderY(t.r, h, sideMat(h), 0, tierBottom + h / 2, 0));
|
||||
}
|
||||
|
||||
// Label plateau: cream label with grooves + catalog print on top.
|
||||
const label = BLOCK_BY_NAME.get('label_cream')!.tint;
|
||||
const labelTier = TIERS[2];
|
||||
this.spin.add(cylinderY(labelTier.r, labelTier.top - tierBottom, sideMat, 0, tierBottom + (labelTier.top - tierBottom) / 2, 0));
|
||||
const lh = labelTier.top - tierBottom;
|
||||
this.spin.add(cylinderY(labelTier.r, lh, sideMat(lh), 0, tierBottom + lh / 2, 0));
|
||||
const topTex = recordTopTexture(vinylDef.tint, label, 'TC-00' + (deck === 'A' ? '1' : '2') + ' · TURNCRAFT');
|
||||
const topMat = new THREE.MeshStandardMaterial({ map: topTex, roughness: 0.5, metalness: 0.05 });
|
||||
const top = new THREE.Mesh(new THREE.CircleGeometry(labelTier.r, 48), topMat);
|
||||
@ -187,7 +216,14 @@ export class Platter extends MachineBase {
|
||||
|
||||
private recomputeTarget(): void {
|
||||
// Records spin clockwise viewed from above = negative rotation about +Y.
|
||||
this.targetOmega = this.playing ? -RPM_TO_OMEGA * this.baseRpm() * this.pitch : 0;
|
||||
this.targetOmega = this.playing ? -RPM_TO_OMEGA * this.baseRpm() * this.pitch * this.dragFactor : 0;
|
||||
}
|
||||
|
||||
/** Drag the platter for the headshell test window (deck A, notch >= 9). */
|
||||
private applyTrackingDrag(): void {
|
||||
this.dragFactor = DRAG_FACTOR;
|
||||
this.dragTimer = DRAG_SECONDS;
|
||||
this.recomputeTarget();
|
||||
}
|
||||
|
||||
private lastEmitPlaying = false;
|
||||
@ -203,6 +239,10 @@ export class Platter extends MachineBase {
|
||||
}
|
||||
|
||||
update(dt: number): void {
|
||||
if (this.dragTimer > 0) {
|
||||
this.dragTimer -= dt;
|
||||
if (this.dragTimer <= 0) { this.dragFactor = 1; this.recomputeTarget(); }
|
||||
}
|
||||
if (this.omega !== this.targetOmega) {
|
||||
// Exponential approach — the Technics spin-up/brake lurch.
|
||||
const k = 1 - Math.exp(-dt / TAU);
|
||||
|
||||
@ -40,6 +40,10 @@ export class Tonearm extends MachineBase {
|
||||
private phi: number; // current sweep angle from baseline
|
||||
private trackT = 0; // 0..1 inward progress while tracking
|
||||
private stylus: boolean;
|
||||
// Service mode: the Headshell Workshop owns the visible/collidable assembly
|
||||
// arm while the `stylus` node is being built, so the real tonearm hides and
|
||||
// its colliders go inert. Restored (setService(false)) on completion.
|
||||
private service = false;
|
||||
|
||||
private readonly head: Vec3 = [0, 0, 0];
|
||||
private readonly prevHead: Vec3 = [0, 0, 0];
|
||||
@ -138,15 +142,33 @@ export class Tonearm extends MachineBase {
|
||||
|
||||
setStylus(on: boolean): void {
|
||||
this.stylus = on;
|
||||
this.stylusMesh.visible = on;
|
||||
this.stylusMesh.visible = on && !this.service;
|
||||
if (on) bus.emit('machine:interact', { machineId: this.id, action: 'stylus_fitted' });
|
||||
}
|
||||
|
||||
hasStylus(): boolean { return this.stylus; }
|
||||
|
||||
/** Hand the arm to the workshop (hide + inert colliders) or take it back. */
|
||||
setService(on: boolean): void {
|
||||
this.service = on;
|
||||
this.group.visible = !on;
|
||||
this.stylusMesh.visible = this.stylus && !on;
|
||||
if (on) this.collapseColliders();
|
||||
}
|
||||
|
||||
/** Park colliders where nothing can touch them (used in service mode). */
|
||||
private collapseColliders(): void {
|
||||
for (const s of [this.headShape, ...this.armSegs]) {
|
||||
s.min[0] = s.min[1] = s.min[2] = -1e6;
|
||||
s.max[0] = s.max[1] = s.max[2] = -1e6 + 0.01;
|
||||
}
|
||||
this.headVel[0] = this.headVel[1] = this.headVel[2] = 0;
|
||||
}
|
||||
|
||||
private enabled(): boolean { return this.stylus && this.deckPlaying(); }
|
||||
|
||||
update(dt: number): void {
|
||||
if (this.service) { this.collapseColliders(); return; }
|
||||
// ---- State machine over the sweep angle φ ----
|
||||
switch (this.state) {
|
||||
case 'rest':
|
||||
|
||||
@ -116,6 +116,99 @@ export function recordTopTexture(vinyl: [number, number, number], label: [number
|
||||
return tex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fader-cap grip texture: ribbed grooves perpendicular to the travel axis with
|
||||
* a single amber index line at the centre — the classic crossfader-knob print,
|
||||
* so the sled reads as a pushable handle instead of a blank slab. Painted on a
|
||||
* canvas from the cap block's tint (no external assets). `along` picks which
|
||||
* UV direction the ribs are arrayed on: 'u' for faces where u maps to the
|
||||
* travel axis, 'v' for the top face of a z-travel fader.
|
||||
*/
|
||||
const gripTexCache = new Map<string, THREE.CanvasTexture>();
|
||||
|
||||
export function faderGripTexture(id: BlockId, along: 'u' | 'v'): THREE.CanvasTexture {
|
||||
const key = `${id}:${along}`;
|
||||
const hit = gripTexCache.get(key);
|
||||
if (hit) return hit;
|
||||
|
||||
const S = 256;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const g = cv.getContext('2d')!;
|
||||
if (along === 'v') {
|
||||
g.translate(S / 2, S / 2);
|
||||
g.rotate(Math.PI / 2);
|
||||
g.translate(-S / 2, -S / 2);
|
||||
}
|
||||
|
||||
// Body: the block tint, slightly dulled so the print carries the contrast.
|
||||
const [r, gr, b] = blockDef(id).tint;
|
||||
g.fillStyle = `rgb(${Math.round(r * 0.9)},${Math.round(gr * 0.9)},${Math.round(b * 0.92)})`;
|
||||
g.fillRect(0, 0, S, S);
|
||||
|
||||
// Bevel: dark frame + inner highlight so every face reads as a moulded cap.
|
||||
g.strokeStyle = 'rgba(10,12,16,0.4)';
|
||||
g.lineWidth = 22;
|
||||
g.strokeRect(0, 0, S, S);
|
||||
g.strokeStyle = 'rgba(255,255,255,0.35)';
|
||||
g.lineWidth = 3;
|
||||
g.strokeRect(14, 14, S - 28, S - 28);
|
||||
|
||||
// Grip ribs: dark groove + light land, arrayed along u, skipping the centre.
|
||||
for (let i = 0; i <= 12; i++) {
|
||||
if (i >= 5 && i <= 7) continue; // leave room for the index line
|
||||
const x = S * (0.09 + (0.82 * i) / 12);
|
||||
g.strokeStyle = 'rgba(18,20,26,0.5)';
|
||||
g.lineWidth = 6;
|
||||
g.beginPath(); g.moveTo(x, 12); g.lineTo(x, S - 12); g.stroke();
|
||||
g.strokeStyle = 'rgba(255,255,255,0.4)';
|
||||
g.lineWidth = 2;
|
||||
g.beginPath(); g.moveTo(x + 5, 14); g.lineTo(x + 5, S - 14); g.stroke();
|
||||
}
|
||||
|
||||
// Centre index: dark slot with an amber core (matches the LED family).
|
||||
g.strokeStyle = '#22262e';
|
||||
g.lineWidth = 12;
|
||||
g.beginPath(); g.moveTo(S / 2, 10); g.lineTo(S / 2, S - 10); g.stroke();
|
||||
g.strokeStyle = '#ffb13d';
|
||||
g.lineWidth = 4;
|
||||
g.beginPath(); g.moveTo(S / 2, 12); g.lineTo(S / 2, S - 12); g.stroke();
|
||||
|
||||
const tex = new THREE.CanvasTexture(cv);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.anisotropy = 4;
|
||||
gripTexCache.set(key, tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
/**
|
||||
* The six materials for a fader cap box, gripped on the faces the player sees
|
||||
* (ribs always perpendicular to travel), plain darker plastic on the rest.
|
||||
* BoxGeometry face order: [+x, -x, +y, -y, +z, -z].
|
||||
*/
|
||||
export function faderCapMaterials(axis: 'x' | 'z', id: BlockId): THREE.MeshStandardMaterial[] {
|
||||
const grip = (along: 'u' | 'v') => {
|
||||
const m = new THREE.MeshStandardMaterial({
|
||||
color: 0xffffff,
|
||||
map: faderGripTexture(id, along),
|
||||
metalness: 0.08,
|
||||
roughness: 0.55,
|
||||
});
|
||||
return m;
|
||||
};
|
||||
const plain = (scale: number) => new THREE.MeshStandardMaterial({
|
||||
color: tintColor(id, scale),
|
||||
metalness: 0.08,
|
||||
roughness: 0.7,
|
||||
});
|
||||
const end = plain(0.55), bottom = plain(0.4);
|
||||
// On ±z faces u→x, on ±x faces u→z, on the top face u→x / v→z; pick the
|
||||
// variant whose rib direction crosses the travel axis on each visible face.
|
||||
return axis === 'x'
|
||||
? [end, end, grip('u'), bottom, grip('u'), grip('u')]
|
||||
: [grip('u'), grip('u'), grip('v'), bottom, end, end];
|
||||
}
|
||||
|
||||
/** A thin box helper (world-space centered). */
|
||||
export function box(w: number, h: number, d: number, mat: THREE.Material, x: number, y: number, z: number): THREE.Mesh {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
|
||||
|
||||
683
src/machines/workshop/Workshop.ts
Normal file
683
src/machines/workshop/Workshop.ts
Normal file
@ -0,0 +1,683 @@
|
||||
// LANE D — The Headshell Workshop (WORKSHOP_CARTRIDGE brief).
|
||||
// One machine that turns the `stylus` node into a five-stage cartridge assembly
|
||||
// at the Deck A pedestal: seat + square the cartridge, crimp four tag-wires,
|
||||
// torque two screws evenly, balance the counterweight while standing on the arm,
|
||||
// then drop the needle — every mistake audible and readable, nothing hard-fails.
|
||||
//
|
||||
// It owns the visible/collidable assembly arm; the real Tonearm sits in service
|
||||
// mode until the node is repaired, then takes back over (cue/track/return).
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { Y_PLINTH_TOP } from '../../core/constants';
|
||||
import { bus } from '../../core/events';
|
||||
import type { Vec3 } from '../../core/types';
|
||||
import { MachineBase } from '../machine';
|
||||
import { blockMaterial } from '../util';
|
||||
import { QUEST_ITEMS, type Quest } from '../quest';
|
||||
import type { Tonearm } from '../tonearm';
|
||||
import {
|
||||
type WorkshopState, type Quarter, type WireSlot, type TestErrors,
|
||||
emptyState, completedState, evaluateErrors, isClean, coerceState,
|
||||
PIN_COLOR, PIN_OF_WIRE, WIRE_COLORS, ERROR_ORDER, ERROR_LABEL,
|
||||
} from './types';
|
||||
|
||||
/** Minimal hotbar surface the workshop needs (Lane D's Hotbar satisfies it). */
|
||||
export interface WorkshopHotbar {
|
||||
activeBlock(): number;
|
||||
consumeActive(n?: number): boolean;
|
||||
add(id: number, n?: number): void;
|
||||
}
|
||||
|
||||
export interface WorkshopOpts {
|
||||
pivot: Vec3; // deck-A tonearm pivot (shared with Tonearm)
|
||||
spindleX: number;
|
||||
spindleZ: number;
|
||||
quest: Quest;
|
||||
tonearm: Tonearm; // deck A — parked in service mode during assembly
|
||||
}
|
||||
|
||||
type ArmMode = 'clip' | 'balance' | 'test' | 'done';
|
||||
type TagKind = 'cart' | 'pin' | 'wire' | 'screw' | 'cue' | 'weight' | 'ride' | 'none';
|
||||
interface Tag { kind: TagKind; index: number; }
|
||||
|
||||
const HEAD_LEN = 9; // head distance from pivot toward spindle
|
||||
const BEAM_Y0 = Y_PLINTH_TOP + 4; // 84 — walkable beam / workbench height
|
||||
const WORK_Y = Y_PLINTH_TOP + 3.5; // 83.5 — reachable part height (aim up from plinth)
|
||||
const TILT_PER_NOTCH = 0.025; // rad of nose pitch per notch off balance (7)
|
||||
const CRIMP_SECONDS = 1.2;
|
||||
const TORQUE_SECONDS = 2.5;
|
||||
const TEST_SECONDS = 4.2; // covers the full ~4.1 s diagnostic loop
|
||||
const SKATE_TEST_SECONDS = 1.0; // the skate abort is a ~0.8 s zip, not the loop
|
||||
const RIDE_SEGMENTS = 8; // cover the whole beam tail→head (-14 .. +9)
|
||||
const D_FAR = -14, D_NEAR = -6; // counterweight travel along the tail
|
||||
|
||||
export class Workshop extends MachineBase {
|
||||
private state: WorkshopState = emptyState();
|
||||
private armMode: ArmMode = 'clip';
|
||||
private theta = 0; // current smoothed nose pitch
|
||||
private carried: number | null = null;// wire index in hand
|
||||
private torquing: number | null = null;// screw index being torqued locally, else null
|
||||
private testTimer = 0;
|
||||
private completed = false;
|
||||
|
||||
private readonly quest: Quest;
|
||||
private readonly tonearm: Tonearm;
|
||||
private readonly px: number;
|
||||
private readonly pz: number;
|
||||
private readonly bx: number; // baseline unit dir pivot→spindle (xz)
|
||||
private readonly bz: number;
|
||||
private readonly perpX: number; // perpendicular (loom side)
|
||||
private readonly perpZ: number;
|
||||
|
||||
// collider shapes keyed for per-tick mutation
|
||||
private readonly shapes = new Map<string, { kind: 'aabb'; min: Vec3; max: Vec3 }>();
|
||||
private readonly rideShapes: { kind: 'aabb'; min: Vec3; max: Vec3 }[] = [];
|
||||
|
||||
// visuals
|
||||
private readonly cartMesh: THREE.Group;
|
||||
private readonly pinMeshes: THREE.Mesh[] = [];
|
||||
private readonly wireLines: THREE.Line[] = [];
|
||||
private readonly lampLeds: THREE.Mesh[] = [];
|
||||
private readonly screwMeshes: THREE.Mesh[] = [];
|
||||
private readonly weightMesh: THREE.Mesh;
|
||||
private readonly bubbleDot: THREE.Mesh;
|
||||
private readonly beamMesh: THREE.Mesh;
|
||||
private readonly cueMesh: THREE.Mesh;
|
||||
private readonly scopeTex: THREE.CanvasTexture;
|
||||
|
||||
private readonly listeners = new Set<(s: WorkshopState) => void>();
|
||||
private lastSig = '';
|
||||
|
||||
constructor(opts: WorkshopOpts) {
|
||||
super('workshop');
|
||||
this.quest = opts.quest;
|
||||
this.tonearm = opts.tonearm;
|
||||
this.px = opts.pivot[0];
|
||||
this.pz = opts.pivot[2];
|
||||
const dx = opts.spindleX - this.px, dz = opts.spindleZ - this.pz;
|
||||
const L = Math.hypot(dx, dz) || 1;
|
||||
this.bx = dx / L; this.bz = dz / L;
|
||||
this.perpX = -this.bz; this.perpZ = this.bx; // 90° in xz
|
||||
|
||||
// ── colliders (interaction targets + ride segments), all stable + mutated ──
|
||||
const mk = (id: string): { kind: 'aabb'; min: Vec3; max: Vec3 } => {
|
||||
const s = { kind: 'aabb' as const, min: [-1e6, -1e6, -1e6] as Vec3, max: [-1e6 + 0.01, -1e6 + 0.01, -1e6 + 0.01] as Vec3 };
|
||||
this.shapes.set(id, s);
|
||||
this.colliders.push({ id, shape: s, velocityAt: () => [0, 0, 0] });
|
||||
return s;
|
||||
};
|
||||
mk('ws_cart');
|
||||
for (let i = 0; i < 4; i++) mk(`ws_pin${i}`);
|
||||
for (let i = 0; i < 4; i++) mk(`ws_wire${i}`);
|
||||
mk('ws_screw0'); mk('ws_screw1');
|
||||
mk('ws_cue'); mk('ws_weight');
|
||||
for (let i = 0; i < RIDE_SEGMENTS; i++) {
|
||||
const s = { kind: 'aabb' as const, min: [-1e6, -1e6, -1e6] as Vec3, max: [-1e6 + 0.01, -1e6 + 0.01, -1e6 + 0.01] as Vec3 };
|
||||
this.rideShapes.push(s);
|
||||
this.colliders.push({ id: `ws_ride${i}`, shape: s, velocityAt: () => [0, 0, 0] });
|
||||
}
|
||||
|
||||
// ── visuals ──
|
||||
// pedestal + service clip
|
||||
const chrome = blockMaterial(11), dark = blockMaterial(5);
|
||||
const ped = new THREE.Mesh(new THREE.CylinderGeometry(2.2, 2.8, BEAM_Y0 - Y_PLINTH_TOP + 2, 16), chrome);
|
||||
ped.position.set(this.px, Y_PLINTH_TOP + (BEAM_Y0 - Y_PLINTH_TOP) / 2, this.pz);
|
||||
this.group.add(ped);
|
||||
|
||||
this.beamMesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), chrome);
|
||||
this.group.add(this.beamMesh);
|
||||
|
||||
// cartridge (built when seated; group toggled)
|
||||
this.cartMesh = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(4, 2.5, 3), blockMaterial(5, { scale: 1.4 }));
|
||||
const cantilever = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.25, 3, 8), chrome);
|
||||
cantilever.rotation.x = Math.PI / 2; cantilever.position.set(0, -1, 1.8);
|
||||
const tip = new THREE.Mesh(new THREE.ConeGeometry(0.4, 1.2, 8), blockMaterial(11, { emissive: 0.4 }));
|
||||
tip.position.set(0, -1.4, 3); tip.rotation.x = Math.PI;
|
||||
this.cartMesh.add(body, cantilever, tip);
|
||||
this.cartMesh.visible = false;
|
||||
this.group.add(this.cartMesh);
|
||||
|
||||
// pins (color-capped)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.35, 1.6, 8), chrome);
|
||||
const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.55, 0.5, 8), capMat(PIN_COLOR[i]));
|
||||
cap.position.y = 1; post.add(cap);
|
||||
this.pinMeshes.push(post);
|
||||
this.group.add(post);
|
||||
}
|
||||
|
||||
// loom + hanging tag-wires (as catenary lines) + continuity lamp
|
||||
const loomRack = new THREE.Mesh(new THREE.BoxGeometry(6, 1, 1.4), dark);
|
||||
this.loomPos = [this.px + this.perpX * 6, WORK_Y + 3, this.pz + this.perpZ * 6];
|
||||
loomRack.position.set(this.loomPos[0], this.loomPos[1], this.loomPos[2]);
|
||||
this.group.add(loomRack);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(CAT_N * 3), 3));
|
||||
const line = new THREE.Line(geo, new THREE.LineBasicMaterial({ color: new THREE.Color(WIRE_COLORS[i][0] / 255, WIRE_COLORS[i][1] / 255, WIRE_COLORS[i][2] / 255) }));
|
||||
this.wireLines.push(line);
|
||||
this.group.add(line);
|
||||
const led = new THREE.Mesh(new THREE.SphereGeometry(0.4, 8, 8), LED_OFF_MAT);
|
||||
this.lampLeds.push(led);
|
||||
this.group.add(led);
|
||||
}
|
||||
|
||||
// screws
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sc = new THREE.Mesh(new THREE.CylinderGeometry(0.6, 0.6, 0.8, 8), blockMaterial(30));
|
||||
this.screwMeshes.push(sc);
|
||||
this.group.add(sc);
|
||||
}
|
||||
|
||||
// counterweight, bubble level, cue lever
|
||||
this.weightMesh = new THREE.Mesh(new THREE.CylinderGeometry(2, 2, 3, 12), dark);
|
||||
this.weightMesh.rotation.z = Math.PI / 2;
|
||||
this.group.add(this.weightMesh);
|
||||
|
||||
const level = new THREE.Mesh(new THREE.BoxGeometry(5, 0.6, 1.6), blockMaterial(22, { opacity: 0.5 }));
|
||||
level.position.set(this.px - this.bx * 2, WORK_Y + 0.5, this.pz - this.bz * 2);
|
||||
this.group.add(level);
|
||||
this.bubbleDot = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 10), blockMaterial(19, { emissive: 1.0 }));
|
||||
this.group.add(this.bubbleDot);
|
||||
this.levelPos = [level.position.x, level.position.y + 0.5, level.position.z];
|
||||
|
||||
this.cueMesh = new THREE.Mesh(new THREE.BoxGeometry(0.8, 3, 0.8), blockMaterial(24));
|
||||
this.cuePos = [this.px + this.perpX * 2.5, WORK_Y + 1, this.pz + this.perpZ * 2.5];
|
||||
this.cueMesh.position.set(this.cuePos[0], this.cuePos[1], this.cuePos[2]);
|
||||
this.group.add(this.cueMesh);
|
||||
|
||||
// scope block
|
||||
this.scopeTex = makeScopeTexture();
|
||||
const scope = new THREE.Mesh(new THREE.BoxGeometry(6, 4, 0.6), new THREE.MeshStandardMaterial({ map: this.scopeTex, emissive: 0x113322, emissiveMap: this.scopeTex, emissiveIntensity: 0.8 }));
|
||||
this.scopePos = [this.px - this.perpX * 5, WORK_Y + 2, this.pz - this.perpZ * 5];
|
||||
scope.position.set(this.scopePos[0], this.scopePos[1], this.scopePos[2]);
|
||||
scope.lookAt(scope.position.x + this.perpX, scope.position.y, scope.position.z + this.perpZ);
|
||||
this.group.add(scope);
|
||||
this.drawScope(null);
|
||||
|
||||
// ── start pose ──
|
||||
if (this.quest.isRepaired('stylus')) {
|
||||
this.complete(); // completed pose on join
|
||||
} else {
|
||||
this.tonearm.setService(true);
|
||||
bus.on('signal:repair', (p) => { if (p.node === 'stylus') this.complete(); });
|
||||
}
|
||||
this.refreshVisuals();
|
||||
this.refreshColliders();
|
||||
}
|
||||
|
||||
private readonly loomPos: Vec3;
|
||||
private readonly levelPos: Vec3;
|
||||
private readonly cuePos: Vec3;
|
||||
private readonly scopePos: Vec3;
|
||||
|
||||
// ── net sync surface ──
|
||||
getState(): WorkshopState { return { ...this.state, wires: [...this.state.wires], screws: [this.state.screws[0], this.state.screws[1]] }; }
|
||||
onChange(fn: (s: WorkshopState) => void): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); }
|
||||
|
||||
/** Apply a remote state (diff + animate). Guarded by the net's applyingRemote. */
|
||||
setState(v: unknown): void {
|
||||
const s = coerceState(v);
|
||||
if (!s) return;
|
||||
// Preserve a screw the local player is actively torquing. Adopting the
|
||||
// teammate's (latency-stale) copy of it would rewind our in-progress torque;
|
||||
// with both players torquing at once — the brief's headline co-op split —
|
||||
// each side's ~8 msgs/s would rewind the other faster than it advances,
|
||||
// oscillating into a soft-lock where neither screw ever reaches 1.
|
||||
const heldScrew = this.torquing;
|
||||
const heldVal = heldScrew !== null ? this.state.screws[heldScrew] : 0;
|
||||
this.state = s;
|
||||
if (heldScrew !== null) this.state.screws[heldScrew] = heldVal;
|
||||
// Keep our locally-carried wire unless the incoming state shows it is now
|
||||
// attached (a teammate crimped it). Clearing unconditionally would strand
|
||||
// our hand — and abort an in-progress crimp — every time the OTHER player
|
||||
// emits state (torque/seat/notch), breaking the split-work co-op flow.
|
||||
if (this.carried !== null && s.wires[this.carried] !== null) this.carried = null;
|
||||
if (s.tested && !this.completed) { this.complete(); return; }
|
||||
this.refreshVisuals();
|
||||
this.maybeEmit(); // filtered by the net guard; keeps other listeners live
|
||||
}
|
||||
|
||||
private emitAll(): void { const s = this.getState(); for (const fn of this.listeners) fn(s); }
|
||||
private maybeEmit(): void {
|
||||
// Quantize screw progress for the change-signature, but give the exact 1.0
|
||||
// endpoint its own bucket — otherwise 0.98 and 1.0 share sig 20 and the
|
||||
// final fully-torqued state never broadcasts (peers settle at ~0.98).
|
||||
const sig = JSON.stringify({ ...this.state, screws: this.state.screws.map((v) => (v >= 1 ? 21 : Math.round(v * 20))) });
|
||||
if (sig === this.lastSig) return;
|
||||
this.lastSig = sig;
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
// ── interaction routing ──
|
||||
tagFor(colliderId: string): Tag {
|
||||
if (!colliderId.startsWith('ws_')) return { kind: 'none', index: -1 };
|
||||
const body = colliderId.slice(3);
|
||||
if (body === 'cart') return { kind: 'cart', index: 0 };
|
||||
if (body === 'cue') return { kind: 'cue', index: 0 };
|
||||
if (body === 'weight') return { kind: 'weight', index: 0 };
|
||||
if (body.startsWith('pin')) return { kind: 'pin', index: +body.slice(3) };
|
||||
if (body.startsWith('wire')) return { kind: 'wire', index: +body.slice(4) };
|
||||
if (body.startsWith('screw')) return { kind: 'screw', index: +body.slice(5) };
|
||||
return { kind: 'none', index: -1 };
|
||||
}
|
||||
|
||||
/** Does this tag use a hold (crimp/torque)? A pin already holding a wire is
|
||||
* not a crimp target — that would stack two wires on one pin. */
|
||||
holdKind(t: Tag): 'crimp' | 'torque' | null {
|
||||
if (this.armMode !== 'clip') return null;
|
||||
if (t.kind === 'pin' && this.carried !== null && !this.state.wires.some((p) => p === t.index)) return 'crimp';
|
||||
if (t.kind === 'screw') return 'torque';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One-shot E press. */
|
||||
press(t: Tag, hb: WorkshopHotbar): void {
|
||||
if (t.kind === 'cue') { this.cue(); return; }
|
||||
if (this.armMode !== 'clip') return;
|
||||
switch (t.kind) {
|
||||
case 'cart':
|
||||
if (!this.state.cartSeated) {
|
||||
if (hb.activeBlock() === QUEST_ITEMS.stylus && hb.consumeActive(1)) {
|
||||
this.state.cartSeated = true;
|
||||
this.state.cartRotation = (1 + Math.floor(Math.random() * 3)) as Quarter; // lands wrong (1-3); squared by the player
|
||||
bus.emit('workshop:msg', { text: 'CARTRIDGE SEATED — SQUARE IT UP (E)' });
|
||||
bus.emit('machine:interact', { machineId: this.id, action: 'seat' });
|
||||
this.after();
|
||||
}
|
||||
} else {
|
||||
this.state.cartRotation = ((this.state.cartRotation + 1) % 4) as Quarter;
|
||||
if (this.state.cartRotation === 0) bus.emit('workshop:msg', { text: 'CARTRIDGE SQUARE' });
|
||||
bus.emit('machine:interact', { machineId: this.id, action: 'rotate' });
|
||||
this.after();
|
||||
}
|
||||
break;
|
||||
case 'wire': {
|
||||
const i = t.index;
|
||||
if (this.carried === null && this.state.wires[i] === null) {
|
||||
this.carried = i;
|
||||
bus.emit('workshop:msg', { text: `CARRYING ${wireName(i)} WIRE — CRIMP IT ONTO ITS PIN (hold E)` });
|
||||
} else if (this.carried === i) {
|
||||
this.carried = null; // tap the carried wire again to drop
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'pin': {
|
||||
// tapping a pin that already holds a wire pulls it off
|
||||
const w = this.state.wires.findIndex((p) => p === t.index);
|
||||
if (this.carried === null && w >= 0) {
|
||||
this.state.wires[w] = null;
|
||||
bus.emit('machine:interact', { machineId: this.id, action: 'unclip' });
|
||||
this.after();
|
||||
} else if (this.carried !== null && w >= 0) {
|
||||
bus.emit('workshop:msg', { text: 'PIN ALREADY WIRED — PULL IT OFF FIRST' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private holdA = 0;
|
||||
holdStart(t: Tag, _shift: boolean): boolean {
|
||||
this.holdA = 0;
|
||||
this.torquing = null;
|
||||
return this.holdKind(t) !== null;
|
||||
}
|
||||
/** Advance a hold; returns whether it is still running, done, or cancelled. */
|
||||
holdTick(t: Tag, dt: number, shift: boolean): 'run' | 'done' | 'cancel' {
|
||||
const kind = this.holdKind(t);
|
||||
if (!kind) { this.torquing = null; return 'cancel'; }
|
||||
if (kind === 'crimp') {
|
||||
this.holdA += dt;
|
||||
if (this.holdA >= CRIMP_SECONDS) {
|
||||
this.state.wires[this.carried!] = t.index as WireSlot;
|
||||
bus.emit('machine:interact', { machineId: this.id, action: 'crimp' });
|
||||
this.carried = null;
|
||||
this.after();
|
||||
return 'done';
|
||||
}
|
||||
return 'run';
|
||||
}
|
||||
// torque
|
||||
const i = t.index;
|
||||
this.torquing = i; // protect this screw from remote-state rewind while held
|
||||
const rate = 1 / TORQUE_SECONDS;
|
||||
this.state.screws[i] = clamp01(this.state.screws[i] + (shift ? -rate : rate) * dt);
|
||||
bus.emit('workshop:torque', { value: this.state.screws[i] });
|
||||
this.maybeEmit();
|
||||
if (!shift && this.state.screws[i] >= 1) {
|
||||
this.torquing = null;
|
||||
bus.emit('machine:interact', { machineId: this.id, action: 'clunk' });
|
||||
bus.emit('workshop:torque', { value: null });
|
||||
return 'done';
|
||||
}
|
||||
return 'run';
|
||||
}
|
||||
holdEnd(): void {
|
||||
this.torquing = null;
|
||||
bus.emit('workshop:torque', { value: null });
|
||||
}
|
||||
|
||||
dropWire(): void {
|
||||
if (this.carried !== null) {
|
||||
bus.emit('workshop:msg', { text: `${wireName(this.carried).toUpperCase()} WIRE RETURNED` });
|
||||
this.carried = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire index currently in hand, or null. */
|
||||
isCarrying(): number | null { return this.carried; }
|
||||
|
||||
/** The cue lever: clip → balance → test → (clean ? done : clip). */
|
||||
private cue(): void {
|
||||
if (this.armMode === 'clip') {
|
||||
if (!this.state.cartSeated) { bus.emit('workshop:msg', { text: 'SEAT THE CARTRIDGE FIRST' }); return; }
|
||||
this.armMode = 'balance';
|
||||
bus.emit('workshop:msg', { text: 'ARM LIVE — BALANCE THE COUNTERWEIGHT, THEN CUE TO TEST' });
|
||||
bus.emit('machine:interact', { machineId: this.id, action: 'cue' });
|
||||
} else if (this.armMode === 'balance') {
|
||||
this.runTest();
|
||||
}
|
||||
}
|
||||
|
||||
private runTest(): void {
|
||||
this.armMode = 'test';
|
||||
const errs = evaluateErrors(this.state);
|
||||
const keys = ERROR_ORDER.filter((k) => errs[k]);
|
||||
const clean = isClean(errs);
|
||||
// A skate abort is over in ~0.8 s — hold the collapsed-collider test state
|
||||
// only that long, not the full loop, so the player isn't stranded in silence.
|
||||
this.testTimer = errs.skate ? SKATE_TEST_SECONDS : TEST_SECONDS;
|
||||
bus.emit('workshop:test', { errors: keys, clean });
|
||||
this.drawScope(clean ? null : keys[0]!);
|
||||
if (!clean && keys[0]) bus.emit('workshop:msg', { text: ERROR_LABEL[keys[0]] });
|
||||
}
|
||||
|
||||
private finishTest(): void {
|
||||
const errs = evaluateErrors(this.state);
|
||||
if (isClean(errs)) {
|
||||
this.state.tested = true;
|
||||
this.quest.repair('stylus'); // → signal:repair → complete()
|
||||
} else {
|
||||
this.state.tested = false;
|
||||
this.armMode = 'clip';
|
||||
this.maybeEmit();
|
||||
}
|
||||
}
|
||||
|
||||
private complete(): void {
|
||||
if (this.completed) return;
|
||||
this.completed = true;
|
||||
this.state = completedState();
|
||||
this.armMode = 'done';
|
||||
this.carried = null;
|
||||
this.tonearm.setService(false);
|
||||
this.tonearm.setStylus(true);
|
||||
bus.emit('workshop:torque', { value: null });
|
||||
this.drawScope(null);
|
||||
this.refreshVisuals();
|
||||
this.refreshColliders();
|
||||
this.maybeEmit();
|
||||
}
|
||||
|
||||
private after(): void { this.refreshVisuals(); this.maybeEmit(); }
|
||||
|
||||
// ── per-tick ──
|
||||
update(dt: number): void {
|
||||
// ease the nose pitch toward the notch target (gentle → no ride slingshot)
|
||||
const target = this.armMode === 'clip' || this.armMode === 'done'
|
||||
? 0 : (this.state.weightNotch - 7) * TILT_PER_NOTCH;
|
||||
this.theta += (target - this.theta) * Math.min(1, dt * 5);
|
||||
|
||||
// a carried wire auto-returns to the loom if you wander >30 voxels off
|
||||
if (this.carried !== null && this.player) {
|
||||
const dx = this.player.position[0] - this.px, dz = this.player.position[2] - this.pz;
|
||||
if (dx * dx + dz * dz > 900) this.dropWire();
|
||||
}
|
||||
|
||||
if (this.armMode === 'balance') this.pushWeight();
|
||||
if (this.armMode === 'test') {
|
||||
this.testTimer -= dt;
|
||||
if (this.testTimer <= 0) this.finishTest();
|
||||
}
|
||||
this.refreshColliders();
|
||||
this.animate(dt);
|
||||
}
|
||||
|
||||
/** Player shoves the counterweight (walk-push, à la the fader sled). */
|
||||
private pushWeight(): void {
|
||||
if (!this.player) return;
|
||||
const pl = this.player.position;
|
||||
const sled = this.weightWorldPos(this.state.weightNotch);
|
||||
const dx = pl[0] - sled[0], dz = pl[2] - sled[2];
|
||||
if (dx * dx + dz * dz > 9 || Math.abs(pl[1] + 0.9 - sled[1]) > 3.5) return;
|
||||
const rel = (pl[0] - this.px) * this.bx + (pl[2] - this.pz) * this.bz; // signed along tail
|
||||
let f = (rel - D_FAR) / (D_NEAR - D_FAR);
|
||||
f = f < 0 ? 0 : f > 1 ? 1 : f;
|
||||
const notch = Math.round(f * 10);
|
||||
if (notch !== this.state.weightNotch) {
|
||||
this.state.weightNotch = notch;
|
||||
bus.emit('machine:interact', { machineId: this.id, action: 'notch' });
|
||||
this.maybeEmit();
|
||||
}
|
||||
}
|
||||
|
||||
private weightWorldPos(notch: number): Vec3 {
|
||||
const d = D_FAR + (D_NEAR - D_FAR) * (notch / 10);
|
||||
return [this.px + this.bx * d, this.beamY(d), this.pz + this.bz * d];
|
||||
}
|
||||
private beamY(d: number): number { return BEAM_Y0 - d * Math.sin(this.theta); }
|
||||
|
||||
// ── colliders per tick ──
|
||||
private setBox(id: string, c: Vec3, hx: number, hy: number, hz: number): void {
|
||||
const s = this.shapes.get(id)!;
|
||||
s.min[0] = c[0] - hx; s.min[1] = c[1] - hy; s.min[2] = c[2] - hz;
|
||||
s.max[0] = c[0] + hx; s.max[1] = c[1] + hy; s.max[2] = c[2] + hz;
|
||||
}
|
||||
private collapse(id: string): void { this.setBox(id, [-1e6, -1e6, -1e6], 0.01, 0.01, 0.01); }
|
||||
private collapseRide(): void {
|
||||
for (const s of this.rideShapes) { s.min[0] = s.min[1] = s.min[2] = -1e6; s.max[0] = s.max[1] = s.max[2] = -1e6 + 0.01; }
|
||||
}
|
||||
|
||||
private refreshColliders(): void {
|
||||
const clip = this.armMode === 'clip';
|
||||
// The beam stays solid through the needle-drop test too, so a player who
|
||||
// cued while standing on the arm isn't dropped through it for the 4 s test.
|
||||
const bal = this.armMode === 'balance' || this.armMode === 'test';
|
||||
// interaction targets: reachable only with the arm in the clip
|
||||
if (clip) {
|
||||
this.setBox('ws_cart', this.headPos(), 2.4, 1.6, 2);
|
||||
for (let i = 0; i < 4; i++) this.setBox(`ws_pin${i}`, this.pinPos(i), 0.9, 0.9, 0.9);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (this.carried === null && this.state.wires[i] === null) this.setBox(`ws_wire${i}`, this.wireLoomPos(i), 0.8, 1.2, 0.8);
|
||||
else this.collapse(`ws_wire${i}`);
|
||||
}
|
||||
this.setBox('ws_screw0', this.screwPos(0), 0.9, 0.9, 0.9);
|
||||
this.setBox('ws_screw1', this.screwPos(1), 0.9, 0.9, 0.9);
|
||||
} else {
|
||||
this.collapse('ws_cart');
|
||||
for (let i = 0; i < 4; i++) { this.collapse(`ws_pin${i}`); this.collapse(`ws_wire${i}`); }
|
||||
this.collapse('ws_screw0'); this.collapse('ws_screw1');
|
||||
}
|
||||
// cue lever always reachable except during the test
|
||||
if (this.armMode === 'test' || this.armMode === 'done') this.collapse('ws_cue');
|
||||
else this.setBox('ws_cue', this.cuePos, 0.8, 1.6, 0.8);
|
||||
// ride beam + weight only while balancing
|
||||
if (bal) {
|
||||
this.setBox('ws_weight', this.weightWorldPos(this.state.weightNotch), 2, 1.6, 2);
|
||||
for (let i = 0; i < RIDE_SEGMENTS; i++) {
|
||||
const d = D_FAR + (HEAD_LEN - D_FAR) * (i + 0.5) / RIDE_SEGMENTS; // -14 .. +9, the full visible beam
|
||||
const c: Vec3 = [this.px + this.bx * d, this.beamY(d), this.pz + this.bz * d];
|
||||
const s = this.rideShapes[i];
|
||||
s.min[0] = c[0] - 1.7; s.min[1] = c[1] - 0.6; s.min[2] = c[2] - 1.7;
|
||||
s.max[0] = c[0] + 1.7; s.max[1] = c[1] + 0.6; s.max[2] = c[2] + 1.7;
|
||||
}
|
||||
} else {
|
||||
this.collapse('ws_weight');
|
||||
this.collapseRide();
|
||||
}
|
||||
}
|
||||
|
||||
// ── geometry helpers (world positions) ──
|
||||
private headPos(): Vec3 {
|
||||
// in balance/test the head rides the tilt; in clip it sits in the service clip
|
||||
const d = HEAD_LEN;
|
||||
const y = this.armMode === 'clip' || this.armMode === 'done' ? WORK_Y : this.beamY(d);
|
||||
return [this.px + this.bx * d, y, this.pz + this.bz * d];
|
||||
}
|
||||
private pinPos(i: number): Vec3 {
|
||||
const h = this.headPos();
|
||||
const off = PIN_OFF[i];
|
||||
return [h[0] - this.bx * 1.6 + this.perpX * off[0], h[1] + off[1], h[2] - this.bz * 1.6 + this.perpZ * off[0]];
|
||||
}
|
||||
private wireLoomPos(i: number): Vec3 {
|
||||
return [this.loomPos[0] + this.perpX * (i - 1.5) * 1.1, this.loomPos[1] - 2, this.loomPos[2] + this.perpZ * (i - 1.5) * 1.1];
|
||||
}
|
||||
private screwPos(i: number): Vec3 {
|
||||
const h = this.headPos();
|
||||
const s = i === 0 ? 1.9 : -1.9;
|
||||
return [h[0] + this.perpX * s, h[1] - 0.6, h[2] + this.perpZ * s];
|
||||
}
|
||||
|
||||
// ── visuals per tick ──
|
||||
private animate(_dt: number): void {
|
||||
this.group.visible = this.armMode !== 'done'; // the real tonearm takes over on completion
|
||||
if (this.armMode === 'done') return;
|
||||
const head = this.headPos();
|
||||
// beam spans the tail (counterweight end) through the pivot to the head
|
||||
const tail: Vec3 = [this.px + this.bx * D_FAR, this.beamY(D_FAR), this.pz + this.bz * D_FAR];
|
||||
placeBeam(this.beamMesh, tail, head, 1.0);
|
||||
// cartridge (armMode is not 'done' here — we returned early above)
|
||||
this.cartMesh.visible = this.state.cartSeated;
|
||||
if (this.cartMesh.visible) {
|
||||
this.cartMesh.position.set(head[0], head[1], head[2]);
|
||||
this.cartMesh.rotation.y = Math.atan2(this.bx, this.bz) + this.state.cartRotation * Math.PI / 2;
|
||||
(this.cartMesh.children[2] as THREE.Mesh).material = this.state.cartRotation === 0 ? TIP_BRIGHT_MAT : TIP_DIM_MAT;
|
||||
}
|
||||
// pins
|
||||
const showParts = this.armMode === 'clip';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const pm = this.pinMeshes[i];
|
||||
pm.visible = showParts;
|
||||
if (showParts) { const p = this.pinPos(i); pm.position.set(p[0], p[1], p[2]); }
|
||||
}
|
||||
// wires (catenary) + continuity lamp
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const loom = this.wireLoomPos(i);
|
||||
let end: Vec3;
|
||||
if (this.carried === i && this.player) {
|
||||
const e = this.player.eye, l = this.player.lookDir;
|
||||
end = [e[0] + l[0] * 2, e[1] + l[1] * 2 - 0.5, e[2] + l[2] * 2];
|
||||
} else if (this.state.wires[i] !== null) {
|
||||
end = this.pinPos(this.state.wires[i]!);
|
||||
} else {
|
||||
end = [loom[0], loom[1] - 1.5, loom[2]];
|
||||
}
|
||||
catenary(this.wireLines[i], loom, end);
|
||||
this.wireLines[i].visible = this.armMode === 'clip' || this.armMode === 'test';
|
||||
// lamp: green when wire i on its correct pin
|
||||
const led = this.lampLeds[i];
|
||||
const lp: Vec3 = [this.loomPos[0] + this.perpX * (i - 1.5) * 1.1, this.loomPos[1] + 0.9, this.loomPos[2] + this.perpZ * (i - 1.5) * 1.1];
|
||||
led.position.set(lp[0], lp[1], lp[2]);
|
||||
led.material = this.state.wires[i] === PIN_OF_WIRE[i] ? LED_ON_MAT : LED_OFF_MAT;
|
||||
}
|
||||
// screws (tilt the cart if uneven while tightening)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sm = this.screwMeshes[i];
|
||||
sm.visible = showParts;
|
||||
if (showParts) { const p = this.screwPos(i); sm.position.set(p[0], p[1], p[2]); sm.rotation.y = this.state.screws[i] * Math.PI * 6; }
|
||||
}
|
||||
// counterweight
|
||||
const wp = this.weightWorldPos(this.state.weightNotch);
|
||||
this.weightMesh.visible = this.armMode === 'balance' || this.armMode === 'test';
|
||||
this.weightMesh.position.set(wp[0], wp[1], wp[2]);
|
||||
// bubble dot: drifts with tilt (centered at notch 7)
|
||||
this.bubbleDot.visible = this.armMode === 'balance';
|
||||
this.bubbleDot.position.set(this.levelPos[0] + this.perpX * this.theta * 40, this.levelPos[1], this.levelPos[2] + this.perpZ * this.theta * 40);
|
||||
// cue lever nudge
|
||||
this.cueMesh.rotation.x = this.armMode === 'balance' ? 0.4 : this.armMode === 'test' ? 0.8 : 0;
|
||||
}
|
||||
|
||||
private refreshVisuals(): void { this.animate(0); }
|
||||
|
||||
private drawScope(err: keyof TestErrors | null): void {
|
||||
drawScopeCanvas(this.scopeTex, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── module helpers ──────────────────────────────────────────────────────────
|
||||
const CAT_N = 14;
|
||||
const PIN_OFF: [number, number][] = [[-1.3, 0.8], [1.3, 0.8], [-1.3, -0.6], [1.3, -0.6]];
|
||||
|
||||
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
|
||||
function wireName(i: number): string { return ['RED', 'WHITE', 'GREEN', 'BLUE'][i] ?? '?'; }
|
||||
|
||||
function capMat(color: number): THREE.MeshStandardMaterial {
|
||||
const c = WIRE_COLORS[color];
|
||||
return new THREE.MeshStandardMaterial({ color: new THREE.Color(c[0] / 255, c[1] / 255, c[2] / 255), roughness: 0.5 });
|
||||
}
|
||||
// Shared lamp + stylus-tip materials, created once and swapped by reference.
|
||||
// animate() runs every frame; allocating fresh MeshStandardMaterials per lamp
|
||||
// and per tip each tick churned ~300 undisposed materials/s through the GC.
|
||||
const LED_ON_MAT = new THREE.MeshStandardMaterial({ color: 0x2b8f3f, emissive: 0x38d64a, emissiveIntensity: 1.2 });
|
||||
const LED_OFF_MAT = new THREE.MeshStandardMaterial({ color: 0x30130f, emissive: 0x200000, emissiveIntensity: 0.2 });
|
||||
const TIP_BRIGHT_MAT = blockMaterial(11, { emissive: 0.9 });
|
||||
const TIP_DIM_MAT = blockMaterial(11, { emissive: 0.2 });
|
||||
|
||||
/** Orient a unit box as a beam between two world points. */
|
||||
function placeBeam(mesh: THREE.Mesh, a: Vec3, b: Vec3, thick: number): void {
|
||||
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2, mz = (a[2] + b[2]) / 2;
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
|
||||
const len = Math.hypot(dx, dy, dz) || 1;
|
||||
mesh.position.set(mx, my, mz);
|
||||
mesh.scale.set(thick, thick, len);
|
||||
mesh.quaternion.setFromUnitVectors(_z, _v.set(dx / len, dy / len, dz / len));
|
||||
}
|
||||
const _z = new THREE.Vector3(0, 0, 1);
|
||||
const _v = new THREE.Vector3();
|
||||
|
||||
/** Update a THREE.Line to a sagging catenary between A and B. */
|
||||
function catenary(line: THREE.Line, a: Vec3, b: Vec3): void {
|
||||
const pos = (line.geometry.getAttribute('position') as THREE.BufferAttribute);
|
||||
const arr = pos.array as Float32Array;
|
||||
const dist = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
|
||||
const sag = Math.min(3, dist * 0.18);
|
||||
for (let i = 0; i < CAT_N; i++) {
|
||||
const t = i / (CAT_N - 1);
|
||||
arr[i * 3] = a[0] + (b[0] - a[0]) * t;
|
||||
arr[i * 3 + 1] = a[1] + (b[1] - a[1]) * t - sag * 4 * t * (1 - t);
|
||||
arr[i * 3 + 2] = a[2] + (b[2] - a[2]) * t;
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
line.geometry.computeBoundingSphere();
|
||||
}
|
||||
|
||||
function makeScopeTexture(): THREE.CanvasTexture {
|
||||
const cv = document.createElement('canvas'); cv.width = cv.height = 256;
|
||||
const tex = new THREE.CanvasTexture(cv);
|
||||
return tex;
|
||||
}
|
||||
function drawScopeCanvas(tex: THREE.CanvasTexture, err: keyof TestErrors | null): void {
|
||||
const cv = tex.image as HTMLCanvasElement;
|
||||
const g = cv.getContext('2d')!; const W = cv.width, H = cv.height;
|
||||
g.fillStyle = '#04140c'; g.fillRect(0, 0, W, H);
|
||||
g.strokeStyle = '#1f5b38'; g.lineWidth = 1;
|
||||
g.beginPath(); g.moveTo(0, H / 2); g.lineTo(W, H / 2); g.stroke();
|
||||
g.strokeStyle = err ? '#ffcf3a' : '#5cff9a'; g.lineWidth = 3; g.beginPath();
|
||||
for (let x = 0; x <= W; x += 2) {
|
||||
const t = x / W * Math.PI * 4;
|
||||
let y = Math.sin(t);
|
||||
if (err === 'incomplete') y = 0; // dead flat line — a channel is silent
|
||||
else if (err === 'swapLR') y = Math.sin(t + Math.PI); // mirrored
|
||||
else if (err === 'phaseCancel') y = Math.sin(t) * 0.2; // collapsed thin
|
||||
else if (err === 'azimuthTilt') y = Math.sin(t) * (0.4 + 0.6 * (x / W)); // lopsided
|
||||
else if (err === 'trackingLight') y = Math.sin(t) * (Math.random() > 0.7 ? 0.1 : 1); // stutter
|
||||
else if (err === 'trackingHeavy') y = Math.sin(t * 0.6) * 0.7; // sagging
|
||||
else if (err === 'skate') y = (x / W) * 2 - 1; // ramp/skate
|
||||
const py = H / 2 - y * H * 0.32;
|
||||
if (x === 0) g.moveTo(x, py); else g.lineTo(x, py);
|
||||
}
|
||||
g.stroke();
|
||||
tex.needsUpdate = true;
|
||||
}
|
||||
149
src/machines/workshop/types.ts
Normal file
149
src/machines/workshop/types.ts
Normal file
@ -0,0 +1,149 @@
|
||||
// LANE D — Headshell Workshop shared types (WORKSHOP_CARTRIDGE brief).
|
||||
// The authoritative state is `WorkshopState` — it is also exactly what syncs
|
||||
// over the relay (last-write-wins). Keep it JSON-primitive and range-bounded so
|
||||
// the server can validate it without importing anything.
|
||||
|
||||
export type Quarter = 0 | 1 | 2 | 3;
|
||||
export type WireSlot = 0 | 1 | 2 | 3 | null;
|
||||
|
||||
export interface WorkshopState {
|
||||
cartSeated: boolean;
|
||||
cartRotation: Quarter; // quarter turns; 0 = correct (stylus forward)
|
||||
wires: WireSlot[]; // wire i (R,W,G,B) -> pin index, or null (unattached)
|
||||
screws: [number, number]; // 0..1 torque each
|
||||
weightNotch: number; // 0..10 along the arm; 7 = zero-balance, 8 = 2 g target
|
||||
tested: boolean; // last needle-drop was clean
|
||||
}
|
||||
|
||||
/** The audible/visible failure modes of the needle-drop test. */
|
||||
export interface TestErrors {
|
||||
incomplete: boolean; // a pin has no wire — that channel is dead (silent)
|
||||
swapLR: boolean; // R/W crossed with G/B — channels swapped
|
||||
phaseCancel: boolean; // a +/- pair swapped — thin/hollow
|
||||
azimuthTilt: boolean; // screws uneven / cart loose — one channel down + distortion
|
||||
trackingLight: boolean;// notch <= 6 — stutter/skip
|
||||
trackingHeavy: boolean;// notch >= 9 — muffled + platter drag
|
||||
skate: boolean; // cartridge rotated wrong — needle skates, test aborts
|
||||
}
|
||||
|
||||
export function emptyState(): WorkshopState {
|
||||
return { cartSeated: false, cartRotation: 0, wires: [null, null, null, null], screws: [0, 0], weightNotch: 7, tested: false };
|
||||
}
|
||||
|
||||
/** Completed pose (used when `stylus` is already repaired on join). Wires must
|
||||
* match the correct puzzle solution (PIN_OF_WIRE), or the "done" cartridge would
|
||||
* fail the game's own evaluateErrors and light dark continuity lamps. */
|
||||
export function completedState(): WorkshopState {
|
||||
return { cartSeated: true, cartRotation: 0, wires: [...PIN_OF_WIRE] as WireSlot[], screws: [1, 1], weightNotch: 8, tested: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + clamp an arbitrary value into a WorkshopState, or return null if
|
||||
* the shape is unusable. Mirrors the relay's server-side check (kept in sync by
|
||||
* hand — the relay is standalone JS by design).
|
||||
*/
|
||||
export function coerceState(v: unknown): WorkshopState | null {
|
||||
if (!v || typeof v !== 'object') return null;
|
||||
const o = v as Record<string, unknown>;
|
||||
if (typeof o.cartSeated !== 'boolean' || typeof o.tested !== 'boolean') return null;
|
||||
const rot = o.cartRotation;
|
||||
if (rot !== 0 && rot !== 1 && rot !== 2 && rot !== 3) return null;
|
||||
if (!Array.isArray(o.wires) || o.wires.length !== 4) return null;
|
||||
const wires: WireSlot[] = [];
|
||||
for (const w of o.wires) {
|
||||
if (w === null) wires.push(null);
|
||||
else if (w === 0 || w === 1 || w === 2 || w === 3) wires.push(w);
|
||||
else return null;
|
||||
}
|
||||
if (!Array.isArray(o.screws) || o.screws.length !== 2) return null;
|
||||
const s0 = o.screws[0], s1 = o.screws[1];
|
||||
if (typeof s0 !== 'number' || typeof s1 !== 'number' || !isFinite(s0) || !isFinite(s1)) return null;
|
||||
const notch = o.weightNotch;
|
||||
if (typeof notch !== 'number' || !isFinite(notch)) return null;
|
||||
return {
|
||||
cartSeated: o.cartSeated,
|
||||
cartRotation: rot,
|
||||
wires,
|
||||
screws: [clamp01(s0), clamp01(s1)],
|
||||
weightNotch: Math.max(0, Math.min(10, Math.round(notch))),
|
||||
tested: o.tested,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
|
||||
|
||||
// ── Fixed per-world wiring puzzle ──────────────────────────────────────────
|
||||
// Pin physical order is [pin0..pin3]; each pin's color cap tells which wire
|
||||
// belongs there. Deterministic (shared by all clients) — NOT random per client,
|
||||
// or multiplayer would disagree. Colors: 0=red(R+) 1=white(L+) 2=green(R-) 3=blue(L-).
|
||||
export const PIN_COLOR: readonly Quarter[] = [2, 0, 3, 1]; // pin p wants wire PIN_COLOR[p]
|
||||
/** Correct pin index for wire i (inverse of PIN_COLOR). */
|
||||
export const PIN_OF_WIRE: readonly number[] = (() => {
|
||||
const out = [0, 0, 0, 0];
|
||||
for (let p = 0; p < 4; p++) out[PIN_COLOR[p]] = p;
|
||||
return out;
|
||||
})();
|
||||
|
||||
export const WIRE_COLORS: readonly [number, number, number][] = [
|
||||
[190, 40, 36], // 0 red R+
|
||||
[225, 225, 220], // 1 white L+
|
||||
[60, 200, 90], // 2 green R-
|
||||
[70, 130, 255], // 3 blue L-
|
||||
];
|
||||
export const WIRE_NAMES = ['red', 'white', 'green', 'blue'] as const;
|
||||
|
||||
/** Channel of a wire: red/green = right (0), white/blue = left (1). */
|
||||
export function wireChannel(i: number): 0 | 1 { return (i === 0 || i === 2) ? 0 : 1; }
|
||||
/** Polarity of a wire: red/white = + (0), green/blue = - (1). */
|
||||
export function wirePolarity(i: number): 0 | 1 { return (i === 0 || i === 1) ? 0 : 1; }
|
||||
|
||||
/** Derive the test error flags from a state. Assumes cart seated + rotation 0
|
||||
* are checked by the caller for the skate-abort; here we classify the rest. */
|
||||
export function evaluateErrors(s: WorkshopState): TestErrors {
|
||||
const e: TestErrors = { incomplete: false, swapLR: false, phaseCancel: false, azimuthTilt: false, trackingLight: false, trackingHeavy: false, skate: false };
|
||||
if (!s.cartSeated || s.cartRotation !== 0) { e.skate = true; return e; }
|
||||
|
||||
const allCorrect = s.wires.every((pin, i) => pin === PIN_OF_WIRE[i]);
|
||||
if (!allCorrect) {
|
||||
const pinWire = [-1, -1, -1, -1]; // which wire sits on pin p
|
||||
s.wires.forEach((pin, i) => { if (pin !== null) pinWire[pin] = i; });
|
||||
let missing = false, chanSwapped = true, anyChanWrong = false, phase = false;
|
||||
for (let p = 0; p < 4; p++) {
|
||||
const w = pinWire[p];
|
||||
if (w < 0) { missing = true; continue; }
|
||||
const wantChan = wireChannel(PIN_COLOR[p]);
|
||||
const wantPol = wirePolarity(PIN_COLOR[p]);
|
||||
if (wireChannel(w) !== wantChan) anyChanWrong = true; else chanSwapped = false;
|
||||
if (wireChannel(w) === wantChan && wirePolarity(w) !== wantPol) phase = true;
|
||||
}
|
||||
if (missing) e.incomplete = true; // a pin has no wire → dead channel
|
||||
else if (chanSwapped && anyChanWrong) e.swapLR = true;
|
||||
else if (phase) e.phaseCancel = true;
|
||||
else e.swapLR = true;
|
||||
}
|
||||
|
||||
const [a, b] = s.screws;
|
||||
if (Math.abs(a - b) > 0.4 || Math.min(a, b) < 0.5) e.azimuthTilt = true;
|
||||
if (s.weightNotch <= 6) e.trackingLight = true;
|
||||
else if (s.weightNotch >= 9) e.trackingHeavy = true;
|
||||
return e;
|
||||
}
|
||||
|
||||
export function isClean(e: TestErrors): boolean {
|
||||
return !e.incomplete && !e.swapLR && !e.phaseCancel && !e.azimuthTilt && !e.trackingLight && !e.trackingHeavy && !e.skate;
|
||||
}
|
||||
|
||||
/** Ordered error keys (for picking the first to announce). incomplete outranks
|
||||
* the miswiring modes — you can't diagnose a swap when a wire is missing. */
|
||||
export const ERROR_ORDER: (keyof TestErrors)[] = ['skate', 'incomplete', 'swapLR', 'phaseCancel', 'azimuthTilt', 'trackingLight', 'trackingHeavy'];
|
||||
|
||||
/** Human label for the first error, for the HUD subtitle. */
|
||||
export const ERROR_LABEL: Record<keyof TestErrors, string> = {
|
||||
skate: 'CARTRIDGE ROTATED WRONG — NEEDLE SKATES',
|
||||
incomplete: 'WIRING INCOMPLETE — A CHANNEL IS DEAD',
|
||||
swapLR: 'LEFT AND RIGHT ARE SWAPPED',
|
||||
phaseCancel: 'A WIRE PAIR IS OUT OF PHASE',
|
||||
azimuthTilt: 'CARTRIDGE IS TILTED — TIGHTEN SCREWS EVENLY',
|
||||
trackingLight: 'TRACKING FORCE TOO LIGHT — IT SKIPS',
|
||||
trackingHeavy: 'TRACKING FORCE TOO HEAVY — IT DRAGS',
|
||||
};
|
||||
20
src/main.ts
20
src/main.ts
@ -12,6 +12,7 @@ import { Interaction, Hotbar } from './interact';
|
||||
import { PlayerController } from './player';
|
||||
import { AudioEngine } from './audio/AudioEngine';
|
||||
import { FxSystem } from './fx/FxSystem';
|
||||
import { DecalSet } from './fx/decals';
|
||||
import { Hud } from './ui/Hud';
|
||||
import { Menu, QUALITY_PIXEL_RATIO } from './ui/Menu';
|
||||
import { Avatars } from './net/Avatars';
|
||||
@ -22,7 +23,7 @@ container.innerHTML = '';
|
||||
|
||||
// 1–2. Renderer + world. createRenderer builds THE atlas; ChunkManager must
|
||||
// share it so setEmissiveBoost reaches the chunk materials (Lane A handoff).
|
||||
const { renderer, scene, camera, atlas, setEmissiveBoost, render } = createRenderer(container);
|
||||
const { renderer, scene, camera, atlas, setEmissiveBoost, setBloom, render } = createRenderer(container);
|
||||
const world = new VoxelWorld(WORLD_X, WORLD_Y, WORLD_Z);
|
||||
|
||||
// 3–4. Build the booth, then mesh it.
|
||||
@ -52,6 +53,11 @@ const questPos: QuestPositions = {
|
||||
const machines = createMachines(world, questPos);
|
||||
for (const o of machines.getObject3Ds()) scene.add(o as Object3D);
|
||||
|
||||
// Glow-Up G3: screen-print decals (labels, socket numbers, party flyers) baked
|
||||
// onto thin proud planes over the static gear. Purely cosmetic, built once.
|
||||
const decals = new DecalSet();
|
||||
scene.add(decals.group);
|
||||
|
||||
// Warm light pools under the six ceiling lamp panels worldgen places
|
||||
// (buildShell); positions must match its [112,224,336] × [70,160] grid.
|
||||
for (const lx of [112, 224, 336]) {
|
||||
@ -97,6 +103,7 @@ const menu = new Menu({
|
||||
player.setSensitivity(0.0022 * s.sensitivity);
|
||||
player.viewBob = s.viewBob;
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, QUALITY_PIXEL_RATIO[s.quality]));
|
||||
setBloom(s.bloom && s.quality !== 'fast'); // Glow-Up G2: LED bloom, off on 'fast'
|
||||
},
|
||||
onOpen: () => { document.exitPointerLock(); hud.setPaused(false); },
|
||||
onClose: () => { if (started) renderer.domElement.requestPointerLock(); },
|
||||
@ -140,12 +147,18 @@ renderer.domElement.addEventListener('mousedown', (e) => {
|
||||
window.addEventListener('mouseup', (e) => { if (e.button === 0) interaction.stopMining(); });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (!locked()) return;
|
||||
if (e.code === 'KeyE') interaction.use();
|
||||
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') interaction.shiftDown = true;
|
||||
if (e.code === 'KeyE') { if (!e.repeat) interaction.useDown(e.shiftKey); } // E press (hold = crimp/torque)
|
||||
else if (e.code === 'KeyQ') interaction.dropCarry(); // drop a carried wire
|
||||
else if (e.code.startsWith('Digit')) {
|
||||
const n = parseInt(e.code.slice(5), 10);
|
||||
if (n >= 1 && n <= 9) hotbar.setActive(n - 1);
|
||||
}
|
||||
});
|
||||
window.addEventListener('keyup', (e) => {
|
||||
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') interaction.shiftDown = false;
|
||||
if (e.code === 'KeyE') interaction.useUp(); // release ends any crimp/torque hold
|
||||
});
|
||||
window.addEventListener('wheel', (e) => {
|
||||
if (locked()) hotbar.setActive(hotbar.active + (e.deltaY > 0 ? 1 : -1));
|
||||
});
|
||||
@ -160,6 +173,7 @@ function frame(now: number): void {
|
||||
let steps = 0;
|
||||
while (acc >= FIXED_DT && steps < MAX_SUBSTEPS) {
|
||||
machines.update(FIXED_DT);
|
||||
player.jumpScale = machines.workshop.isCarrying() !== null ? 0.5 : 1; // wire tug caps jump
|
||||
player.update(FIXED_DT);
|
||||
interaction.update(FIXED_DT);
|
||||
fx.update(FIXED_DT);
|
||||
@ -185,4 +199,4 @@ requestAnimationFrame(frame);
|
||||
declare global {
|
||||
interface Window { TURNCRAFT: unknown }
|
||||
}
|
||||
window.TURNCRAFT = { world, player, machines, quest: machines.quest, hotbar, audio, fx, hud, chunks, interaction, camera, render, net, avatars };
|
||||
window.TURNCRAFT = { world, player, machines, quest: machines.quest, hotbar, audio, fx, hud, chunks, interaction, camera, render, net, avatars, setBloom, setEmissiveBoost, decals };
|
||||
|
||||
@ -72,6 +72,12 @@ export class NetClient {
|
||||
this.unsubs.push(bus.on('platter:state', ({ deck, playing, rpm }) => {
|
||||
if (!this.applyingRemote) this.send({ t: 'platter', deck, playing, rpm });
|
||||
}));
|
||||
// Headshell Workshop assembly state (last-write-wins). The workshop fires
|
||||
// onChange on every change, local OR remote; the applyingRemote guard drops
|
||||
// the remote echo, same as the bus handlers above.
|
||||
this.unsubs.push(this.o.machines.workshop.onChange((s) => {
|
||||
if (!this.applyingRemote) this.send({ t: 'workshop', s });
|
||||
}));
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
@ -130,6 +136,7 @@ export class NetClient {
|
||||
const pl = this.platter(deck);
|
||||
if (s && pl) { pl.setSpeed(s.rpm); pl.setPlaying(s.playing); }
|
||||
}
|
||||
if (m.state?.workshop) this.o.machines.workshop.setState(m.state.workshop);
|
||||
});
|
||||
this.o.onStatus?.(this.peerCount, true);
|
||||
break;
|
||||
@ -162,6 +169,9 @@ export class NetClient {
|
||||
if (pl) this.applyRemote(() => { pl.setSpeed(m.rpm); pl.setPlaying(m.playing); });
|
||||
break;
|
||||
}
|
||||
case 'workshop':
|
||||
this.applyRemote(() => { this.o.machines.workshop.setState(m.s); });
|
||||
break;
|
||||
case 'full':
|
||||
console.warn('[net] booth is full — playing solo');
|
||||
this.dispose();
|
||||
|
||||
@ -36,6 +36,12 @@ player.setFlying(true); // debug fly toggle (also 'F' in-game)
|
||||
sled / oscillating platform / tonearm pusher). Standing adds the collider's
|
||||
`velocityAt` as a position-level carry; leaving (jump or walk-off) inherits it
|
||||
once → the record-edge launch. Near-center is calm, rpm45 rim outruns sprint.
|
||||
- **Vertically-moving platforms (the Headshell Workshop seesaw):** riding a
|
||||
platform whose top tilts/rises/drops under you is glued via **ride-snap**
|
||||
(rising grounds by penetration; descending snaps back onto the top across a
|
||||
≤`RIDE_SNAP` per-tick gap). Glued up to ~2 rad/s tilt (~24 v/s surface), far
|
||||
past counterweight-driven balancing; graceful slide-off beyond. Full contract
|
||||
in `docs/INTEGRATION_NOTES.md` → "Physics — riding tilting … platforms".
|
||||
- Events: `player:landed { impactSpeed }` on ground contact, `player:step
|
||||
{ block }` every 1.8 voxels of real-ground travel (reads block under feet).
|
||||
- Camera: view-bob (toggleable, off while riding/airborne), +5° sprint FOV,
|
||||
@ -66,17 +72,15 @@ not edit `src/core/`.
|
||||
(above). Not a contract change — just flagging where the numbers live.
|
||||
- `KinematicCollider.velocityAt` is consumed exactly as specified (voxels/sec,
|
||||
world-space point). Cylinder colliders are treated as +Y-axis only, per §4.
|
||||
- **`velocityAt(x,y,z): Vec3` forces a per-sample allocation.** While riding a
|
||||
platform I call it every substep (≤8/frame → ~480 short-lived arrays/sec),
|
||||
which brushes against CONTRACTS §8 ("near-zero per-frame allocation in hot
|
||||
paths"). My own code is allocation-free; the array is minted inside the core
|
||||
interface's return. If we care, the clean fix is a core-owned out-param
|
||||
overload, e.g. `velocityAt(x, y, z, out?: Vec3): Vec3`, so I can pass a reused
|
||||
buffer. Flagging for the integrator — I did not edit `src/core/`.
|
||||
- **Vertical platform carry is dropped** (`carryVel[1]` forced to 0). Fine for
|
||||
the v1 gear (platters rotate about +Y with no vertical surface velocity;
|
||||
faders/tonearm carry horizontally). If Lane D ships a vertically-moving
|
||||
rideable collider, `applyCarry` needs a Y sweep added.
|
||||
- **`velocityAt(x,y,z): Vec3` allocation** — I earlier flagged that sampling it
|
||||
each substep mints a short-lived array. Integrator resolved it lane-side by
|
||||
having machines return a reused per-collider scratch array (see
|
||||
INTEGRATION_NOTES); the core signature was left unchanged. No action for Lane B.
|
||||
- **Vertical platform carry** — previously dropped; now handled. Riding a
|
||||
vertically-moving collider (the workshop seesaw) is glued via ride-snap +
|
||||
penetration grounding rather than velocity carry, which avoids the per-substep
|
||||
overshoot a naive vertical carry causes (the arm's collider is static within a
|
||||
tick while the player substeps). See INTEGRATION_NOTES physics section.
|
||||
|
||||
## How to verify
|
||||
|
||||
|
||||
@ -24,6 +24,8 @@ const FLY_HORIZ_MULT = 1.6;
|
||||
const TERMINAL = 40; // fall-speed clamp (voxels/sec)
|
||||
const STAND_UP_EPS = 0.06; // feet may sit this far above a platform top and still stand
|
||||
const LAND_DOWN_EPS = 0.30; // ...and snap down onto it from within this gap (> max substep)
|
||||
const RIDE_SNAP = 0.75; // while riding a platform, stay stuck to its top across this
|
||||
// per-tick vertical gap (a fast-tilting arm dropping away)
|
||||
const STEP_DISTANCE = 1.8; // grounded travel between footstep events
|
||||
const LAND_MIN_SPEED = 3.0; // don't emit landing below this downward speed
|
||||
const MAX_SUBSTEPS = 8;
|
||||
@ -49,6 +51,9 @@ export class PlayerController implements IPlayerView {
|
||||
// Default OFF: playtest feedback ("the screen kinda rumbles") — head-bob
|
||||
// reads as jank to players without Minecraft habits. Settings re-enable it.
|
||||
viewBob = false;
|
||||
// Multiplier on jump take-off velocity (1 = normal). Lane D drives this to 0.5
|
||||
// while the player carries a Workshop tag-wire — the wire tugs, capping the jump.
|
||||
jumpScale = 1;
|
||||
|
||||
private world: IVoxelWorld;
|
||||
private getColliders: () => KinematicCollider[];
|
||||
@ -65,6 +70,11 @@ export class PlayerController implements IPlayerView {
|
||||
private carryVel: [number, number, number] = [0, 0, 0];
|
||||
private _scratch: [number, number, number] = [0, 0, 0];
|
||||
|
||||
// The kinematic collider we rode last step. Kept across steps so a tilting/
|
||||
// dropping platform (the Headshell Workshop seesaw) can "ride-snap" the player
|
||||
// back onto its surface instead of separating. Null on voxels or airborne.
|
||||
private rideCollider: KinematicCollider | null = null;
|
||||
|
||||
private flying = false;
|
||||
private stepAccum = 0;
|
||||
private bobPhase = 0;
|
||||
@ -91,11 +101,12 @@ export class PlayerController implements IPlayerView {
|
||||
this.carryVel[0] = this.carryVel[1] = this.carryVel[2] = 0;
|
||||
this.onGround = false;
|
||||
this.groundedOn = null;
|
||||
this.rideCollider = null;
|
||||
}
|
||||
|
||||
setFlying(b: boolean): void {
|
||||
this.flying = b;
|
||||
if (b) this._vel[1] = 0;
|
||||
if (b) { this._vel[1] = 0; this.rideCollider = null; }
|
||||
}
|
||||
|
||||
get isFlying(): boolean { return this.flying; }
|
||||
@ -154,7 +165,7 @@ export class PlayerController implements IPlayerView {
|
||||
const dv = (inp.flyUp ? 1 : 0) - (inp.flyDown ? 1 : 0);
|
||||
this._vel[1] += (dv * FLY_SPEED - this._vel[1]) * (1 - Math.exp(-FLY_K * h));
|
||||
} else {
|
||||
if (inp.jump && prevOnGround) this._vel[1] = PLAYER.jumpVelocity;
|
||||
if (inp.jump && prevOnGround) this._vel[1] = PLAYER.jumpVelocity * this.jumpScale;
|
||||
this._vel[1] -= GRAVITY * h;
|
||||
if (this._vel[1] < -TERMINAL) this._vel[1] = -TERMINAL;
|
||||
}
|
||||
@ -282,8 +293,29 @@ export class PlayerController implements IPlayerView {
|
||||
const pMnY = p[1], pMxY = p[1] + HEIGHT;
|
||||
const pMnZ = p[2] - HALF_W, pMxZ = p[2] + HALF_W;
|
||||
const ox = Math.min(pMxX, s.max[0]) - Math.max(pMnX, s.min[0]);
|
||||
const oy = Math.min(pMxY, s.max[1]) - Math.max(pMnY, s.min[1]);
|
||||
const oz = Math.min(pMxZ, s.max[2]) - Math.max(pMnZ, s.min[2]);
|
||||
|
||||
// Ride-snap: if THIS is the platform we were riding and it has tilted or
|
||||
// dropped just out from under us (we now hover a hair above its top, still
|
||||
// over its footprint, no upward intent), snap back down onto it. The
|
||||
// `c === this.rideCollider` identity check is essential: without it, a
|
||||
// player leaving any platform gets vacuum-snapped onto a DIFFERENT nearby
|
||||
// collider they merely clip — magnet-feet, and it eats the record-edge
|
||||
// launch. A rising arm needs no help — it penetrates us and grounds
|
||||
// through the normal path below. Gated to a downward/level vel so it never
|
||||
// fights a jump, and skipped once real voxel ground already caught us.
|
||||
if (
|
||||
c === this.rideCollider && !this.flying && !this.onGround &&
|
||||
this._vel[1] <= 0.001 && ox > 0 && oz > 0
|
||||
) {
|
||||
const gap = pMnY - s.max[1]; // feet above the top
|
||||
if (gap >= 0 && gap <= RIDE_SNAP && s.max[1] > groundTop) {
|
||||
groundTop = s.max[1]; groundCollider = c;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const oy = Math.min(pMxY, s.max[1]) - Math.max(pMnY, s.min[1]);
|
||||
if (ox <= 0 || oy <= 0 || oz <= 0) continue;
|
||||
|
||||
if (oy <= ox && oy <= oz) {
|
||||
@ -313,8 +345,11 @@ export class PlayerController implements IPlayerView {
|
||||
if (this._vel[1] < 0) this._vel[1] = 0;
|
||||
this.onGround = true;
|
||||
this.groundedOn = groundCollider.id;
|
||||
this.rideCollider = groundCollider;
|
||||
this.setCarry(groundCollider, p);
|
||||
this.applyCarry(h);
|
||||
} else {
|
||||
this.rideCollider = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -37,3 +37,13 @@ adapt Lane D's hotbar to this shape).
|
||||
from block names / node labels.
|
||||
- The start overlay must be clicked before audio can start (autoplay policy);
|
||||
wire `onStart` to `audio.init()` and pointer-lock in `src/main.ts`.
|
||||
|
||||
## Round 2 — Headshell Workshop
|
||||
|
||||
`Hud` self-wires two more bus reactions (Lane D emits):
|
||||
- `workshop:torque {value}` → radial torque gauge, bottom-center;
|
||||
`value === null` hides it. Also exposed as public `showTorque(v, label?)` /
|
||||
`hideTorque()`.
|
||||
- `workshop:msg {text}` → the existing subtitle line. Lane D sends the authored
|
||||
fault labels (`ERROR_LABEL` in `machines/workshop/types.ts`), so the HUD does
|
||||
not duplicate that text.
|
||||
|
||||
@ -52,6 +52,14 @@ const CSS = `
|
||||
font-size:15px; letter-spacing:.14em; color:#ffb028; text-shadow:0 0 12px rgba(255,176,40,.5);
|
||||
opacity:0; transition:opacity .4s; white-space:nowrap; }
|
||||
.tc-sub.show { opacity:1; }
|
||||
.tc-torque { position:absolute; left:50%; bottom:104px; transform:translateX(-50%);
|
||||
width:78px; height:78px; border-radius:50%; display:none; align-items:center;
|
||||
justify-content:center; pointer-events:none;
|
||||
background: conic-gradient(#ffb028 var(--deg,0deg), rgba(255,255,255,.10) 0); }
|
||||
.tc-torque::before { content:""; position:absolute; inset:9px; border-radius:50%; background:rgba(12,12,14,.95); }
|
||||
.tc-torque-val { position:relative; font-size:13px; color:#ffb028; letter-spacing:.04em; }
|
||||
.tc-torque-cap { position:absolute; left:50%; bottom:90px; transform:translateX(-50%);
|
||||
display:none; font-size:9px; letter-spacing:.16em; color:#9ab; }
|
||||
.tc-hotbar { position:absolute; left:50%; bottom:22px; transform:translateX(-50%);
|
||||
display:flex; gap:5px; padding:6px; background:rgba(12,12,14,.55); border:1px solid #333;
|
||||
border-radius:6px; }
|
||||
@ -88,6 +96,9 @@ export class Hud {
|
||||
private nodeEls: Record<string, HTMLDivElement> = {};
|
||||
private progressEl!: HTMLDivElement;
|
||||
private subEl!: HTMLDivElement;
|
||||
private torqueEl!: HTMLDivElement;
|
||||
private torqueVal!: HTMLDivElement;
|
||||
private torqueCap!: HTMLDivElement;
|
||||
private startEl!: HTMLDivElement;
|
||||
private pauseEl!: HTMLDivElement;
|
||||
private winEl!: HTMLDivElement;
|
||||
@ -106,6 +117,7 @@ export class Hud {
|
||||
this.buildCrosshair();
|
||||
this.buildQuest();
|
||||
this.buildSubtitle();
|
||||
this.buildTorque();
|
||||
this.buildHotbar();
|
||||
this.buildStart();
|
||||
this.buildPause();
|
||||
@ -147,6 +159,23 @@ export class Hud {
|
||||
this.pauseEl.classList.toggle('hidden', !paused);
|
||||
}
|
||||
|
||||
// ── Headshell Workshop (additive; called by the workshop machine) ──
|
||||
|
||||
/** Show the radial torque gauge while a screw is being tightened (0..1). */
|
||||
showTorque(v: number, label = 'TORQUE'): void {
|
||||
const pct = Math.max(0, Math.min(1, v));
|
||||
this.torqueEl.style.setProperty('--deg', `${pct * 360}deg`);
|
||||
this.torqueVal.textContent = `${Math.round(pct * 100)}`;
|
||||
this.torqueCap.textContent = label;
|
||||
this.torqueEl.style.display = 'flex';
|
||||
this.torqueCap.style.display = 'block';
|
||||
}
|
||||
|
||||
hideTorque(): void {
|
||||
this.torqueEl.style.display = 'none';
|
||||
this.torqueCap.style.display = 'none';
|
||||
}
|
||||
|
||||
/** Hide the start splash programmatically (e.g. after audio init succeeds). */
|
||||
hideStart(): void { this.startEl.classList.add('hidden'); }
|
||||
|
||||
@ -201,6 +230,18 @@ export class Hud {
|
||||
this.el.appendChild(this.subEl);
|
||||
}
|
||||
|
||||
private buildTorque(): void {
|
||||
this.torqueEl = document.createElement('div');
|
||||
this.torqueEl.className = 'tc-torque';
|
||||
this.torqueVal = document.createElement('div');
|
||||
this.torqueVal.className = 'tc-torque-val';
|
||||
this.torqueEl.appendChild(this.torqueVal);
|
||||
this.torqueCap = document.createElement('div');
|
||||
this.torqueCap.className = 'tc-torque-cap';
|
||||
this.el.appendChild(this.torqueEl);
|
||||
this.el.appendChild(this.torqueCap);
|
||||
}
|
||||
|
||||
private buildHotbar(): void {
|
||||
this.hotbarEl = document.createElement('div');
|
||||
this.hotbarEl.className = 'tc-hotbar';
|
||||
@ -292,5 +333,12 @@ export class Hud {
|
||||
|
||||
this.disposers.push(bus.on('block:break', () => this.refreshHotbar()));
|
||||
this.disposers.push(bus.on('block:place', () => this.refreshHotbar()));
|
||||
|
||||
// Headshell Workshop (Lane D emits): radial torque gauge + transient prompts.
|
||||
this.disposers.push(bus.on('workshop:torque', (p) => {
|
||||
if (p.value === null) this.hideTorque();
|
||||
else this.showTorque(p.value);
|
||||
}));
|
||||
this.disposers.push(bus.on('workshop:msg', (p) => this.showSubtitle(p.text)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ export interface Settings {
|
||||
volume: number; // 0..1 master
|
||||
sensitivity: number; // multiplier on the 0.0022 rad/px base
|
||||
viewBob: boolean;
|
||||
bloom: boolean; // selective LED glow (auto-off on 'fast' quality)
|
||||
quality: 'sharp' | 'balanced' | 'fast'; // pixelRatio cap 2 / 1.5 / 1
|
||||
name: string; // DJ name shown to other players (applies on reload)
|
||||
}
|
||||
@ -19,7 +20,7 @@ const randomName = () =>
|
||||
`dj ${DJ_NAMES[Math.floor(Math.random() * DJ_NAMES.length)]}${Math.floor(Math.random() * 90 + 10)}`;
|
||||
|
||||
const DEFAULTS: Settings = {
|
||||
volume: 0.85, sensitivity: 1, viewBob: false, quality: 'sharp', name: '',
|
||||
volume: 0.85, sensitivity: 1, viewBob: false, bloom: true, quality: 'sharp', name: '',
|
||||
};
|
||||
const LS_SETTINGS = 'turncraft.settings';
|
||||
const LS_HELP_SEEN = 'turncraft.helpSeen';
|
||||
@ -43,17 +44,23 @@ mix comes back to life — one layer of music per repair.</p>
|
||||
<tr><td>SHIFT</td><td>sprint</td></tr>
|
||||
<tr><td>HOLD LEFT CLICK</td><td>mine the block you're looking at</td></tr>
|
||||
<tr><td>RIGHT CLICK</td><td>place the selected block</td></tr>
|
||||
<tr><td>E</td><td>use — buttons, faders, plugs, sockets</td></tr>
|
||||
<tr><td>E</td><td>use — buttons, faders, plugs, sockets; <em>hold</em> to crimp a
|
||||
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>1–9 / SCROLL</td><td>pick a hotbar slot</td></tr>
|
||||
<tr><td>F</td><td>fly mode (cheat — great for exploring)</td></tr>
|
||||
<tr><td>H</td><td>this menu</td></tr>
|
||||
</table>
|
||||
<h3>THE FIVE REPAIRS</h3>
|
||||
<table class="quest">
|
||||
<tr><td>STYLUS</td><td>the needle is missing. Climb down under the table (vent
|
||||
<tr><td>STYLUS</td><td>the cartridge is missing. Climb down under the table (vent
|
||||
slot behind a deck, or the hatch left of Deck A) and find the chrome block
|
||||
spot-lit in the parts bin. Carry it up and press E on Deck A's empty
|
||||
headshell.</td></tr>
|
||||
spot-lit in the parts bin. Carry it up to the Deck A workbench and press E on
|
||||
the empty headshell to seat it — then <em>assemble</em> it: square the cartridge,
|
||||
crimp the four colour-coded wires onto their pins, torque the two screws evenly,
|
||||
balance the counterweight while riding the arm, and drop the needle to test. The
|
||||
continuity lamp, the bubble level and the scope tell you what's wrong — sound is
|
||||
the win.</td></tr>
|
||||
<tr><td>RCA</td><td>behind the gear is a canyon of cables. One gold plug lies
|
||||
loose on the floor — press E on it to shove it into the empty socket on the
|
||||
wall panel (three shoves, clunk-clunk-CLICK).</td></tr>
|
||||
@ -200,6 +207,8 @@ export class Menu {
|
||||
(v) => { this.settings.sensitivity = v; this.save(); }));
|
||||
st.appendChild(this.checkbox('View bob (head sway while walking)', s.viewBob,
|
||||
(v) => { this.settings.viewBob = v; this.save(); }));
|
||||
st.appendChild(this.checkbox('LED bloom (glow — off automatically on Fast)', s.bloom,
|
||||
(v) => { this.settings.bloom = v; this.save(); }));
|
||||
st.appendChild(this.select('Render quality',
|
||||
[['sharp', 'Sharp'], ['balanced', 'Balanced'], ['fast', 'Fast']], s.quality,
|
||||
(v) => { this.settings.quality = v as Settings['quality']; this.save(); }));
|
||||
|
||||
@ -3,6 +3,52 @@
|
||||
**Status: complete.** `npm run typecheck` clean, demo runs with no console errors,
|
||||
build is deterministic and fully validated (see below).
|
||||
|
||||
---
|
||||
|
||||
## Glow-Up G4 — worldgen density pass (2026-07-14)
|
||||
|
||||
Implemented the `src/worldgen/**` slice of `docs/briefs/GLOWUP_ART.md` (task G4).
|
||||
The atlas (G1), bloom (G2), decals (G3) and record texture (G5) are engine/fx work
|
||||
and are NOT in this lane — left for their sessions.
|
||||
|
||||
**Files touched (this pass):** `src/worldgen/buildBooth.ts`,
|
||||
`src/demo/worldgenDemo.ts`. Nothing else.
|
||||
|
||||
**Merge points (shared with the Workshop session):** only `src/worldgen/buildBooth.ts`
|
||||
(main.ts is untouched here). My edits are confined to `buildMixer`, `buildUnderTable`
|
||||
(+ new `pagoda`/`buildPcbDetail`), and the deck-vent loop — all disjoint from the
|
||||
Workshop brief's *Deck A pedestal* "workbench dressing", so a merge is clean.
|
||||
|
||||
**What it adds (additive; every pre-existing zone stays byte-identical):**
|
||||
- **Mixer face** — dense mushroom knobs (1×1 stem + 2×2 cap, mixed heights, 6 rows ×
|
||||
3 cols × 4 channels, club-mixer palette) replacing the sparse specks; flush rubber
|
||||
**panel-seam grooves** sectioning the four channels; amber/green **LED depth pips**
|
||||
at each fader-alley floor and flanking; an amber glow behind each speaker-mesh vent.
|
||||
- **PCB Depths** — walk-under **IC-chip pagodas** (matte_black slabs on solder pin-legs,
|
||||
stacked tiers, copper pin-1 dot, green status LED), **ribbon-cable walls** (deck rooms),
|
||||
extra copper trace inlays. Kept ≥15 voxels clear of every quest pickup.
|
||||
- **Deck vents** — an amber pip at each shaft bottom so the drop reads as deep.
|
||||
|
||||
**Determinism:** the mixer detail is index-based (no `rng()`), and `buildPcbDetail` uses
|
||||
its OWN seed (`WORLD_SEED ^ 0x9e3779b1`), so the main PRNG stream is untouched and all
|
||||
other zones are identical. Two full builds are byte-identical (verified headless).
|
||||
|
||||
**Anchors:** no `LAYOUT`/`QUEST_POS` value moved. All 8 quest anchors still land on the
|
||||
correct block (re-verified). Build 21–30 ms (« 800 ms budget). Chunk count unchanged.
|
||||
|
||||
**One demo fix:** the concept-art pass added an intentional shallow well step-ring
|
||||
(outside `PLATTER.radius`); my old "well empty" assertion checked the full well and went
|
||||
red. Retitled to **"platter footprint clear"** and scoped to `PLATTER.radius` — the region
|
||||
Lane D's platter actually needs. Green again.
|
||||
|
||||
**Note (not mine to fix):** at this checkpoint the shared tree briefly failed global
|
||||
`tsc` on the Workshop session's in-progress `src/audio/testSignal.ts` (a
|
||||
`Float32Array<ArrayBuffer>` generic mismatch); it cleared on their next save. My files
|
||||
typecheck clean in isolation.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Public API surface (import from `src/worldgen`)
|
||||
|
||||
```ts
|
||||
|
||||
@ -210,25 +210,46 @@ export function buildMixer(w: IVoxelWorld, rng: () => number): void {
|
||||
box(w, M.maxX - 1, MIX_Y1, M.minZ, M.maxX, MIX_Y1, M.maxZ, T.alu);
|
||||
|
||||
// Speaker-mesh side vents with climbable alu rungs every 2 voxels.
|
||||
for (const [xFace, rungX] of [[M.minX, M.minX - 1], [M.maxX, M.maxX + 1]] as const) {
|
||||
for (const [xFace, rungX, inX] of [[M.minX, M.minX - 1, M.minX + 1], [M.maxX, M.maxX + 1, M.maxX - 1]] as const) {
|
||||
box(w, xFace, Y_TABLETOP + 2, M.minZ + 6, xFace, MIX_Y1 - 2, M.minZ + 16, T.mesh);
|
||||
for (let y = Y_TABLETOP + 3; y < MIX_Y1; y += 2) setVox(w, rungX, y, M.minZ + 11, T.alu);
|
||||
setVox(w, inX, Y_TABLETOP + 6, M.minZ + 11, T.ledA); // amber glow behind the grille (G4)
|
||||
}
|
||||
|
||||
// Four channel strips: 3x3 knob-stem grid (back) + fader alley (front).
|
||||
// Knob caps get a colorful club-mixer palette (concept-art pass): reds,
|
||||
// whites, golds and a few glowing LED caps popping out of the black face.
|
||||
const knobPalette = [T.knobK, T.knobK, T.cableR, T.cableW, T.gold, T.ledB, T.ledG, T.ledA];
|
||||
// Four channel strips — DENSE mushroom-knob grid (Glow-Up G4): a 1x1 stem with
|
||||
// a 2x2 cap of mixed height in a colourful club-mixer palette, six rows deep
|
||||
// (trim + 3-band EQ + filter + aux). Index-based (no rng) so no other zone
|
||||
// shifts. A fader alley is carved at the front with LED depth cues.
|
||||
const knobPalette = [T.knobK, T.cableR, T.cableW, T.gold, T.ledB, T.ledG, T.ledA];
|
||||
const mushroom = (x: number, z: number, cap: number, h: number) => {
|
||||
setVox(w, x, MIXER.faceTopY, z, T.knobK); // stem
|
||||
box(w, x, MIXER.faceTopY + 1, z, x + 1, MIXER.faceTopY + h, z + 1, cap); // 2x2 cap
|
||||
};
|
||||
for (let ci = 0; ci < MIXER.channelX.length; ci++) {
|
||||
const cx = MIXER.channelX[ci];
|
||||
for (let gx = -1; gx <= 1; gx++) for (let gz = -1; gz <= 1; gz++) {
|
||||
// top row = silver gain trims, lower rows = colorful EQ knobs
|
||||
const cap = gz === -1 ? T.knobS : knobPalette[(ci * 5 + (gx + 1) * 3 + (gz + 1) * 7) % knobPalette.length];
|
||||
setVox(w, cx + gx * 2, MIXER.faceTopY, M.maxZ - 20 + gz * 2, cap);
|
||||
for (let col = -1; col <= 1; col++) {
|
||||
for (let row = 0; row < 6; row++) {
|
||||
const kx = cx + col * 3, kz = M.maxZ - 12 - row * 4;
|
||||
const h = 1 + ((ci + col + row) & 1); // mixed cap heights
|
||||
const cap = row === 0 ? T.knobS // trim row = silver
|
||||
: knobPalette[(ci * 7 + (col + 1) * 5 + row * 3) % knobPalette.length];
|
||||
mushroom(kx, kz, cap, h);
|
||||
}
|
||||
}
|
||||
// fader alley: 14 long (Z) x 3 wide (X) x 5 deep — Lane D adds the sled/cap.
|
||||
box(w, cx - 1, MIXER.faceTopY - 5, M.minZ + 12, cx + 1, MIX_Y1, M.minZ + 25, T.air);
|
||||
// depth cues (G4): an amber pip at the dark alley floor + LED pips flanking it.
|
||||
setVox(w, cx, MIXER.faceTopY - 5, M.minZ + 18, T.ledA);
|
||||
setVox(w, cx - 2, MIX_Y1, M.minZ + 14, ci % 2 ? T.ledG : T.ledA);
|
||||
setVox(w, cx + 2, MIX_Y1, M.minZ + 22, ci % 2 ? T.ledA : T.ledG);
|
||||
}
|
||||
// Panel seam grooves (G4): flush rubber lines sectioning the black face into
|
||||
// the four channel strips, plus one horizontal divider under the knob banks.
|
||||
for (let i = 0; i < MIXER.channelX.length - 1; i++) {
|
||||
const sx = (MIXER.channelX[i] + MIXER.channelX[i + 1]) >> 1;
|
||||
box(w, sx, MIX_Y1, M.minZ + 30, sx, MIX_Y1, M.maxZ - 8, T.rubber);
|
||||
}
|
||||
box(w, M.minX + 4, MIX_Y1, M.maxZ - 34, M.maxX - 4, MIX_Y1, M.maxZ - 34, T.rubber);
|
||||
|
||||
// Crossfader tram along the front, with the quest dust-jam plug at centre.
|
||||
const trZ = MIXER.crossfaderZ, half = MIXER.crossfaderHalfLen, dep = MIXER.crossfaderDepth;
|
||||
@ -358,6 +379,7 @@ export function buildUnderTable(w: IVoxelWorld, rng: () => number): void {
|
||||
for (const f of [LAYOUT.deckA, LAYOUT.deckB]) {
|
||||
const vx = f.spindleX;
|
||||
box(w, vx - 5, UNDER_FLOOR + 1, f.maxZ + 4, vx + 5, TABLE_Y1, f.maxZ + 12, T.air);
|
||||
setVox(w, vx, UNDER_FLOOR + 1, f.maxZ + 8, T.ledA); // amber pip so the drop reads as deep (G4)
|
||||
}
|
||||
const [hx, , hz] = UNDER.crateHatch;
|
||||
box(w, hx, UNDER_FLOOR + 1, hz - 2, hx + 5, TABLE_Y1, hz + 3, T.air); // 6x6 hatch
|
||||
@ -437,6 +459,71 @@ export function buildUnderTable(w: IVoxelWorld, rng: () => number): void {
|
||||
}
|
||||
// one leaning slab as a walkable ramp climbing back toward the hatch.
|
||||
for (let k = 0; k < 12; k++) discX(w, cX0 + 6, UNDER_FLOOR + 1 + k, cZ0 + 2 + k, 4, T.vinylB, 1);
|
||||
|
||||
buildPcbDetail(w); // Glow-Up G4 density: IC pagodas, ribbon walls, extra traces
|
||||
}
|
||||
|
||||
// IC-chip pagoda (Glow-Up G4): a matte_black slab body raised on solder pin-legs
|
||||
// you can walk under, optionally stacked into tiers, with a green status LED on
|
||||
// top and a copper pin-1 dot. `bx`/`bz` are body half-extents.
|
||||
function pagoda(w: IVoxelWorld, cx: number, cz: number, bx: number, bz: number, tiers: number): void {
|
||||
const legH = 5; // walk-under gap (player is 1.8 tall)
|
||||
const bodyY = UNDER_FLOOR + legH;
|
||||
for (const dx of [-bx, bx]) for (const dz of [-bz, bz])
|
||||
box(w, cx + dx, UNDER_FLOOR + 1, cz + dz, cx + dx, bodyY - 1, cz + dz, T.solder); // corner legs
|
||||
for (let dz = -bz + 1; dz <= bz - 1; dz += 2) { // DIP pin nubs
|
||||
setVox(w, cx - bx, bodyY - 1, cz + dz, T.solder);
|
||||
setVox(w, cx + bx, bodyY - 1, cz + dz, T.solder);
|
||||
}
|
||||
box(w, cx - bx, bodyY, cz - bz, cx + bx, bodyY + 1, cz + bz, T.black); // chip body slab
|
||||
setVox(w, cx - bx, bodyY + 1, cz - bz, T.copper); // pin-1 marker
|
||||
let ty = bodyY + 2;
|
||||
for (let t = 1; t <= tiers; t++) { // stacked tiers
|
||||
const sx = Math.max(1, bx - t), sz = Math.max(1, bz - t);
|
||||
box(w, cx - sx, ty, cz - sz, cx + sx, ty + 1, cz + sz, T.black);
|
||||
ty += 2;
|
||||
}
|
||||
setVox(w, cx, ty, cz, T.ledG); // status LED
|
||||
}
|
||||
|
||||
// Dresses the PCB Depths rooms with pagodas, ribbon-cable walls and extra copper
|
||||
// trace inlays. Uses its OWN seeded PRNG so it never perturbs the main build
|
||||
// stream — every other zone stays byte-identical. Keeps clear of quest pickups.
|
||||
export function buildPcbDetail(w: IVoxelWorld): void {
|
||||
const rng = mulberry32((WORLD_SEED ^ 0x9e3779b1) >>> 0);
|
||||
const M = LAYOUT.mixer;
|
||||
const clearPts: Vec3[] = [QUEST_POS.stylus.pickup, QUEST_POS.fuse.box, QUEST_POS.fuse.pickup, UNDER.partsBin];
|
||||
const clear = (x: number, z: number) => clearPts.every(([qx, , qz]) => (x - qx) ** 2 + (z - qz) ** 2 > 225);
|
||||
const rooms = [
|
||||
{ ...LAYOUT.deckA, ribbon: true },
|
||||
{ ...LAYOUT.deckB, ribbon: true },
|
||||
{ minX: M.minX, maxX: M.maxX, minZ: M.minZ, maxZ: M.maxZ, ribbon: false }, // mixer room is congested (fuse/parts)
|
||||
];
|
||||
for (const rm of rooms) {
|
||||
// 2–3 IC pagodas of mixed sizes, kept clear of the quest pickups.
|
||||
let placed = 0;
|
||||
for (let tries = 0; tries < 24 && placed < 3; tries++) {
|
||||
const size = randInt(rng, 2, 4);
|
||||
const px = randInt(rng, rm.minX + size + 3, rm.maxX - size - 3);
|
||||
const pz = randInt(rng, rm.minZ + size + 3, rm.maxZ - size - 3);
|
||||
if (!clear(px, pz)) continue;
|
||||
pagoda(w, px, pz, size, size + 1, randInt(rng, 0, 2));
|
||||
placed++;
|
||||
}
|
||||
// extra copper trace inlays connecting features (short straight runs).
|
||||
for (let n = 0; n < 4; n++) {
|
||||
const az = randInt(rng, rm.minZ + 3, rm.maxZ - 3);
|
||||
const ax = randInt(rng, rm.minX + 3, rm.maxX - 12);
|
||||
box(w, ax, UNDER_FLOOR + 1, az, ax + randInt(rng, 6, 12), UNDER_FLOOR + 1, az, T.copper);
|
||||
}
|
||||
// ribbon-cable wall (deck rooms only): a ~16-wide bundle of alternating
|
||||
// black/white columns rising off the board — vertical interest.
|
||||
if (rm.ribbon) {
|
||||
const rx0 = rm.minX + 10, rz = rm.minZ + 4;
|
||||
for (let x = rx0; x < rx0 + 16; x++)
|
||||
box(w, x, UNDER_FLOOR + 1, rz, x, UNDER_FLOOR + 8 + (x & 3), rz, (x & 1) ? T.cableW : T.cableK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user