TURNCRAFT: contracts, docs, and all five lane deliverables (pre-integration)
Lanes A (engine), B (player), C (worldgen), D (machines/quest), E (audio/fx/ui) as landed, each with HANDOFF.md + update docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
5a39e3a947
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "turncraft",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--", "--port", "5173", "--strictPort"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
41
README.md
Normal file
41
README.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# TURNCRAFT
|
||||||
|
|
||||||
|
*Honey I Shrunk the DJ.* A Minecraft-style voxel game where the entire world
|
||||||
|
is a vinyl DJ booth — two Technics-style 1200s, a 4-channel mixer, cable
|
||||||
|
canyons, a patch-bay dungeon, and circuit-board caves — and you're 7 mm tall,
|
||||||
|
riding the records.
|
||||||
|
|
||||||
|
- **Design**: [docs/DESIGN.md](docs/DESIGN.md)
|
||||||
|
- **Rules for parallel build lanes**: [docs/CONTRACTS.md](docs/CONTRACTS.md)
|
||||||
|
- **Integration (after lanes land)**: [docs/INTEGRATION.md](docs/INTEGRATION.md)
|
||||||
|
|
||||||
|
## Building it with parallel agent lanes
|
||||||
|
|
||||||
|
Five independent lanes, disjoint file ownership, shared contracts already
|
||||||
|
written in `src/core/` (read-only for all lanes).
|
||||||
|
|
||||||
|
| lane | brief | delivers |
|
||||||
|
|---|---|---|
|
||||||
|
| A | [docs/briefs/LANE_A_ENGINE.md](docs/briefs/LANE_A_ENGINE.md) | voxel storage, greedy mesher, procedural texture atlas, renderer |
|
||||||
|
| B | [docs/briefs/LANE_B_PLAYER.md](docs/briefs/LANE_B_PLAYER.md) | FPS controller, voxel collision, ride-the-record platform physics |
|
||||||
|
| C | [docs/briefs/LANE_C_WORLDGEN.md](docs/briefs/LANE_C_WORLDGEN.md) | the whole booth, built parametrically in voxels |
|
||||||
|
| D | [docs/briefs/LANE_D_MACHINES.md](docs/briefs/LANE_D_MACHINES.md) | spinning platters, tonearm, faders, break/place, the quest |
|
||||||
|
| E | [docs/briefs/LANE_E_AUDIO_FX.md](docs/briefs/LANE_E_AUDIO_FX.md) | synthesized house groove in stems, beat-reactive lights, HUD |
|
||||||
|
|
||||||
|
**Launch prompt for each lane** (separate session/agent each, all five at once):
|
||||||
|
|
||||||
|
> Read docs/CONTRACTS.md, docs/DESIGN.md, and docs/briefs/LANE_X_….md, then
|
||||||
|
> implement the lane exactly as briefed. Work only inside your owned paths.
|
||||||
|
> Verify with your demo page and the acceptance checklist, write your
|
||||||
|
> HANDOFF.md, and ensure `npm run typecheck` passes.
|
||||||
|
|
||||||
|
Recommended: `git init` first and give each lane its own branch or worktree;
|
||||||
|
ownership is disjoint so merges are trivial.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i
|
||||||
|
npm run dev # game shell at /, lane demos at /demo-<lane>.html
|
||||||
|
npm run build # static bundle
|
||||||
|
```
|
||||||
16
demo-audio.html
Normal file
16
demo-audio.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TURNCRAFT — Lane E (Audio / FX / UI) demo</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #08080a; }
|
||||||
|
#app { position: absolute; inset: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/demo/audioDemo.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
demo-engine.html
Normal file
17
demo-engine.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TURNCRAFT — Lane A Engine Demo</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #0a0a0c; }
|
||||||
|
#app { width: 100%; height: 100%; }
|
||||||
|
button:hover { filter: brightness(1.15); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/demo/engineDemo.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
demo-machines.html
Normal file
41
demo-machines.html
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>TURNCRAFT · Lane D — Machines & Interaction demo</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
html, body { margin: 0; height: 100%; background: #17150f; overflow: hidden;
|
||||||
|
font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace; color: #d8d4c8; }
|
||||||
|
#app { position: fixed; inset: 0; }
|
||||||
|
#app canvas { display: block; }
|
||||||
|
#crosshair { position: fixed; left: 50%; top: 50%; width: 14px; height: 14px;
|
||||||
|
transform: translate(-50%, -50%); pointer-events: none; opacity: 0.8; }
|
||||||
|
#crosshair::before, #crosshair::after { content: ""; position: absolute; background: #f2eede; }
|
||||||
|
#crosshair::before { left: 6px; top: 0; width: 2px; height: 14px; }
|
||||||
|
#crosshair::after { top: 6px; left: 0; height: 2px; width: 14px; }
|
||||||
|
#panel { position: fixed; top: 8px; left: 8px; width: 268px; max-height: calc(100vh - 16px);
|
||||||
|
overflow: auto; background: rgba(20,19,14,0.86); border: 1px solid #3a352a;
|
||||||
|
border-radius: 8px; padding: 10px 11px; backdrop-filter: blur(3px); }
|
||||||
|
#panel h1 { font-size: 12px; margin: 0 0 6px; letter-spacing: 0.04em; color: #f0e9d6; }
|
||||||
|
#panel h2 { font-size: 11px; margin: 12px 0 4px; color: #9fd0ff; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
#panel .legend { color: #948d7c; font-size: 11px; margin-bottom: 4px; }
|
||||||
|
#panel button { font: inherit; margin: 2px 3px 2px 0; padding: 3px 7px; cursor: pointer;
|
||||||
|
background: #2b2820; color: #e7e1d0; border: 1px solid #46402f; border-radius: 5px; }
|
||||||
|
#panel button:hover { background: #383322; }
|
||||||
|
#panel .row { margin: 3px 0; }
|
||||||
|
#status { white-space: pre-wrap; color: #cfe8c8; font-size: 11px; margin-top: 4px; }
|
||||||
|
.node { display: inline-block; width: 9px; height: 9px; border-radius: 50%;
|
||||||
|
margin-right: 5px; vertical-align: middle; background: #6b3030; }
|
||||||
|
.node.on { background: #59d67a; box-shadow: 0 0 6px #59d67a; }
|
||||||
|
#won { color: #ffd54a; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<div id="crosshair"></div>
|
||||||
|
<div id="panel"></div>
|
||||||
|
<script type="module" src="/src/demo/machinesDemo.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
demo-player.html
Normal file
50
demo-player.html
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TURNCRAFT — Lane B Player Demo</title>
|
||||||
|
<!--
|
||||||
|
WHAT TO VERIFY (Lane B acceptance):
|
||||||
|
1. Walk/sprint/jump/fly. WASD move, Space jump, Shift sprint, F toggles
|
||||||
|
fly (Space/Ctrl = up/down in fly). Mouse look after clicking to lock.
|
||||||
|
2. Auto-step: walk into the grey 1-voxel staircase — you climb it without
|
||||||
|
jumping. Walk into the black 2-voxel wall — you are blocked (no step).
|
||||||
|
3. Bridge + pit: cross the 2-wide plywood bridge over the near pit; don't
|
||||||
|
fall stepping onto it; the gaps on each side drop you.
|
||||||
|
4. Ride the record: walk onto the big spinning disc (or press "Teleport to
|
||||||
|
disc"). Standing anywhere carries you in a clean circle; near the
|
||||||
|
center is calm, the rim is fast. Toggle "Speed: 45" — the rim now
|
||||||
|
outruns sprint and flings you when you jump off.
|
||||||
|
5. Moving platform: the copper slab slides across the far pit with zero
|
||||||
|
jitter — ride it, don't fall through.
|
||||||
|
6. Console logs player:step and player:landed events. HUD (top-left) shows
|
||||||
|
position, onGround, groundedOn, and carry velocity.
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #0a0a0c;
|
||||||
|
font-family: ui-monospace, Menlo, monospace; color: #e6e6e6; }
|
||||||
|
#hud { position: fixed; top: 8px; left: 8px; font-size: 12px; line-height: 1.5;
|
||||||
|
white-space: pre; background: #000a; padding: 8px 10px; border-radius: 5px;
|
||||||
|
pointer-events: none; }
|
||||||
|
#panel { position: fixed; top: 8px; right: 8px; display: flex;
|
||||||
|
flex-direction: column; gap: 6px; }
|
||||||
|
#panel button { font-family: inherit; font-size: 12px; padding: 6px 10px;
|
||||||
|
background: #1c1c22; color: #e6e6e6; border: 1px solid #33333c;
|
||||||
|
border-radius: 5px; cursor: pointer; }
|
||||||
|
#panel button:hover { background: #2a2a33; }
|
||||||
|
#hint { position: fixed; bottom: 8px; left: 8px; font-size: 12px; color: #9a9aa2; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="hud"></div>
|
||||||
|
<div id="panel">
|
||||||
|
<button id="spin">Speed: 33</button>
|
||||||
|
<button id="tp">Teleport to disc</button>
|
||||||
|
<button id="fly">Fly: off</button>
|
||||||
|
<button id="bob">View-bob: on</button>
|
||||||
|
</div>
|
||||||
|
<div id="hint">Click to lock mouse · WASD move · Space jump · Shift sprint · F fly · Ctrl fly-down</div>
|
||||||
|
<script type="module" src="/src/demo/playerDemo.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
demo-worldgen.html
Normal file
17
demo-worldgen.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TURNCRAFT — Lane C · World Builder demo</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #0d0d10; }
|
||||||
|
#app { position: fixed; inset: 0; }
|
||||||
|
canvas { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/demo/worldgenDemo.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
104
docs/CONTRACTS.md
Normal file
104
docs/CONTRACTS.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# TURNCRAFT — Lane Contracts
|
||||||
|
|
||||||
|
**Every lane reads this file and [DESIGN.md](./DESIGN.md) before writing code.**
|
||||||
|
This document exists so five agents can build simultaneously without touching
|
||||||
|
each other's files or breaking each other's assumptions.
|
||||||
|
|
||||||
|
## 1. The Law
|
||||||
|
|
||||||
|
1. **`src/core/` is read-only for all lanes.** It defines the shared types,
|
||||||
|
block registry, constants, and event bus. If you believe a contract file
|
||||||
|
has a bug or a missing field, STOP and report it in your summary instead of
|
||||||
|
editing it — the integrator resolves contract changes.
|
||||||
|
2. **Own your directory, never write outside it.** Ownership map in §3.
|
||||||
|
3. **Cross-lane imports: `src/core/` types only.** Never import a concrete
|
||||||
|
class from another lane's directory. Depend on `IVoxelWorld`, `IMachine`,
|
||||||
|
`IPlayerView`, `KinematicCollider`, `bus` — not on implementations. In your
|
||||||
|
standalone demo, mock what you need (§6).
|
||||||
|
4. **Communicate via the event bus** (`src/core/events.ts`) for anything
|
||||||
|
fire-and-forget; via the core interfaces for anything synchronous.
|
||||||
|
5. **No new runtime dependencies.** `three` only. Dev deps: `vite`,
|
||||||
|
`typescript`, `@types/three`. Textures are painted procedurally on canvas;
|
||||||
|
audio is synthesized with WebAudio. No binary assets, no CDN loads.
|
||||||
|
6. **TypeScript strict mode must pass**: `npm run typecheck` clean at handoff.
|
||||||
|
7. Coordinate system: Three.js right-handed, **Y-up**, 1 voxel = 1 world unit.
|
||||||
|
Voxel (x,y,z) occupies world space [x,x+1)×[y,y+1)×[z,z+1). World bounds
|
||||||
|
and all gear placement come from `src/core/constants.ts` (`LAYOUT`) —
|
||||||
|
never hardcode a magic position.
|
||||||
|
|
||||||
|
## 2. Contract Files (already written — read them)
|
||||||
|
|
||||||
|
| file | contents |
|
||||||
|
|---|---|
|
||||||
|
| `src/core/constants.ts` | world size, chunk size, elevations, `LAYOUT` gear placement, player physics numbers, platter RPMs, quest node list |
|
||||||
|
| `src/core/blocks.ts` | the full block registry: ids 0–30, solidity, transparency, emissive, tint colors, texture `pattern` names, sounds |
|
||||||
|
| `src/core/types.ts` | `IVoxelWorld`, `KinematicCollider`, `IMachine`, `RayHit`, `IPlayerView`, `Vec3` |
|
||||||
|
| `src/core/events.ts` | the typed global `bus` and every event name/payload |
|
||||||
|
|
||||||
|
## 3. Ownership Map
|
||||||
|
|
||||||
|
| lane | owns (create/edit freely) | delivers |
|
||||||
|
|---|---|---|
|
||||||
|
| **A — Voxel Engine** | `src/engine/**`, `src/demo/engineDemo.ts`, `demo-engine.html` | `VoxelWorld` (implements `IVoxelWorld`), chunk mesher, texture atlas, renderer/scene/lighting setup, chunk-remesh-on-dirty |
|
||||||
|
| **B — Player & Physics** | `src/player/**`, `src/demo/playerDemo.ts`, `demo-player.html` | `PlayerController` (implements `IPlayerView`): input, AABB-vs-voxel collision, kinematic platform riding, fly mode |
|
||||||
|
| **C — World Builder** | `src/worldgen/**`, `src/demo/worldgenDemo.ts`, `demo-worldgen.html` | `buildBooth(world: IVoxelWorld): void` — writes the entire diorama |
|
||||||
|
| **D — Machines & Interaction** | `src/machines/**`, `src/interact/**`, `src/demo/machinesDemo.ts`, `demo-machines.html` | platter/tonearm/fader/button machines (implement `IMachine`), raycaster, break/place, hotbar model, quest state machine |
|
||||||
|
| **E — Audio, FX & UI** | `src/audio/**`, `src/fx/**`, `src/ui/**`, `src/demo/audioDemo.ts`, `demo-audio.html` | synthesized stem mix, positional audio, beat events, LED pulse/VU systems, particles, HUD/hotbar UI, win screen |
|
||||||
|
| **integration** (after lanes land) | `src/main.ts` | final wiring per `docs/INTEGRATION.md` |
|
||||||
|
|
||||||
|
Shared read-only: `src/core/**`, `index.html`, configs, `docs/**`.
|
||||||
|
|
||||||
|
## 4. Key Cross-Lane Behaviors (who does what)
|
||||||
|
|
||||||
|
- **Dirty chunks**: `VoxelWorld.setBlock` (Lane A) marks the containing chunk
|
||||||
|
(and neighbors when the block borders them) dirty; the engine remeshes dirty
|
||||||
|
chunks, budgeted per frame. Nobody else touches meshing.
|
||||||
|
- **Riding platforms**: Lane D machines expose `KinematicCollider`s with
|
||||||
|
`velocityAt`. Lane B, when the player's ground contact is a kinematic
|
||||||
|
collider, adds that surface velocity to the player each tick and reports it
|
||||||
|
via `IPlayerView.groundedOn`. Cylinder colliders rotate about +Y only.
|
||||||
|
- **Break/place**: Lane D raycasts (voxel DDA for blocks + analytic tests for
|
||||||
|
machine colliders), calls `world.setBlock`, emits `block:break`/`block:place`.
|
||||||
|
Lane A reacts by remeshing; Lane E reacts with sound/particles.
|
||||||
|
- **Quest**: Lane D owns quest state and emits `signal:repair` / `game:win`.
|
||||||
|
Lane E owns everything audible/visible that reacts to those events.
|
||||||
|
- **Emissive blocks**: Lane A renders `emissive > 0` blocks with an emissive
|
||||||
|
material channel. Lane E may pulse a global uniform (via a small exported
|
||||||
|
hook Lane A provides: `setEmissiveBoost(v: number)` on the renderer) — Lane
|
||||||
|
A must expose that one function; Lane E must not reach into A's materials.
|
||||||
|
|
||||||
|
## 5. Fixed Timestep
|
||||||
|
|
||||||
|
The game loop runs physics/machines at `FIXED_DT` (1/60) with up to
|
||||||
|
`MAX_SUBSTEPS` catch-up steps, render decoupled. Every lane's `update(dt)`
|
||||||
|
must be stable if called with dt = FIXED_DT repeatedly. No lane creates its
|
||||||
|
own `requestAnimationFrame` loop except inside its own demo.
|
||||||
|
|
||||||
|
## 6. Demo Harness Rules (how you verify alone)
|
||||||
|
|
||||||
|
Each lane ships a standalone Vite page (`demo-<lane>.html` at repo root +
|
||||||
|
`src/demo/<lane>Demo.ts`) that runs with **zero code from other lanes**.
|
||||||
|
Mock the interfaces you consume — e.g. Lane B's demo includes a ~30-line
|
||||||
|
`MockWorld implements IVoxelWorld` (flat floor + some steps) and one mock
|
||||||
|
spinning-cylinder `KinematicCollider`; Lane C's demo may include a naive
|
||||||
|
brute-force mesher (one box per voxel face is fine at demo scale, or
|
||||||
|
`InstancedMesh`) purely to eyeball the build. Keep mocks inside your demo
|
||||||
|
file(s), clearly marked `// DEMO MOCK — not for integration`.
|
||||||
|
|
||||||
|
`npm run dev` then open `http://localhost:5173/demo-<lane>.html`.
|
||||||
|
|
||||||
|
## 7. Definition of Done (every lane)
|
||||||
|
|
||||||
|
- [ ] `npm run typecheck` passes with your code included
|
||||||
|
- [ ] Your demo page runs, and its README-style header comment says exactly
|
||||||
|
what to look at / press to verify each acceptance criterion
|
||||||
|
- [ ] No writes outside your owned paths; no imports of other lanes' concrete code
|
||||||
|
- [ ] No console errors in a 2-minute demo session
|
||||||
|
- [ ] A `HANDOFF.md` in your owned directory: what's done, what's stubbed,
|
||||||
|
any contract friction you hit, and your public API surface in 10 lines
|
||||||
|
|
||||||
|
## 8. Style
|
||||||
|
|
||||||
|
Match the existing core files: plain TypeScript, no classes-for-the-sake-of-it,
|
||||||
|
comments only where a constraint isn't obvious from code. Keep per-frame
|
||||||
|
allocations near zero in hot paths (meshing, physics): reuse arrays/vectors.
|
||||||
164
docs/DESIGN.md
Normal file
164
docs/DESIGN.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# TURNCRAFT — Design Document
|
||||||
|
|
||||||
|
*Honey-I-Shrunk-the-DJ. A Minecraft-style voxel world contained entirely inside
|
||||||
|
a vinyl DJ booth: two Technics-style 1200 turntables, a 4-channel mixer, cables,
|
||||||
|
a patch bay, and the plywood console that holds it all.*
|
||||||
|
|
||||||
|
Read this together with [CONTRACTS.md](./CONTRACTS.md). Numbers quoted here are
|
||||||
|
defined once in `src/core/constants.ts` — the code is the source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Vision
|
||||||
|
|
||||||
|
You are ~7 mm tall, standing on a spinning record. The world is finite, dense,
|
||||||
|
and hand-authored — a diorama, not a landscape. Every zone is a piece of DJ
|
||||||
|
gear rendered at monumental scale: the mixer is a mesa of faders and knob
|
||||||
|
forests, the tonearm is a chrome bridge, an unplugged RCA lead is a rope
|
||||||
|
bridge into a canyon, and beneath the plinths lie glowing circuit-board caves.
|
||||||
|
|
||||||
|
The palette is deliberately restrained — greys, silvers, matte blacks, dark
|
||||||
|
rubber — inside a warm birch-plywood shell, with two loud accents: translucent
|
||||||
|
**blue vinyl** records and emissive **LEDs** (red/green/amber/blue). Think the
|
||||||
|
inspiration photo: utilitarian gear, warm wood, blue records.
|
||||||
|
|
||||||
|
The fantasy: *the booth is dead when you arrive.* Your job is to bring the mix
|
||||||
|
back — repair the signal chain, press start, and ride the record while the
|
||||||
|
whole world lights up in sync with the music.
|
||||||
|
|
||||||
|
## 2. Pillars
|
||||||
|
|
||||||
|
1. **The world IS the gear.** No abstract terrain. Every structure reads as a
|
||||||
|
real DJ-booth object at 4 mm/voxel scale.
|
||||||
|
2. **The platters spin and you can ride them.** Rotating kinematic platforms
|
||||||
|
are the signature mechanic. Everything else is standard voxel play.
|
||||||
|
3. **Sound is the win state.** Repairs unmute stems of one continuous house
|
||||||
|
groove; lights and particles react to it. A silent booth is a broken booth.
|
||||||
|
4. **Zero external assets.** All textures are procedurally painted, all audio
|
||||||
|
is synthesized. The repo builds from `npm i && npm run dev` alone.
|
||||||
|
|
||||||
|
## 3. Scale & World Map
|
||||||
|
|
||||||
|
1 voxel = 4 mm. Player: 1.8 voxels tall (~7 mm). World: **448 × 160 × 256**
|
||||||
|
voxels, chunked at 32³ (14 × 5 × 8 = 560 chunks, ~18 M voxels — trivially fits
|
||||||
|
in Uint8Arrays).
|
||||||
|
|
||||||
|
Vertical strata (Y):
|
||||||
|
|
||||||
|
| y | what |
|
||||||
|
|---|------|
|
||||||
|
| 0–40 | **Under-table**: inside the plywood console — record crate, parts bin, PCB caves under the gear, cable runs |
|
||||||
|
| 40 | **Tabletop** — plywood plateau the gear sits on |
|
||||||
|
| 40–80 | Turntable plinths (steel-grey mesas), mixer body |
|
||||||
|
| 80–85 | Plinth tops, platter + slipmat + record surface (85 = vinyl top) |
|
||||||
|
| 67 | Mixer face plate (knob forest, fader alleys) |
|
||||||
|
| 85–140 | Tonearm bridge, dust-cover glass arcs, booth walls, patch-bay wall |
|
||||||
|
|
||||||
|
Horizontal layout (X across, Z deep; see `LAYOUT` in constants.ts):
|
||||||
|
|
||||||
|
```
|
||||||
|
z=256 ─────────────────────────────────────────────── back
|
||||||
|
│ plywood back wall (z=200) — PATCH BAY dungeon │
|
||||||
|
│ C A B L E C A N Y O N (z 140–200) │
|
||||||
|
│ RCA/power leads as rope bridges & tunnels │
|
||||||
|
│ ┌────────────┐ ┌──────────┐ ┌────────────┐ │
|
||||||
|
│ │ DECK A │ │ MIXER │ │ DECK B │ │
|
||||||
|
│ │ x 24–136 │ │ x183–265 │ │ x 296–408 │ │
|
||||||
|
│ │ spindle │ │ 4ch face │ │ spindle │ │
|
||||||
|
│ │ (80, 96) │ │ plate │ │ (352, 96) │ │
|
||||||
|
│ └────────────┘ └──────────┘ └────────────┘ │
|
||||||
|
│ front lip / headphone ledge (z<48) │
|
||||||
|
z=0 ─────────────────────────────────────────────── front
|
||||||
|
x=0 x=448
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Zones (biomes)
|
||||||
|
|
||||||
|
1. **Deck A — "The Blue Record"** (spawn). Steel-grey plinth mesa. On top: the
|
||||||
|
platter (rotating machine, not voxels) carrying a translucent blue vinyl
|
||||||
|
disc with a cream label. Strobe dots glow on the platter rim. The pitch
|
||||||
|
fader is a canyon slot in the plinth; start/stop is a giant press-plate;
|
||||||
|
the spindle is a climbable chrome pole. The tonearm arcs overhead as a
|
||||||
|
chrome bridge from its rest to over the record.
|
||||||
|
2. **The Mixer Massif** (center). Tallest mesa. Face plate at y=67 is a
|
||||||
|
grid-city: EQ **knob forest** (mushroom-cap knobs on stems), four **fader
|
||||||
|
alleys** (deep slots with sliding fader sleds), the **crossfader tram**
|
||||||
|
running left–right at the front, **VU towers** at the back — columns of
|
||||||
|
LED blocks that light bottom-to-top with the music. Speaker-mesh vents on
|
||||||
|
the sides are climbable ladders and the way into the mixer's interior.
|
||||||
|
3. **Cable Canyon** (behind the gear, z 140–200). Tangled RCA and power leads
|
||||||
|
spanning tabletop to back wall — walkable rope bridges (2×2 cable-block
|
||||||
|
tubes with red/white/gold RCA plug heads). Dust drifts here; dust-block
|
||||||
|
boulders to mine.
|
||||||
|
4. **The Patch Bay** (back wall, y 90–118). The wall-mounted RCA switcher from
|
||||||
|
the photo, scaled to a cliff-face dungeon: gold jack sockets are round
|
||||||
|
doorways into shallow rooms; one socket needs its plug pushed home (quest).
|
||||||
|
5. **PCB Depths** (under-table, y 0–40). Mine down through a plinth vent:
|
||||||
|
green circuit-board floors, copper-trace paths that glow faintly, solder
|
||||||
|
blob stalagmites, capacitor pillar rooms, the fuse box (quest), and the
|
||||||
|
record crate — a plywood cavern of giant vinyl slabs to spelunk between.
|
||||||
|
6. **Deck B — "The Silent Deck"** (mirror of A, but stopped, dust-covered, its
|
||||||
|
half-open glass dust cover forming a crystal cave overhead). Comes alive
|
||||||
|
only at the end.
|
||||||
|
|
||||||
|
## 5. Mechanics
|
||||||
|
|
||||||
|
- **Standard voxel play**: mine breakable blocks (dust, vinyl, knobs, LEDs…),
|
||||||
|
carry them in a 9-slot hotbar, place them to build bridges/stairs anywhere.
|
||||||
|
Structural blocks (plywood shell, cables) are unbreakable — the booth keeps
|
||||||
|
its shape.
|
||||||
|
- **Riding the record**: platters are kinematic rotating cylinders. Standing
|
||||||
|
on one adds its surface velocity to you. At "33" (2.0 game-RPM) the label
|
||||||
|
area is a lazy carousel and the rim is a brisk travelator; at "45" the rim
|
||||||
|
will fling you — which is a legitimate traversal move (record-edge launch
|
||||||
|
onto the mixer).
|
||||||
|
- **The needle plow**: while a deck plays, the stylus tracks slowly inward
|
||||||
|
along the groove spiral. Its contact shoe is a kinematic pusher — get in
|
||||||
|
front of it and it shoves you along the groove. Jumping the tonearm's
|
||||||
|
moving shadow is a mini-game.
|
||||||
|
- **Machine interactions** (press E / tap): start/stop plates toggle platters,
|
||||||
|
33/45 buttons switch speed, pitch faders scale RPM ±50%, channel faders and
|
||||||
|
crossfader slide (and duck their stem's volume live), the cue lamp toggles
|
||||||
|
headlamp-style local light.
|
||||||
|
- **No mobs in v1.** Ambience only: drifting dust motes, LED pulses. (Stretch:
|
||||||
|
static-spark wisps in Cable Canyon.)
|
||||||
|
|
||||||
|
## 6. The Quest — "Bring Back the Mix"
|
||||||
|
|
||||||
|
The booth starts silent and dim. Five signal-chain breaks (order-agnostic,
|
||||||
|
tracked in `SIGNAL_NODES`):
|
||||||
|
|
||||||
|
| node | where | fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `stylus` | Deck A headshell is empty | find the chrome stylus block in the PCB Depths parts bin, carry it up, place it in the headshell socket |
|
||||||
|
| `rca` | Patch bay socket, back wall | walk Cable Canyon's loose lead to its plug, push the plug block into the gold socket |
|
||||||
|
| `crossfader` | Mixer crossfader slot | a dust boulder jams the tram — mine it out |
|
||||||
|
| `fuse` | Fuse box in PCB Depths | swap the blackened fuse block for a fresh one from the parts bin |
|
||||||
|
| `power` | Master switch, mixer rear | climb and press it (only lights up once the other four are done — it's the finale trigger) |
|
||||||
|
|
||||||
|
Each repair: a `signal:repair` event → an LED trace lights along the *actual
|
||||||
|
signal path* through the world (cartridge → tonearm → RCA → mixer → out), and
|
||||||
|
one more stem of the groove unmutes (drums → bass → chords → lead → full mix
|
||||||
|
with sweeps). Final repair: both platters spin up, every LED in the world goes
|
||||||
|
audio-reactive, `game:win` fires, and the player just… gets to ride records in
|
||||||
|
a living booth. Sandbox continues after the win.
|
||||||
|
|
||||||
|
## 7. Look & Feel
|
||||||
|
|
||||||
|
- **Rendering**: chunky voxels, per-face procedural 16×16 textures from a
|
||||||
|
canvas atlas (see palette in `src/core/blocks.ts`), soft baked-ish AO at
|
||||||
|
voxel corners, one warm key light + cool fill + emissive LED blocks.
|
||||||
|
Fog tinted warm plywood-brown at the world edges so the booth walls fade
|
||||||
|
like a horizon.
|
||||||
|
- **Audio**: one endless synthesized 118 BPM house groove in stems. Platter
|
||||||
|
start/stop bends pitch (the classic Technics spin-up/brake). Positional:
|
||||||
|
the music comes *from the decks*.
|
||||||
|
- **Feel targets**: 60 fps on a mid laptop; Minecraft-familiar controls
|
||||||
|
(WASD, space, mouse-look, E interact, LMB break, RMB place, 1–9 hotbar,
|
||||||
|
F toggles fly for debugging).
|
||||||
|
|
||||||
|
## 8. Out of Scope (v1)
|
||||||
|
|
||||||
|
Mobs, crafting, survival/health, saving, multiplayer, infinite terrain,
|
||||||
|
mobile controls. The diffusion-terrain research that inspired this project is
|
||||||
|
explicitly *not* needed: the world is a finite authored diorama.
|
||||||
63
docs/INTEGRATION.md
Normal file
63
docs/INTEGRATION.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# TURNCRAFT — Integration Guide
|
||||||
|
|
||||||
|
Run this phase AFTER the five lanes land, in one session (the integrator may
|
||||||
|
be any agent; it completes `src/main.ts` and may make minimal cross-lane glue
|
||||||
|
fixes, documenting every one).
|
||||||
|
|
||||||
|
## Pre-flight
|
||||||
|
|
||||||
|
1. `npm i && npm run typecheck` — must be clean with all lanes merged.
|
||||||
|
2. Read every lane's `HANDOFF.md` (in each owned directory). They list stubs
|
||||||
|
and contract friction — resolve friction FIRST, before wiring.
|
||||||
|
3. Open all five demo pages; each lane's acceptance boxes should be checkable.
|
||||||
|
|
||||||
|
## Wiring order (src/main.ts)
|
||||||
|
|
||||||
|
1. `createRenderer(#app)` → renderer, scene, camera, `setEmissiveBoost`.
|
||||||
|
2. `new VoxelWorld(WORLD_X, WORLD_Y, WORLD_Z)`.
|
||||||
|
3. `buildBooth(world)` (Lane C) — time it; expect < 500 ms.
|
||||||
|
4. `ChunkManager(world, scene, atlas)` — initial full mesh; expect < 3 s.
|
||||||
|
5. `createMachines(world)` (Lane D) with `QUEST_POS` from
|
||||||
|
`src/worldgen/questPositions.ts`; add `getObject3Ds()` to scene.
|
||||||
|
6. `new PlayerController(world, { getColliders: machines.getColliders,
|
||||||
|
camera, domElement, spawn: SPAWN })` (Lane B, spawn from Lane C).
|
||||||
|
7. `new Interaction(world, player, machines, hotbar)` (Lane D).
|
||||||
|
8. `AudioEngine` + `FxSystem({ scene, setEmissiveBoost })` + `Hud` (Lane E);
|
||||||
|
audio init gated behind Lane E's start overlay click.
|
||||||
|
9. Loop: fixed-step accumulator at `FIXED_DT`, max `MAX_SUBSTEPS`:
|
||||||
|
`machines.update(dt)` → `player.update(dt)` → `interaction.update(dt)` →
|
||||||
|
`fx.update(dt)` + `audio.updateListener(player)` per frame →
|
||||||
|
`chunkManager.update()` → render.
|
||||||
|
|
||||||
|
## Smoke tests (manual, in order)
|
||||||
|
|
||||||
|
1. **Spawn**: on Deck A plinth, facing mixer, 60 fps, booth fully visible,
|
||||||
|
world dim (pre-repair), groove silent.
|
||||||
|
2. **Ride**: press deck A start — platter spins up (no stylus yet = no
|
||||||
|
music), step on: you ride. Center calm, rim brisk. 45 + rim + jump flings
|
||||||
|
you toward the mixer.
|
||||||
|
3. **Mine/build**: break dust blocks, place a bridge from plinth to mixer,
|
||||||
|
auto-step up knob stems.
|
||||||
|
4. **Quest end-to-end**: complete all five nodes per DESIGN §6 — each fires a
|
||||||
|
subtitle, an LED trace, and one more stem. Power switch finale: win
|
||||||
|
sequence plays, both decks spin, booth fully lit and audio-reactive.
|
||||||
|
5. **Soak**: 10 minutes of free play — no console errors, no fps decay
|
||||||
|
(watch for per-frame allocation creep), audio never drifts.
|
||||||
|
|
||||||
|
## Known integration risks (check these specifically)
|
||||||
|
|
||||||
|
- Lane C's duck-typed `fillBox` fallback vs Lane A's real one — verify the
|
||||||
|
real path is taken (log once).
|
||||||
|
- Chunk dirty flood during quest LED traces if Lane E ever setBlocks — it
|
||||||
|
must NOT (sprites only); grep `setBlock` outside A/C/D.
|
||||||
|
- Platter collider height vs Lane C's platter-well depth: player must step
|
||||||
|
from plinth top onto the record (≤1 voxel lip, B's auto-step covers it).
|
||||||
|
- Double-grounding: player standing where a kinematic cylinder overlaps
|
||||||
|
voxels (well rim) — B resolves voxels first, then platforms; verify no
|
||||||
|
jitter standing on the rim edge.
|
||||||
|
- Audio autoplay policy: nothing audible before the start-overlay click.
|
||||||
|
|
||||||
|
## After integration
|
||||||
|
|
||||||
|
- `npm run build` must produce a deployable static bundle.
|
||||||
|
- Deploy per the deploy-map skill (partly.party) when John says ship.
|
||||||
148
docs/LANE_A_update.md
Normal file
148
docs/LANE_A_update.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# LANE A — Voxel Engine & Rendering — Progress Update
|
||||||
|
|
||||||
|
**Lane:** A (Voxel Engine & Rendering)
|
||||||
|
**Status:** ✅ Complete — all tasks A1–A5 implemented, browser-verified, typechecks clean in isolation.
|
||||||
|
**Owned paths:** `src/engine/**`, `src/demo/engineDemo.ts`, `demo-engine.html` (nothing else touched).
|
||||||
|
**For the reviewer (Fable):** see "Cross-lane issue found" and "What I'd want next" below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I built
|
||||||
|
|
||||||
|
The whole block-rendering core the other four lanes build on top of. Six files in `src/engine/`:
|
||||||
|
|
||||||
|
| file | what it is |
|
||||||
|
|---|---|
|
||||||
|
| [VoxelWorld.ts](../src/engine/VoxelWorld.ts) | `IVoxelWorld` impl — 32³ `Uint8Array` chunks (lazy), dirty tracking, `fillBox` bulk paint, `readChunkPadded` for the mesher |
|
||||||
|
| [atlas.ts](../src/engine/atlas.ts) | `buildAtlas()` — procedural 16×16 texture atlas (all 10 patterns) + emissive atlas + the two block materials + `setEmissiveBoost` |
|
||||||
|
| [mesher.ts](../src/engine/mesher.ts) | greedy mesher: 6 face dirs, `(blockId, AO)` merge, baked per-vertex AO, opaque/transparent split, atlas-tiled UVs |
|
||||||
|
| [ChunkManager.ts](../src/engine/ChunkManager.ts) | `buildAll()` + budgeted per-frame `update()` that remeshes dirty chunks and swaps geometry in place |
|
||||||
|
| [renderer.ts](../src/engine/renderer.ts) | `createRenderer()` — the one place Three.js is set up: renderer, scene, camera, lighting, fog, `setEmissiveBoost` |
|
||||||
|
| [index.ts](../src/engine/index.ts) | public barrel |
|
||||||
|
|
||||||
|
Plus the standalone demo ([engineDemo.ts](../src/demo/engineDemo.ts) + [demo-engine.html](../demo-engine.html)) and a [HANDOFF.md](../src/engine/HANDOFF.md) with the 10-line API surface.
|
||||||
|
|
||||||
|
### Public API (what other lanes / integration consume)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { renderer, scene, camera, atlas, setEmissiveBoost, render, resize } = createRenderer(container);
|
||||||
|
const world = new VoxelWorld(WORLD_X, WORLD_Y, WORLD_Z); // implements IVoxelWorld
|
||||||
|
buildBooth(world); // Lane C fills it (fillBox/setBlock)
|
||||||
|
const chunks = new ChunkManager(world, scene, atlas);
|
||||||
|
chunks.buildAll(); // once (~25 ms for the full world)
|
||||||
|
// per frame: chunks.update(); render();
|
||||||
|
setEmissiveBoost(v); // Lane E pulses LED glow 0..3
|
||||||
|
```
|
||||||
|
|
||||||
|
Matches the signatures in `docs/INTEGRATION.md`. `createRenderer` builds the atlas for you and
|
||||||
|
returns it as `.atlas` — hand that same object to `ChunkManager` so meshes and `setEmissiveBoost`
|
||||||
|
share one material set (don't call `buildAtlas()` twice).
|
||||||
|
|
||||||
|
### Key technical decisions
|
||||||
|
|
||||||
|
- **Atlas UV via shader `fract` (option (a) from the brief).** Greedy quads carry a tile-repeat
|
||||||
|
`uv` (0..w, 0..h) plus an `aTile` attribute (atlas cell). A small `onBeforeCompile` patch on
|
||||||
|
`MeshStandardMaterial` fracts the UV into the block's cell. `NearestFilter`, no mips, **no tile
|
||||||
|
padding needed** — `fract` keeps every sample strictly inside its cell so there's no bleed.
|
||||||
|
- **Emissive via a second atlas + `emissiveMap`**, `emissive=white`, `emissiveIntensity` = the
|
||||||
|
boost. LEDs glow in-colour and stay bright regardless of AO; `setEmissiveBoost` just sets
|
||||||
|
`emissiveIntensity` on both materials (nothing reaches into materials from outside — Lane E uses
|
||||||
|
the one hook, per the contract).
|
||||||
|
- **AO** is classic Minecraft 3-neighbour corner darkening baked to vertex colour; quad diagonals
|
||||||
|
flip on the AO-anisotropy case; transparent blocks don't occlude AO.
|
||||||
|
- **Absolute world coords baked into geometry** (no per-chunk matrix); bounding spheres drive
|
||||||
|
Three.js frustum culling for free.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification (ran it, didn't just typecheck it)
|
||||||
|
|
||||||
|
Launched the demo under Vite and drove it in a real browser. Every acceptance box in the brief:
|
||||||
|
|
||||||
|
| acceptance criterion | result |
|
||||||
|
|---|---|
|
||||||
|
| Full-size world + test pattern ≥ 60 fps, < 300 draw calls | **86–128 fps, 46–47 draw calls**, build ~25 ms |
|
||||||
|
| AO darkens inner corners; no chunk-seam cracks/z-fighting | ✅ steel cave interior corners darken cleanly (cave spans a chunk boundary) |
|
||||||
|
| Transparent blue vinyl & glass render correctly vs opaque | ✅ glass arch see-through onto geometry behind; blue vinyl blends over the cream wall |
|
||||||
|
| Torture (500 edits/s): no drop below ~55 fps, edits within 2 frames | **min fps 128**, dirty set drains at budget, edits appear within ~1 frame |
|
||||||
|
| All 10 patterns visually distinct | ✅ labeled pillar row — every one of the 31 block ids renders with its pattern |
|
||||||
|
| `npm run typecheck` clean + `HANDOFF.md` | Lane A clean in isolation (see below); HANDOFF written |
|
||||||
|
|
||||||
|
Screenshots taken at each step (cave AO, glass transparency, LED wall at emissive boost 1 vs 3,
|
||||||
|
torture flicker, labeled pillar row). The demo's HUD shows draw calls / triangles / fps / remesh
|
||||||
|
time live; a TORTURE button and an emissive-boost slider (0→3) exercise the last two criteria.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Cross-lane issue found (for the reviewer)
|
||||||
|
|
||||||
|
**The repo-wide `npm run typecheck` currently fails — but not on Lane A code.** The single error is
|
||||||
|
in **Lane C's** `src/worldgen/anchors.ts`:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/worldgen/anchors.ts(60,35): error TS2345: '{ ...deckB... }' is not assignable to a parameter
|
||||||
|
expecting the deckA-shaped type ('296' is not assignable to type '24').
|
||||||
|
```
|
||||||
|
|
||||||
|
Looks like a helper typed against a literal `LAYOUT.deckA` shape is being passed `LAYOUT.deckB`.
|
||||||
|
It's not my file to fix (core is read-only and `src/worldgen` is Lane C's). I verified Lane A is
|
||||||
|
clean by typechecking `src/core` + `src/engine` + `src/demo/engineDemo.ts` in isolation — **0 errors.**
|
||||||
|
Flagging so it's fixed before integration, since it blocks the shared typecheck gate for everyone.
|
||||||
|
|
||||||
|
(Also: a dev-only "Multiple instances of Three.js" console *warning* on the demo page, from
|
||||||
|
`OrbitControls` — demo-only, not in the shipped engine API. Harmless; details in HANDOFF.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I'd want next (suggestions for Fable)
|
||||||
|
|
||||||
|
1. **Unblock the shared typecheck** — get Lane C's `anchors.ts` `deckA`/`deckB` type fixed so
|
||||||
|
`npm run typecheck` is green for everyone (integration pre-flight depends on it).
|
||||||
|
2. **I'm ready to integrate.** The engine API is stable and matches `INTEGRATION.md`. When Lane C's
|
||||||
|
`buildBooth` and Lane B/D/E land, I can take the integrator seat (wire `src/main.ts`) or support
|
||||||
|
whoever does — the risk items in `INTEGRATION.md` (dirty-chunk floods, platter well depth) touch
|
||||||
|
my meshing/dirty paths.
|
||||||
|
3. **Possible follow-on engine work** if the dense authored booth needs it: worker-threaded meshing
|
||||||
|
and/or transparent-geometry merging *if* draw calls approach 300 at full scale (demo is at ~47,
|
||||||
|
so likely fine — but worth a check once `buildBooth` fills the real world).
|
||||||
|
|
||||||
|
## Adversarial self-review — found and fixed a real bug
|
||||||
|
|
||||||
|
I ran a 5-dimension adversarial correctness review over the engine (13 agents, each finding
|
||||||
|
independently verified by a skeptic pass). It raised 8 findings; 5 confirmed. **Two were real and
|
||||||
|
are now fixed; three were correctly refuted.**
|
||||||
|
|
||||||
|
### 🔴 FIXED — HIGH: Z-face winding was inverted ([mesher.ts:41](../src/engine/mesher.ts))
|
||||||
|
|
||||||
|
The `AXIS[2]` (Z-axis) entry in the greedy mesher's winding table had `flipPlus`/`flipMinus`
|
||||||
|
swapped — copied from the Y-axis row — even though Z's in-plane cross product (`X×Y = +Z`) matches
|
||||||
|
the X-axis case, not Y's. Effect: **every opaque block's +Z and −Z faces were triangulated with
|
||||||
|
reversed winding**, so `THREE.FrontSide` culling rendered the *wrong* face.
|
||||||
|
|
||||||
|
Why my initial visual pass missed it: on a 1-voxel-thick wall, reversed winding just draws the
|
||||||
|
*opposite* face (one voxel away, same texture) — visually near-identical. I nearly refuted the
|
||||||
|
finding on eyeball evidence. I caught it with a **programmatic check** instead: for every triangle
|
||||||
|
in the built geometry, compare its geometric winding normal against its stored normal attribute.
|
||||||
|
Result before the fix: **520 Z-axis triangles inverted (248 on +Z, 272 on −Z), 0 on X/Y.** After
|
||||||
|
changing `AXIS[2]` to `{ flipPlus:false, flipMinus:true }`: **0 mismatches across all six normals,
|
||||||
|
opaque and transparent.** Visually, the steel cave now reads as a correctly-lit solid box.
|
||||||
|
|
||||||
|
*(This is exactly the class of bug that would have surfaced during integration as z-fighting,
|
||||||
|
see-through opaque blocks, or wrong lighting on half the world's faces — much cheaper to kill now.)*
|
||||||
|
|
||||||
|
### 🟡 FIXED — LOW: dead resize fallback ([renderer.ts:55](../src/engine/renderer.ts))
|
||||||
|
|
||||||
|
`container.clientWidth ?? window.innerWidth` never fell back, because `clientWidth` is `0` (not
|
||||||
|
nullish) for an unlaid-out container. Switched the fallback chain to `||` so a 0-width container
|
||||||
|
sizes to the window instead of bailing.
|
||||||
|
|
||||||
|
### Refuted (correctly — no change needed)
|
||||||
|
|
||||||
|
- "meshChunk allocates per frame" — allocations are the *output geometry* (necessary) and only
|
||||||
|
happen on remesh (budgeted), not every frame. Hot-path scratch (`pad`, `mask`) is reused.
|
||||||
|
- "ChunkManager.update allocates a closure per frame" — one tiny inline arrow when there are dirty
|
||||||
|
chunks; no measurable churn, no wrong result.
|
||||||
|
- "`window.__demo` global in the demo" — intentional, dev-only, clearly commented inspection hook
|
||||||
|
(it's what I used to run the winding check above). Demo-only, never in the shipped engine.
|
||||||
|
|
||||||
|
Post-fix: `npm run typecheck` clean for Lane A in isolation; demo re-verified, 0 console errors.
|
||||||
109
docs/LANE_C_update.md
Normal file
109
docs/LANE_C_update.md
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# Lane C — World Builder · Progress Update
|
||||||
|
|
||||||
|
*For Fable's review. Lane C = the level-design lane: author the entire DJ-booth
|
||||||
|
diorama in voxels via `buildBooth(world)`.*
|
||||||
|
|
||||||
|
**Status: ✅ complete and verified.** Typecheck clean, demo renders with no console
|
||||||
|
errors, build deterministic and fully validated. Ready for Lane D to mount machines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What shipped
|
||||||
|
|
||||||
|
| file | role |
|
||||||
|
|---|---|
|
||||||
|
| `src/worldgen/tools.ts` | voxel toolkit: `box`, `hollowBox`, `cylinderY`, `sphere`, `line3`, `spline` (Catmull-Rom), `mulberry32` PRNG, bounds-guarded `setVox`, duck-typed `fillBox` fast path |
|
||||||
|
| `src/worldgen/anchors.ts` | **single source of truth** for every LAYOUT-derived position (deck furniture, mixer face, patch-bay grid, under-table) — so no coordinate is written twice |
|
||||||
|
| `src/worldgen/questPositions.ts` | `QUEST_POS` — the Lane C→D contract: exact voxels for stylus/rca/crossfader/fuse/power |
|
||||||
|
| `src/worldgen/buildBooth.ts` | the build: `buildShell`, `buildDeckA/B`, `buildMixer`, `buildCableCanyon`, `buildPatchBay`, `buildUnderTable`, set-dressing; exports `SPAWN` |
|
||||||
|
| `src/worldgen/index.ts` | public barrel for integrators |
|
||||||
|
| `src/demo/worldgenDemo.ts` + `demo-worldgen.html` | standalone InstancedMesh visualizer, zone teleports, live validation panel |
|
||||||
|
| `src/worldgen/HANDOFF.md` | API surface + integration notes |
|
||||||
|
|
||||||
|
## 2. What the booth contains (DESIGN §4, all present + eyeballed)
|
||||||
|
|
||||||
|
- **Shell**: warm plywood console, `ply_edge` laminate caps, front lip, hinge plates,
|
||||||
|
tabletop with vent shafts behind each deck + a front-left crate hatch & stair.
|
||||||
|
- **Deck A / Deck B**: steel-grey plinths, rubber feet, brushed-alu top border, a
|
||||||
|
**recessed empty platter well** with chrome spindle stub, start/stop + 33/45 button
|
||||||
|
recesses, pitch-fader canyon with green centre-detent, tonearm base pedestal + empty
|
||||||
|
headshell socket, Technics strobe lamp. Deck B adds dust drifts + a half-open glass
|
||||||
|
dust cover.
|
||||||
|
- **Mixer Massif**: matte-black body, speaker-mesh climb vents, 4 channel strips
|
||||||
|
(silver/black knob-stem grids + **empty fader alleys**), the crossfader tram with the
|
||||||
|
dust-jam plug, two VU-meter towers (green→amber→red), power-switch recess, fuse chimney.
|
||||||
|
- **Cable Canyon**: 5 splined RCA/power cables draping to the patch bay, gold plug heads,
|
||||||
|
a power strip, and the quest cable ending short with a loose gold plug on the floor.
|
||||||
|
- **Patch Bay**: proud alu panel, 2×6 gold socket grid, the conspicuously **empty quest
|
||||||
|
socket** (red blink), two sockets that are real doorways into copper-busbar rooms.
|
||||||
|
- **PCB Depths**: green board floors, copper trace paths, capacitor pillars, solder
|
||||||
|
stalagmites, sparse LEDs, the fuse box (blown fuse), the parts bin (fresh-fuse + the
|
||||||
|
blue-spotlit stylus block), and the record crate of blue/black vinyl slabs on edge.
|
||||||
|
|
||||||
|
## 3. Verification (headless + in-demo + cross-lane, all green)
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ build < 500ms (14ms mock / 30.7ms real VoxelWorld)
|
||||||
|
✓ deterministic — two runs byte-identical (seed 1210)
|
||||||
|
✓ 0 out-of-bounds writes
|
||||||
|
✓ deck A + deck B platter wells empty (left for Lane D's platter)
|
||||||
|
✓ spawn stands on solid steel with headroom, facing the mixer
|
||||||
|
✓ 8/8 quest anchor points reachable (adjacent air)
|
||||||
|
✓ fuse & stylus anchors land on the intended block (not a neighbour)
|
||||||
|
✓ record crate populated (regression-guarded)
|
||||||
|
~4.5M non-air voxels · 24.6% fill · 29/30 non-air block types used
|
||||||
|
```
|
||||||
|
|
||||||
|
Screenshotted the spawn deck, whole booth, PCB Depths, and the record crate — all read
|
||||||
|
clearly as the intended DJ-booth diorama.
|
||||||
|
|
||||||
|
**Adversarial review pass.** I ran a 5-dimension multi-agent review (12 agents, each
|
||||||
|
finding independently verified). It CONFIRMED 5 defects my first-pass validation missed —
|
||||||
|
all now fixed and re-verified:
|
||||||
|
- **[HIGH]** the record crate placed **zero** records (footprint had collapsed to ~2
|
||||||
|
voxels) → re-anchored, now a full row of blue/black vinyl on edge.
|
||||||
|
- **[MED]** `QUEST_POS.fuse.box` sat on the frame, one voxel off the fuse → now exact.
|
||||||
|
- **[MED]** VU-tower LEDs were sealed inside a closed casing → front face opened.
|
||||||
|
- **[LOW]** added the PCB "resistor beams" the brief lists.
|
||||||
|
- **[LOW]** the demo's "Record Crate" teleport aimed into the left wall → re-aimed.
|
||||||
|
(Two other raw findings were false positives — one described already-fixed code; a
|
||||||
|
doc-comment nit I tightened anyway.) I added regression guards for the crate and anchors.
|
||||||
|
|
||||||
|
**Cross-lane smoke test.** Since all five lanes have now landed, I ran `buildBooth`
|
||||||
|
against Lane A's **real** `VoxelWorld` (not my mock): the `fillBox` fast path engages
|
||||||
|
(Lane A's signature + inclusive semantics match my assumption exactly), 30.7 ms build,
|
||||||
|
and every acceptance check still passes. Lane C drops into the engine with zero friction.
|
||||||
|
|
||||||
|
## 4. Contract friction (needs an integrator/Fable call)
|
||||||
|
|
||||||
|
1. **Mixer height**: the brief says "tabletop → `topY`+40", but `constants.ts` + DESIGN
|
||||||
|
both define the mixer face plate top surface as `topY = 67`. I built to **67** (with a
|
||||||
|
raised rear ridge + VU towers for the "tallest mesa" look). Flag if a 107-tall mixer
|
||||||
|
was actually intended — it's a one-liner.
|
||||||
|
2. **`fillBox` signature**: `tools.box()` calls `world.fillBox(x0,y0,z0,x1,y1,z1,id)`
|
||||||
|
(inclusive) if Lane A provides it, else loops `setBlock`. `buildBooth` logs which path
|
||||||
|
ran. Lane A should match that signature (or we reconcile at integration; the loop is
|
||||||
|
always correct).
|
||||||
|
|
||||||
|
## 5. Handoff to Lane D (machines)
|
||||||
|
|
||||||
|
Lane D reads `QUEST_POS` + the exported anchors — **no re-hardcoding coordinates**. Every
|
||||||
|
moving part has a socket waiting: platter wells, tonearm pedestal + headshell anchor,
|
||||||
|
empty fader/crossfader slots, button/switch recesses. Anchor semantics are documented in
|
||||||
|
`src/worldgen/HANDOFF.md`.
|
||||||
|
|
||||||
|
## 6. Suggested next steps for Fable
|
||||||
|
|
||||||
|
- Greenlight (or correct) the **mixer-height** interpretation in §4.1.
|
||||||
|
- Confirm the **`fillBox` signature** with Lane A so the fast path engages at integration.
|
||||||
|
- Lane D can start now against `QUEST_POS` + anchors; nothing blocks it.
|
||||||
|
- (Optional polish later) chunkier cable cross-sections, a lower/hinged glass dust cover,
|
||||||
|
and more copper-trace detail in the PCB rooms.
|
||||||
|
|
||||||
|
## 7. Repo note
|
||||||
|
|
||||||
|
Work is in the shared working tree (`/Users/jing/TURNCRAFT`), not yet pushed. The fresh
|
||||||
|
remote `ssh://git@100.71.119.27:222/monster/TURNCRAFT.git` is empty and there's no local
|
||||||
|
git repo yet — I did **not** `git init`/push, since seeding a shared repo from one of five
|
||||||
|
parallel lanes is an orchestration decision (whole scaffold + all lanes) better made by
|
||||||
|
the integrator. Say the word and I'll initialize + push Lane C on a branch.
|
||||||
116
docs/LANE_D_update.md
Normal file
116
docs/LANE_D_update.md
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# LANE D — Machines, Interaction & Quest — Progress Update
|
||||||
|
|
||||||
|
**Status: complete.** All brief tasks (D1–D6) implemented, `npm run typecheck`
|
||||||
|
clean, every acceptance criterion verified at runtime. Adversarially reviewed:
|
||||||
|
6 confirmed defects found (incl. one quest deadlock) — **all fixed and
|
||||||
|
re-verified**. Ready for integration.
|
||||||
|
|
||||||
|
_For Fable / the integrator. Companion doc: [`src/machines/HANDOFF.md`](../src/machines/HANDOFF.md) (API surface + contract friction)._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I built
|
||||||
|
|
||||||
|
Everything that moves or responds in the booth, plus the interaction layer and
|
||||||
|
the quest.
|
||||||
|
|
||||||
|
| Area | Files | Delivers |
|
||||||
|
|---|---|---|
|
||||||
|
| Framework (D1) | `machines/machine.ts`, `machines/util.ts` | `MachineBase`, block-tint materials, procedural record texture |
|
||||||
|
| Platters ×2 (D2) | `machines/platter.ts` | rotating ride cylinder, `velocityAt = ω×r`, spin-up/brake, `platter:state` |
|
||||||
|
| Tonearms ×2 (D3) | `machines/tonearm.ts` | pivoting arm, cue→inward-track→return, needle-plow headshell collider, stylus socket |
|
||||||
|
| Faders + buttons (D4) | `machines/faders.ts`, `machines/buttons.ts`, `machines/rca.ts` | pitch/channel/crossfader sleds, start-stop/33-45/cue/power, RCA plug |
|
||||||
|
| Quest (D6) | `machines/quest.ts` | `SIGNAL_NODES` state machine, `signal:repair` / `game:win` |
|
||||||
|
| Assembly | `machines/index.ts` | `createMachines(world, questPos?) → MachineSet` (wires everything) |
|
||||||
|
| Interaction (D5) | `interact/raycast.ts`, `interact/interaction.ts`, `interact/hotbar.ts` | DDA+analytic raycast, break/place/use, 9-slot hotbar |
|
||||||
|
| Demo | `demo-machines.html`, `demo/machinesDemo.ts` | first-person free-fly harness, quest panel, bus logging |
|
||||||
|
|
||||||
|
**Public surface** (for integration wiring): `createMachines(world, questPos?)`
|
||||||
|
returns `{ machines, quest, tonearmA, getColliders(), update(dt), getObject3Ds(),
|
||||||
|
attachPlayer(player) }`; `new Interaction(world, player, machines, hotbar)`;
|
||||||
|
`new Hotbar()`. Full details in HANDOFF.md.
|
||||||
|
|
||||||
|
## Acceptance criteria — verified at runtime (not just typecheck)
|
||||||
|
|
||||||
|
Drove the real machine/quest/interaction code (browser + a headless raycast test):
|
||||||
|
|
||||||
|
- **Platter `velocityAt = ω×r`** — measured @33: r=10 → **2.08 v/s**, r=41 → **8.52 v/s**;
|
||||||
|
@45: r=41 → **11.57 v/s**; `v·r == 0` (perfectly tangential); **0 when stopped**.
|
||||||
|
Matches the DESIGN targets (label ~2, rim ~8.6/11.6) exactly. Exponential
|
||||||
|
spin-up/brake confirmed.
|
||||||
|
- **Tonearm tracks inward** — headshell radius-from-spindle **42 (rest) → 35 (cue)
|
||||||
|
→ 24.6 (after 80 s tracking)**; headshell collider carries real velocity during
|
||||||
|
the cue (the plow pushes).
|
||||||
|
- **Faders operable; crossfader gated** — crossfader refuses to move while its
|
||||||
|
slot holds dust; after mining the plug, one full slide **repairs `crossfader`**.
|
||||||
|
- **DDA raycast, no tunneling** — 8/8 headless assertions: correct face/distance,
|
||||||
|
`maxDist` respected, a shallow diagonal does **not** tunnel a wall, ray↔cylinder
|
||||||
|
and ray↔aabb hit, and **a nearer machine collider beats a farther block**.
|
||||||
|
- **Quest end-to-end** — `game:win` fires **exactly once**, both platters
|
||||||
|
**auto-start on win**, `power` is refused until the other four are done,
|
||||||
|
re-repairing a node is a no-op (exactly 4 repair events for 4 fixes).
|
||||||
|
- No console errors across the session; `npm run typecheck` and `vite build` clean.
|
||||||
|
|
||||||
|
## Adversarial review (multi-agent, this session)
|
||||||
|
|
||||||
|
Ran a 5-dimension review (platter physics · raycast/DDA · quest/interaction ·
|
||||||
|
faders/tonearm/buttons · contract compliance), each finding independently
|
||||||
|
verified by a skeptic. **It found 6 confirmed defects — all now fixed and
|
||||||
|
re-verified through the real code paths**, plus 1 refuted nit. This caught a bug
|
||||||
|
my own runtime pass missed because I'd cleared the crossfader dust with a direct
|
||||||
|
`setBlock` instead of mining it.
|
||||||
|
|
||||||
|
| # | sev | fix |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **high** | **Crossfader deadlock** — the crossfader cap collider *enclosed* its own jam dust, so the interaction ray always hit the machine (cap) instead of the block (dust) → dust un-mineable → gate never cleared → `game:win` unreachable. Fixed by excluding Fader caps from the interaction ray (faders are push-operated, never aimed at). Re-verified: dust now mines via the real ray+hold path → crossfader repairs. |
|
||||||
|
| 2 | **high** | **Tonearm phantom collider** — the arm was one AABB bounding pivot→head, ballooning into a ~700-voxel² box over the rideable record when diagonal. Replaced with a 3-segment AABB chain tiling the tube (~126 voxel²; a far ride point is now outside all arm colliders). |
|
||||||
|
| 3 | med | Mixer channel-fader / crossfader coords were hardcoded — now derived from `LAYOUT.mixer` and `pos.crossfaderSlot` (keeps gate + geometry coincident). |
|
||||||
|
| 4 | low | `Platter.velocityAt` returned a fresh array each call (physics hot path) — now writes a reused per-collider scratch (documented "consume immediately"). |
|
||||||
|
| 5 | low | Tonearm allocated a `THREE.Vector3` per tick — hoisted to a module scratch. |
|
||||||
|
| 6 | low | `raycast` allocated short-lived arrays per collider — `rayAabb`/`rayCylinder` unrolled. |
|
||||||
|
| — | refuted | A stopped deck could re-emit a byte-identical `platter:state`; verified harmless (brief says "emit on play/speed change"), but I deduped it anyway. |
|
||||||
|
|
||||||
|
Re-verified after the fixes: velocity exact (2.094 / 8.587, tangential=0),
|
||||||
|
`game:win` once, both platters auto-start, crossfader repairs via real mining,
|
||||||
|
tonearm still tracks inward, raycast 8/8, no console errors, typecheck clean.
|
||||||
|
|
||||||
|
## What the integrator needs to know (3 friction points)
|
||||||
|
|
||||||
|
1. **`createMachines` takes an optional 2nd arg** `questPos: Partial<QuestPositions>`.
|
||||||
|
Pass Lane C's `QUEST_POS`; omit it and LAYOUT-derived defaults are used.
|
||||||
|
`QuestPositions` is defined in `machines/quest.ts` — Lane C's
|
||||||
|
`worldgen/questPositions.ts` should export that shape (I never import Lane C).
|
||||||
|
2. **Call `machines.attachPlayer(player)`** right after building the Lane B player
|
||||||
|
(a new step ~6.5 in INTEGRATION). Faders (walk-to-push) and walk-press buttons
|
||||||
|
need the player view, which doesn't exist at `createMachines` time.
|
||||||
|
3. **Quest item blocks** (`QUEST_ITEMS`): stylus pickup = `chrome` (11), fuse
|
||||||
|
pickup = `glass` (22). Lane C should place these in the PCB-Depths parts bin.
|
||||||
|
|
||||||
|
No contract files were edited; no other lane's concrete code is imported; all
|
||||||
|
positions derive from `LAYOUT`/`PLATTER`. One small addition to the event surface
|
||||||
|
was avoided — hotbar changes use `hotbar.onChange(fn)` (the core bus has no
|
||||||
|
hotbar events by contract); Lane E's HUD subscribes to that.
|
||||||
|
|
||||||
|
## Suggested next steps (for Fable to assign)
|
||||||
|
|
||||||
|
- **Integration**: wire `src/main.ts` per INTEGRATION.md; add the `attachPlayer`
|
||||||
|
step; confirm Lane C's `QUEST_POS` matches `QuestPositions`.
|
||||||
|
- **Cross-lane check with C**: my default quest/gear positions come from `LAYOUT`;
|
||||||
|
once C's booth lands, verify the crossfader slot span, fuse-box voxel, RCA
|
||||||
|
plug/socket, and headshell socket line up with C's actual geometry.
|
||||||
|
- **Cross-lane check with E**: confirm Lane E listens to `platter:state`,
|
||||||
|
`signal:repair`, `game:win`, `machine:interact`, `fader:move`, and renders the
|
||||||
|
`Hotbar` via `onChange`.
|
||||||
|
- **Optional polish** (not blocking): per-deck tonearm side-swing tuning once C's
|
||||||
|
plinths exist; a `unlocked45` gate if DESIGN's "45 unlocked at win" is taken
|
||||||
|
literally (I kept 45 always-on for the INTEGRATION rim-launch smoke test).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Notes / repo state
|
||||||
|
|
||||||
|
- Local tree is **not a git repo** (`git status` → not a repository). I did not
|
||||||
|
init or push to `ssh://git@100.71.119.27:222/monster/TURNCRAFT.git` — say the
|
||||||
|
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).
|
||||||
154
docs/LANE_E_update.md
Normal file
154
docs/LANE_E_update.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# LANE E — Update (Audio / FX / UI)
|
||||||
|
|
||||||
|
**Status: ✅ Complete, typechecks clean (whole 5-lane repo), verified live in the
|
||||||
|
browser, adversarially reviewed (6 findings — all fixed).**
|
||||||
|
|
||||||
|
Lane E owns `src/audio/**`, `src/fx/**`, `src/ui/**`, `src/demo/audioDemo.ts`,
|
||||||
|
`demo-audio.html`. Built strictly against the contract layer in `src/core/` —
|
||||||
|
nothing there was edited, no `setBlock` anywhere, no imports of other lanes'
|
||||||
|
concrete code, no rAF loop outside the demo, no new runtime deps (three only),
|
||||||
|
all audio synthesized, all textures procedural.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I built
|
||||||
|
|
||||||
|
### Audio (`src/audio/`) — "sound is the win state"
|
||||||
|
- **`synth.ts`** — low-level voices: kick (pitch-drop sine + click), hats,
|
||||||
|
clap, detuned-saw bass, resonant chord stabs, square/sine lead, noise
|
||||||
|
sweeps, plus procedural white-noise and vinyl-crackle buffers. No samples.
|
||||||
|
- **`scheduler.ts`** — a drift-free lookahead transport (25 ms tick, notes
|
||||||
|
scheduled on `AudioContext.currentTime`, not `setInterval` timing). A single
|
||||||
|
`rate` models the Technics spin-up/brake: it eases torque-ily toward its
|
||||||
|
target and scales tempo **and** per-voice detune together. Beat/bar events
|
||||||
|
are queued and fired at their audible time so LED pulses stay in sync.
|
||||||
|
- **`groove.ts`** — the endless 118 BPM, 8-bar F-minor deep-house loop in five
|
||||||
|
stems (drums → bass → chords → lead → sweeps). `setStemCount(0..5)` stacks
|
||||||
|
them musically; muted stems are not scheduled (near-zero cost). Sidechain-
|
||||||
|
ducked bass.
|
||||||
|
- **`sfx.ts`** — every diegetic one-shot: footsteps per surface category,
|
||||||
|
impact-scaled land, break/place, fader zip, button clunk, RCA
|
||||||
|
"clunk-clunk-CLICK", fuse zap, tonearm creak, needle drop.
|
||||||
|
- **`AudioEngine.ts`** — the WebAudio graph, positional sound from two
|
||||||
|
PannerNodes at the deck spindles (+ an always-on low-passed dry bass bed),
|
||||||
|
the analyser for VU `getLevels()`, the full bus wiring, and the win audio
|
||||||
|
timeline (silence → +1.8 s needle-drop → full mix slams in behind an opening
|
||||||
|
lowpass — all scheduled on the ctx clock). **Nothing sounds before `init()`
|
||||||
|
on a user gesture** (autoplay policy).
|
||||||
|
|
||||||
|
### FX (`src/fx/`) — the booth comes alive
|
||||||
|
- **`particles.ts`** — `DustField` (ambient brownian motes) and `Burst` (a
|
||||||
|
round-robin pool powering break puffs, win confetti, and 45-rpm rim
|
||||||
|
sparkle). Preallocated typed arrays, mutated in place — **zero per-frame
|
||||||
|
allocation**. One procedural glow texture, no assets.
|
||||||
|
- **`overlays.ts`** — emissive sprite overlays we own (never mutating another
|
||||||
|
lane's materials): mixer VU towers keyed to `getLevels()`, a bright sprite
|
||||||
|
that runs the signal-path polyline on each repair, and a patch-bay blink.
|
||||||
|
- **`layout.ts`** — every overlay anchor derived from `constants.ts` `LAYOUT`.
|
||||||
|
- **`FxSystem.ts`** — the global LED pulse via Lane A's injected
|
||||||
|
`setEmissiveBoost` (dead-booth base 0.35 → steps toward 1.0 over the five
|
||||||
|
repairs → pulses to ~1.8 on beats), plus the win visual timeline (1.8 s dark
|
||||||
|
→ drop flash + confetti → living-booth steady state).
|
||||||
|
|
||||||
|
### UI (`src/ui/Hud.ts`) — DOM overlay
|
||||||
|
Crosshair, 9-slot hotbar (tint swatches + counts + active ring, via an injected
|
||||||
|
`getHotbar()`), 5-node quest tracker, event subtitle line, the **start splash
|
||||||
|
that gates the AudioContext + pointer lock**, a pause overlay, and the win
|
||||||
|
banner. `pointer-events:none` except the gate screens, so it never eats
|
||||||
|
gameplay input. Dynamic text via `textContent` (no injection risk).
|
||||||
|
|
||||||
|
### Demo (`demo-audio.html` + `src/demo/audioDemo.ts`)
|
||||||
|
Runs with **zero code from other lanes**: a mock stage (dark tabletop + two
|
||||||
|
glowing "record" discs at the real deck spindle positions, orbit camera) and a
|
||||||
|
full control panel exercising every acceptance criterion, including a quest
|
||||||
|
simulator that previews the entire signal→stem→light arc and the win sequence.
|
||||||
|
A mock `setEmissiveBoost` flashes the discs on every beat via the real FX code
|
||||||
|
path; a mock hotbar feeds the HUD.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance criteria — all met
|
||||||
|
|
||||||
|
| # | Criterion | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Groove runs indefinitely with zero drift; stems stack 0→5 | ✅ lookahead scheduler; gated stems |
|
||||||
|
| 2 | Spin-up/brake bends tempo+pitch together; pitch fader detunes | ✅ single `rate` → tempo+detune; `setPitch` |
|
||||||
|
| 3 | Walking between decks pans/attenuates | ✅ two PannerNodes + listener follow; dry bed remains |
|
||||||
|
| 4 | Every SFX distinct, non-clipping; footsteps vary by surface | ✅ per-category footstep bodies |
|
||||||
|
| 5 | Beat pulse, VU, dust, break puffs at 60 fps | ✅ zero-alloc hot paths, verified live |
|
||||||
|
| 6 | Full win sequence previews from the quest simulator | ✅ verified end-to-end in browser |
|
||||||
|
| 7 | No audio before user gesture; typecheck clean; HANDOFF | ✅ start-splash gate; clean; 3 HANDOFFs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npm run typecheck` — **clean across the entire repo** (all five lanes are
|
||||||
|
now present; the whole tree compiles under strict mode).
|
||||||
|
- **Browser smoke test** (`/demo-audio.html`): start gate → booth alive (discs,
|
||||||
|
VU towers, dust, hotbar) → all five repairs light the tracker, run the LED
|
||||||
|
signal-path trace, show subtitles, and step the booth brighter → WIN plays
|
||||||
|
the dark→needle-drop→slam sequence with the "THE MIX IS LIVE" banner.
|
||||||
|
**Zero console errors** across the whole session.
|
||||||
|
|
||||||
|
## Adversarial review (multi-agent) — 6 findings, all fixed
|
||||||
|
|
||||||
|
Ran a 5-dimension review (contract / audio / fx / ui / demo) with an
|
||||||
|
adversarial verify pass. 10 raw findings → 4 refuted as false positives → **6
|
||||||
|
confirmed, all now fixed**:
|
||||||
|
|
||||||
|
1. **[med] Hud** — pause "resume" reused `onStart`, re-running first-start side
|
||||||
|
effects. → Added a separate `onResume` callback (re-lock only).
|
||||||
|
2. **[low] FxSystem** — `game:win` wasn't idempotent (audio was), so a repeated
|
||||||
|
event desynced visuals vs audio. → Guarded with an already-won check.
|
||||||
|
3. **[low] groove** — `audio:beat.energy` was a bare constant vs the contract's
|
||||||
|
"low-band energy". → Now scaled by the drums channel gain.
|
||||||
|
4. **[low] Hud** — the win-banner `setTimeout` leaked (not cleared in
|
||||||
|
`dispose`). → Tracked and cleared.
|
||||||
|
5. **[low] demo** — control panel `z-index` sat above the start splash,
|
||||||
|
defeating the gesture gate. → Panel is hidden until start.
|
||||||
|
6. **[low] demo** — resume force-restarted Deck A and desynced its button. →
|
||||||
|
`hasStarted` guard + button label sync + pure `onResume`.
|
||||||
|
|
||||||
|
Fix #1 changed the `Hud` public API (added optional `onResume`) — noted in the
|
||||||
|
HANDOFFs. All fixes re-typechecked and re-verified live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for integration (friction to confirm — details in `src/audio/HANDOFF.md`)
|
||||||
|
|
||||||
|
1. **`fader:move` → channel mapping**: I map `faderId` containing `pitch` →
|
||||||
|
pitch bend, else the first digit `N` → stem channel `N-1`. Confirm Lane D's
|
||||||
|
`faderId`s (crossfader / named channels) and extend if needed.
|
||||||
|
2. **Channel↔stem mapping**: 5 stems vs 4 mixer channels + crossfader —
|
||||||
|
`setChannelGain(ch, v)` treats `ch` as a stem index 0..4.
|
||||||
|
3. **`player:landed`/`player:step`** carry no surface category / position; I use
|
||||||
|
the last stepped surface and listener-centered playback.
|
||||||
|
4. **`setEmissiveBoost` semantics**: I drive it as an absolute intensity
|
||||||
|
(0.35 → 1.0 base, ~1.8 peak). If Lane A's hook is a multiplier, tune
|
||||||
|
`FxSystem` constants.
|
||||||
|
5. **VU tower positions** are derived from `LAYOUT.mixer`. If Lane C placed
|
||||||
|
physical LED towers elsewhere, align `src/fx/layout.ts` `VU_TOWERS`.
|
||||||
|
6. **Hotbar model** — no core type; `Hud` exports `HotbarModel`/`HotbarSlot`,
|
||||||
|
consumed via injected `getHotbar()`. Adapt Lane D's hotbar to this shape.
|
||||||
|
7. **`game:win`** forces deck playback in audio; ensure Lane D also spins the
|
||||||
|
real platters at the finale so audio and visuals agree.
|
||||||
|
|
||||||
|
Wiring is exactly as `docs/INTEGRATION.md` step 8 describes:
|
||||||
|
`AudioEngine` + `FxSystem({ scene, setEmissiveBoost })` + `Hud`, audio init
|
||||||
|
behind the start-overlay click, `fx.update(dt)` + `audio.updateListener(player)`
|
||||||
|
each frame.
|
||||||
|
|
||||||
|
## Repo / git
|
||||||
|
The working tree is **not yet a git repo** locally, and the fresh remote
|
||||||
|
(`ssh://git@100.71.119.27:222/monster/TURNCRAFT.git`) is empty. I did **not**
|
||||||
|
`git init`/commit/push — that's a cross-lane integration decision, so I left it
|
||||||
|
for Fable/the integrator to sequence (init, land all five lanes, run
|
||||||
|
`docs/INTEGRATION.md`, then push).
|
||||||
|
|
||||||
|
## Suggested next steps for Fable
|
||||||
|
1. Integrate per `docs/INTEGRATION.md` (all five lanes' `HANDOFF.md` first,
|
||||||
|
resolve the friction items above — especially #1, #4, #6).
|
||||||
|
2. Wire `src/main.ts` step 8 and run the smoke tests.
|
||||||
|
3. Once integrated, do a full-game soak (the demo already proves Lane E holds
|
||||||
|
60 fps and never drifts in isolation).
|
||||||
120
docs/UPDATE_B.md
Normal file
120
docs/UPDATE_B.md
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# 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/`,
|
||||||
|
`src/main.ts`, or any other lane's directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
First-person controller with Minecraft feel **and** the signature record-riding
|
||||||
|
mechanic is done. `npm run typecheck` is clean, the standalone demo runs with no
|
||||||
|
console errors, and all 10 brief acceptance criteria are verified by driving the
|
||||||
|
physics in a browser (not just compiling). I then ran a 20-agent adversarial
|
||||||
|
review, which confirmed 6 issues (from 15 raised); I fixed 3 in code and
|
||||||
|
documented 2 as core-contract friction for the integrator. Re-verified after the
|
||||||
|
fixes — no regressions.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| [src/player/PlayerController.ts](../src/player/PlayerController.ts) | The controller. Implements `IPlayerView`. |
|
||||||
|
| [src/player/collision.ts](../src/player/collision.ts) | Swept per-axis AABB-vs-voxel resolution + auto-step. |
|
||||||
|
| [src/player/input.ts](../src/player/input.ts) | Scriptable `InputState` + pointer-lock `InputController`. |
|
||||||
|
| [src/player/index.ts](../src/player/index.ts) | Public surface (what integration imports). |
|
||||||
|
| [src/player/HANDOFF.md](../src/player/HANDOFF.md) | API + tuning + friction detail. |
|
||||||
|
| [src/demo/playerDemo.ts](../src/demo/playerDemo.ts) + [demo-player.html](../demo-player.html) | Standalone demo (all mocks marked `// DEMO MOCK`). |
|
||||||
|
|
||||||
|
**Public API** (per the brief, exactly):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const player = new PlayerController(world /* IVoxelWorld */, {
|
||||||
|
getColliders: () => machines.flatMap(m => m.getColliders()),
|
||||||
|
camera, domElement, spawn: [x, y, z],
|
||||||
|
});
|
||||||
|
player.update(FIXED_DT); // call each fixed tick AFTER machines.update(dt)
|
||||||
|
player.teleport(v); player.setFlying(b);
|
||||||
|
// IPlayerView for Lanes D/E: position (feet), eye, lookDir, onGround, groundedOn
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance — verified live
|
||||||
|
|
||||||
|
Driven headlessly through a debug handle in the demo (deterministic `update()`
|
||||||
|
stepping), so these are measured, not eyeballed:
|
||||||
|
|
||||||
|
| Criterion | Measured result |
|
||||||
|
|---|---|
|
||||||
|
| Walk / sprint / jump / fly | ✅ all correct |
|
||||||
|
| Auto-step 1-voxel ledge | ✅ climbs staircase to y=6 |
|
||||||
|
| 2-voxel wall NOT stepped | ✅ blocked at x=29.7, never climbed |
|
||||||
|
| No tunneling (400× sprint-jump into wall) | ✅ never crosses x=30 |
|
||||||
|
| Disc ride = clean circle, no rim drift | ✅ radius holds at 20.00 over 2s |
|
||||||
|
| Near-center calm / rpm45 rim > sprint | ✅ carry 0.42 vs 11.31 (sprint=5.6) |
|
||||||
|
| Fling on jump off rim | ✅ ~10.2 v/s, airborne |
|
||||||
|
| Oscillating AABB platform, no fall-through | ✅ groundedOn=platform, y=1.0 |
|
||||||
|
| `player:step` / `player:landed` fire | ✅ 6 steps over 10.5 voxels / impact 26.5 |
|
||||||
|
| `npm run typecheck` clean, no console errors | ✅ |
|
||||||
|
|
||||||
|
## Adversarial review outcome (self-run, 20 agents, 6/15 confirmed)
|
||||||
|
|
||||||
|
**Fixed in code (re-verified, no regressions):**
|
||||||
|
|
||||||
|
1. **Overlapping colliders double-applied carry** *(medium)* — `resolveColliders`
|
||||||
|
applied surface carry per-collider, so two overlapping rideable colliders
|
||||||
|
(e.g. a future fader sled crossing a platter rim) would translate the player
|
||||||
|
by the *sum* and report only the last one via `groundedOn`/`carryVelocity`.
|
||||||
|
Fix: ground on exactly one collider (highest supporting top), apply carry once.
|
||||||
|
2. **Keyboard input wasn't gated by pointer-lock** *(medium)* — WASD/F drove the
|
||||||
|
character while unlocked (only mouse-look was gated). Fix: gate key handlers on
|
||||||
|
lock state, mirroring mouse-look. **Integrator note:** in-game, movement now
|
||||||
|
requires clicking to lock first (standard FPS behavior).
|
||||||
|
3. **Footsteps sampled a single center column** *(low)* — at a bridge/ledge edge
|
||||||
|
the center column can be air while a neighbor supports the foot, silently
|
||||||
|
dropping the event. Fix: sample the full footprint (same cells grounding uses).
|
||||||
|
Verified: walking a bridge edge now fires footsteps where it fired none before.
|
||||||
|
|
||||||
|
**Documented as core-contract friction (needs an integrator/core decision — I did
|
||||||
|
not edit `src/core/`):**
|
||||||
|
|
||||||
|
4. **`KinematicCollider.velocityAt(x,y,z): Vec3` allocates per sample** *(low)* —
|
||||||
|
while riding I sample it up to 8×/frame (~480 arrays/sec), brushing CONTRACTS
|
||||||
|
§8. Clean fix is a core out-param overload `velocityAt(x, y, z, out?: Vec3)`.
|
||||||
|
5. **Vertical platform carry is intentionally dropped** — fine for v1 gear (+Y
|
||||||
|
platters, horizontal faders/tonearm). Only matters if Lane D ships a
|
||||||
|
vertically-moving rideable collider; then `applyCarry` needs a Y sweep.
|
||||||
|
|
||||||
|
*(9 findings were refuted on verification — e.g. "getters leak live arrays" and
|
||||||
|
"held-jump re-emits landing"; details in the review, both judged non-issues.)*
|
||||||
|
|
||||||
|
## Integration notes for `main.ts` (per docs/INTEGRATION.md step 6)
|
||||||
|
|
||||||
|
- Construct **after** Lane A's `world` and Lane D's machines exist; pass
|
||||||
|
`getColliders: () => machines.flatMap(m => m.getColliders())`.
|
||||||
|
- Fixed loop order: `machines.update(dt)` **then** `player.update(dt)` — the
|
||||||
|
player reads the *current* collider positions/velocities that tick.
|
||||||
|
- The controller owns the camera's transform + FOV each tick; don't also move it.
|
||||||
|
- Local feel constants (accel/friction/step/bob) live at the top of
|
||||||
|
`PlayerController.ts`, not in core, because `PLAYER` has no such fields. If you
|
||||||
|
want them centralized, promote them to a **new** non-core config file.
|
||||||
|
|
||||||
|
## Suggested next steps (for Fable to weigh)
|
||||||
|
|
||||||
|
- **Contract tweak decision:** approve the `velocityAt` out-param overload above?
|
||||||
|
It's a one-line core change that removes the only hot-path allocation. If yes,
|
||||||
|
I'll wire Lane B to the buffered form once core changes.
|
||||||
|
- **Cross-lane:** Lane D should confirm cylinder colliders are +Y-axis only and
|
||||||
|
expose platter/fader/tonearm colliders with `velocityAt` in voxels/sec — Lane B
|
||||||
|
already rides anything matching `KinematicCollider`.
|
||||||
|
- Optional polish I left out of scope (not in brief): crouch, coyote-time jump,
|
||||||
|
ladder/vent climbing for the speaker-mesh (DESIGN §4.2) — flag if wanted.
|
||||||
|
|
||||||
|
## Repo / git
|
||||||
|
|
||||||
|
This working copy is **not yet a git repo** (`git status` fails). I did not run
|
||||||
|
`git init`, branch, or push to `ssh://git@100.71.119.27:222/monster/TURNCRAFT.git`
|
||||||
|
— initializing the shared repo and landing five lanes is an integration decision,
|
||||||
|
not Lane B's to make unilaterally. Everything above is on disk under
|
||||||
|
`/Users/jing/TURNCRAFT`, ready to commit on the Lane B branch when you say go.
|
||||||
105
docs/briefs/LANE_A_ENGINE.md
Normal file
105
docs/briefs/LANE_A_ENGINE.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# LANE A — Voxel Engine & Rendering
|
||||||
|
|
||||||
|
**Read first:** `docs/DESIGN.md`, `docs/CONTRACTS.md`, everything in `src/core/`.
|
||||||
|
**You own:** `src/engine/**`, `src/demo/engineDemo.ts`, `demo-engine.html`.
|
||||||
|
**You may not touch anything else.**
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Build the voxel core: chunked block storage, greedy meshing with a procedural
|
||||||
|
texture atlas and per-vertex AO, and the Three.js renderer/scene/lighting rig.
|
||||||
|
Everything the player sees that is made of blocks goes through you. Target:
|
||||||
|
the full 448×160×256 world renders at 60 fps on a mid-range laptop.
|
||||||
|
|
||||||
|
## Provides (consumed by other lanes)
|
||||||
|
|
||||||
|
- `VoxelWorld` — implements `IVoxelWorld` from `src/core/types.ts`
|
||||||
|
- `createRenderer(container: HTMLElement)` → `{ renderer, scene, camera,
|
||||||
|
setEmissiveBoost(v: number), render() }` — the one place Three.js is set up
|
||||||
|
- `ChunkManager` — builds/updates chunk meshes from a `VoxelWorld`, adds them
|
||||||
|
to the scene, and remeshes dirty chunks with a per-frame budget
|
||||||
|
- `buildAtlas()` — the procedural texture atlas (CanvasTexture)
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### A1. VoxelWorld storage
|
||||||
|
- One `Uint8Array` per 32³ chunk, allocated lazily (empty chunks read as AIR).
|
||||||
|
- `getBlock`: out of bounds → AIR for y ≥ 0, a solid sentinel below y < 0
|
||||||
|
(per the `IVoxelWorld` doc comment). `setBlock`: ignore out-of-bounds.
|
||||||
|
- `setBlock` marks the containing chunk dirty, plus each adjacent chunk when
|
||||||
|
the voxel lies on a shared face (otherwise cross-chunk faces go stale).
|
||||||
|
- Expose `forEachDirtyChunk(cb)` / `clearDirty(cx,cy,cz)` for the ChunkManager.
|
||||||
|
- Bulk-fill fast path: `fillBox(x0,y0,z0,x1,y1,z1,id)` (worldgen will paint
|
||||||
|
millions of voxels; per-voxel setBlock with dirty bookkeeping is too slow —
|
||||||
|
fillBox writes raw and dirties each touched chunk once).
|
||||||
|
|
||||||
|
### A2. Procedural texture atlas
|
||||||
|
- One canvas atlas of 16×16 px tiles, one tile per block id (index = id),
|
||||||
|
built from `BLOCKS` in `src/core/blocks.ts`: paint each tile with its
|
||||||
|
`pattern` + `tint`. Implement all patterns in the `Pattern` union:
|
||||||
|
`solid` (±6% value noise), `brushed` (fine horizontal streaks),
|
||||||
|
`plywood` (wavy grain lines, slightly darker for `ply_edge` with laminate
|
||||||
|
stripes), `speckle`, `grooves` (fine concentric-ish dark lines — straight
|
||||||
|
parallel lines are fine at tile scale), `pcb` (traces + pads), `mesh`
|
||||||
|
(dark hole grid), `glass` (faint diagonal streaks, low alpha), `led`
|
||||||
|
(bright core, radial falloff), `felt` (soft high-frequency noise).
|
||||||
|
- `NearestFilter`, no mips (or `NearestMipmapLinear` with generated mips if
|
||||||
|
you handle bleeding via 1px tile padding — your call, document it).
|
||||||
|
- Two materials: opaque (alphaTest off) and transparent pass (for `glass`,
|
||||||
|
`vinyl_blue`) — both from the same atlas. Emissive: vertex-color or a
|
||||||
|
second emissive atlas so `emissive > 0` blocks glow; expose
|
||||||
|
`setEmissiveBoost(v)` multiplying emissive intensity (Lane E pulses it to
|
||||||
|
the beat; default 1.0).
|
||||||
|
|
||||||
|
### A3. Greedy mesher
|
||||||
|
- Per chunk: greedy meshing over the 6 face directions, merging coplanar
|
||||||
|
faces with identical (blockId, AO tuple). Neighbor lookups read through
|
||||||
|
`VoxelWorld` so chunk borders cull correctly.
|
||||||
|
- Transparent blocks: don't occlude opaque neighbors; mesh them into the
|
||||||
|
separate transparent geometry, don't merge across different transparent ids.
|
||||||
|
- Per-vertex AO: classic 3-neighbor corner darkening (the Minecraft 0–3
|
||||||
|
levels), baked into vertex colors. Flip quad triangulation on the AO
|
||||||
|
anisotropy case.
|
||||||
|
- Output non-indexed or indexed BufferGeometry — your call — with position,
|
||||||
|
uv (atlas tile + repeat handled by writing uvs per merged quad using a
|
||||||
|
tiny shader patch or by splitting quads; greedy + atlas needs one of:
|
||||||
|
(a) uv wrap via shader `fract`, or (b) cap merge length and tile uvs.
|
||||||
|
Choose (a): patch `onBeforeCompile` to fract the uv within the tile.
|
||||||
|
Document the approach in code).
|
||||||
|
|
||||||
|
### A4. ChunkManager
|
||||||
|
- Initial full-world mesh build must complete < 3 s (parallelize nothing —
|
||||||
|
just be efficient; greedy over 560 mostly-empty chunks is fast).
|
||||||
|
- Per-frame remesh budget (e.g. 4 chunks/frame) draining the dirty set;
|
||||||
|
listens to nothing — poll `forEachDirtyChunk` from its `update()`.
|
||||||
|
- Frustum culling is Three.js default per chunk mesh; nothing fancier needed.
|
||||||
|
|
||||||
|
### A5. Renderer rig
|
||||||
|
- `createRenderer(container)`: WebGLRenderer (antialias off, pixelRatio
|
||||||
|
capped at 2), sRGB output, ACES tone mapping, `PerspectiveCamera` 75°.
|
||||||
|
- Lighting per DESIGN §7: warm key `DirectionalLight`, cool ambient/hemi
|
||||||
|
fill, fog colored warm plywood-brown, background near-black warm.
|
||||||
|
- No OrbitControls in the delivered API (demo may use one).
|
||||||
|
|
||||||
|
## Demo (`demo-engine.html`)
|
||||||
|
|
||||||
|
Standalone scene: build a `VoxelWorld`, fill a test pattern that exercises
|
||||||
|
everything — a 60×60 plywood floor, stepped mesas of every block id in a
|
||||||
|
labeled row (one pillar per id), a glass arch, an LED wall, a hollowed cave —
|
||||||
|
OrbitControls to fly around, on-screen text: draw calls, triangles, remesh
|
||||||
|
time. A button "torture" edits 500 random blocks/sec to prove dirty remeshing
|
||||||
|
keeps 60 fps. A slider drives `setEmissiveBoost` 0→3.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] Full-size empty-ish world + test pattern ≥ 60 fps, < 300 draw calls
|
||||||
|
- [ ] AO visibly darkens inner corners; no cracks/z-fighting at chunk borders
|
||||||
|
- [ ] Transparent blue vinyl & glass render correctly against opaque blocks
|
||||||
|
- [ ] Torture button: no frame drops below ~55 fps, edits appear within 2 frames
|
||||||
|
- [ ] All 10 patterns visually distinct at `demo-engine.html`
|
||||||
|
- [ ] `npm run typecheck` clean; `HANDOFF.md` written
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Player, physics, worldgen content, machines, audio, UI. No web workers in v1
|
||||||
|
(note in HANDOFF if meshing budget suggests they're needed later).
|
||||||
94
docs/briefs/LANE_B_PLAYER.md
Normal file
94
docs/briefs/LANE_B_PLAYER.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# LANE B — Player Controller & Physics
|
||||||
|
|
||||||
|
**Read first:** `docs/DESIGN.md`, `docs/CONTRACTS.md`, everything in `src/core/`.
|
||||||
|
**You own:** `src/player/**`, `src/demo/playerDemo.ts`, `demo-player.html`.
|
||||||
|
**You may not touch anything else.**
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
First-person character controller with Minecraft feel: AABB-vs-voxel collision,
|
||||||
|
gravity/jump, sprint, pointer-lock mouse look, fly mode — plus the project's
|
||||||
|
signature requirement: **riding kinematic platforms** (spinning platters,
|
||||||
|
sliding fader sleds, the swinging tonearm) supplied as `KinematicCollider`s.
|
||||||
|
|
||||||
|
## Provides
|
||||||
|
|
||||||
|
- `PlayerController` — implements `IPlayerView` (`src/core/types.ts`).
|
||||||
|
Constructor: `(world: IVoxelWorld, opts: { getColliders(): KinematicCollider[],
|
||||||
|
camera: PerspectiveCamera, domElement: HTMLElement, spawn: Vec3 })`.
|
||||||
|
Methods: `update(dt)` (fixed step), `teleport(v: Vec3)`, `setFlying(b)`.
|
||||||
|
|
||||||
|
## Consumes
|
||||||
|
|
||||||
|
`IVoxelWorld` (interface only), `KinematicCollider` list via the injected
|
||||||
|
getter (Lane D implements the real ones; you mock in your demo), constants
|
||||||
|
from `src/core/constants.ts` (`PLAYER`, `GRAVITY`, `FIXED_DT`).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### B1. Input
|
||||||
|
- Pointer lock on click; WASD + Space (jump) + Shift (sprint) + F (toggle
|
||||||
|
fly; in fly, Space/Ctrl = up/down, no gravity). Mouse look with pitch clamp.
|
||||||
|
- Input state object separated from physics so the demo can script inputs.
|
||||||
|
|
||||||
|
### B2. Voxel collision
|
||||||
|
- Player is an AABB (`PLAYER.width/height`), moved with swept per-axis
|
||||||
|
resolution (move X, resolve; Y; Z — Minecraft order) sampling
|
||||||
|
`world.isSolid` over the overlapped voxel range. No tunneling at sprint +
|
||||||
|
platform speeds combined (clamp per-step displacement to < 0.5 voxel per
|
||||||
|
axis by substepping inside `update` when needed).
|
||||||
|
- Step-assist: auto-step up 1-voxel ledges when grounded and moving into them
|
||||||
|
(makes knob forests and cable bridges traversable without jump spam).
|
||||||
|
- Track `onGround`, emit `player:landed { impactSpeed }` on landing and
|
||||||
|
`player:step { block }` every ~1.8 voxels of grounded travel (Lane E plays
|
||||||
|
footsteps off this; read the block under the feet via `world.getBlock`).
|
||||||
|
|
||||||
|
### B3. Kinematic platforms (the signature feature)
|
||||||
|
- Each tick, after voxel resolution, test the player AABB against every
|
||||||
|
`KinematicCollider`:
|
||||||
|
- `aabb` shape: standard AABB resolution, pushing the player out along the
|
||||||
|
minimum axis; if it pushes from below→up, treat as ground.
|
||||||
|
- `cylinder` shape (axis +Y): resolve top-surface standing (player bottom
|
||||||
|
within a small epsilon above `center.y + halfHeight` and horizontal
|
||||||
|
distance < radius → grounded on it) and side push-out (horizontal radial).
|
||||||
|
- When grounded on a kinematic collider: set `groundedOn` to its id and add
|
||||||
|
`collider.velocityAt(playerPos)` to the player's displacement this tick
|
||||||
|
(position-level carry, not velocity accumulation — jumping off should
|
||||||
|
inherit the carry velocity at the moment of leaving, so the record-edge
|
||||||
|
launch works: cache last carry velocity and add it to the jump).
|
||||||
|
- Standing on a spinning cylinder must be rock-solid: no drift toward the rim
|
||||||
|
at 2 game-RPM near the center, believable slide/fling at 45-speed rim
|
||||||
|
(it's fine if this emerges naturally from carry velocity + inertia; verify
|
||||||
|
it feels right rather than simulating friction properly).
|
||||||
|
|
||||||
|
### B4. Feel tuning
|
||||||
|
- Air control ~30% of ground accel; ground friction snappy (Minecraft-like).
|
||||||
|
Use the constants; if a constant feels wrong, tune locally with a
|
||||||
|
clearly-marked multiplier and flag it in HANDOFF (don't edit core).
|
||||||
|
- Camera: subtle view-bob when walking (toggleable), FOV +5° while sprinting.
|
||||||
|
|
||||||
|
## Demo (`demo-player.html`)
|
||||||
|
|
||||||
|
All mocks in your demo file, marked `// DEMO MOCK`:
|
||||||
|
- `MockWorld implements IVoxelWorld`: 96×96 floor, a staircase of 1-voxel
|
||||||
|
steps, a 2-voxel wall (must NOT auto-step), a narrow 2-wide bridge, a pit.
|
||||||
|
- Two mock `KinematicCollider`s: a spinning cylinder (r=41, using
|
||||||
|
`PLATTER.rpm33` / a button to switch to `rpm45`) rendered as a visible disc
|
||||||
|
mesh you rotate in the demo loop, and a slow horizontal oscillating AABB
|
||||||
|
platform. Buttons: toggle spin speed, teleport-to-disc, toggle fly.
|
||||||
|
- HUD text: position, onGround, groundedOn, current carry velocity.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] Walk/sprint/jump/fly all correct; 1-voxel auto-step works, 2-voxel blocked
|
||||||
|
- [ ] Standing anywhere on the spinning disc carries you in a clean circle;
|
||||||
|
near-center is calm; rim at rpm45 outruns sprint and flings on jump
|
||||||
|
- [ ] Riding the oscillating AABB platform: zero jitter, no falling through
|
||||||
|
- [ ] No tunneling: sprint-jump at the wall/bridge/pit 20 times, never clip
|
||||||
|
- [ ] `player:step` / `player:landed` fire correctly (log to console in demo)
|
||||||
|
- [ ] `npm run typecheck` clean; `HANDOFF.md` written
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Raycasting/block interaction (Lane D), rendering the real world (Lane A),
|
||||||
|
sounds (Lane E), mobile/touch input.
|
||||||
160
docs/briefs/LANE_C_WORLDGEN.md
Normal file
160
docs/briefs/LANE_C_WORLDGEN.md
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
# LANE C — World Builder (the Booth)
|
||||||
|
|
||||||
|
**Read first:** `docs/DESIGN.md` (especially §3–§4), `docs/CONTRACTS.md`,
|
||||||
|
everything in `src/core/`.
|
||||||
|
**You own:** `src/worldgen/**`, `src/demo/worldgenDemo.ts`, `demo-worldgen.html`.
|
||||||
|
**You may not touch anything else.**
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Author the entire diorama in voxels: `buildBooth(world: IVoxelWorld): void`
|
||||||
|
writes every static block of the DJ booth — plywood shell, tabletop, two
|
||||||
|
turntable plinths, the mixer massif, cable canyon, patch-bay wall, and the
|
||||||
|
PCB Depths under the table. This is the level-design lane: you are building a
|
||||||
|
*place*, and the inspiration photo (plywood console, silver 1200s, black
|
||||||
|
4-channel mixer, blue records, wall patch bay, visible hinges) is your mood
|
||||||
|
board. Deterministic output: same world every run (seedable PRNG, fixed seed).
|
||||||
|
|
||||||
|
**What you do NOT build:** the platter, record disc, tonearm, fader caps/sleds,
|
||||||
|
and buttons — those are moving machines (Lane D). You build the *sockets* they
|
||||||
|
sit in: the platter well (a recessed circular pit around each spindle), the
|
||||||
|
tonearm base mount, empty fader slots, button recesses. Use `LAYOUT` from
|
||||||
|
constants for every position Lane D also needs.
|
||||||
|
|
||||||
|
## Provides
|
||||||
|
|
||||||
|
- `buildBooth(world: IVoxelWorld): void` — main entry, composed of exported
|
||||||
|
sub-builders so they're individually testable: `buildShell`, `buildDeck(A|B)`,
|
||||||
|
`buildMixer`, `buildCableCanyon`, `buildPatchBay`, `buildUnderTable`.
|
||||||
|
- `SPAWN: Vec3` — export the player spawn (on Deck A's plinth top, near the
|
||||||
|
platter well, facing the mixer).
|
||||||
|
|
||||||
|
## Consumes
|
||||||
|
|
||||||
|
`IVoxelWorld` interface only. Prefer `fillBox` when available (Lane A adds it;
|
||||||
|
code against the interface plus an optional duck-typed
|
||||||
|
`(world as any).fillBox?.(…) ?? loop` fallback so your demo's simple mock
|
||||||
|
still works).
|
||||||
|
|
||||||
|
## Tasks & structure specs
|
||||||
|
|
||||||
|
Work top-down; keep each builder a pure function of (world, LAYOUT, prng).
|
||||||
|
A tiny toolkit first (`src/worldgen/tools.ts`): `box`, `hollowBox`,
|
||||||
|
`cylinderY` (disc/annulus fill), `line3` (thick voxel line), `spline`
|
||||||
|
(Catmull-Rom sampled → spheres stamped along it, for cables), `mulberry32`
|
||||||
|
PRNG.
|
||||||
|
|
||||||
|
### C1. Shell & tabletop
|
||||||
|
- Plywood floor at world bottom, walls (rimThickness) up to
|
||||||
|
`Y_BOOTH_WALL_TOP`, back wall at `backWallZ`. `ply_edge` striping on every
|
||||||
|
exposed wall top/cut edge (that laminate look). Two chrome `screw`+
|
||||||
|
`brushed_alu` hinge plates on the back wall like the photo.
|
||||||
|
- Tabletop slab at `Y_TABLETOP` spanning inside the rim, with: a rectangular
|
||||||
|
vent slot behind each deck (the way down to PCB Depths), and a 6×6 open
|
||||||
|
hatch near the front-left with a plywood step-stair down into the crate.
|
||||||
|
|
||||||
|
### C2. Decks A & B (steel-grey mesas)
|
||||||
|
- Plinth: filled `steel_grey` box from tabletop to `Y_PLINTH_TOP` over the
|
||||||
|
LAYOUT footprint, 2-voxel `rubber` feet at corners, a `brushed_alu` top
|
||||||
|
face border. Rounded-ish corners (chamfer 2–3 voxels).
|
||||||
|
- **Platter well**: recessed circular pit centered on the spindle, radius
|
||||||
|
`PLATTER.radius + 2`, depth 6 below plinth top — leave it EMPTY (Lane D's
|
||||||
|
rotating platter fills it). Chrome 1-voxel spindle stub at center bottom.
|
||||||
|
- Deck-top furniture (all voxel, non-moving): start/stop button recess
|
||||||
|
(4×4×1 pit, front-left), two small round 33/45 button recesses beside it,
|
||||||
|
**pitch fader canyon** — an 18-long, 3-wide, 4-deep slot on the right side
|
||||||
|
with `led_green` center-detent dot at its midpoint, tonearm base mount —
|
||||||
|
a 10×10 `brushed_alu` raised pedestal at back-right with a 3-voxel chrome
|
||||||
|
post stub, and a `strobe_dot` + `led_red` strobe lamp pillar at front-left
|
||||||
|
corner (the classic Technics pop-up light).
|
||||||
|
- Deck B only: drifts of `dust` blocks banked on top and around; its glass
|
||||||
|
dust cover half-open — a `glass` slab canopy over the rear half at
|
||||||
|
y ≈ `Y_PLINTH_TOP`+18, hinged at the back wall side, forming the crystal
|
||||||
|
cave (2-voxel-thick, walkable on top).
|
||||||
|
|
||||||
|
### C3. Mixer Massif
|
||||||
|
- `matte_black` body over LAYOUT.mixer footprint, tabletop → `topY`+40
|
||||||
|
(i.e. face plate at y=67... use `LAYOUT.mixer.topY`); `speaker_mesh`
|
||||||
|
side-vent ladders (climbable = 1-voxel ledges every 2), `brushed_alu`
|
||||||
|
face plate border.
|
||||||
|
- Face plate furniture: 4 channel strips, each with a 3×3 grid of knob *stems*
|
||||||
|
(1-voxel `knob_black` stubs — caps are Lane D machines) and a **fader
|
||||||
|
alley**: 14×3 slot, 5 deep. Front edge: the **crossfader tram** — a
|
||||||
|
40-long, 4-wide, 6-deep slot running left–right; fill its center with a
|
||||||
|
plug of 12 `dust` blocks (the quest jam). Back edge: two **VU towers** —
|
||||||
|
12-tall columns, alternating `led_green`×8 / `led_amber`×3 / `led_red`×1,
|
||||||
|
plus a `matte_black` casing open on the front face.
|
||||||
|
- Rear face: master **power switch recess** (Lane D machine) and a fuse-door
|
||||||
|
slot leading down a chimney into the PCB Depths fuse room.
|
||||||
|
|
||||||
|
### C4. Cable Canyon (z 140–200)
|
||||||
|
- 4–6 cables, each a 2×2 `cable_black` tube along a drooping Catmull-Rom
|
||||||
|
spline from gear rear panels (RCA heights on deck/mixer backs) over the
|
||||||
|
canyon floor to the patch-bay wall; two of them sheathed `cable_red` /
|
||||||
|
`cable_white` near the ends with `rca_gold` plug heads (3×3×4).
|
||||||
|
- One cable is THE quest cable: it ends 8 voxels short of the patch bay, its
|
||||||
|
gold plug resting on the canyon floor, aligned with an empty socket.
|
||||||
|
- Canyon floor: tabletop plywood with dust drifts, a few vinyl slab
|
||||||
|
off-cuts leaning on walls (walkable ramps), power-strip block (matte_black
|
||||||
|
box with `led_amber` pilot light) feeding cables.
|
||||||
|
|
||||||
|
### C5. Patch Bay (back wall)
|
||||||
|
- Over `LAYOUT.patchBay`: a `brushed_alu` panel proud of the wall by 2, with
|
||||||
|
a 2×6 grid of round `rca_gold` sockets (3-voxel-diameter rings, 2 deep);
|
||||||
|
most plugged with cable-colored plugs, one conspicuously EMPTY with an
|
||||||
|
`led_red` blink block above it (the quest socket — Lane D puts the
|
||||||
|
interactive socket machine here; you build the hole).
|
||||||
|
- Two sockets are real doorways: 3-deep tunnels opening into a small room
|
||||||
|
*inside the wall* with copper busbars and a `led_blue` glow — a secret.
|
||||||
|
|
||||||
|
### C6. PCB Depths & the crate (under-table, y 0–40)
|
||||||
|
- Under each deck and the mixer: rooms with `pcb_green` floors, `copper`
|
||||||
|
trace paths inlaid (1-wide runs connecting solder-blob clusters), solder
|
||||||
|
stalagmites, capacitor pillars (cylinders: `matte_black` body, `brushed_alu`
|
||||||
|
top with cross-scored cap), resistor beams bridging gaps. LED sprinkles
|
||||||
|
(`led_green/amber`, sparse, per DESIGN's "dim until repaired" — just place
|
||||||
|
them; Lane E handles dimming).
|
||||||
|
- **Fuse room** under the mixer: fuse box on the wall — a `brushed_alu` frame
|
||||||
|
holding one blackened fuse (use `rubber` block flanked by `chrome` caps to
|
||||||
|
read as "blown"), and a parts bin nearby: an open plywood tray holding a
|
||||||
|
fresh fuse (`glass` block with `chrome` caps), plus the **stylus block**
|
||||||
|
(single `chrome` block on a felt pad, spotlit by one `led_blue`) — the two
|
||||||
|
quest pickups. Coordinate: place pickups exactly at positions exported as
|
||||||
|
`QUEST_POS` (export a const with stylus/fuse/plug/socket world coords so
|
||||||
|
Lane D reads positions from you at integration — put it in
|
||||||
|
`src/worldgen/questPositions.ts`, derived from LAYOUT, no magic numbers
|
||||||
|
elsewhere).
|
||||||
|
- **Record crate** (front-left, under the hatch): plywood cavern holding 8–10
|
||||||
|
giant vinyl slabs (2-thick discs on edge, alternating `vinyl_black` /
|
||||||
|
`vinyl_blue` with `label_cream` centers), gaps walkable, one leaning slab
|
||||||
|
as a ramp back up.
|
||||||
|
|
||||||
|
### C7. Set dressing pass
|
||||||
|
- The booth should feel *used*: dust in corners, one screw block half-out,
|
||||||
|
scattered `label_cream` sticker blocks on the shell, cable stubs. Restrained
|
||||||
|
— this is a tidy booth, per the photo.
|
||||||
|
|
||||||
|
## Demo (`demo-worldgen.html`)
|
||||||
|
|
||||||
|
Build the full booth into a simple array-backed mock world, render with a
|
||||||
|
naive `InstancedMesh`-per-block-id visualizer (flat colors from `blocks.ts`
|
||||||
|
tints — fine at this scale), OrbitControls, per-zone teleport buttons, and a
|
||||||
|
stats line: total non-air voxels, per-id counts, build time (< 500 ms).
|
||||||
|
Validation asserts (console.error on fail): platter wells empty, spawn stands
|
||||||
|
on solid, quest positions non-air-adjacent-reachable (has an adjacent air
|
||||||
|
voxel), world edges sealed (no non-air outside bounds… i.e. no writes OOB).
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] `buildBooth` writes only through `IVoxelWorld`, deterministic across runs
|
||||||
|
- [ ] Every zone from DESIGN §4 exists and is visually recognizable in the demo
|
||||||
|
- [ ] All LAYOUT-adjacent geometry derives from `constants.ts` (grep-proof: no
|
||||||
|
duplicated literal for spindle/mixer coords)
|
||||||
|
- [ ] Platter wells, fader slots, button recesses left empty for Lane D
|
||||||
|
- [ ] `questPositions.ts` exports all quest coords; validation asserts pass
|
||||||
|
- [ ] Build < 500 ms; `npm run typecheck` clean; `HANDOFF.md` written
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Anything that moves (Lane D), real meshing (Lane A), lighting/audio (E),
|
||||||
|
player (B).
|
||||||
137
docs/briefs/LANE_D_MACHINES.md
Normal file
137
docs/briefs/LANE_D_MACHINES.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# LANE D — Machines, Interaction & Quest
|
||||||
|
|
||||||
|
**Read first:** `docs/DESIGN.md` (§5–§6), `docs/CONTRACTS.md`, everything in
|
||||||
|
`src/core/`.
|
||||||
|
**You own:** `src/machines/**`, `src/interact/**`, `src/demo/machinesDemo.ts`,
|
||||||
|
`demo-machines.html`.
|
||||||
|
**You may not touch anything else.**
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Everything that moves or responds: the rotating platters + records, the
|
||||||
|
tonearm, faders, buttons — all as `IMachine`s with `KinematicCollider`s —
|
||||||
|
plus the interaction layer (raycast, break/place, hotbar model) and the
|
||||||
|
"Bring Back the Mix" quest state machine.
|
||||||
|
|
||||||
|
## Provides
|
||||||
|
|
||||||
|
- `createMachines(world: IVoxelWorld): MachineSet` where `MachineSet` has
|
||||||
|
`machines: IMachine[]`, `getColliders(): KinematicCollider[]` (flattened,
|
||||||
|
for Lane B), `update(dt)`, `getObject3Ds(): unknown[]`.
|
||||||
|
- `Interaction` — constructor `(world, player: IPlayerView, machines,
|
||||||
|
hotbar)`: raycast + break/place + use-key dispatch.
|
||||||
|
- `Hotbar` — 9-slot inventory model (counts per BlockId, active slot). Pure
|
||||||
|
model + events; Lane E renders it.
|
||||||
|
- `Quest` — the signal-chain state machine.
|
||||||
|
|
||||||
|
## Consumes
|
||||||
|
|
||||||
|
`IVoxelWorld`, `IPlayerView` (interfaces), `bus`, constants (`LAYOUT`,
|
||||||
|
`PLATTER`, `PLAYER.reach`, `SIGNAL_NODES`). At integration you'll also read
|
||||||
|
`QUEST_POS` from `src/worldgen/questPositions.ts` — code against a
|
||||||
|
`QuestPositions` shape you define locally and have the demo supply mock
|
||||||
|
coords; take real positions as a constructor arg, don't import Lane C.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### D1. Machine framework
|
||||||
|
- `IMachine` base helper: owns a THREE.Group, colliders, interact zones.
|
||||||
|
Machines are built from simple Three.js primitives (cylinders, boxes) with
|
||||||
|
MeshStandardMaterial matching block tints from `blocks.ts` (visual
|
||||||
|
consistency with the voxel world; grab tints via `blockDef`).
|
||||||
|
|
||||||
|
### D2. Platter machine (×2, the star)
|
||||||
|
- Visual: layered cylinders at the spindle from `LAYOUT` — platter
|
||||||
|
(`PLATTER.radius`, brushed dark alu, strobe-dot ring: small emissive red
|
||||||
|
studs around the rim), slipmat, record (`recordRadius`, translucent blue
|
||||||
|
on deck A / black on deck B, cream label with a procedurally-drawn canvas
|
||||||
|
label texture — have fun: fake catalog number "TC-001 · TURNCRAFT"),
|
||||||
|
chrome spindle pin. Fine groove rings via a canvas texture on the record
|
||||||
|
top face.
|
||||||
|
- Simulation: angular velocity → target RPM (`rpm33`/`rpm45` × pitch
|
||||||
|
multiplier), exponential spin-up/brake with `PLATTER.spinUpSeconds` (the
|
||||||
|
Technics lurch). Rotate the record+platter group.
|
||||||
|
- Collider: one `cylinder` `KinematicCollider` (radius, halfHeight matching
|
||||||
|
platter+record stack); `velocityAt` returns tangential velocity ω×r (zero
|
||||||
|
y). When stopped, velocity zero.
|
||||||
|
- Emits `platter:state` on any play/speed change.
|
||||||
|
|
||||||
|
### D3. Tonearm machine (×2)
|
||||||
|
- Visual: chrome tube from the pedestal (LAYOUT-derived) arcing over the
|
||||||
|
record, counterweight, headshell. Deck A's headshell has an EMPTY stylus
|
||||||
|
socket until the quest fixes it (then a chrome stylus appears).
|
||||||
|
- Behavior: rest position off-disc; when deck playing *and* stylus repaired,
|
||||||
|
swings to outer groove then tracks slowly inward (one full traversal ≈ 4
|
||||||
|
min, then auto-return). The whole arm is a slow kinematic `aabb` collider
|
||||||
|
chain (2–3 segments is enough); the headshell is the "needle plow" — its
|
||||||
|
collider pushes the player along.
|
||||||
|
- Interact on headshell with stylus block selected in hotbar → consumes it,
|
||||||
|
repairs `stylus` node.
|
||||||
|
|
||||||
|
### D4. Fader & button machines
|
||||||
|
- **Pitch fader** (per deck): sled (fader_cap visual) sliding in Lane C's
|
||||||
|
canyon slot; player pushes it by walking into it (kinematic aabb that
|
||||||
|
yields — clamp to slot, 11 detents); position → pitch multiplier 0.5–1.5,
|
||||||
|
emits `fader:move`.
|
||||||
|
- **Channel faders ×4 + crossfader**: same sled mechanic on the mixer face.
|
||||||
|
Crossfader starts blocked by Lane C's dust plug — the machine checks the
|
||||||
|
slot voxels are clear before it can move; when the player mines the dust
|
||||||
|
(normal block breaking), first successful full slide repairs `crossfader`.
|
||||||
|
- **Buttons**: start/stop plate (E or walk-press: interact toggles deck),
|
||||||
|
33/45 rockers, cue lamp, **master power switch** (rear of mixer; inert
|
||||||
|
until other 4 nodes repaired — then glows via a swapped emissive material
|
||||||
|
and one press triggers the finale). All emit `machine:interact`.
|
||||||
|
|
||||||
|
### D5. Interaction layer
|
||||||
|
- **Raycast**: voxel DDA from eye along lookDir up to `PLAYER.reach` (Amanatides
|
||||||
|
& Woo), tested against machine colliders analytically (ray-cylinder,
|
||||||
|
ray-aabb); nearest hit wins → `RayHit`.
|
||||||
|
- **Break** (LMB hold): breakable check via `blockDef(id).breakable`, break
|
||||||
|
time ~0.4 s with a crack overlay hook (emit progress on bus? keep simple:
|
||||||
|
Lane E just needs `block:break`), adds block to hotbar, `world.setBlock(AIR)`,
|
||||||
|
emit `block:break`.
|
||||||
|
- **Place** (RMB): active hotbar block onto hit face, blocked if it overlaps
|
||||||
|
the player AABB or a machine collider, emit `block:place`.
|
||||||
|
- **Use** (E): machine hit → `onInteract()`; block hit → quest checks (fuse
|
||||||
|
socket, RCA plug, patch-bay socket).
|
||||||
|
- Selection wireframe box on the aimed block (thin line box, Lane A-style
|
||||||
|
visuals not required — a THREE.LineSegments you own).
|
||||||
|
|
||||||
|
### D6. Quest state machine
|
||||||
|
- Tracks `SIGNAL_NODES` repair state; constructor takes quest positions.
|
||||||
|
- Node fix logic: `stylus` (D3), `crossfader` (D4), `rca` — the loose gold
|
||||||
|
plug is a small machine; interacting shoves it 1 voxel toward the socket
|
||||||
|
(3 shoves, clunk-clunk-click) then repairs; `fuse` — interact fuse box with
|
||||||
|
fresh-fuse block in hotbar (picked up by breaking the parts-bin fuse) after
|
||||||
|
breaking the blown one; `power` — D4's switch.
|
||||||
|
- Emits `signal:repair` with running count; when count == total, `game:win`,
|
||||||
|
and: both platters auto-start, 45-mode unlocked. Quest state queryable
|
||||||
|
(`isRepaired(node)`) for Lane E.
|
||||||
|
|
||||||
|
## Demo (`demo-machines.html`)
|
||||||
|
|
||||||
|
A mock flat-world stage (simple `IVoxelWorld` mock + naive box renderer or
|
||||||
|
just a ground plane — your call) with: both platters spinning (buttons for
|
||||||
|
33/45/stop), tonearm tracking a spinning record, all faders operable via
|
||||||
|
click-drag AND a scripted "player AABB" you can drive with arrow keys to
|
||||||
|
shove sleds and press plates (no Lane B import — a 20-line mock body),
|
||||||
|
quest panel showing node states with buttons to simulate each repair path,
|
||||||
|
raycast tester: click to break/place blocks on a small mock wall. Console
|
||||||
|
logs every bus event.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] Platters: correct spin-up/brake feel; `velocityAt` returns ω×r
|
||||||
|
tangential values (unit-test in demo: log at r=10 & r=41 vs expected)
|
||||||
|
- [ ] Tonearm tracks inward over minutes; headshell collider moves with it
|
||||||
|
- [ ] All faders/buttons operable; crossfader gated on clear slot voxels
|
||||||
|
- [ ] DDA raycast: no misses/tunneling at reach 5; machine hits beat farther blocks
|
||||||
|
- [ ] Full quest completable in demo via the real logic (mock positions)
|
||||||
|
- [ ] `game:win` fires exactly once; platters auto-start on win
|
||||||
|
- [ ] `npm run typecheck` clean; `HANDOFF.md` written
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Player physics (B — you only consume `IPlayerView` + provide colliders),
|
||||||
|
voxel meshing (A), booth geometry (C), all audio/UI rendering (E — you emit
|
||||||
|
events; hotbar is a model only).
|
||||||
124
docs/briefs/LANE_E_AUDIO_FX.md
Normal file
124
docs/briefs/LANE_E_AUDIO_FX.md
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
# LANE E — Audio, FX & UI
|
||||||
|
|
||||||
|
**Read first:** `docs/DESIGN.md` (§5–§7), `docs/CONTRACTS.md`, everything in
|
||||||
|
`src/core/`.
|
||||||
|
**You own:** `src/audio/**`, `src/fx/**`, `src/ui/**`, `src/demo/audioDemo.ts`,
|
||||||
|
`demo-audio.html`.
|
||||||
|
**You may not touch anything else.**
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Make the booth alive: a fully synthesized house groove in stems that unmute as
|
||||||
|
the quest progresses, positional audio from the decks, beat events driving
|
||||||
|
LED pulses/VU meters/particles, all diegetic SFX, and the HUD. "Sound is the
|
||||||
|
win state" — you own the payoff of the whole game.
|
||||||
|
|
||||||
|
## Provides
|
||||||
|
|
||||||
|
- `AudioEngine` — WebAudio graph, `init()` on first user gesture,
|
||||||
|
`setStemCount(n)` (0–5 stems audible), `setChannelGain(ch, v)` (live fader
|
||||||
|
ducking), `setPlaybackState(deck, playing, rpm)` (pitch-bend spin-up/brake),
|
||||||
|
`playSfx(name, at?: Vec3)`, positional listener sync `updateListener(view:
|
||||||
|
IPlayerView)`.
|
||||||
|
- `FxSystem` — `update(dt)`, needs `{ scene, setEmissiveBoost }` injected
|
||||||
|
(Lane A's hook), owns particles + LED pulse logic + VU animation hooks.
|
||||||
|
- `Hud` — DOM overlay: crosshair, hotbar (renders Lane D's hotbar model via
|
||||||
|
events/injected getter), quest tracker (5 nodes), subtitles line ("SIGNAL
|
||||||
|
RESTORED: CROSSFADER — 3/5"), win screen, start/pause overlay (pointer-lock
|
||||||
|
gate + "click to drop the needle" splash).
|
||||||
|
|
||||||
|
## Consumes
|
||||||
|
|
||||||
|
`bus` events (`signal:repair`, `game:win`, `platter:state`, `fader:move`,
|
||||||
|
`audio:*` are yours to emit, `block:break/place`, `player:step/landed`,
|
||||||
|
`machine:interact`), `IPlayerView` interface, `blockDef().sound` categories.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### E1. The groove (all synthesized, no samples)
|
||||||
|
- 118 BPM, 8-bar loop, five stems built from oscillators/noise through a
|
||||||
|
shared WebAudio clock (lookahead scheduler, ~25 ms tick — do not use
|
||||||
|
`setInterval` naively for note timing; schedule on `AudioContext.currentTime`):
|
||||||
|
1. **drums** — kick (sine drop + click), hats (filtered noise, offbeat),
|
||||||
|
clap on 2/4
|
||||||
|
2. **bass** — detuned saw through lowpass, one-bar riff, sidechain-ducked
|
||||||
|
from the kick (a simple gain envelope is fine)
|
||||||
|
3. **chords** — filtered saw stabs, minor 7th vamp, ping-pong-ish delay
|
||||||
|
4. **lead** — square/sine hook, sparse (every 4th bar), long release
|
||||||
|
5. **sweeps** — noise riser + downlifter each 8 bars, vinyl crackle bed
|
||||||
|
- Stems map to quest progress (`setStemCount`): silence → drums → +bass →
|
||||||
|
+chords → +lead → +sweeps/full. Keep musical when partial.
|
||||||
|
- Vinyl-truth: master through a gentle highshelf + crackle when playing off
|
||||||
|
the blue record; `setPlaybackState` bends a master playbackRate-equivalent
|
||||||
|
(detune all stems' pitch AND tempo via a global rate param — simplest:
|
||||||
|
scale scheduler tempo and per-voice detune together) for spin-up/brake, and
|
||||||
|
pitch-fader moves detune ±.
|
||||||
|
- Positional: stems output into two `PannerNode`s at the deck spindle world
|
||||||
|
positions (constants LAYOUT), plus a dry low bed so it never fully
|
||||||
|
disappears; `updateListener` follows the player eye/orientation.
|
||||||
|
|
||||||
|
### E2. Analysis & beat events
|
||||||
|
- Drive `audio:beat { energy }` from the scheduler itself (you know when the
|
||||||
|
kick fires — no FFT needed; energy = current kick gain), `audio:bar`.
|
||||||
|
Additionally an `AnalyserNode` on master for VU levels (expose
|
||||||
|
`getLevels(): { low, mid, high }` for FX).
|
||||||
|
|
||||||
|
### E3. SFX (synthesized, short)
|
||||||
|
- Footsteps by `blockDef.sound` category (metal/wood/plastic/soft/glass —
|
||||||
|
filtered noise bursts with different bodies), land thump scaled by
|
||||||
|
impactSpeed, block break/place clicks per category, fader zip, button
|
||||||
|
clunk, RCA "clunk-clunk-CLICK" (rising), fuse zap, tonearm cue lever creak,
|
||||||
|
win: needle-drop crackle → the full mix slams in (brief lowpass sweep open).
|
||||||
|
|
||||||
|
### E4. FX systems
|
||||||
|
- **LED pulse**: on `audio:beat`, pulse Lane A's `setEmissiveBoost` (1.0 →
|
||||||
|
~1.8 decay ~150 ms) — but only after ≥1 repair (dead booth = dim: start
|
||||||
|
boost at 0.35, step toward 1.0 per repair).
|
||||||
|
- **VU towers**: Lane C built LED columns at known LAYOUT-derived positions;
|
||||||
|
animate them by swapping emissive boost per-level is Lane-A-internal, so
|
||||||
|
instead: overlay thin emissive sprite quads you own, positioned over the
|
||||||
|
tower faces, height-keyed to `getLevels()`. Same trick for the patch-bay
|
||||||
|
blink and signal-path trace on repairs (a moving bright sprite running the
|
||||||
|
cartridge→tonearm→cable→mixer path — hardcode the polyline from LAYOUT).
|
||||||
|
- **Particles**: drifting dust motes in light shafts (a few hundred point
|
||||||
|
sprites, gentle brownian), block-break puff at break position, record-rim
|
||||||
|
sparkle when a platter spins at 45.
|
||||||
|
- **Win sequence**: on `game:win` — 2 s: kill boost, silence… then needle
|
||||||
|
drop, full mix, boost pulse ×2 for 8 bars, every LED sprite strobing to
|
||||||
|
levels, confetti of tiny vinyl-black quads over the decks. Then settle to
|
||||||
|
living-booth steady state.
|
||||||
|
|
||||||
|
### E5. HUD/UI
|
||||||
|
- DOM (not canvas): crosshair; hotbar strip (9 slots, block tint swatches +
|
||||||
|
counts, active ring); quest tracker top-right (5 icons, lit as repaired);
|
||||||
|
event subtitle line; start overlay (title "TURNCRAFT", "click to drop the
|
||||||
|
needle") gating AudioContext + pointer lock; pause on lock-loss; win
|
||||||
|
banner. Styling: dark, minimal, DJM-font-ish (system mono is fine),
|
||||||
|
amber/green LED accent colors from `blocks.ts` tints.
|
||||||
|
|
||||||
|
## Demo (`demo-audio.html`)
|
||||||
|
|
||||||
|
No other lanes: a mock stage (dark plane, two glowing discs at deck
|
||||||
|
positions, a camera you orbit) + a control panel: stem count slider 0–5,
|
||||||
|
play/stop per deck with spin-up bend, pitch slider, channel-fader sliders
|
||||||
|
(live ducking), every SFX as a button, beat-pulse visual (the discs flash via
|
||||||
|
the same code path as `setEmissiveBoost` — inject a mock), quest simulator
|
||||||
|
buttons firing `signal:repair` 1→5 then `game:win` to preview the entire
|
||||||
|
audio-visual arc, HUD rendered live with a mock hotbar.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] Groove runs indefinitely with zero timing drift (scheduler, not
|
||||||
|
setInterval); stems musically stack 0→5
|
||||||
|
- [ ] Spin-up/brake audibly bends tempo+pitch together; pitch fader detunes
|
||||||
|
- [ ] Walking between decks audibly pans/attenuates (test with camera move)
|
||||||
|
- [ ] Every SFX distinct and non-clipping; footsteps vary by surface category
|
||||||
|
- [ ] Beat pulse, VU sprites, dust motes, break puffs all run at 60 fps
|
||||||
|
- [ ] Full win sequence previews correctly from the quest simulator
|
||||||
|
- [ ] No audio starts before user gesture; `npm run typecheck` clean; `HANDOFF.md`
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Gameplay logic (D), world/meshes (A/C), player (B). You react to events and
|
||||||
|
render overlays/sprites you own — you never `setBlock` and never mutate
|
||||||
|
another lane's materials beyond the provided `setEmissiveBoost` hook.
|
||||||
16
index.html
Normal file
16
index.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TURNCRAFT</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #0a0a0c; }
|
||||||
|
#app { width: 100%; height: 100%; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1225
package-lock.json
generated
Normal file
1225
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "turncraft",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.170.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/three": "^0.170.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
359
src/audio/AudioEngine.ts
Normal file
359
src/audio/AudioEngine.ts
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
// TURNCRAFT — Lane E. The audio engine: WebAudio graph, the synthesized stem
|
||||||
|
// mix, positional sound from the two decks, all diegetic SFX, beat/bar events,
|
||||||
|
// and the win audio timeline. Nothing sounds until init() runs on a user
|
||||||
|
// gesture (autoplay policy). Self-wires to the event bus so the integrator just
|
||||||
|
// constructs it; the public methods exist for the demo and direct control.
|
||||||
|
//
|
||||||
|
// music path: stems -> vinylShelf -> musicGain -> masterLP -> {pannerA,
|
||||||
|
// pannerB, dryBed, analyser} -> outGain -> destination
|
||||||
|
// sfx path: oneshot -> [panner(at)] -> sfxGain -> outGain -> destination
|
||||||
|
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import { LAYOUT, PLATTER, Y_RECORD_TOP } from '../core/constants';
|
||||||
|
import { blockDef, type BlockDef } from '../core/blocks';
|
||||||
|
import type { IPlayerView, Vec3 } from '../core/types';
|
||||||
|
import { Groove } from './groove';
|
||||||
|
import { Transport } from './scheduler';
|
||||||
|
import { createNoiseBuffer, createCrackleBuffer } from './synth';
|
||||||
|
import * as SFX from './sfx';
|
||||||
|
|
||||||
|
type Deck = 'A' | 'B';
|
||||||
|
type SoundCat = BlockDef['sound'];
|
||||||
|
|
||||||
|
interface DeckState { playing: boolean; rpm: number; }
|
||||||
|
|
||||||
|
export class AudioEngine {
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
private groove!: Groove;
|
||||||
|
private transport!: Transport;
|
||||||
|
private noise!: AudioBuffer;
|
||||||
|
|
||||||
|
// graph nodes
|
||||||
|
private outGain!: GainNode;
|
||||||
|
private musicGain!: GainNode;
|
||||||
|
private masterLP!: BiquadFilterNode;
|
||||||
|
private sfxGain!: GainNode;
|
||||||
|
private crackleGain!: GainNode;
|
||||||
|
private crackleSrc: AudioBufferSourceNode | null = null;
|
||||||
|
private panner: Record<Deck, PannerNode> = {} as any;
|
||||||
|
private panGate: Record<Deck, GainNode> = {} as any;
|
||||||
|
private analyser!: AnalyserNode;
|
||||||
|
private freqData!: Uint8Array<ArrayBuffer>;
|
||||||
|
|
||||||
|
// desired state (kept even before init so events pre-gesture aren't lost)
|
||||||
|
private deck: Record<Deck, DeckState> = { A: { playing: false, rpm: PLATTER.rpm33 }, B: { playing: false, rpm: PLATTER.rpm33 } };
|
||||||
|
private stemCount = 0;
|
||||||
|
private channel: number[] = [1, 1, 1, 1, 1];
|
||||||
|
private pitchTrim = 0;
|
||||||
|
private lastStepCat: SoundCat = 'wood';
|
||||||
|
private won = false;
|
||||||
|
|
||||||
|
private readonly levels = { low: 0, mid: 0, high: 0 };
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.wireBus();
|
||||||
|
}
|
||||||
|
|
||||||
|
isReady(): boolean { return this.ctx !== null; }
|
||||||
|
|
||||||
|
/** Build the graph and start the clock. Call from a user gesture. */
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this.ctx) { if (this.ctx.state === 'suspended') await this.ctx.resume(); return; }
|
||||||
|
const AC: typeof AudioContext = window.AudioContext ?? (window as any).webkitAudioContext;
|
||||||
|
const ctx = new AC();
|
||||||
|
this.ctx = ctx;
|
||||||
|
if (ctx.state === 'suspended') await ctx.resume();
|
||||||
|
|
||||||
|
this.noise = createNoiseBuffer(ctx, 2);
|
||||||
|
|
||||||
|
// ── output + master ──
|
||||||
|
this.outGain = ctx.createGain();
|
||||||
|
this.outGain.gain.value = 0.85;
|
||||||
|
this.outGain.connect(ctx.destination);
|
||||||
|
|
||||||
|
this.sfxGain = ctx.createGain();
|
||||||
|
this.sfxGain.gain.value = 0.9;
|
||||||
|
this.sfxGain.connect(this.outGain);
|
||||||
|
|
||||||
|
this.masterLP = ctx.createBiquadFilter();
|
||||||
|
this.masterLP.type = 'lowpass';
|
||||||
|
this.masterLP.frequency.value = 20000; // transparent until the win sweep
|
||||||
|
|
||||||
|
this.musicGain = ctx.createGain();
|
||||||
|
this.musicGain.gain.value = 1;
|
||||||
|
this.musicGain.connect(this.masterLP);
|
||||||
|
|
||||||
|
const vinylShelf = ctx.createBiquadFilter();
|
||||||
|
vinylShelf.type = 'highshelf';
|
||||||
|
vinylShelf.frequency.value = 3500;
|
||||||
|
vinylShelf.gain.value = 2.5; // gentle vinyl sizzle
|
||||||
|
vinylShelf.connect(this.musicGain);
|
||||||
|
|
||||||
|
// stem mix bus feeds the vinyl shelf
|
||||||
|
const mixBus = ctx.createGain();
|
||||||
|
mixBus.gain.value = 0.9;
|
||||||
|
mixBus.connect(vinylShelf);
|
||||||
|
this.groove = new Groove(ctx, this.noise, mixBus);
|
||||||
|
|
||||||
|
// crackle bed (post-shelf, into the music path)
|
||||||
|
this.crackleGain = ctx.createGain();
|
||||||
|
this.crackleGain.gain.value = 0;
|
||||||
|
this.crackleGain.connect(this.musicGain);
|
||||||
|
const crackleBuf = createCrackleBuffer(ctx, 4);
|
||||||
|
const csrc = ctx.createBufferSource();
|
||||||
|
csrc.buffer = crackleBuf; csrc.loop = true;
|
||||||
|
csrc.connect(this.crackleGain); csrc.start();
|
||||||
|
this.crackleSrc = csrc;
|
||||||
|
|
||||||
|
// analyser tap (VU levels)
|
||||||
|
this.analyser = ctx.createAnalyser();
|
||||||
|
this.analyser.fftSize = 256;
|
||||||
|
this.analyser.smoothingTimeConstant = 0.7;
|
||||||
|
this.freqData = new Uint8Array(this.analyser.frequencyBinCount);
|
||||||
|
this.masterLP.connect(this.analyser);
|
||||||
|
|
||||||
|
// dry low bed so bass never fully disappears at distance
|
||||||
|
const dryLP = ctx.createBiquadFilter();
|
||||||
|
dryLP.type = 'lowpass'; dryLP.frequency.value = 380;
|
||||||
|
const dryGain = ctx.createGain(); dryGain.gain.value = 0.5;
|
||||||
|
this.masterLP.connect(dryLP); dryLP.connect(dryGain); dryGain.connect(this.outGain);
|
||||||
|
|
||||||
|
// positional taps at each deck spindle
|
||||||
|
for (const d of ['A', 'B'] as Deck[]) {
|
||||||
|
const lay = d === 'A' ? LAYOUT.deckA : LAYOUT.deckB;
|
||||||
|
const p = ctx.createPanner();
|
||||||
|
p.panningModel = 'equalpower';
|
||||||
|
p.distanceModel = 'inverse';
|
||||||
|
p.refDistance = 22; p.maxDistance = 500; p.rolloffFactor = 1.1;
|
||||||
|
this.setPos(p, lay.spindleX, Y_RECORD_TOP, lay.spindleZ);
|
||||||
|
const gate = ctx.createGain();
|
||||||
|
gate.gain.value = 0; // faded in when that deck is playing
|
||||||
|
this.masterLP.connect(gate); gate.connect(p); p.connect(this.outGain);
|
||||||
|
this.panner[d] = p; this.panGate[d] = gate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── transport ──
|
||||||
|
this.transport = new Transport(
|
||||||
|
ctx,
|
||||||
|
(s, t, det, emit) => this.groove.scheduleStep(s, t, det, emit),
|
||||||
|
PLATTER.spinUpSeconds,
|
||||||
|
);
|
||||||
|
this.transport.start();
|
||||||
|
|
||||||
|
// apply any state that arrived before init
|
||||||
|
this.groove.setStemCount(this.stemCount);
|
||||||
|
for (let i = 0; i < this.channel.length; i++) this.groove.setChannelGain(i, this.channel[i]);
|
||||||
|
this.transport.setPitchTrim(this.pitchTrim);
|
||||||
|
this.applyPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
setStemCount(n: number): void {
|
||||||
|
this.stemCount = Math.max(0, Math.min(5, Math.round(n)));
|
||||||
|
if (this.ctx) { this.groove.setStemCount(this.stemCount); this.updateCrackle(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
setChannelGain(ch: number, v: number): void {
|
||||||
|
if (ch < 0 || ch >= this.channel.length) return;
|
||||||
|
this.channel[ch] = v;
|
||||||
|
if (this.ctx) this.groove.setChannelGain(ch, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pitch fader in [-1,1] -> ± ~2 semitones of detune (tempo unchanged). */
|
||||||
|
setPitch(norm: number): void {
|
||||||
|
this.pitchTrim = Math.max(-1, Math.min(1, norm)) * 200;
|
||||||
|
if (this.ctx) this.transport.setPitchTrim(this.pitchTrim);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlaybackState(deck: Deck, playing: boolean, rpm: number): void {
|
||||||
|
this.deck[deck] = { playing, rpm: rpm > 0 ? rpm : PLATTER.rpm33 };
|
||||||
|
if (this.ctx) this.applyPlayback();
|
||||||
|
}
|
||||||
|
|
||||||
|
playSfx(name: SFX.SfxName, at?: Vec3, arg?: number | SoundCat): void {
|
||||||
|
if (!this.ctx) return;
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const t = ctx.currentTime + 0.001;
|
||||||
|
const dest = at ? this.spatialDest(at) : this.sfxGain;
|
||||||
|
switch (name) {
|
||||||
|
case 'footstep': SFX.footstep(ctx, dest, this.noise, t, (arg as SoundCat) ?? this.lastStepCat); break;
|
||||||
|
case 'land': SFX.land(ctx, dest, this.noise, t, typeof arg === 'number' ? arg : 6, this.lastStepCat); break;
|
||||||
|
case 'break': SFX.blockHit(ctx, dest, this.noise, t, (arg as SoundCat) ?? 'plastic', 'break'); break;
|
||||||
|
case 'place': SFX.blockHit(ctx, dest, this.noise, t, (arg as SoundCat) ?? 'plastic', 'place'); break;
|
||||||
|
case 'fader': SFX.faderZip(ctx, dest, this.noise, t); break;
|
||||||
|
case 'button': SFX.buttonClunk(ctx, dest, t); break;
|
||||||
|
case 'rca': SFX.rcaClick(ctx, dest, this.noise, t); break;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync the WebAudio listener to the player each frame. */
|
||||||
|
updateListener(view: IPlayerView): void {
|
||||||
|
if (!this.ctx) return;
|
||||||
|
const L = this.ctx.listener;
|
||||||
|
const [ex, ey, ez] = view.eye;
|
||||||
|
const [fx, fy, fz] = view.lookDir;
|
||||||
|
if ('positionX' in L) {
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
L.positionX.setValueAtTime(ex, now);
|
||||||
|
L.positionY.setValueAtTime(ey, now);
|
||||||
|
L.positionZ.setValueAtTime(ez, now);
|
||||||
|
L.forwardX.setValueAtTime(fx, now);
|
||||||
|
L.forwardY.setValueAtTime(fy, now);
|
||||||
|
L.forwardZ.setValueAtTime(fz, now);
|
||||||
|
L.upX.setValueAtTime(0, now);
|
||||||
|
L.upY.setValueAtTime(1, now);
|
||||||
|
L.upZ.setValueAtTime(0, now);
|
||||||
|
} else {
|
||||||
|
// deprecated fallback for older Safari
|
||||||
|
(L as any).setPosition(ex, ey, ez);
|
||||||
|
(L as any).setOrientation(fx, fy, fz, 0, 1, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized low/mid/high band energies for the VU meters. */
|
||||||
|
getLevels(): { low: number; mid: number; high: number } {
|
||||||
|
if (!this.ctx) { this.levels.low = this.levels.mid = this.levels.high = 0; return this.levels; }
|
||||||
|
this.analyser.getByteFrequencyData(this.freqData);
|
||||||
|
const n = this.freqData.length;
|
||||||
|
const a = Math.floor(n * 0.12), b = Math.floor(n * 0.45);
|
||||||
|
let lo = 0, mi = 0, hi = 0;
|
||||||
|
for (let i = 0; i < a; i++) lo += this.freqData[i];
|
||||||
|
for (let i = a; i < b; i++) mi += this.freqData[i];
|
||||||
|
for (let i = b; i < n; i++) hi += this.freqData[i];
|
||||||
|
this.levels.low = lo / (a * 255) || 0;
|
||||||
|
this.levels.mid = mi / ((b - a) * 255) || 0;
|
||||||
|
this.levels.high = hi / ((n - b) * 255) || 0;
|
||||||
|
return this.levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private applyPlayback(): void {
|
||||||
|
// transport is driven by whichever deck is spinning (A has priority)
|
||||||
|
const A = this.deck.A, B = this.deck.B;
|
||||||
|
const src = A.playing ? A : B.playing ? B : null;
|
||||||
|
this.transport.setRateTarget(src ? src.rpm / PLATTER.rpm33 : 0);
|
||||||
|
const now = this.ctx!.currentTime;
|
||||||
|
this.fadeGate(this.panGate.A, A.playing ? 1 : 0, now);
|
||||||
|
this.fadeGate(this.panGate.B, B.playing ? 1 : 0, now);
|
||||||
|
this.updateCrackle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCrackle(): void {
|
||||||
|
if (!this.ctx) return;
|
||||||
|
const moving = this.deck.A.playing || this.deck.B.playing;
|
||||||
|
const target = moving ? (0.1 + (this.stemCount >= 5 ? 0.12 : 0)) : 0;
|
||||||
|
const g = this.crackleGain.gain, now = this.ctx.currentTime;
|
||||||
|
g.cancelScheduledValues(now); g.setValueAtTime(g.value, now);
|
||||||
|
g.linearRampToValueAtTime(target, now + 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fadeGate(gate: GainNode, target: number, now: number): void {
|
||||||
|
const g = gate.gain;
|
||||||
|
g.cancelScheduledValues(now); g.setValueAtTime(g.value, now);
|
||||||
|
g.linearRampToValueAtTime(target, now + 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPos(p: PannerNode, x: number, y: number, z: number): void {
|
||||||
|
if ('positionX' in p) { p.positionX.value = x; p.positionY.value = y; p.positionZ.value = z; }
|
||||||
|
else (p as any).setPosition(x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short-lived panner at world `at` for a positional one-shot. */
|
||||||
|
private spatialDest(at: Vec3): AudioNode {
|
||||||
|
const ctx = this.ctx!;
|
||||||
|
const p = ctx.createPanner();
|
||||||
|
p.panningModel = 'equalpower';
|
||||||
|
p.distanceModel = 'inverse';
|
||||||
|
p.refDistance = 6; p.maxDistance = 200; p.rolloffFactor = 1.4;
|
||||||
|
this.setPos(p, at[0], at[1], at[2]);
|
||||||
|
p.connect(this.sfxGain);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── win audio timeline (fully scheduled on the ctx clock, no timers) ──
|
||||||
|
private runWinAudio(): void {
|
||||||
|
if (!this.ctx || this.won) return; // idempotent: a second game:win is ignored
|
||||||
|
this.won = true;
|
||||||
|
const ctx = this.ctx, now = ctx.currentTime;
|
||||||
|
// both decks alive, full mix armed
|
||||||
|
this.deck.A.playing = true; this.deck.B.playing = true;
|
||||||
|
this.setStemCount(5);
|
||||||
|
this.fadeGate(this.panGate.A, 1, now);
|
||||||
|
this.fadeGate(this.panGate.B, 1, now);
|
||||||
|
|
||||||
|
// 1) duck to silence
|
||||||
|
const mg = this.musicGain.gain;
|
||||||
|
mg.cancelScheduledValues(now);
|
||||||
|
mg.setValueAtTime(mg.value, now);
|
||||||
|
mg.linearRampToValueAtTime(0.0001, now + 0.18);
|
||||||
|
|
||||||
|
// 2) at +1.8s: snap platters to speed, needle drop, mix slams in with a
|
||||||
|
// lowpass that sweeps open
|
||||||
|
const drop = now + 1.8;
|
||||||
|
this.transport.setRateImmediate(1);
|
||||||
|
SFX.needleDrop(ctx, this.sfxGain, this.noise, drop);
|
||||||
|
mg.setValueAtTime(0.0001, drop);
|
||||||
|
mg.linearRampToValueAtTime(1, drop + 0.35);
|
||||||
|
const lp = this.masterLP.frequency;
|
||||||
|
lp.cancelScheduledValues(drop);
|
||||||
|
lp.setValueAtTime(300, drop);
|
||||||
|
lp.exponentialRampToValueAtTime(20000, drop + 1.6);
|
||||||
|
this.updateCrackle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── event bus wiring ──────────────────────────────────────────────────────
|
||||||
|
private wireBus(): void {
|
||||||
|
bus.on('signal:repair', (p) => {
|
||||||
|
this.setStemCount(p.repaired);
|
||||||
|
const sfx: Record<string, () => void> = {
|
||||||
|
stylus: () => this.playSfx('place', undefined, 'metal'),
|
||||||
|
rca: () => this.playSfx('rca'),
|
||||||
|
crossfader: () => this.playSfx('break', undefined, 'soft'),
|
||||||
|
fuse: () => this.playSfx('fuse'),
|
||||||
|
power: () => this.playSfx('button'),
|
||||||
|
};
|
||||||
|
sfx[p.node]?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on('game:win', () => this.runWinAudio());
|
||||||
|
|
||||||
|
bus.on('platter:state', (p) => {
|
||||||
|
this.setPlaybackState(p.deck, p.playing, p.rpm);
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on('fader:move', (p) => {
|
||||||
|
const id = p.faderId.toLowerCase();
|
||||||
|
if (id.includes('pitch')) { this.setPitch(p.value * 2 - 1); return; }
|
||||||
|
const m = id.match(/(\d)/);
|
||||||
|
if (m) this.setChannelGain(Math.max(0, Math.min(4, parseInt(m[1], 10) - 1)), p.value);
|
||||||
|
this.playSfx('fader');
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on('block:break', (p) => {
|
||||||
|
this.playSfx('break', [p.x + 0.5, p.y + 0.5, p.z + 0.5], blockDef(p.id).sound);
|
||||||
|
});
|
||||||
|
bus.on('block:place', (p) => {
|
||||||
|
this.playSfx('place', [p.x + 0.5, p.y + 0.5, p.z + 0.5], blockDef(p.id).sound);
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on('player:step', (p) => {
|
||||||
|
this.lastStepCat = blockDef(p.block).sound;
|
||||||
|
this.playSfx('footstep', undefined, this.lastStepCat);
|
||||||
|
});
|
||||||
|
bus.on('player:landed', (p) => {
|
||||||
|
this.playSfx('land', undefined, p.impactSpeed);
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on('machine:interact', (p) => {
|
||||||
|
const a = p.action.toLowerCase();
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/audio/HANDOFF.md
Normal file
73
src/audio/HANDOFF.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Lane E — HANDOFF (Audio / FX / UI)
|
||||||
|
|
||||||
|
Lane E owns `src/audio/**`, `src/fx/**`, `src/ui/**`, `src/demo/audioDemo.ts`,
|
||||||
|
`demo-audio.html`. This is the primary handoff; see also `src/fx/HANDOFF.md`
|
||||||
|
and `src/ui/HANDOFF.md`. **Everything is done and typechecks clean; the demo
|
||||||
|
runs with zero console errors and previews the full audio-visual arc.**
|
||||||
|
|
||||||
|
## Public API surface (what the integrator constructs)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const audio = new AudioEngine(); // self-wires to the bus
|
||||||
|
await audio.init(); // MUST be a user gesture (autoplay)
|
||||||
|
audio.updateListener(playerView); // call per frame
|
||||||
|
audio.getLevels(); // { low, mid, high } for FX VU
|
||||||
|
// also: setStemCount, setChannelGain, setPitch, setPlaybackState, playSfx
|
||||||
|
|
||||||
|
const fx = new FxSystem({ scene, setEmissiveBoost, getLevels: () => audio.getLevels() });
|
||||||
|
fx.update(dt); // call each fixed tick
|
||||||
|
|
||||||
|
const hud = new Hud({ onStart, onResume, getHotbar }); // onStart(once)=>audio.init()+lock; onResume=>re-lock only
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's done
|
||||||
|
|
||||||
|
- **Groove** (`groove.ts` + `synth.ts` + `scheduler.ts`): fully synthesized
|
||||||
|
118 BPM, 8-bar F-minor deep-house loop in 5 stems (drums/bass/chords/lead/
|
||||||
|
sweeps), scheduled on a drift-free lookahead transport (25 ms tick, notes on
|
||||||
|
`AudioContext.currentTime`). `setStemCount(0..5)` stacks them musically;
|
||||||
|
muted stems cost nothing (scheduling is gated). Sidechain-ducked bass.
|
||||||
|
- **Spin-up/brake**: `setPlaybackState(deck, playing, rpm)` eases a `rate` that
|
||||||
|
scales tempo AND per-voice detune together (`rpm/rpm33`; 45 = +35% & sharper).
|
||||||
|
`setPitch(±1)` detunes ±2 semitones. Braked to a stop = silence.
|
||||||
|
- **Positional**: mix outputs into two PannerNodes at the deck spindles
|
||||||
|
(`LAYOUT`), plus an always-on low-passed dry bass bed. `updateListener`
|
||||||
|
follows the player eye/orientation.
|
||||||
|
- **Beat/bar events**: `audio:beat {energy}` (energy = kick gain) and
|
||||||
|
`audio:bar` are emitted from the scheduler at the *audible* time (queued and
|
||||||
|
drained on the ctx clock), so LED pulses stay in sync.
|
||||||
|
- **SFX** (`sfx.ts`): footsteps per surface category, land (impact-scaled),
|
||||||
|
break/place, fader zip, button clunk, RCA "clunk-clunk-CLICK", fuse zap,
|
||||||
|
tonearm cue creak, needle drop. Positional when a world `at` is given.
|
||||||
|
- **Win audio timeline**: on `game:win`, ducks to silence, then at +1.8 s snaps
|
||||||
|
the platters to speed, drops the needle, and slams the full mix in behind a
|
||||||
|
lowpass that sweeps open — all scheduled on the ctx clock (no timers).
|
||||||
|
- **FX & HUD**: see the two sibling HANDOFF files.
|
||||||
|
|
||||||
|
## Contract friction / assumptions the integrator should confirm
|
||||||
|
|
||||||
|
1. **`fader:move` mapping**: I map `faderId` containing `pitch` → pitch bend,
|
||||||
|
else the first digit `N` → stem channel `N-1`. If Lane D uses other
|
||||||
|
`faderId`s (`crossfader`, named channels), confirm/extend the map in
|
||||||
|
`AudioEngine.wireBus()`.
|
||||||
|
2. **Channel↔stem mapping**: 5 stems vs the mixer's 4 channels + crossfader.
|
||||||
|
`setChannelGain(ch, v)` treats `ch` as a stem index 0..4. Reconcile with
|
||||||
|
Lane D's channel model if needed.
|
||||||
|
3. **`player:landed` has no surface category** (and `player:step` no position);
|
||||||
|
I use the last stepped surface and play footsteps listener-centered. Fine
|
||||||
|
unless you want spatialized steps.
|
||||||
|
4. **`setEmissiveBoost` semantics**: I drive it as an absolute intensity —
|
||||||
|
dead-booth base `0.35`, stepping toward `1.0` over the 5 repairs, pulsing to
|
||||||
|
~`1.8` on beats. If Lane A's hook is a multiplier with a different baseline,
|
||||||
|
tune the constants in `FxSystem` (`boostBase`, `pulseTau`).
|
||||||
|
5. **VU tower positions** are derived from `LAYOUT.mixer` (no explicit VU coords
|
||||||
|
in constants). If Lane C placed physical LED-block towers elsewhere, align
|
||||||
|
`src/fx/layout.ts` `VU_TOWERS` to them.
|
||||||
|
6. **Hotbar model**: no core type exists, so `Hud` defines `HotbarModel`/
|
||||||
|
`HotbarSlot` and reads it via an injected `getHotbar()`. Adapt Lane D's
|
||||||
|
hotbar to this shape (or wrap it).
|
||||||
|
7. **`game:win` forces deck playback in audio.** Ensure Lane D also spins the
|
||||||
|
real platters visually at the finale so audio and visuals agree.
|
||||||
|
|
||||||
|
Nothing in `src/core/` was edited. No `setBlock` anywhere in Lane E. No rAF
|
||||||
|
loop outside `src/demo/`. `npm run typecheck` is clean.
|
||||||
188
src/audio/groove.ts
Normal file
188
src/audio/groove.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
// TURNCRAFT — Lane E. The groove: one endless 118 BPM, 8-bar house loop in five
|
||||||
|
// stems (drums, bass, chords, lead, sweeps). Pattern is authored on a 16-step
|
||||||
|
// per-bar grid; `scheduleStep` is called by the transport for each 16th note.
|
||||||
|
//
|
||||||
|
// Stems map to quest progress via `setStemCount` (0 silence -> 5 full mix) and
|
||||||
|
// gate scheduling so muted stems cost nothing. `setChannelGain` is live fader
|
||||||
|
// ducking. `detune` (cents) from the transport bends the whole mix for
|
||||||
|
// spin-up/brake and pitch-fader moves.
|
||||||
|
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import { mtof, kick, hat, clap, bass, stab, lead, sweep } from './synth';
|
||||||
|
|
||||||
|
export const STEP_PER_BAR = 16;
|
||||||
|
export const BARS = 8;
|
||||||
|
export const LOOP_STEPS = STEP_PER_BAR * BARS; // 128
|
||||||
|
|
||||||
|
// Stem indices (order = unmute order as the quest is repaired).
|
||||||
|
export const STEM = { DRUMS: 0, BASS: 1, CHORDS: 2, LEAD: 3, SWEEPS: 4 } as const;
|
||||||
|
export const STEM_COUNT = 5;
|
||||||
|
|
||||||
|
// F-minor deep-house vamp: i – VI – III – VII, two bars each (MIDI note sets).
|
||||||
|
const CHORDS: number[][] = [
|
||||||
|
[53, 56, 60, 63], // Fm7 (F3 Ab3 C4 Eb4)
|
||||||
|
[49, 53, 56, 60], // Dbmaj7 (Db3 F3 Ab3 C4)
|
||||||
|
[56, 60, 63, 67], // Abmaj7 (Ab3 C4 Eb4 G4)
|
||||||
|
[51, 55, 58, 61], // Eb7 (Eb3 G3 Bb3 Db4)
|
||||||
|
];
|
||||||
|
const BASS_ROOT = [41, 37, 44, 39]; // F2 Db2 Ab2 Eb2
|
||||||
|
|
||||||
|
// Per-bar step patterns.
|
||||||
|
const KICK_STEPS = new Set([0, 4, 8, 12]);
|
||||||
|
const CLAP_STEPS = new Set([4, 12]);
|
||||||
|
const HAT_ACCENT = new Set([2, 6, 10, 14]); // offbeat "open-ish" hats
|
||||||
|
const BASS_STEPS = [0, 6, 10, 14]; // rolling offbeat plucks (root)
|
||||||
|
const BASS_OCT_UP = new Set([6]); // one octave jump for movement
|
||||||
|
|
||||||
|
function chordForBar(bar: number): { chord: number[]; root: number } {
|
||||||
|
const idx = Math.floor(bar / 2) % CHORDS.length;
|
||||||
|
return { chord: CHORDS[idx], root: BASS_ROOT[idx] };
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmitAt = (t: number, fn: () => void) => void;
|
||||||
|
|
||||||
|
export class Groove {
|
||||||
|
private ctx: BaseAudioContext;
|
||||||
|
private noise: AudioBuffer;
|
||||||
|
private out: AudioNode;
|
||||||
|
|
||||||
|
// Per-stem chain: voices -> [bassDuck] -> enable -> chan -> out.
|
||||||
|
private enable: GainNode[] = [];
|
||||||
|
private chan: GainNode[] = [];
|
||||||
|
private bassDuck: GainNode;
|
||||||
|
|
||||||
|
private stemCount = 0;
|
||||||
|
private barCounter = 0;
|
||||||
|
private chanValue = [1, 1, 1, 1, 1]; // last setChannelGain per stem (for beat energy)
|
||||||
|
|
||||||
|
constructor(ctx: BaseAudioContext, noise: AudioBuffer, out: AudioNode) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.noise = noise;
|
||||||
|
this.out = out;
|
||||||
|
|
||||||
|
for (let i = 0; i < STEM_COUNT; i++) {
|
||||||
|
const enable = ctx.createGain();
|
||||||
|
enable.gain.value = 0; // all stems start muted (silent booth)
|
||||||
|
const chan = ctx.createGain();
|
||||||
|
chan.gain.value = 1;
|
||||||
|
enable.connect(chan);
|
||||||
|
chan.connect(out);
|
||||||
|
this.enable.push(enable);
|
||||||
|
this.chan.push(chan);
|
||||||
|
}
|
||||||
|
this.bassDuck = ctx.createGain();
|
||||||
|
this.bassDuck.gain.value = 1;
|
||||||
|
this.bassDuck.connect(this.enable[STEM.BASS]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destination a stem's voices should connect into for this step. */
|
||||||
|
private stemDest(stem: number): AudioNode {
|
||||||
|
return stem === STEM.BASS ? this.bassDuck : this.enable[stem];
|
||||||
|
}
|
||||||
|
|
||||||
|
setStemCount(n: number): void {
|
||||||
|
const c = Math.max(0, Math.min(STEM_COUNT, Math.round(n)));
|
||||||
|
this.stemCount = c;
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
for (let i = 0; i < STEM_COUNT; i++) {
|
||||||
|
const target = i < c ? 1 : 0;
|
||||||
|
const g = this.enable[i].gain;
|
||||||
|
g.cancelScheduledValues(now);
|
||||||
|
g.setValueAtTime(g.value, now);
|
||||||
|
g.linearRampToValueAtTime(target, now + 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStemCount(): number { return this.stemCount; }
|
||||||
|
|
||||||
|
/** Live channel/fader ducking (0..1). `ch` is a stem index (0..4). */
|
||||||
|
setChannelGain(ch: number, v: number): void {
|
||||||
|
if (ch < 0 || ch >= STEM_COUNT) return;
|
||||||
|
this.chanValue[ch] = Math.max(0, Math.min(1, v));
|
||||||
|
const g = this.chan[ch].gain;
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
g.cancelScheduledValues(now);
|
||||||
|
g.setValueAtTime(g.value, now);
|
||||||
|
g.linearRampToValueAtTime(Math.max(0, Math.min(1, v)), now + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedule every voice that fires on absolute 16th-note `absStep` at `time`. */
|
||||||
|
scheduleStep(absStep: number, time: number, detune: number, emitAt: EmitAt): void {
|
||||||
|
const local = ((absStep % LOOP_STEPS) + LOOP_STEPS) % LOOP_STEPS;
|
||||||
|
const bar = Math.floor(local / STEP_PER_BAR);
|
||||||
|
const s = local % STEP_PER_BAR;
|
||||||
|
const { chord, root } = chordForBar(bar);
|
||||||
|
const ct = this.stemCount;
|
||||||
|
|
||||||
|
// ── DRUMS (stem 0) ──
|
||||||
|
if (ct > STEM.DRUMS) {
|
||||||
|
if (KICK_STEPS.has(s)) {
|
||||||
|
kick(this.ctx, this.stemDest(STEM.DRUMS), time, { gain: 1, detune });
|
||||||
|
// sidechain the bass on every kick
|
||||||
|
const d = this.bassDuck.gain;
|
||||||
|
d.cancelScheduledValues(time);
|
||||||
|
d.setValueAtTime(0.32, time);
|
||||||
|
d.linearRampToValueAtTime(1, time + 0.14);
|
||||||
|
}
|
||||||
|
// hats: accent on offbeats, ghost on the rest
|
||||||
|
const accent = HAT_ACCENT.has(s);
|
||||||
|
hat(this.ctx, this.stemDest(STEM.DRUMS), this.noise, time, {
|
||||||
|
gain: accent ? 0.26 : 0.07, open: accent && s === 14 && bar % 2 === 1, detune,
|
||||||
|
});
|
||||||
|
if (CLAP_STEPS.has(s)) {
|
||||||
|
clap(this.ctx, this.stemDest(STEM.DRUMS), this.noise, time, { gain: 0.5, detune });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BASS (stem 1) ──
|
||||||
|
if (ct > STEM.BASS && BASS_STEPS.includes(s)) {
|
||||||
|
const midi = root + (BASS_OCT_UP.has(s) ? 12 : 0);
|
||||||
|
const dur = s === 0 ? 0.3 : 0.2;
|
||||||
|
bass(this.ctx, this.stemDest(STEM.BASS), time, mtof(midi), { gain: 0.55, detune, dur });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CHORDS (stem 2) ── offbeat stabs, syncopated by bar parity
|
||||||
|
if (ct > STEM.CHORDS) {
|
||||||
|
const full = bar % 2 === 0;
|
||||||
|
const stabHere = full ? HAT_ACCENT.has(s) : (s === 6 || s === 14);
|
||||||
|
if (stabHere) {
|
||||||
|
const freqs = chord.map((m) => mtof(m));
|
||||||
|
stab(this.ctx, this.stemDest(STEM.CHORDS), time, freqs, { gain: 0.3, detune });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LEAD (stem 3) ── sparse hook every 4th bar (bars 3 and 7)
|
||||||
|
if (ct > STEM.LEAD && (bar === 3 || bar === 7)) {
|
||||||
|
const motif = bar === 3 ? [72, 68, 65] : [73, 70, 67];
|
||||||
|
const at = bar === 3 ? [8, 11, 14] : [8, 10, 14];
|
||||||
|
const idx = at.indexOf(s);
|
||||||
|
if (idx >= 0) {
|
||||||
|
lead(this.ctx, this.stemDest(STEM.LEAD), time, mtof(motif[idx]), { gain: 0.24, detune, dur: 0.55 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SWEEPS (stem 4) ── riser into the loop, downlifter on the one
|
||||||
|
if (ct > STEM.SWEEPS) {
|
||||||
|
if (bar === 7 && s === 0) {
|
||||||
|
sweep(this.ctx, this.stemDest(STEM.SWEEPS), this.noise, time, { up: true, gain: 0.2, dur: 2.0 });
|
||||||
|
}
|
||||||
|
if (bar === 0 && s === 0) {
|
||||||
|
sweep(this.ctx, this.stemDest(STEM.SWEEPS), this.noise, time, { up: false, gain: 0.22, dur: 1.6 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Beat / bar events (only meaningful once drums are audible) ──
|
||||||
|
// energy ≈ low-band loudness of this beat: kick emphasis (downbeat louder)
|
||||||
|
// scaled by the drums channel gain, so ducking the fader drops the pulse.
|
||||||
|
if (ct > STEM.DRUMS) {
|
||||||
|
if (s % 4 === 0) {
|
||||||
|
const energy = (s === 0 ? 1.0 : 0.82) * this.chanValue[STEM.DRUMS];
|
||||||
|
emitAt(time, () => bus.emit('audio:beat', { energy }));
|
||||||
|
}
|
||||||
|
if (s === 0) {
|
||||||
|
const bn = this.barCounter++;
|
||||||
|
emitAt(time, () => bus.emit('audio:bar', { bar: bn }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/audio/scheduler.ts
Normal file
98
src/audio/scheduler.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// TURNCRAFT — Lane E. Lookahead transport (Chris Wilson "two clocks" pattern).
|
||||||
|
// A ~25 ms setInterval tick schedules 16th notes AHEAD on the drift-free
|
||||||
|
// AudioContext clock, so tempo never drifts. `rate` is the playback-speed
|
||||||
|
// multiplier that models the Technics spin-up/brake: it eases toward a target
|
||||||
|
// (torque-y), scales the note interval (tempo) AND becomes per-voice detune
|
||||||
|
// (pitch) — tempo and pitch bend together, exactly like a real platter.
|
||||||
|
|
||||||
|
export const BPM = 118;
|
||||||
|
const SEC_PER_16TH = 60 / BPM / 4; // 0.1271 s at nominal speed
|
||||||
|
const LOOKAHEAD = 0.12; // schedule this far ahead (s)
|
||||||
|
const TICK_MS = 25; // scheduler wake interval
|
||||||
|
const RATE_MIN = 0.02; // below this the platter counts as stopped
|
||||||
|
|
||||||
|
type StepFn = (absStep: number, time: number, detune: number, emitAt: EmitAt) => void;
|
||||||
|
type EmitAt = (time: number, fn: () => void) => void;
|
||||||
|
|
||||||
|
interface Pending { time: number; fn: () => void; }
|
||||||
|
|
||||||
|
export class Transport {
|
||||||
|
private ctx: BaseAudioContext;
|
||||||
|
private onStep: StepFn;
|
||||||
|
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private nextStep = 0;
|
||||||
|
private nextNoteTime = 0;
|
||||||
|
private lastTick = 0;
|
||||||
|
|
||||||
|
private rate = 0;
|
||||||
|
private rateTarget = 0;
|
||||||
|
private spinTau: number; // exponential time constant for spin-up/brake
|
||||||
|
private pitchTrim = 0; // extra detune (cents) from the pitch fader
|
||||||
|
|
||||||
|
private pending: Pending[] = [];
|
||||||
|
|
||||||
|
constructor(ctx: BaseAudioContext, onStep: StepFn, spinUpSeconds = 1.2) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.onStep = onStep;
|
||||||
|
this.spinTau = spinUpSeconds / 3; // ~settles within spinUpSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin the wake loop. Idempotent; stays cheap while the platter is stopped. */
|
||||||
|
start(): void {
|
||||||
|
if (this.timer !== null) return;
|
||||||
|
this.lastTick = this.ctx.currentTime;
|
||||||
|
this.nextNoteTime = this.ctx.currentTime + 0.06;
|
||||||
|
this.timer = setInterval(() => this.tick(), TICK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer !== null) { clearInterval(this.timer); this.timer = null; }
|
||||||
|
this.pending.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rate target: 0 = braked to a stop, 1 = nominal 33, >1 = faster (e.g. 45). */
|
||||||
|
setRateTarget(target: number): void { this.rateTarget = Math.max(0, target); }
|
||||||
|
|
||||||
|
/** Snap the rate instantly (used by the win sequence to force full speed). */
|
||||||
|
setRateImmediate(rate: number): void { this.rate = this.rateTarget = Math.max(0, rate); }
|
||||||
|
|
||||||
|
setPitchTrim(cents: number): void { this.pitchTrim = cents; }
|
||||||
|
|
||||||
|
getRate(): number { return this.rate; }
|
||||||
|
isMoving(): boolean { return this.rate > RATE_MIN; }
|
||||||
|
|
||||||
|
private emitAt: EmitAt = (time, fn) => { this.pending.push({ time, fn }); };
|
||||||
|
|
||||||
|
private tick(): void {
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
const dt = Math.max(0, now - this.lastTick);
|
||||||
|
this.lastTick = now;
|
||||||
|
|
||||||
|
// ease rate toward target (torque-y spin-up / brake)
|
||||||
|
const k = 1 - Math.exp(-dt / this.spinTau);
|
||||||
|
this.rate += (this.rateTarget - this.rate) * k;
|
||||||
|
if (this.rateTarget === 0 && this.rate < RATE_MIN) this.rate = 0;
|
||||||
|
|
||||||
|
if (this.rate > RATE_MIN) {
|
||||||
|
// if we were stopped a while, don't schedule a burst in the past
|
||||||
|
if (this.nextNoteTime < now) this.nextNoteTime = now + 0.03;
|
||||||
|
while (this.nextNoteTime < now + LOOKAHEAD) {
|
||||||
|
const detune = 1200 * Math.log2(this.rate) + this.pitchTrim;
|
||||||
|
this.onStep(this.nextStep, this.nextNoteTime, detune, this.emitAt);
|
||||||
|
this.nextNoteTime += SEC_PER_16TH / this.rate;
|
||||||
|
this.nextStep++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fire any queued beat/bar events whose audible time has arrived
|
||||||
|
if (this.pending.length) {
|
||||||
|
let i = 0;
|
||||||
|
while (i < this.pending.length && this.pending[i].time <= now) {
|
||||||
|
this.pending[i].fn();
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i > 0) this.pending.splice(0, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/audio/sfx.ts
Normal file
204
src/audio/sfx.ts
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
// TURNCRAFT — Lane E. One-shot diegetic SFX, all synthesized and short.
|
||||||
|
// Each function synthesizes into `dest` (AudioEngine routes `dest` through a
|
||||||
|
// PannerNode when a world position is supplied). None of these hold state.
|
||||||
|
|
||||||
|
import type { BlockDef } from '../core/blocks';
|
||||||
|
|
||||||
|
type SoundCat = BlockDef['sound']; // 'metal'|'wood'|'plastic'|'soft'|'glass'|'none'
|
||||||
|
|
||||||
|
/** Short filtered noise burst, body tuned per surface category. */
|
||||||
|
function noiseHit(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
o: { type: BiquadFilterType; freq: number; q: number; dur: number; gain: number },
|
||||||
|
): void {
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise;
|
||||||
|
src.loop = true;
|
||||||
|
const f = ctx.createBiquadFilter();
|
||||||
|
f.type = o.type;
|
||||||
|
f.frequency.value = o.freq;
|
||||||
|
f.Q.value = o.q;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(o.gain, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + o.dur);
|
||||||
|
src.connect(f); f.connect(g); g.connect(dest);
|
||||||
|
src.start(t);
|
||||||
|
src.stop(t + o.dur + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_BY_CAT: Record<SoundCat, { type: BiquadFilterType; freq: number; q: number; dur: number; gain: number }> = {
|
||||||
|
metal: { type: 'bandpass', freq: 3200, q: 3, dur: 0.06, gain: 0.18 },
|
||||||
|
wood: { type: 'lowpass', freq: 900, q: 1, dur: 0.07, gain: 0.22 },
|
||||||
|
plastic: { type: 'highpass', freq: 1800, q: 0.7, dur: 0.045, gain: 0.16 },
|
||||||
|
soft: { type: 'lowpass', freq: 420, q: 0.7, dur: 0.09, gain: 0.20 },
|
||||||
|
glass: { type: 'bandpass', freq: 6000, q: 6, dur: 0.05, gain: 0.14 },
|
||||||
|
none: { type: 'lowpass', freq: 700, q: 1, dur: 0.05, gain: 0.10 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function footstep(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number, cat: SoundCat,
|
||||||
|
): void {
|
||||||
|
const p = STEP_BY_CAT[cat] ?? STEP_BY_CAT.none;
|
||||||
|
noiseHit(ctx, dest, noise, t, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Land thump: low body scaled by impact speed + a surface tick. */
|
||||||
|
export function land(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
impactSpeed: number, cat: SoundCat,
|
||||||
|
): void {
|
||||||
|
const amt = Math.min(1, impactSpeed / 12);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.25 + 0.5 * amt, t + 0.005);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.12 + 0.1 * amt);
|
||||||
|
g.connect(dest);
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(120 + 60 * amt, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(50, t + 0.12);
|
||||||
|
osc.connect(g);
|
||||||
|
osc.start(t); osc.stop(t + 0.24);
|
||||||
|
const p = STEP_BY_CAT[cat] ?? STEP_BY_CAT.none;
|
||||||
|
noiseHit(ctx, dest, noise, t, { ...p, gain: p.gain * (0.6 + amt) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Break/place click: brighter for break, softer for place, category-flavoured. */
|
||||||
|
export function blockHit(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
cat: SoundCat, kind: 'break' | 'place',
|
||||||
|
): void {
|
||||||
|
const p = STEP_BY_CAT[cat] ?? STEP_BY_CAT.none;
|
||||||
|
const dur = kind === 'break' ? 0.09 : 0.05;
|
||||||
|
noiseHit(ctx, dest, noise, t, { ...p, dur, gain: p.gain * (kind === 'break' ? 1.4 : 0.9) });
|
||||||
|
if (kind === 'break') {
|
||||||
|
// little pitch-down thock so breaks feel chunkier
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.14, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.08);
|
||||||
|
g.connect(dest);
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(320, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(120, t + 0.08);
|
||||||
|
osc.connect(g); osc.start(t); osc.stop(t + 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fader zip: filtered noise sweep, quick. */
|
||||||
|
export function faderZip(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 = 4;
|
||||||
|
bp.frequency.setValueAtTime(1200, t);
|
||||||
|
bp.frequency.exponentialRampToValueAtTime(3600, t + 0.12);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.12, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.13);
|
||||||
|
src.connect(bp); bp.connect(g); g.connect(dest);
|
||||||
|
src.start(t); src.stop(t + 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Button clunk: short low thock with a click. */
|
||||||
|
export function buttonClunk(ctx: BaseAudioContext, dest: AudioNode, t: number): void {
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.3, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.09);
|
||||||
|
g.connect(dest);
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(220, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(90, t + 0.08);
|
||||||
|
osc.connect(g); osc.start(t); osc.stop(t + 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RCA plug seating: "clunk-clunk-CLICK", three rising transients. */
|
||||||
|
export function rcaClick(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
|
||||||
|
const steps = [
|
||||||
|
{ off: 0.0, f: 140, g: 0.22 },
|
||||||
|
{ off: 0.12, f: 200, g: 0.26 },
|
||||||
|
{ off: 0.26, f: 340, g: 0.34 },
|
||||||
|
];
|
||||||
|
for (const s of steps) {
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(s.g, t + s.off);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + s.off + 0.06);
|
||||||
|
g.connect(dest);
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(s.f, t + s.off);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(s.f * 0.6, t + s.off + 0.05);
|
||||||
|
osc.connect(g); osc.start(t + s.off); osc.stop(t + s.off + 0.08);
|
||||||
|
}
|
||||||
|
noiseHit(ctx, dest, noise, t + 0.26, { type: 'highpass', freq: 4000, q: 2, dur: 0.04, gain: 0.18 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuse zap: buzzy electric discharge. */
|
||||||
|
export function fuseZap(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.frequency.value = 2200; bp.Q.value = 8;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.28, t);
|
||||||
|
g.gain.setValueAtTime(0.05, t + 0.02);
|
||||||
|
g.gain.setValueAtTime(0.24, t + 0.05);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
|
||||||
|
src.connect(bp); bp.connect(g); g.connect(dest);
|
||||||
|
src.start(t); src.stop(t + 0.24);
|
||||||
|
// buzzy saw underneath
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.setValueAtTime(320, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(80, t + 0.2);
|
||||||
|
const og = ctx.createGain();
|
||||||
|
og.gain.setValueAtTime(0.12, t);
|
||||||
|
og.gain.exponentialRampToValueAtTime(0.0001, t + 0.2);
|
||||||
|
osc.connect(og); og.connect(dest); osc.start(t); osc.stop(t + 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tonearm cue lever creak: slow filtered-noise groan. */
|
||||||
|
export function cueCreak(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 = 12;
|
||||||
|
bp.frequency.setValueAtTime(420, t);
|
||||||
|
bp.frequency.linearRampToValueAtTime(280, t + 0.3);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.linearRampToValueAtTime(0.1, t + 0.06);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.34);
|
||||||
|
src.connect(bp); bp.connect(g); g.connect(dest);
|
||||||
|
src.start(t); src.stop(t + 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Needle drop: crackle burst as the stylus meets the groove (used at win). */
|
||||||
|
export function needleDrop(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise; src.loop = true;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.linearRampToValueAtTime(0.35, t + 0.01);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.04, t + 0.18);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.5);
|
||||||
|
const hp = ctx.createBiquadFilter();
|
||||||
|
hp.type = 'highpass'; hp.frequency.value = 1500;
|
||||||
|
src.connect(hp); hp.connect(g); g.connect(dest);
|
||||||
|
src.start(t); src.stop(t + 0.55);
|
||||||
|
// the "thunk" of the tonearm landing
|
||||||
|
const tg = ctx.createGain();
|
||||||
|
tg.gain.setValueAtTime(0.3, t);
|
||||||
|
tg.gain.exponentialRampToValueAtTime(0.0001, t + 0.12);
|
||||||
|
tg.connect(dest);
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(90, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(45, t + 0.1);
|
||||||
|
osc.connect(tg); osc.start(t); osc.stop(t + 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SfxName =
|
||||||
|
| 'footstep' | 'land' | 'break' | 'place'
|
||||||
|
| 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle';
|
||||||
273
src/audio/synth.ts
Normal file
273
src/audio/synth.ts
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
// TURNCRAFT — Lane E. Low-level voice synthesis.
|
||||||
|
// Pure helpers: each creates + starts the Web Audio nodes for a single voice at
|
||||||
|
// an absolute AudioContext time `t`, routed into `dest`, then auto-stops. No
|
||||||
|
// shared mutable state, so they are safe to call from the lookahead scheduler.
|
||||||
|
//
|
||||||
|
// `detune` (cents) is applied to every pitched source so the turntable
|
||||||
|
// spin-up/brake (which detunes the whole mix) bends drums, bass and synths
|
||||||
|
// together. Noise sources honour it too via their `.detune` AudioParam.
|
||||||
|
|
||||||
|
/** MIDI note number -> frequency in Hz (A4 = 69 = 440 Hz). */
|
||||||
|
export function mtof(midi: number): number {
|
||||||
|
return 440 * Math.pow(2, (midi - 69) / 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A few seconds of white noise, generated once and looped by noise voices. */
|
||||||
|
export function createNoiseBuffer(ctx: BaseAudioContext, seconds = 2): AudioBuffer {
|
||||||
|
const len = Math.floor(ctx.sampleRate * seconds);
|
||||||
|
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||||
|
const data = buf.getChannelData(0);
|
||||||
|
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sparse vinyl crackle: faint hiss plus randomly placed pops. Looped at a low
|
||||||
|
* level under the mix when the record plays ("vinyl truth").
|
||||||
|
*/
|
||||||
|
export function createCrackleBuffer(ctx: BaseAudioContext, seconds = 4): AudioBuffer {
|
||||||
|
const len = Math.floor(ctx.sampleRate * seconds);
|
||||||
|
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||||
|
const data = buf.getChannelData(0);
|
||||||
|
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * 0.06; // hiss bed
|
||||||
|
// scatter pops: short decaying impulses
|
||||||
|
const pops = Math.floor(seconds * 24);
|
||||||
|
for (let p = 0; p < pops; p++) {
|
||||||
|
const start = Math.floor(Math.random() * (len - 400));
|
||||||
|
const amp = 0.3 + Math.random() * 0.6;
|
||||||
|
const dur = 20 + Math.floor(Math.random() * 180);
|
||||||
|
for (let i = 0; i < dur; i++) {
|
||||||
|
const env = 1 - i / dur;
|
||||||
|
data[start + i] += (Math.random() * 2 - 1) * amp * env * env;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drum voices ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 4-on-the-floor kick: pitch-drop sine body + a short click transient. */
|
||||||
|
export function kick(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number,
|
||||||
|
o: { gain?: number; detune?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 1;
|
||||||
|
const rate = Math.pow(2, (o.detune ?? 0) / 1200);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.004);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.34);
|
||||||
|
g.connect(dest);
|
||||||
|
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(150 * rate, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(48 * rate, t + 0.12);
|
||||||
|
osc.connect(g);
|
||||||
|
osc.start(t);
|
||||||
|
osc.stop(t + 0.36);
|
||||||
|
|
||||||
|
// click transient
|
||||||
|
const cg = ctx.createGain();
|
||||||
|
cg.gain.setValueAtTime(0.5 * gain, t);
|
||||||
|
cg.gain.exponentialRampToValueAtTime(0.0001, t + 0.02);
|
||||||
|
cg.connect(dest);
|
||||||
|
const co = ctx.createOscillator();
|
||||||
|
co.type = 'triangle';
|
||||||
|
co.frequency.setValueAtTime(1200 * rate, t);
|
||||||
|
co.connect(cg);
|
||||||
|
co.start(t);
|
||||||
|
co.stop(t + 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closed hat: highpassed noise burst, very short. */
|
||||||
|
export function hat(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
o: { gain?: number; detune?: number; open?: boolean } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 0.3;
|
||||||
|
const dur = o.open ? 0.18 : 0.045;
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise;
|
||||||
|
src.loop = true;
|
||||||
|
src.detune.value = o.detune ?? 0;
|
||||||
|
const hp = ctx.createBiquadFilter();
|
||||||
|
hp.type = 'highpass';
|
||||||
|
hp.frequency.value = 7500;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(gain, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
src.connect(hp); hp.connect(g); g.connect(dest);
|
||||||
|
src.start(t);
|
||||||
|
src.stop(t + dur + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clap: a few tight noise bursts through a bandpass for the classic texture. */
|
||||||
|
export function clap(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
o: { gain?: number; detune?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 0.5;
|
||||||
|
const bp = ctx.createBiquadFilter();
|
||||||
|
bp.type = 'bandpass';
|
||||||
|
bp.frequency.value = 1500;
|
||||||
|
bp.Q.value = 1.2;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.connect(dest);
|
||||||
|
bp.connect(g);
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise;
|
||||||
|
src.loop = true;
|
||||||
|
src.detune.value = o.detune ?? 0;
|
||||||
|
src.connect(bp);
|
||||||
|
// three fast pre-claps + a longer body
|
||||||
|
const offs = [0, 0.011, 0.022, 0.033];
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
for (const off of offs) {
|
||||||
|
g.gain.setValueAtTime(gain, t + off);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + off + 0.02);
|
||||||
|
}
|
||||||
|
g.gain.setValueAtTime(gain * 0.9, t + 0.033);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.18);
|
||||||
|
src.start(t);
|
||||||
|
src.stop(t + 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tonal voices ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Bass: detuned saws through a lowpass, plucky envelope. Route via sidechain. */
|
||||||
|
export function bass(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
|
||||||
|
o: { gain?: number; detune?: number; dur?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 0.5;
|
||||||
|
const dur = o.dur ?? 0.22;
|
||||||
|
const det = o.detune ?? 0;
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass';
|
||||||
|
lp.frequency.setValueAtTime(520, t);
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(180, t + dur);
|
||||||
|
lp.Q.value = 6;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.012);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
lp.connect(g); g.connect(dest);
|
||||||
|
for (const cents of [-6, 7]) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
osc.detune.value = det + cents;
|
||||||
|
osc.connect(lp);
|
||||||
|
osc.start(t);
|
||||||
|
osc.stop(t + dur + 0.05);
|
||||||
|
}
|
||||||
|
// sub sine for weight
|
||||||
|
const sub = ctx.createOscillator();
|
||||||
|
sub.type = 'sine';
|
||||||
|
sub.frequency.value = freq;
|
||||||
|
sub.detune.value = det;
|
||||||
|
const sg = ctx.createGain();
|
||||||
|
sg.gain.setValueAtTime(0.0001, t);
|
||||||
|
sg.gain.exponentialRampToValueAtTime(gain * 0.8, t + 0.012);
|
||||||
|
sg.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
sub.connect(sg); sg.connect(dest);
|
||||||
|
sub.start(t);
|
||||||
|
sub.stop(t + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chord stab: multiple saws (a chord) through a resonant lowpass, short. */
|
||||||
|
export function stab(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freqs: number[],
|
||||||
|
o: { gain?: number; detune?: number; dur?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = (o.gain ?? 0.28) / Math.max(1, freqs.length);
|
||||||
|
const dur = o.dur ?? 0.16;
|
||||||
|
const det = o.detune ?? 0;
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass';
|
||||||
|
lp.frequency.setValueAtTime(2600, t);
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(700, t + dur);
|
||||||
|
lp.Q.value = 4;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.006);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
lp.connect(g); g.connect(dest);
|
||||||
|
for (const f of freqs) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.value = f;
|
||||||
|
osc.detune.value = det;
|
||||||
|
osc.connect(lp);
|
||||||
|
osc.start(t);
|
||||||
|
osc.stop(t + dur + 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lead: a fat square/sine blend with a long release for the sparse hook. */
|
||||||
|
export function lead(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
|
||||||
|
o: { gain?: number; detune?: number; dur?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 0.24;
|
||||||
|
const dur = o.dur ?? 0.5;
|
||||||
|
const det = o.detune ?? 0;
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass';
|
||||||
|
lp.frequency.value = 3200;
|
||||||
|
lp.Q.value = 1;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.02);
|
||||||
|
g.gain.setValueAtTime(gain, t + dur * 0.4);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
lp.connect(g); g.connect(dest);
|
||||||
|
const sq = ctx.createOscillator();
|
||||||
|
sq.type = 'square';
|
||||||
|
sq.frequency.value = freq;
|
||||||
|
sq.detune.value = det;
|
||||||
|
sq.connect(lp);
|
||||||
|
sq.start(t); sq.stop(t + dur + 0.05);
|
||||||
|
const si = ctx.createOscillator();
|
||||||
|
si.type = 'sine';
|
||||||
|
si.frequency.value = freq * 2;
|
||||||
|
si.detune.value = det;
|
||||||
|
const sig = ctx.createGain();
|
||||||
|
sig.gain.value = 0.4;
|
||||||
|
si.connect(sig); sig.connect(lp);
|
||||||
|
si.start(t); si.stop(t + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* White-noise sweep (riser when up=true, downlifter when up=false) through a
|
||||||
|
* moving bandpass. Used at 8-bar boundaries; also the win-open sweep.
|
||||||
|
*/
|
||||||
|
export function sweep(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
o: { up?: boolean; gain?: number; dur?: number } = {},
|
||||||
|
): void {
|
||||||
|
const up = o.up ?? true;
|
||||||
|
const gain = o.gain ?? 0.22;
|
||||||
|
const dur = o.dur ?? 1.8;
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise;
|
||||||
|
src.loop = true;
|
||||||
|
const bp = ctx.createBiquadFilter();
|
||||||
|
bp.type = 'bandpass';
|
||||||
|
bp.Q.value = 0.8;
|
||||||
|
bp.frequency.setValueAtTime(up ? 300 : 6000, t);
|
||||||
|
bp.frequency.exponentialRampToValueAtTime(up ? 6000 : 250, t + dur);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
if (up) {
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + dur * 0.9);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
} else {
|
||||||
|
g.gain.setValueAtTime(gain, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
}
|
||||||
|
src.connect(bp); bp.connect(g); g.connect(dest);
|
||||||
|
src.start(t);
|
||||||
|
src.stop(t + dur + 0.05);
|
||||||
|
}
|
||||||
92
src/core/blocks.ts
Normal file
92
src/core/blocks.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
// TURNCRAFT — block registry. This file is CONTRACT: no lane may edit it.
|
||||||
|
// Block ids are stable u8 values stored in chunk arrays. 0 is always AIR.
|
||||||
|
//
|
||||||
|
// `pattern` tells Lane A's procedural texture painter which painter to use;
|
||||||
|
// `tint` is the base sRGB color it paints with. Emissive blocks glow.
|
||||||
|
|
||||||
|
export type BlockId = number;
|
||||||
|
|
||||||
|
export type Pattern =
|
||||||
|
| 'solid' // flat color with slight per-pixel value noise
|
||||||
|
| 'brushed' // fine horizontal lines (brushed aluminium)
|
||||||
|
| 'plywood' // warm grain; PLY_EDGE uses layered laminate stripes
|
||||||
|
| 'speckle' // plastic with darker speckles
|
||||||
|
| 'grooves' // concentric fine dark lines (vinyl surface)
|
||||||
|
| 'pcb' // green board with copper trace squiggles
|
||||||
|
| 'mesh' // dark grid holes (speaker grille)
|
||||||
|
| 'glass' // mostly transparent with faint streaks
|
||||||
|
| 'led' // bright center, soft falloff (use with emissive)
|
||||||
|
| 'felt'; // soft fibrous noise (slipmat)
|
||||||
|
|
||||||
|
export interface BlockDef {
|
||||||
|
id: BlockId;
|
||||||
|
name: string;
|
||||||
|
solid: boolean; // collides + occludes neighbors
|
||||||
|
transparent: boolean; // rendered in the transparent pass, doesn't occlude
|
||||||
|
emissive: number; // 0..1, >0 means glows (Lane A: emissive material / light)
|
||||||
|
tint: [number, number, number]; // 0..255 sRGB
|
||||||
|
pattern: Pattern;
|
||||||
|
breakable: boolean; // false = world structure the player cannot mine
|
||||||
|
sound: 'metal' | 'wood' | 'plastic' | 'soft' | 'glass' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const B = (
|
||||||
|
id: number, name: string, tint: [number, number, number], pattern: Pattern,
|
||||||
|
opts: Partial<Omit<BlockDef, 'id' | 'name' | 'tint' | 'pattern'>> = {},
|
||||||
|
): BlockDef => ({
|
||||||
|
id, name, tint, pattern,
|
||||||
|
solid: true, transparent: false, emissive: 0, breakable: true, sound: 'plastic',
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AIR = 0;
|
||||||
|
|
||||||
|
export const BLOCKS: BlockDef[] = [
|
||||||
|
B(0, 'air', [0, 0, 0], 'solid', { solid: false, transparent: true, sound: 'none' }),
|
||||||
|
B(1, 'plywood', [222, 189, 140], 'plywood', { sound: 'wood', breakable: false }),
|
||||||
|
B(2, 'ply_edge', [204, 168, 118], 'plywood', { sound: 'wood', breakable: false }),
|
||||||
|
B(3, 'brushed_alu', [176, 178, 182], 'brushed', { sound: 'metal' }),
|
||||||
|
B(4, 'steel_grey', [138, 141, 138], 'speckle', { sound: 'metal' }), // Technics plinth grey
|
||||||
|
B(5, 'matte_black', [38, 38, 40], 'speckle', { sound: 'plastic' }), // mixer body
|
||||||
|
B(6, 'rubber', [30, 30, 32], 'solid', { sound: 'soft' }),
|
||||||
|
B(7, 'slipmat_felt', [52, 52, 56], 'felt', { sound: 'soft' }),
|
||||||
|
B(8, 'vinyl_black', [22, 22, 26], 'grooves', { sound: 'plastic' }),
|
||||||
|
B(9, 'vinyl_blue', [40, 90, 220], 'grooves', { transparent: true, sound: 'glass' }), // translucent blue pressing
|
||||||
|
B(10, 'label_cream', [232, 224, 200], 'solid', { sound: 'soft' }),
|
||||||
|
B(11, 'chrome', [210, 214, 220], 'brushed', { sound: 'metal' }),
|
||||||
|
B(12, 'knob_black', [24, 24, 26], 'speckle', { sound: 'plastic' }),
|
||||||
|
B(13, 'knob_silver', [190, 192, 196], 'brushed', { sound: 'metal' }),
|
||||||
|
B(14, 'fader_cap', [230, 230, 234], 'solid', { sound: 'plastic' }),
|
||||||
|
B(15, 'pcb_green', [24, 96, 52], 'pcb', { sound: 'plastic' }),
|
||||||
|
B(16, 'copper', [188, 118, 62], 'brushed', { sound: 'metal' }),
|
||||||
|
B(17, 'solder', [200, 202, 206], 'speckle', { sound: 'metal' }),
|
||||||
|
B(18, 'led_red', [255, 48, 40], 'led', { emissive: 1.0, sound: 'glass' }),
|
||||||
|
B(19, 'led_green', [60, 255, 90], 'led', { emissive: 1.0, sound: 'glass' }),
|
||||||
|
B(20, 'led_amber', [255, 176, 40], 'led', { emissive: 0.9, sound: 'glass' }),
|
||||||
|
B(21, 'led_blue', [70, 130, 255], 'led', { emissive: 1.0, sound: 'glass' }),
|
||||||
|
B(22, 'glass', [200, 220, 230], 'glass', { transparent: true, sound: 'glass' }),
|
||||||
|
B(23, 'cable_black', [20, 20, 22], 'solid', { sound: 'soft', breakable: false }),
|
||||||
|
B(24, 'cable_red', [190, 40, 36], 'solid', { sound: 'soft', breakable: false }),
|
||||||
|
B(25, 'cable_white', [225, 225, 220], 'solid', { sound: 'soft', breakable: false }),
|
||||||
|
B(26, 'dust', [120, 116, 110], 'felt', { sound: 'soft' }),
|
||||||
|
B(27, 'speaker_mesh', [40, 40, 44], 'mesh', { sound: 'metal' }),
|
||||||
|
B(28, 'strobe_dot', [255, 60, 50], 'led', { emissive: 0.8, sound: 'glass' }),
|
||||||
|
B(29, 'rca_gold', [212, 172, 60], 'brushed', { sound: 'metal' }),
|
||||||
|
B(30, 'screw', [160, 162, 166], 'brushed', { sound: 'metal' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BLOCK_BY_ID: ReadonlyArray<BlockDef | undefined> = (() => {
|
||||||
|
const arr: (BlockDef | undefined)[] = [];
|
||||||
|
for (const b of BLOCKS) arr[b.id] = b;
|
||||||
|
return arr;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export const BLOCK_BY_NAME: ReadonlyMap<string, BlockDef> =
|
||||||
|
new Map(BLOCKS.map((b) => [b.name, b]));
|
||||||
|
|
||||||
|
export function blockDef(id: BlockId): BlockDef {
|
||||||
|
return BLOCK_BY_ID[id] ?? BLOCKS[0];
|
||||||
|
}
|
||||||
|
export function isSolidId(id: BlockId): boolean {
|
||||||
|
return blockDef(id).solid;
|
||||||
|
}
|
||||||
76
src/core/constants.ts
Normal file
76
src/core/constants.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
// TURNCRAFT — shared constants. This file is CONTRACT: no lane may edit it.
|
||||||
|
// Coordinate system: Three.js right-handed, Y-up. 1 voxel = 1 world unit.
|
||||||
|
// See docs/DESIGN.md for the world map these numbers describe.
|
||||||
|
|
||||||
|
/** Real-world scale: 1 voxel = 4 mm of actual DJ booth. Player is ~7 mm tall. */
|
||||||
|
export const VOXEL_MM = 4;
|
||||||
|
|
||||||
|
export const CHUNK = 32; // chunk edge length in voxels (32x32x32)
|
||||||
|
|
||||||
|
// World bounds in voxels (must be multiples of CHUNK).
|
||||||
|
export const WORLD_X = 448; // across the booth (deck A -> mixer -> deck B)
|
||||||
|
export const WORLD_Y = 160; // floor of the crate cave up to the top of the booth walls
|
||||||
|
export const WORLD_Z = 256; // front lip -> gear -> cable canyon -> back wall
|
||||||
|
|
||||||
|
// Key elevations (y values of the TOP surface of each stratum).
|
||||||
|
export const Y_FLOOR = 0; // bottom of the under-table crate/cave region
|
||||||
|
export const Y_TABLETOP = 40; // plywood tabletop the gear sits on
|
||||||
|
export const Y_PLINTH_TOP = 80; // top surface of the turntable plinths
|
||||||
|
export const Y_RECORD_TOP = 85; // vinyl surface (platter + mat + record above plinth)
|
||||||
|
export const Y_BOOTH_WALL_TOP = 140;
|
||||||
|
|
||||||
|
// Gear placement — single source of truth for BOTH worldgen (Lane C) and
|
||||||
|
// machines (Lane D). Spindle centers are where rotating platters mount.
|
||||||
|
export const LAYOUT = {
|
||||||
|
deckA: {
|
||||||
|
minX: 24, maxX: 136, minZ: 52, maxZ: 140, // plinth footprint on tabletop
|
||||||
|
spindleX: 80, spindleZ: 96, // platter axis (x,z), axis is +Y
|
||||||
|
},
|
||||||
|
deckB: {
|
||||||
|
minX: 296, maxX: 408, minZ: 52, maxZ: 140,
|
||||||
|
spindleX: 352, spindleZ: 96,
|
||||||
|
},
|
||||||
|
mixer: {
|
||||||
|
minX: 183, maxX: 265, minZ: 48, maxZ: 143, // body footprint
|
||||||
|
topY: 67, // mixer face plate top surface (27 voxels tall above tabletop)
|
||||||
|
},
|
||||||
|
backWallZ: 200, // plywood back wall with the RCA patch bay
|
||||||
|
patchBay: { minX: 190, maxX: 258, minY: 90, maxY: 118 }, // on the back wall
|
||||||
|
frontLipZ: 40, // player-facing plywood lip of the booth
|
||||||
|
rimThickness: 16, // plywood booth walls on all sides
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Player physics (Minecraft-ish feel, tuned for this scale).
|
||||||
|
export const PLAYER = {
|
||||||
|
width: 0.6,
|
||||||
|
height: 1.8,
|
||||||
|
eyeHeight: 1.62,
|
||||||
|
walkSpeed: 4.3, // voxels/sec
|
||||||
|
sprintSpeed: 5.6,
|
||||||
|
jumpVelocity: 9.0,
|
||||||
|
reach: 5.0, // block interaction distance from the eye
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const GRAVITY = 32; // voxels/sec^2
|
||||||
|
export const FIXED_DT = 1 / 60;
|
||||||
|
export const MAX_SUBSTEPS = 4;
|
||||||
|
|
||||||
|
// Platters run at "shrunk-time" RPM: real 33rpm would move the record edge at
|
||||||
|
// ~143 voxels/sec (instant yeet). These are gameplay speeds; keep tunable.
|
||||||
|
export const PLATTER = {
|
||||||
|
radius: 41, // voxels — the full platter incl. strobe-dot edge
|
||||||
|
recordRadius: 38, // the vinyl disc sitting on it
|
||||||
|
rpm33: 2.0, // "33" — rideable: edge ~8.6 v/s, label area ~2 v/s
|
||||||
|
rpm45: 2.7, // "45" — hazardous: edge ~11.6 v/s, faster than sprint
|
||||||
|
spinUpSeconds: 1.2, // torque-y Technics start/stop feel
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// The signal-chain quest (docs/DESIGN.md §6). Order matters for LED path fx.
|
||||||
|
export const SIGNAL_NODES = [
|
||||||
|
'stylus', // missing stylus for deck A headshell
|
||||||
|
'rca', // unplugged RCA in cable canyon / patch bay
|
||||||
|
'crossfader', // dust boulder jamming the crossfader slot
|
||||||
|
'fuse', // blown fuse under the mixer
|
||||||
|
'power', // master power switch
|
||||||
|
] as const;
|
||||||
|
export type SignalNode = (typeof SIGNAL_NODES)[number];
|
||||||
50
src/core/events.ts
Normal file
50
src/core/events.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
// TURNCRAFT — typed event bus. This file is CONTRACT: no lane may edit it.
|
||||||
|
// Cross-lane communication happens here, not through direct imports.
|
||||||
|
|
||||||
|
import type { BlockId } from './blocks';
|
||||||
|
import type { SignalNode } from './constants';
|
||||||
|
|
||||||
|
export type GameEvents = {
|
||||||
|
// Voxel edits (emitted by whoever calls setBlock with gameplay intent).
|
||||||
|
'block:break': { x: number; y: number; z: number; id: BlockId };
|
||||||
|
'block:place': { x: number; y: number; z: number; id: BlockId };
|
||||||
|
|
||||||
|
// Machines (Lane D emits).
|
||||||
|
'machine:interact': { machineId: string; action: string };
|
||||||
|
'platter:state': { deck: 'A' | 'B'; playing: boolean; rpm: number };
|
||||||
|
'fader:move': { faderId: string; value: number }; // 0..1
|
||||||
|
|
||||||
|
// Signal-chain quest (Lane D emits, Lane E reacts with stems/lights).
|
||||||
|
'signal:repair': { node: SignalNode; repaired: number; total: number };
|
||||||
|
'game:win': Record<string, never>;
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
|
||||||
|
// Player (Lane B emits).
|
||||||
|
'player:step': { block: BlockId };
|
||||||
|
'player:landed': { impactSpeed: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Handler<T> = (payload: T) => void;
|
||||||
|
|
||||||
|
class EventBus {
|
||||||
|
private handlers = new Map<keyof GameEvents, Set<Handler<never>>>();
|
||||||
|
|
||||||
|
on<K extends keyof GameEvents>(event: K, fn: Handler<GameEvents[K]>): () => void {
|
||||||
|
let set = this.handlers.get(event);
|
||||||
|
if (!set) { set = new Set(); this.handlers.set(event, set); }
|
||||||
|
set.add(fn as Handler<never>);
|
||||||
|
return () => set!.delete(fn as Handler<never>);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends keyof GameEvents>(event: K, payload: GameEvents[K]): void {
|
||||||
|
const set = this.handlers.get(event);
|
||||||
|
if (!set) return;
|
||||||
|
for (const fn of set) (fn as Handler<GameEvents[K]>)(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one global bus. Import this; never construct another. */
|
||||||
|
export const bus = new EventBus();
|
||||||
72
src/core/types.ts
Normal file
72
src/core/types.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
// TURNCRAFT — shared interfaces. This file is CONTRACT: no lane may edit it.
|
||||||
|
// Lanes implement these; other lanes consume ONLY these types, never concrete
|
||||||
|
// classes from another lane's directory.
|
||||||
|
|
||||||
|
import type { BlockId } from './blocks';
|
||||||
|
|
||||||
|
export type Vec3 = [number, number, number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The voxel store. Implemented by Lane A (engine/VoxelWorld).
|
||||||
|
* Lane C writes into it via setBlock; Lane B collides against it via
|
||||||
|
* getBlock/isSolid; Lane D breaks/places through it.
|
||||||
|
*
|
||||||
|
* Out-of-bounds reads return AIR above y=0 and a solid sentinel below y=0
|
||||||
|
* (so nothing falls out of the world). Out-of-bounds writes are ignored.
|
||||||
|
*/
|
||||||
|
export interface IVoxelWorld {
|
||||||
|
readonly sizeX: number;
|
||||||
|
readonly sizeY: number;
|
||||||
|
readonly sizeZ: number;
|
||||||
|
getBlock(x: number, y: number, z: number): BlockId;
|
||||||
|
setBlock(x: number, y: number, z: number, id: BlockId): void;
|
||||||
|
isSolid(x: number, y: number, z: number): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A moving collider owned by a machine (platter, tonearm, fader sled).
|
||||||
|
* Lane B's physics treats these as kinematic: they push the player and,
|
||||||
|
* when the player stands on one, `velocityAt` of the contact point is
|
||||||
|
* added to the player's frame (this is how riding the record works).
|
||||||
|
*/
|
||||||
|
export type ColliderShape =
|
||||||
|
| { kind: 'cylinder'; center: Vec3; radius: number; halfHeight: number } // axis +Y
|
||||||
|
| { kind: 'aabb'; min: Vec3; max: Vec3 };
|
||||||
|
|
||||||
|
export interface KinematicCollider {
|
||||||
|
id: string;
|
||||||
|
shape: ColliderShape;
|
||||||
|
/** Surface velocity (voxels/sec) of the collider at a world-space point. */
|
||||||
|
velocityAt(x: number, y: number, z: number): Vec3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A machine: a dynamic, non-voxel entity (platter, tonearm, fader, button).
|
||||||
|
* Implemented by Lane D. The integrator adds getObject3D() results to the
|
||||||
|
* scene and calls update(dt) each fixed tick.
|
||||||
|
*/
|
||||||
|
export interface IMachine {
|
||||||
|
readonly id: string;
|
||||||
|
update(dt: number): void;
|
||||||
|
getColliders(): KinematicCollider[];
|
||||||
|
/** Returns a THREE.Object3D; typed as unknown so core stays deps-free. */
|
||||||
|
getObject3D(): unknown;
|
||||||
|
/** Called when the player presses "use" while aiming at this machine. */
|
||||||
|
onInteract?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the interaction raycast can hit. */
|
||||||
|
export type RayHit =
|
||||||
|
| { kind: 'block'; x: number; y: number; z: number; id: BlockId;
|
||||||
|
face: Vec3 /* outward normal of the hit face */ }
|
||||||
|
| { kind: 'machine'; machine: IMachine; point: Vec3 }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
/** Read-only view of the player Lane D/E need. Implemented by Lane B. */
|
||||||
|
export interface IPlayerView {
|
||||||
|
readonly position: Vec3; // feet center
|
||||||
|
readonly eye: Vec3; // camera position
|
||||||
|
readonly lookDir: Vec3; // normalized
|
||||||
|
readonly onGround: boolean;
|
||||||
|
readonly groundedOn: string | null; // KinematicCollider id, if riding one
|
||||||
|
}
|
||||||
319
src/demo/audioDemo.ts
Normal file
319
src/demo/audioDemo.ts
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
// TURNCRAFT — Lane E standalone demo. Runs with ZERO code from other lanes: a
|
||||||
|
// mock stage (dark tabletop plane + two glowing "record" discs at the deck
|
||||||
|
// spindle world positions) with an orbit camera, plus a control panel that
|
||||||
|
// exercises every acceptance criterion.
|
||||||
|
//
|
||||||
|
// WHAT TO CHECK (open /demo-audio.html, click "drop the needle" first):
|
||||||
|
// • Groove: Deck A auto-plays. Push STEMS 0→5 — drums, bass, chords, lead,
|
||||||
|
// sweeps stack musically and loop forever with no drift. [E1]
|
||||||
|
// • Spin bend: STOP Deck A — tempo+pitch bend down together (brake); START —
|
||||||
|
// spin-up. Toggle 33/45 — the whole mix pitches/tempos up. [E1]
|
||||||
|
// • Pitch fader: drag PITCH — the mix detunes ±. [E1]
|
||||||
|
// • Positional: click "→ Deck A" / "→ Deck B" (moves the listener) — the mix
|
||||||
|
// pans and attenuates toward that deck; a dry bass bed always remains. [E1]
|
||||||
|
// • Faders: drag the 5 CHANNEL sliders — each stem ducks live. [E1]
|
||||||
|
// • SFX: every SFX button is distinct and non-clipping; footsteps vary by
|
||||||
|
// surface category. [E3]
|
||||||
|
// • FX: the discs flash on every beat via setEmissiveBoost; break a block to
|
||||||
|
// see a puff; ambient dust drifts; VU towers (on the mixer) dance to the
|
||||||
|
// mix; 45 rpm sparkles the record rim. [E4]
|
||||||
|
// • Quest arc: REPAIR 1→5 lights the quest tracker, runs an LED signal-path
|
||||||
|
// trace, unmutes a stem, and steps the booth brighter each time; WIN plays
|
||||||
|
// 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]
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import { LAYOUT, PLATTER, Y_TABLETOP, Y_RECORD_TOP, SIGNAL_NODES } from '../core/constants';
|
||||||
|
import { BLOCK_BY_NAME } from '../core/blocks';
|
||||||
|
import type { IPlayerView, Vec3 } from '../core/types';
|
||||||
|
import { AudioEngine } from '../audio/AudioEngine';
|
||||||
|
import { FxSystem } from '../fx/FxSystem';
|
||||||
|
import { Hud, type HotbarModel } from '../ui/Hud';
|
||||||
|
|
||||||
|
// ── renderer / scene / camera ───────────────────────────────────────────────
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
renderer.setClearColor(0x08080a, 1);
|
||||||
|
app.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.Fog(0x0b0a08, 260, 640);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.5, 2000);
|
||||||
|
const TARGET = new THREE.Vector3(224, Y_RECORD_TOP, 100);
|
||||||
|
let az = 0.6, el = 0.9, dist = 320;
|
||||||
|
function placeCamera() {
|
||||||
|
camera.position.set(
|
||||||
|
TARGET.x + dist * Math.sin(az) * Math.sin(el),
|
||||||
|
TARGET.y + dist * Math.cos(el),
|
||||||
|
TARGET.z + dist * Math.cos(az) * Math.sin(el),
|
||||||
|
);
|
||||||
|
camera.lookAt(TARGET);
|
||||||
|
}
|
||||||
|
placeCamera();
|
||||||
|
|
||||||
|
scene.add(new THREE.AmbientLight(0x404050, 1.4));
|
||||||
|
const key = new THREE.DirectionalLight(0xfff0e0, 1.1);
|
||||||
|
key.position.set(120, 260, 60);
|
||||||
|
scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0x6080ff, 0.4);
|
||||||
|
fill.position.set(360, 160, 240);
|
||||||
|
scene.add(fill);
|
||||||
|
|
||||||
|
// dark tabletop plane
|
||||||
|
const plane = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(600, 360),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x1a1a1e, roughness: 0.9 }),
|
||||||
|
);
|
||||||
|
plane.rotation.x = -Math.PI / 2;
|
||||||
|
plane.position.set(224, Y_TABLETOP - 0.5, 120);
|
||||||
|
scene.add(plane);
|
||||||
|
|
||||||
|
// two "record" discs at the deck spindles — emissive so setEmissiveBoost flashes
|
||||||
|
const blue = BLOCK_BY_NAME.get('vinyl_blue')!.tint;
|
||||||
|
const discMats: THREE.MeshStandardMaterial[] = [];
|
||||||
|
const discs: THREE.Mesh[] = [];
|
||||||
|
for (const lay of [LAYOUT.deckA, LAYOUT.deckB]) {
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color: new THREE.Color(blue[0] / 255, blue[1] / 255, blue[2] / 255),
|
||||||
|
emissive: new THREE.Color(blue[0] / 255, blue[1] / 255, blue[2] / 255),
|
||||||
|
emissiveIntensity: 0.35, roughness: 0.4, metalness: 0.1,
|
||||||
|
});
|
||||||
|
const disc = new THREE.Mesh(new THREE.CircleGeometry(PLATTER.recordRadius, 48), mat);
|
||||||
|
disc.rotation.x = -Math.PI / 2;
|
||||||
|
disc.position.set(lay.spindleX, Y_RECORD_TOP, lay.spindleZ);
|
||||||
|
scene.add(disc);
|
||||||
|
// cream label
|
||||||
|
const label = new THREE.Mesh(
|
||||||
|
new THREE.CircleGeometry(9, 24),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0xe8e0c8, roughness: 0.8 }),
|
||||||
|
);
|
||||||
|
label.rotation.x = -Math.PI / 2;
|
||||||
|
label.position.set(lay.spindleX, Y_RECORD_TOP + 0.05, lay.spindleZ);
|
||||||
|
scene.add(label);
|
||||||
|
discMats.push(mat); discs.push(disc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── engine wiring ───────────────────────────────────────────────────────────
|
||||||
|
const audio = new AudioEngine();
|
||||||
|
|
||||||
|
// mock setEmissiveBoost: the "LEDs" of this demo are the two discs
|
||||||
|
const setEmissiveBoost = (v: number) => { for (const m of discMats) m.emissiveIntensity = v; };
|
||||||
|
|
||||||
|
const fx = new FxSystem({ scene, setEmissiveBoost, getLevels: () => audio.getLevels() });
|
||||||
|
|
||||||
|
// mock hotbar (Lane D owns the real one)
|
||||||
|
const hotbar: HotbarModel = {
|
||||||
|
active: 0,
|
||||||
|
slots: (['vinyl_blue', 'dust', 'knob_black', 'chrome', 'led_amber', 'pcb_green', 'rca_gold', 'slipmat_felt', 'screw'] as const)
|
||||||
|
.map((name, i) => ({ id: BLOCK_BY_NAME.get(name)!.id, count: (i % 4) + 1 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasStarted = false;
|
||||||
|
let panelEl: HTMLDivElement | null = null;
|
||||||
|
let deckAButton: HTMLButtonElement | null = null;
|
||||||
|
const hud = new Hud({
|
||||||
|
// first-start only: init audio, reveal the panel, auto-spin Deck A once
|
||||||
|
onStart: () => {
|
||||||
|
if (hasStarted) return;
|
||||||
|
hasStarted = true;
|
||||||
|
if (panelEl) panelEl.style.display = '';
|
||||||
|
audio.init().then(() => {
|
||||||
|
deckPlaying.A = true;
|
||||||
|
bus.emit('platter:state', { deck: 'A', playing: true, rpm: rpm.A });
|
||||||
|
if (deckAButton) { deckAButton.textContent = 'Deck A ⏹'; deckAButton.classList.add('on'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// resume is a pure unpause here (no pointer lock in the demo) — must not restart Deck A
|
||||||
|
onResume: () => {},
|
||||||
|
getHotbar: () => hotbar,
|
||||||
|
});
|
||||||
|
|
||||||
|
// listener follows the camera (so moving/orbiting pans the deck audio)
|
||||||
|
const view: IPlayerView = {
|
||||||
|
position: [camera.position.x, camera.position.y - 1.6, camera.position.z] as Vec3,
|
||||||
|
eye: [camera.position.x, camera.position.y, camera.position.z] as Vec3,
|
||||||
|
lookDir: [0, 0, -1] as Vec3,
|
||||||
|
onGround: true,
|
||||||
|
groundedOn: null,
|
||||||
|
};
|
||||||
|
const fwd = new THREE.Vector3();
|
||||||
|
function syncView() {
|
||||||
|
view.eye[0] = camera.position.x; view.eye[1] = camera.position.y; view.eye[2] = camera.position.z;
|
||||||
|
view.position[0] = camera.position.x; view.position[1] = camera.position.y - 1.6; view.position[2] = camera.position.z;
|
||||||
|
camera.getWorldDirection(fwd);
|
||||||
|
view.lookDir[0] = fwd.x; view.lookDir[1] = fwd.y; view.lookDir[2] = fwd.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── control panel ───────────────────────────────────────────────────────────
|
||||||
|
const deckPlaying: Record<'A' | 'B', boolean> = { A: false, B: false };
|
||||||
|
const rpm: Record<'A' | 'B', number> = { A: PLATTER.rpm33, B: PLATTER.rpm33 };
|
||||||
|
buildPanel();
|
||||||
|
|
||||||
|
// ── loop (a demo may own a rAF loop; see CONTRACTS §5) ──────────────────────
|
||||||
|
let last = performance.now();
|
||||||
|
function frame(now: number) {
|
||||||
|
const dt = Math.min(0.05, (now - last) / 1000);
|
||||||
|
last = now;
|
||||||
|
// spin the discs (visual) while their deck plays
|
||||||
|
if (deckPlaying.A) discs[0].rotation.z -= rpm.A * 0.03;
|
||||||
|
if (deckPlaying.B) discs[1].rotation.z -= rpm.B * 0.03;
|
||||||
|
syncView();
|
||||||
|
audio.updateListener(view);
|
||||||
|
fx.update(dt);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
// ── input: orbit + hotbar keys ──────────────────────────────────────────────
|
||||||
|
let dragging = false, px = 0, py = 0;
|
||||||
|
renderer.domElement.addEventListener('pointerdown', (e) => { dragging = true; px = e.clientX; py = e.clientY; });
|
||||||
|
window.addEventListener('pointerup', () => { dragging = false; });
|
||||||
|
window.addEventListener('pointermove', (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
az -= (e.clientX - px) * 0.005;
|
||||||
|
el = Math.max(0.2, Math.min(1.5, el - (e.clientY - py) * 0.005));
|
||||||
|
px = e.clientX; py = e.clientY;
|
||||||
|
placeCamera();
|
||||||
|
});
|
||||||
|
renderer.domElement.addEventListener('wheel', (e) => {
|
||||||
|
dist = Math.max(80, Math.min(560, dist + e.deltaY * 0.4));
|
||||||
|
placeCamera();
|
||||||
|
}, { passive: true });
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
const n = parseInt(e.key, 10);
|
||||||
|
if (n >= 1 && n <= 9) { hotbar.active = n - 1; hud.refreshHotbar(); }
|
||||||
|
if (e.key === 'p' || e.key === 'P') hud.setPaused(true);
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
camera.aspect = window.innerWidth / window.innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── panel builder ───────────────────────────────────────────────────────────
|
||||||
|
function buildPanel() {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.tc-panel { position:absolute; top:10px; left:10px; z-index:20; width:250px; max-height:96vh;
|
||||||
|
overflow:auto; padding:10px; background:rgba(14,14,17,.86); border:1px solid #333;
|
||||||
|
border-radius:8px; color:#cdd; font:11px ui-monospace,Menlo,monospace; pointer-events:auto; }
|
||||||
|
.tc-panel h4 { margin:10px 0 5px; font-size:10px; letter-spacing:.14em; color:#7a9; text-transform:uppercase; }
|
||||||
|
.tc-panel h4:first-child { margin-top:0; }
|
||||||
|
.tc-panel .row { display:flex; align-items:center; gap:6px; margin:3px 0; }
|
||||||
|
.tc-panel label { flex:0 0 74px; color:#9ab; }
|
||||||
|
.tc-panel input[type=range] { flex:1; }
|
||||||
|
.tc-panel button { background:#22242a; color:#cde; border:1px solid #3a3d45; border-radius:4px;
|
||||||
|
padding:4px 6px; cursor:pointer; font:11px ui-monospace,Menlo,monospace; }
|
||||||
|
.tc-panel button:hover { background:#2c2f37; }
|
||||||
|
.tc-panel button.on { background:#2a5; color:#031; border-color:#3c7; }
|
||||||
|
.tc-grid { display:grid; grid-template-columns:1fr 1fr; gap:4px; }
|
||||||
|
.tc-grid.three { grid-template-columns:1fr 1fr 1fr; }
|
||||||
|
.tc-val { flex:0 0 30px; text-align:right; color:#8ab; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
const p = document.createElement('div');
|
||||||
|
p.className = 'tc-panel';
|
||||||
|
p.style.display = 'none'; // gated: revealed only after the start splash is clicked
|
||||||
|
panelEl = p;
|
||||||
|
app.appendChild(p);
|
||||||
|
|
||||||
|
const h = (t: string) => { const e = document.createElement('h4'); e.textContent = t; p.appendChild(e); };
|
||||||
|
const rowEl = () => { const e = document.createElement('div'); e.className = 'row'; p.appendChild(e); return e; };
|
||||||
|
const slider = (label: string, min: number, max: number, step: number, val: number, on: (v: number) => void) => {
|
||||||
|
const r = rowEl();
|
||||||
|
const l = document.createElement('label'); l.textContent = label; r.appendChild(l);
|
||||||
|
const s = document.createElement('input'); s.type = 'range';
|
||||||
|
s.min = String(min); s.max = String(max); s.step = String(step); s.value = String(val);
|
||||||
|
const out = document.createElement('span'); out.className = 'tc-val'; out.textContent = val.toFixed(2);
|
||||||
|
s.addEventListener('input', () => { const v = parseFloat(s.value); out.textContent = v.toFixed(2); on(v); });
|
||||||
|
r.appendChild(s); r.appendChild(out);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const btn = (label: string, on: (b: HTMLButtonElement) => void, parent: HTMLElement = p) => {
|
||||||
|
const b = document.createElement('button'); b.textContent = label;
|
||||||
|
b.addEventListener('click', () => on(b)); parent.appendChild(b); return b;
|
||||||
|
};
|
||||||
|
const grid = (cls = '') => { const g = document.createElement('div'); g.className = 'tc-grid ' + cls; p.appendChild(g); return g; };
|
||||||
|
|
||||||
|
// TRANSPORT
|
||||||
|
h('Transport');
|
||||||
|
slider('Stems', 0, 5, 1, 0, (v) => audio.setStemCount(v));
|
||||||
|
slider('Pitch', -1, 1, 0.01, 0, (v) => audio.setPitch(v));
|
||||||
|
const deckRow = grid();
|
||||||
|
for (const d of ['A', 'B'] as const) {
|
||||||
|
const play = btn(`Deck ${d} ▶`, (b) => {
|
||||||
|
deckPlaying[d] = !deckPlaying[d];
|
||||||
|
b.textContent = deckPlaying[d] ? `Deck ${d} ⏹` : `Deck ${d} ▶`;
|
||||||
|
b.classList.toggle('on', deckPlaying[d]);
|
||||||
|
bus.emit('platter:state', { deck: d, playing: deckPlaying[d], rpm: rpm[d] });
|
||||||
|
}, deckRow);
|
||||||
|
if (d === 'A') deckAButton = play;
|
||||||
|
}
|
||||||
|
const speedRow = grid();
|
||||||
|
for (const d of ['A', 'B'] as const) {
|
||||||
|
btn(`${d}: 33`, (b) => {
|
||||||
|
const is45 = b.textContent!.includes('33');
|
||||||
|
rpm[d] = is45 ? PLATTER.rpm45 : PLATTER.rpm33;
|
||||||
|
b.textContent = is45 ? `${d}: 45` : `${d}: 33`;
|
||||||
|
b.classList.toggle('on', is45);
|
||||||
|
if (deckPlaying[d]) bus.emit('platter:state', { deck: d, playing: true, rpm: rpm[d] });
|
||||||
|
}, speedRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LISTENER
|
||||||
|
h('Listener (positional test)');
|
||||||
|
const listenRow = grid();
|
||||||
|
btn('→ Deck A', () => moveCamTo(LAYOUT.deckA.spindleX, LAYOUT.deckA.spindleZ), listenRow);
|
||||||
|
btn('→ Deck B', () => moveCamTo(LAYOUT.deckB.spindleX, LAYOUT.deckB.spindleZ), listenRow);
|
||||||
|
|
||||||
|
// CHANNEL FADERS
|
||||||
|
h('Channel faders (live duck)');
|
||||||
|
const chNames = ['drums', 'bass', 'chords', 'lead', 'sweeps'];
|
||||||
|
chNames.forEach((name, i) => slider(name, 0, 1, 0.01, 1, (v) => audio.setChannelGain(i, v)));
|
||||||
|
|
||||||
|
// SFX
|
||||||
|
h('SFX');
|
||||||
|
const sfxCats = grid('three');
|
||||||
|
(['metal', 'wood', 'plastic', 'soft', 'glass'] as const).forEach((cat) =>
|
||||||
|
btn(`step:${cat}`, () => audio.playSfx('footstep', undefined, cat), sfxCats));
|
||||||
|
const sfxG = grid('three');
|
||||||
|
btn('land', () => audio.playSfx('land', undefined, 10), sfxG);
|
||||||
|
btn('fader', () => audio.playSfx('fader'), sfxG);
|
||||||
|
btn('button', () => audio.playSfx('button'), sfxG);
|
||||||
|
btn('rca', () => audio.playSfx('rca'), sfxG);
|
||||||
|
btn('fuse', () => audio.playSfx('fuse'), sfxG);
|
||||||
|
btn('cue', () => audio.playSfx('cue'), sfxG);
|
||||||
|
btn('needle', () => audio.playSfx('needle'), sfxG);
|
||||||
|
btn('break blk', () => {
|
||||||
|
const x = LAYOUT.deckA.spindleX + 30, y = Y_RECORD_TOP, z = LAYOUT.deckA.spindleZ;
|
||||||
|
bus.emit('block:break', { x, y, z, id: BLOCK_BY_NAME.get('dust')!.id });
|
||||||
|
}, sfxG);
|
||||||
|
btn('place blk', () => {
|
||||||
|
const x = LAYOUT.deckA.spindleX + 30, y = Y_RECORD_TOP, z = LAYOUT.deckA.spindleZ;
|
||||||
|
bus.emit('block:place', { x, y, z, id: BLOCK_BY_NAME.get('vinyl_blue')!.id });
|
||||||
|
}, sfxG);
|
||||||
|
|
||||||
|
// QUEST SIMULATOR
|
||||||
|
h('Quest simulator');
|
||||||
|
const qRow = grid('three');
|
||||||
|
SIGNAL_NODES.forEach((node, i) => btn(`fix ${node}`, () => {
|
||||||
|
bus.emit('signal:repair', { node, repaired: i + 1, total: SIGNAL_NODES.length });
|
||||||
|
}, qRow));
|
||||||
|
const winRow = grid();
|
||||||
|
btn('▶ WIN', () => bus.emit('game:win', {}), winRow);
|
||||||
|
btn('⟳ reset', () => location.reload(), winRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCamTo(x: number, z: number) {
|
||||||
|
// slide the orbit target toward a deck so the listener sits beside it
|
||||||
|
TARGET.set(x, Y_RECORD_TOP, z);
|
||||||
|
dist = 90;
|
||||||
|
placeCamera();
|
||||||
|
}
|
||||||
239
src/demo/engineDemo.ts
Normal file
239
src/demo/engineDemo.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
// TURNCRAFT — Lane A standalone demo. Run: `npm run dev` → open /demo-engine.html
|
||||||
|
//
|
||||||
|
// WHAT TO LOOK AT / PRESS (maps to the acceptance criteria in LANE_A_ENGINE.md):
|
||||||
|
// • Full-size (448×160×256) empty world + a test pattern near the origin renders
|
||||||
|
// at 60 fps with few draw calls — see the HUD (top-left): FPS, draw calls,
|
||||||
|
// triangles, meshes. Draw calls stay well under 300.
|
||||||
|
// • AO: orbit into the hollow steel CAVE (left) — inner corners visibly darken;
|
||||||
|
// no cracks or z-fighting where chunks meet.
|
||||||
|
// • Transparency: the GLASS ARCH and the BLUE VINYL wall (in front of a cream
|
||||||
|
// wall) render in a correct transparent pass over the opaque blocks behind.
|
||||||
|
// • All 10 patterns + every block id: the labeled PILLAR ROW — one stepped
|
||||||
|
// pillar per block id, each with a floating "id name" label.
|
||||||
|
// • Emissive: the LED WALL. Drag the "emissive boost" slider (0→3) — LED/strobe
|
||||||
|
// glow scales; nothing else changes.
|
||||||
|
// • Dirty remeshing: press "TORTURE" — ~500 random block edits/sec in the flicker
|
||||||
|
// cube; FPS stays ≳55 and edits appear within ~1–2 frames.
|
||||||
|
//
|
||||||
|
// Uses ONLY Lane A code + core. OrbitControls is a demo convenience (not shipped
|
||||||
|
// in the engine API).
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
|
import { WORLD_X, WORLD_Y, WORLD_Z } from '../core/constants';
|
||||||
|
import { BLOCKS, BLOCK_BY_NAME } from '../core/blocks';
|
||||||
|
import { createRenderer } from '../engine/renderer';
|
||||||
|
import { VoxelWorld } from '../engine/VoxelWorld';
|
||||||
|
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);
|
||||||
|
|
||||||
|
let hud: HTMLDivElement; // populated by buildHud(), read by updateHud()
|
||||||
|
|
||||||
|
const world = new VoxelWorld(WORLD_X, WORLD_Y, WORLD_Z);
|
||||||
|
buildTestPattern(world);
|
||||||
|
|
||||||
|
const chunks = new ChunkManager(world, scene, atlas, 4);
|
||||||
|
const buildT0 = performance.now();
|
||||||
|
chunks.buildAll();
|
||||||
|
const buildMs = performance.now() - buildT0;
|
||||||
|
|
||||||
|
// Camera / controls framed on the test pattern.
|
||||||
|
camera.position.set(34, 46, 118);
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.target.set(33, 8, 33);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
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,
|
||||||
|
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);
|
||||||
|
controls.update();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
render,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Floating labels over each pillar.
|
||||||
|
addPillarLabels(scene);
|
||||||
|
|
||||||
|
buildHud();
|
||||||
|
|
||||||
|
// --- main loop ---
|
||||||
|
let last = performance.now();
|
||||||
|
let fps = 60, minFpsTorture = 999;
|
||||||
|
let torturing = false;
|
||||||
|
|
||||||
|
function frame() {
|
||||||
|
const t = performance.now();
|
||||||
|
const dt = Math.min(0.1, (t - last) / 1000);
|
||||||
|
last = t;
|
||||||
|
fps = fps * 0.9 + (1 / Math.max(1e-3, dt)) * 0.1;
|
||||||
|
|
||||||
|
if (torturing) {
|
||||||
|
torture(dt);
|
||||||
|
if (fps < minFpsTorture) minFpsTorture = fps;
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.update();
|
||||||
|
chunks.update();
|
||||||
|
render();
|
||||||
|
updateHud(dt);
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test pattern
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function buildTestPattern(w: VoxelWorld): void {
|
||||||
|
// 64×64 plywood floor at y=0.
|
||||||
|
w.fillBox(2, 0, 2, 65, 0, 65, B('plywood'));
|
||||||
|
|
||||||
|
// Labeled pillar row: one stepped pillar per block id (skip air id 0).
|
||||||
|
for (const def of BLOCKS) {
|
||||||
|
if (def.id === 0) continue;
|
||||||
|
const x = 3 + def.id * 2;
|
||||||
|
const h = 2 + (def.id % 7);
|
||||||
|
w.fillBox(x, 1, 6, x, h, 6, def.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glass arch (transparent portal) — see through it to the pillars/floor.
|
||||||
|
w.fillBox(10, 1, 40, 11, 8, 41, B('glass'));
|
||||||
|
w.fillBox(20, 1, 40, 21, 8, 41, B('glass'));
|
||||||
|
w.fillBox(10, 8, 40, 21, 9, 41, B('glass'));
|
||||||
|
|
||||||
|
// Blue translucent vinyl wall in front of a cream label wall.
|
||||||
|
w.fillBox(30, 1, 46, 40, 6, 46, B('label_cream'));
|
||||||
|
w.fillBox(30, 1, 44, 40, 6, 44, B('vinyl_blue'));
|
||||||
|
|
||||||
|
// LED wall — mixed colours + strobe dots (emissive slider test).
|
||||||
|
const leds = [B('led_red'), B('led_green'), B('led_amber'), B('led_blue')];
|
||||||
|
for (let x = 48; x <= 58; x++)
|
||||||
|
for (let y = 1; y <= 8; y++) {
|
||||||
|
const id = (x + y) % 5 === 0 ? B('strobe_dot') : leds[x % 4];
|
||||||
|
w.setBlock(x, y, 40, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hollow steel cave — AO test on interior corners, with an entrance.
|
||||||
|
w.fillBox(2, 1, 50, 20, 12, 64, B('steel_grey'));
|
||||||
|
w.fillBox(4, 2, 52, 18, 11, 63, 0); // hollow it
|
||||||
|
w.fillBox(9, 1, 50, 13, 6, 50, 0); // doorway on the front face
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Torture: ~500 random edits/sec in a flicker cube (dirty-remesh stress).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const TZ = { x0: 4, x1: 28, y0: 1, y1: 24, z0: 20, z1: 44 };
|
||||||
|
const TORTURE_BLOCKS = [0, B('steel_grey'), B('vinyl_blue'), B('led_green'), B('brushed_alu')];
|
||||||
|
let editCarry = 0;
|
||||||
|
function torture(dt: number): void {
|
||||||
|
editCarry += 500 * dt;
|
||||||
|
const n = editCarry | 0;
|
||||||
|
editCarry -= n;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = TZ.x0 + ((Math.random() * (TZ.x1 - TZ.x0 + 1)) | 0);
|
||||||
|
const y = TZ.y0 + ((Math.random() * (TZ.y1 - TZ.y0 + 1)) | 0);
|
||||||
|
const z = TZ.z0 + ((Math.random() * (TZ.z1 - TZ.z0 + 1)) | 0);
|
||||||
|
world.setBlock(x, y, z, TORTURE_BLOCKS[(Math.random() * TORTURE_BLOCKS.length) | 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Labels + HUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function addPillarLabels(sc: THREE.Scene): void {
|
||||||
|
for (const def of BLOCKS) {
|
||||||
|
if (def.id === 0) continue;
|
||||||
|
const x = 3 + def.id * 2;
|
||||||
|
const h = 2 + (def.id % 7);
|
||||||
|
const sprite = makeTextSprite(`${def.id} ${def.name}`);
|
||||||
|
sprite.position.set(x + 0.5, h + 2.2, 6.5);
|
||||||
|
sc.add(sprite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTextSprite(text: string): THREE.Sprite {
|
||||||
|
const pad = 6, fs = 22;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
const ctx = c.getContext('2d')!;
|
||||||
|
ctx.font = `${fs}px monospace`;
|
||||||
|
const w = Math.ceil(ctx.measureText(text).width) + pad * 2;
|
||||||
|
const hgt = fs + pad * 2;
|
||||||
|
c.width = w; c.height = hgt;
|
||||||
|
ctx.font = `${fs}px monospace`;
|
||||||
|
ctx.fillStyle = 'rgba(10,10,12,0.72)';
|
||||||
|
ctx.fillRect(0, 0, w, hgt);
|
||||||
|
ctx.fillStyle = '#e8e6df';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(text, pad, hgt / 2);
|
||||||
|
const tex = new THREE.CanvasTexture(c);
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
const mat = new THREE.SpriteMaterial({ map: tex, depthTest: true, transparent: true });
|
||||||
|
const s = new THREE.Sprite(mat);
|
||||||
|
s.scale.set((w / hgt) * 2.2, 2.2, 1);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHud(): void {
|
||||||
|
hud = document.createElement('div');
|
||||||
|
hud.style.cssText =
|
||||||
|
'position:fixed;top:8px;left:8px;font:12px/1.5 monospace;color:#d8d6cf;' +
|
||||||
|
'background:rgba(10,9,8,0.72);padding:10px 12px;border-radius:6px;white-space:pre;' +
|
||||||
|
'user-select:none;pointer-events:none;z-index:10';
|
||||||
|
document.body.appendChild(hud);
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.style.cssText =
|
||||||
|
'position:fixed;top:8px;right:8px;font:12px monospace;color:#d8d6cf;' +
|
||||||
|
'background:rgba(10,9,8,0.8);padding:10px 12px;border-radius:6px;z-index:10;' +
|
||||||
|
'display:flex;flex-direction:column;gap:8px;align-items:stretch';
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = 'TORTURE: off';
|
||||||
|
btn.style.cssText = 'font:12px monospace;padding:6px 10px;cursor:pointer';
|
||||||
|
btn.onclick = () => {
|
||||||
|
torturing = !torturing;
|
||||||
|
if (torturing) minFpsTorture = 999;
|
||||||
|
btn.textContent = torturing ? 'TORTURE: ON (500 edits/s)' : 'TORTURE: off';
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.style.cssText = 'display:flex;flex-direction:column;gap:2px';
|
||||||
|
label.textContent = 'emissive boost';
|
||||||
|
const slider = document.createElement('input');
|
||||||
|
slider.type = 'range'; slider.min = '0'; slider.max = '3'; slider.step = '0.05'; slider.value = '1';
|
||||||
|
const val = document.createElement('span');
|
||||||
|
val.textContent = '1.00';
|
||||||
|
slider.oninput = () => { setEmissiveBoost(+slider.value); val.textContent = (+slider.value).toFixed(2); };
|
||||||
|
label.appendChild(slider);
|
||||||
|
label.appendChild(val);
|
||||||
|
|
||||||
|
panel.appendChild(btn);
|
||||||
|
panel.appendChild(label);
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hudAccum = 0;
|
||||||
|
function updateHud(dt: number): void {
|
||||||
|
hudAccum += dt;
|
||||||
|
if (hudAccum < 0.2) return; // refresh ~5×/s
|
||||||
|
hudAccum = 0;
|
||||||
|
const calls = renderer.info.render.calls;
|
||||||
|
const tris = renderer.info.render.triangles;
|
||||||
|
hud.textContent =
|
||||||
|
`TURNCRAFT — Lane A engine demo\n` +
|
||||||
|
`world ${WORLD_X}×${WORLD_Y}×${WORLD_Z} (initial build ${buildMs.toFixed(0)} ms)\n` +
|
||||||
|
`fps ${fps.toFixed(0)}${torturing ? ` (min under torture ${minFpsTorture.toFixed(0)})` : ''}\n` +
|
||||||
|
`draw calls ${calls}\n` +
|
||||||
|
`triangles ${tris.toLocaleString()}\n` +
|
||||||
|
`meshes ${chunks.meshCount}\n` +
|
||||||
|
`dirty ${world.dirtyCount} remesh ${chunks.lastRemeshMs.toFixed(2)} ms / ${chunks.lastRemeshCount} chunk(s)`;
|
||||||
|
}
|
||||||
382
src/demo/machinesDemo.ts
Normal file
382
src/demo/machinesDemo.ts
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
// LANE D — standalone demo (`/demo-machines.html`). Runs with ZERO code from
|
||||||
|
// other lanes: a mock IVoxelWorld + a mock IPlayerView (first-person free-fly),
|
||||||
|
// the REAL machines/interaction/quest.
|
||||||
|
//
|
||||||
|
// ─── HOW TO VERIFY EACH ACCEPTANCE CRITERION ─────────────────────────────────
|
||||||
|
// Click the 3D view to capture the mouse (Esc releases it to click panel
|
||||||
|
// buttons). Move: arrows / WASD. Fly: Space (up), Left-Shift (down).
|
||||||
|
// Look: mouse. Mine: hold Left. Place: Right. Interact: E. Hotbar: 1–9.
|
||||||
|
//
|
||||||
|
// • Platter spin-up/brake + velocityAt: panel → "Deck A ▶" then "Log ride
|
||||||
|
// velocity" — console prints measured ω×r at r=10 & r=41 vs expected.
|
||||||
|
// Watch the blue record lurch up to speed and brake.
|
||||||
|
// • Tonearm: with Deck A playing AND stylus fitted (Quest → stylus, or aim at
|
||||||
|
// the headshell holding the chrome block and press E), the arm cues to the
|
||||||
|
// outer groove and creeps inward. "Fast tonearm" speeds time ×120 to see it.
|
||||||
|
// • Faders: fly into a fader to shove it (or drag isn't needed — walking works);
|
||||||
|
// pitch fader retunes Deck A. Crossfader is jammed by dust until you mine the
|
||||||
|
// 3 grey blocks in its slot ("Focus: Crossfader"), then a full slide repairs it.
|
||||||
|
// • DDA raycast: "Focus: Test wall", mine/place on the wall — the selection box
|
||||||
|
// never tunnels; a machine in front of a block is picked first.
|
||||||
|
// • Quest: complete all five (real paths, or the Quest panel's simulate
|
||||||
|
// buttons). On the fifth, game:win fires ONCE and both platters auto-start.
|
||||||
|
// • Every bus event is logged to the console.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import {
|
||||||
|
WORLD_X, WORLD_Y, WORLD_Z, PLATTER, LAYOUT, FIXED_DT, MAX_SUBSTEPS, PLAYER, Y_PLINTH_TOP,
|
||||||
|
} from '../core/constants';
|
||||||
|
import { AIR, blockDef, isSolidId, type BlockId } from '../core/blocks';
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import type { IPlayerView, IVoxelWorld, Vec3 } from '../core/types';
|
||||||
|
import { createMachines, QUEST_ITEMS, NODES, type Platter, type Fader } from '../machines';
|
||||||
|
import { blockMaterial } from '../machines/util';
|
||||||
|
import { Hotbar, Interaction } from '../interact';
|
||||||
|
|
||||||
|
// ── DEMO MOCK — not for integration ──────────────────────────────────────────
|
||||||
|
class MockWorld implements IVoxelWorld {
|
||||||
|
readonly sizeX = WORLD_X;
|
||||||
|
readonly sizeY = WORLD_Y;
|
||||||
|
readonly sizeZ = WORLD_Z;
|
||||||
|
private o = new Map<number, BlockId>();
|
||||||
|
private idx(x: number, y: number, z: number): number { return (x * this.sizeY + y) * this.sizeZ + z; }
|
||||||
|
getBlock(x: number, y: number, z: number): BlockId {
|
||||||
|
if (x < 0 || y < 0 || z < 0 || x >= this.sizeX || y >= this.sizeY || z >= this.sizeZ) {
|
||||||
|
return y < 0 ? 1 : AIR; // solid sentinel below the world, else air (contract)
|
||||||
|
}
|
||||||
|
return this.o.get(this.idx(x, y, z)) ?? AIR;
|
||||||
|
}
|
||||||
|
setBlock(x: number, y: number, z: number, id: BlockId): void {
|
||||||
|
if (x < 0 || y < 0 || z < 0 || x >= this.sizeX || y >= this.sizeY || z >= this.sizeZ) return;
|
||||||
|
if (id === AIR) this.o.delete(this.idx(x, y, z));
|
||||||
|
else this.o.set(this.idx(x, y, z), id);
|
||||||
|
}
|
||||||
|
isSolid(x: number, y: number, z: number): boolean { return isSolidId(this.getBlock(x, y, z)); }
|
||||||
|
}
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ---- Renderer / scene ----
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
|
||||||
|
renderer.setClearColor(0x1b1810);
|
||||||
|
app.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.Fog(0x1b1810, 220, 520);
|
||||||
|
const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1400);
|
||||||
|
|
||||||
|
// Size defensively: the preview pane can report 0×0 at load, which would make
|
||||||
|
// the camera aspect NaN and render nothing. Fall back, and re-check each frame.
|
||||||
|
let lastW = 0, lastH = 0;
|
||||||
|
function resize(): void {
|
||||||
|
const w = window.innerWidth || 1280, h = window.innerHeight || 720;
|
||||||
|
if (w === lastW && h === lastH) return;
|
||||||
|
lastW = w; lastH = h;
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
scene.add(new THREE.HemisphereLight(0xdfe6ff, 0x2a2216, 0.85));
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.25));
|
||||||
|
const key = new THREE.DirectionalLight(0xfff2d6, 1.15);
|
||||||
|
key.position.set(120, 260, 60);
|
||||||
|
scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0x88aaff, 0.35);
|
||||||
|
fill.position.set(-80, 120, 220);
|
||||||
|
scene.add(fill);
|
||||||
|
|
||||||
|
// Cosmetic context (NOT world voxels / colliders) so the floating gear reads.
|
||||||
|
function slab(minX: number, maxX: number, minZ: number, maxZ: number, topY: number, h: number, color: number): void {
|
||||||
|
const g = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(maxX - minX, h, maxZ - minZ),
|
||||||
|
new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.1 }),
|
||||||
|
);
|
||||||
|
g.position.set((minX + maxX) / 2, topY - h / 2, (minZ + maxZ) / 2);
|
||||||
|
scene.add(g);
|
||||||
|
}
|
||||||
|
slab(LAYOUT.deckA.minX, LAYOUT.deckA.maxX, LAYOUT.deckA.minZ, LAYOUT.deckA.maxZ, Y_PLINTH_TOP, 40, 0x6f7175);
|
||||||
|
slab(LAYOUT.deckB.minX, LAYOUT.deckB.maxX, LAYOUT.deckB.minZ, LAYOUT.deckB.maxZ, Y_PLINTH_TOP, 40, 0x6f7175);
|
||||||
|
slab(LAYOUT.mixer.minX, LAYOUT.mixer.maxX, LAYOUT.mixer.minZ, LAYOUT.mixer.maxZ, LAYOUT.mixer.topY, 27, 0x26262a);
|
||||||
|
// back wall / patch-bay context
|
||||||
|
const backWall = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(180, 100, 3),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0xc7b085, roughness: 0.9 }),
|
||||||
|
);
|
||||||
|
backWall.position.set(224, 90, LAYOUT.backWallZ + 1.5);
|
||||||
|
scene.add(backWall);
|
||||||
|
|
||||||
|
// ---- Machines (demo quest positions: tiny crossfader dust plug) ----
|
||||||
|
const world = new MockWorld();
|
||||||
|
const CROSS_SLOT = { min: [221, 67, 54] as Vec3, max: [223, 67, 54] as Vec3 };
|
||||||
|
const machines = createMachines(world, { crossfaderSlot: CROSS_SLOT });
|
||||||
|
for (const o of machines.getObject3Ds()) scene.add(o as THREE.Object3D);
|
||||||
|
|
||||||
|
const platterA = machines.machines.find((m) => m.id === 'platterA') as Platter;
|
||||||
|
const platterB = machines.machines.find((m) => m.id === 'platterB') as Platter;
|
||||||
|
const pitchA = machines.machines.find((m) => m.id === 'fader_pitchA') as Fader;
|
||||||
|
|
||||||
|
// ---- Voxel viz for the mock overlay blocks ----
|
||||||
|
const unitBox = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
const blockMeshes = new Map<number, THREE.Mesh>();
|
||||||
|
function vidx(x: number, y: number, z: number): number { return (x * WORLD_Y + y) * WORLD_Z + z; }
|
||||||
|
function setViz(x: number, y: number, z: number, id: BlockId): void {
|
||||||
|
const k = vidx(x, y, z);
|
||||||
|
const ex = blockMeshes.get(k);
|
||||||
|
if (ex) { scene.remove(ex); blockMeshes.delete(k); }
|
||||||
|
if (id !== AIR) {
|
||||||
|
const m = new THREE.Mesh(unitBox, blockMaterial(id));
|
||||||
|
m.position.set(x + 0.5, y + 0.5, z + 0.5);
|
||||||
|
scene.add(m);
|
||||||
|
blockMeshes.set(k, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function seed(x: number, y: number, z: number, id: BlockId): void { world.setBlock(x, y, z, id); setViz(x, y, z, id); }
|
||||||
|
|
||||||
|
// crossfader dust plug (mine these to free the crossfader)
|
||||||
|
for (let x = CROSS_SLOT.min[0]; x <= CROSS_SLOT.max[0]; x++) seed(x, 67, 54, 26); // dust
|
||||||
|
// blown fuse at the fuse box (break it, then seat a glass fuse with E)
|
||||||
|
const FB = machines.quest.pos.fuseBox.map(Math.floor) as Vec3;
|
||||||
|
seed(FB[0], FB[1], FB[2], 5); // matte_black "blown fuse"
|
||||||
|
// raycast test wall between Deck A and the mixer
|
||||||
|
for (let z = 88; z <= 94; z++) for (let y = 82; y <= 85; y++) seed(160, y, z, (y + z) % 2 ? 13 : 26);
|
||||||
|
|
||||||
|
// keep the viz in sync with gameplay edits
|
||||||
|
bus.on('block:break', (e) => setViz(e.x, e.y, e.z, AIR));
|
||||||
|
bus.on('block:place', (e) => setViz(e.x, e.y, e.z, e.id));
|
||||||
|
|
||||||
|
// ---- Mock player (IPlayerView), first-person free-fly ----
|
||||||
|
const mockPlayer = {
|
||||||
|
position: [LAYOUT.deckA.spindleX, 86, LAYOUT.deckA.spindleZ] as Vec3,
|
||||||
|
eye: [0, 0, 0] as Vec3,
|
||||||
|
lookDir: [0, 0, 1] as Vec3,
|
||||||
|
onGround: false,
|
||||||
|
groundedOn: null as string | null,
|
||||||
|
};
|
||||||
|
let yaw = Math.PI / 2; // face +X (toward the mixer)
|
||||||
|
let pitch = -0.12;
|
||||||
|
function refreshView(): void {
|
||||||
|
const cp = Math.cos(pitch);
|
||||||
|
mockPlayer.lookDir[0] = Math.sin(yaw) * cp;
|
||||||
|
mockPlayer.lookDir[1] = Math.sin(pitch);
|
||||||
|
mockPlayer.lookDir[2] = Math.cos(yaw) * cp;
|
||||||
|
mockPlayer.eye[0] = mockPlayer.position[0];
|
||||||
|
mockPlayer.eye[1] = mockPlayer.position[1] + PLAYER.eyeHeight;
|
||||||
|
mockPlayer.eye[2] = mockPlayer.position[2];
|
||||||
|
}
|
||||||
|
refreshView();
|
||||||
|
machines.attachPlayer(mockPlayer as IPlayerView);
|
||||||
|
|
||||||
|
// ---- Interaction + hotbar ----
|
||||||
|
const hotbar = new Hotbar();
|
||||||
|
hotbar.add(26, 32); // dust
|
||||||
|
hotbar.add(13, 32); // knob_silver
|
||||||
|
hotbar.add(QUEST_ITEMS.stylus, 4); // chrome stylus
|
||||||
|
hotbar.add(QUEST_ITEMS.fuse, 4); // glass fuse
|
||||||
|
hotbar.add(18, 16); // led_red
|
||||||
|
const interaction = new Interaction(world, mockPlayer as IPlayerView, machines, hotbar);
|
||||||
|
scene.add(interaction.getObject3D());
|
||||||
|
|
||||||
|
// ---- Input ----
|
||||||
|
const keys = new Set<string>();
|
||||||
|
let locked = false;
|
||||||
|
const canvas = renderer.domElement;
|
||||||
|
canvas.addEventListener('click', () => { if (!locked) canvas.requestPointerLock(); });
|
||||||
|
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
document.addEventListener('pointerlockchange', () => { locked = document.pointerLockElement === canvas; });
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!locked) return;
|
||||||
|
yaw -= e.movementX * 0.0022;
|
||||||
|
pitch -= e.movementY * 0.0022;
|
||||||
|
pitch = Math.max(-1.5, Math.min(1.5, pitch));
|
||||||
|
});
|
||||||
|
canvas.addEventListener('mousedown', (e) => {
|
||||||
|
if (!locked) return;
|
||||||
|
if (e.button === 0) interaction.startMining();
|
||||||
|
else if (e.button === 2) interaction.place();
|
||||||
|
});
|
||||||
|
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')) {
|
||||||
|
const n = Number(e.code.slice(5));
|
||||||
|
if (n >= 1 && n <= 9) hotbar.setActive(n - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('keyup', (e) => keys.delete(e.code));
|
||||||
|
|
||||||
|
function moveTick(dt: number): void {
|
||||||
|
const spd = (keys.has('KeyR') ? 90 : 34) * dt;
|
||||||
|
const fx = Math.sin(yaw), fz = Math.cos(yaw);
|
||||||
|
const rx = Math.cos(yaw), rz = -Math.sin(yaw);
|
||||||
|
const p = mockPlayer.position;
|
||||||
|
if (keys.has('ArrowUp') || keys.has('KeyW')) { p[0] += fx * spd; p[2] += fz * spd; }
|
||||||
|
if (keys.has('ArrowDown') || keys.has('KeyS')) { p[0] -= fx * spd; p[2] -= fz * spd; }
|
||||||
|
if (keys.has('ArrowRight') || keys.has('KeyD')) { p[0] += rx * spd; p[2] += rz * spd; }
|
||||||
|
if (keys.has('ArrowLeft') || keys.has('KeyA')) { p[0] -= rx * spd; p[2] -= rz * spd; }
|
||||||
|
if (keys.has('Space')) p[1] += spd;
|
||||||
|
if (keys.has('ShiftLeft') || keys.has('ShiftRight')) p[1] -= spd;
|
||||||
|
refreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Panel + status state (declared before buildPanel / frame reference them) ----
|
||||||
|
let timeScale = 1;
|
||||||
|
let statusEl!: HTMLElement;
|
||||||
|
const nodeDots = new Map<string, HTMLElement>();
|
||||||
|
buildPanel();
|
||||||
|
|
||||||
|
// ---- Main loop ----
|
||||||
|
let last = performance.now();
|
||||||
|
let acc = 0;
|
||||||
|
function frame(now: number): void {
|
||||||
|
const dt = Math.min(0.05, (now - last) / 1000);
|
||||||
|
last = now;
|
||||||
|
resize(); // pick up the real pane size once it lays out
|
||||||
|
moveTick(dt);
|
||||||
|
|
||||||
|
acc += dt * timeScale;
|
||||||
|
let steps = 0;
|
||||||
|
while (acc >= FIXED_DT && steps < MAX_SUBSTEPS * 4) { machines.update(FIXED_DT); acc -= FIXED_DT; steps++; }
|
||||||
|
if (acc > FIXED_DT) acc = 0;
|
||||||
|
|
||||||
|
interaction.update(dt);
|
||||||
|
|
||||||
|
camera.position.set(mockPlayer.eye[0], mockPlayer.eye[1], mockPlayer.eye[2]);
|
||||||
|
camera.lookAt(mockPlayer.eye[0] + mockPlayer.lookDir[0], mockPlayer.eye[1] + mockPlayer.lookDir[1], mockPlayer.eye[2] + mockPlayer.lookDir[2]);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
updateStatus();
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
// ---- Bus logging ----
|
||||||
|
for (const ev of ['machine:interact', 'platter:state', 'fader:move', 'signal:repair', 'game:win', 'block:break', 'block:place'] as const) {
|
||||||
|
bus.on(ev, (p) => console.log(`[bus] ${ev}`, p));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug handle (demo only): poke the machine set / step the sim from the console,
|
||||||
|
// e.g. TURNCRAFT_D.step(120) to advance 2 s when a background tab pauses rAF.
|
||||||
|
(window as unknown as { TURNCRAFT_D?: unknown }).TURNCRAFT_D = {
|
||||||
|
machines, interaction, hotbar, world, bus, platterA, platterB, pitchA, player: mockPlayer,
|
||||||
|
step: (n = 1) => { for (let i = 0; i < n; i++) machines.update(FIXED_DT); },
|
||||||
|
// Aim the mock player's eye at a world point (for headless interaction tests).
|
||||||
|
aimAt: (x: number, y: number, z: number, from = 3) => {
|
||||||
|
const dx = x - mockPlayer.position[0], dy = y - (mockPlayer.position[1] + PLAYER.eyeHeight), dz = z - mockPlayer.position[2];
|
||||||
|
const L = Math.hypot(dx, dy, dz) || 1;
|
||||||
|
// place the eye `from` voxels back along the aim so the target is within reach
|
||||||
|
mockPlayer.position[0] = x - (dx / L) * from;
|
||||||
|
mockPlayer.position[1] = y - (dy / L) * from - PLAYER.eyeHeight;
|
||||||
|
mockPlayer.position[2] = z - (dz / L) * from;
|
||||||
|
refreshView();
|
||||||
|
mockPlayer.lookDir[0] = dx / L; mockPlayer.lookDir[1] = dy / L; mockPlayer.lookDir[2] = dz / L;
|
||||||
|
mockPlayer.eye[0] = mockPlayer.position[0];
|
||||||
|
mockPlayer.eye[1] = mockPlayer.position[1] + PLAYER.eyeHeight;
|
||||||
|
mockPlayer.eye[2] = mockPlayer.position[2];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================ panel + status ================================
|
||||||
|
|
||||||
|
function focus(pos: Vec3, y: number, pt: number): void {
|
||||||
|
mockPlayer.position[0] = pos[0]; mockPlayer.position[1] = pos[1]; mockPlayer.position[2] = pos[2];
|
||||||
|
yaw = y; pitch = pt; refreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPanel(): void {
|
||||||
|
const panel = document.getElementById('panel')!;
|
||||||
|
const h = (t: string) => { const e = document.createElement('h2'); e.textContent = t; panel.appendChild(e); };
|
||||||
|
const p = (t: string) => { const e = document.createElement('div'); e.className = 'legend'; e.textContent = t; panel.appendChild(e); };
|
||||||
|
const btn = (label: string, fn: () => void) => {
|
||||||
|
const b = document.createElement('button'); b.textContent = label; b.onclick = fn; return b;
|
||||||
|
};
|
||||||
|
const row = (...els: HTMLElement[]) => { const r = document.createElement('div'); r.className = 'row'; els.forEach((e) => r.appendChild(e)); panel.appendChild(r); };
|
||||||
|
|
||||||
|
const title = document.createElement('h1');
|
||||||
|
title.textContent = 'TURNCRAFT · Lane D';
|
||||||
|
panel.appendChild(title);
|
||||||
|
p('Click view to look (Esc frees mouse). Arrows/WASD move · Space/Shift fly · L-drag mine · R-click place · E use · 1–9 hotbar.');
|
||||||
|
|
||||||
|
h('Decks');
|
||||||
|
row(
|
||||||
|
btn('Deck A ▶⏸', () => platterA.togglePlay()),
|
||||||
|
btn('33', () => platterA.setSpeed(33)),
|
||||||
|
btn('45', () => platterA.setSpeed(45)),
|
||||||
|
);
|
||||||
|
row(
|
||||||
|
btn('pitch −', () => pitchA.setValue(pitchA.getValue() - 0.1)),
|
||||||
|
btn('pitch +', () => pitchA.setValue(pitchA.getValue() + 0.1)),
|
||||||
|
btn('Log ride velocity', logRideVelocity),
|
||||||
|
);
|
||||||
|
row(
|
||||||
|
btn('Deck B ▶⏸', () => platterB.togglePlay()),
|
||||||
|
btn('33', () => platterB.setSpeed(33)),
|
||||||
|
btn('45', () => platterB.setSpeed(45)),
|
||||||
|
);
|
||||||
|
|
||||||
|
h('Quest — simulate a repair path');
|
||||||
|
const nodeRow = document.createElement('div');
|
||||||
|
for (const n of NODES) {
|
||||||
|
const dot = document.createElement('span'); dot.className = 'node'; nodeDots.set(n, dot);
|
||||||
|
const b = btn(n, () => { const ok = machines.quest.repair(n); if (!ok && n === 'power') console.warn('power needs the other four first'); });
|
||||||
|
const wrap = document.createElement('span'); wrap.style.marginRight = '8px'; wrap.appendChild(dot); wrap.appendChild(b);
|
||||||
|
nodeRow.appendChild(wrap);
|
||||||
|
}
|
||||||
|
panel.appendChild(nodeRow);
|
||||||
|
|
||||||
|
h('Fly to');
|
||||||
|
row(
|
||||||
|
btn('Deck A', () => focus([LAYOUT.deckA.spindleX, 88, LAYOUT.deckA.spindleZ], Math.PI / 2, -0.15)),
|
||||||
|
btn('Mixer', () => focus([224, 76, 78], Math.PI, -0.55)),
|
||||||
|
btn('Deck B', () => focus([LAYOUT.deckB.spindleX, 88, LAYOUT.deckB.spindleZ], -Math.PI / 2, -0.15)),
|
||||||
|
);
|
||||||
|
row(
|
||||||
|
btn('Crossfader', () => focus([224, 74, 60], Math.PI, -0.5)),
|
||||||
|
btn('RCA plug', () => focus([224, 98, 158], 0, -0.2)),
|
||||||
|
btn('Fuse box', () => focus([FB[0], FB[1] + 5, FB[2] - 7], 0, -0.35)),
|
||||||
|
btn('Test wall', () => focus([156, 84, 91], Math.PI / 2, 0)),
|
||||||
|
);
|
||||||
|
row(
|
||||||
|
btn('Tonearm A', () => focus([100, 92, 122], Math.PI * 0.75, -0.35)),
|
||||||
|
btn('Fast tonearm ×120', () => { timeScale = timeScale === 1 ? 120 : 1; }),
|
||||||
|
);
|
||||||
|
|
||||||
|
h('Status');
|
||||||
|
statusEl = document.createElement('div'); statusEl.id = 'status'; panel.appendChild(statusEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logRideVelocity(): void {
|
||||||
|
const col = platterA.getColliders()[0];
|
||||||
|
const cx = LAYOUT.deckA.spindleX, cz = LAYOUT.deckA.spindleZ, y = 85;
|
||||||
|
const expectedOmega = (platterA.currentRpm() * Math.PI) / 30;
|
||||||
|
for (const r of [10, 41]) {
|
||||||
|
const v = col.velocityAt(cx + r, y, cz);
|
||||||
|
const speed = Math.hypot(v[0], v[1], v[2]);
|
||||||
|
console.log(`[velocityAt] r=${r}: measured |v|=${speed.toFixed(3)} v/s · expected ω·r=${(expectedOmega * r).toFixed(3)} (rpm=${platterA.currentRpm().toFixed(2)})`);
|
||||||
|
}
|
||||||
|
if (!platterA.isPlaying()) console.log('[velocityAt] (Deck A is stopped → all zero; press "Deck A ▶⏸" first)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(): void {
|
||||||
|
for (const n of NODES) nodeDots.get(n)?.classList.toggle('on', machines.quest.isRepaired(n));
|
||||||
|
const won = machines.quest.isWon();
|
||||||
|
const active = hotbar.slots[hotbar.active];
|
||||||
|
const activeName = active.count > 0 ? blockDef(active.id).name : '—';
|
||||||
|
statusEl.innerHTML =
|
||||||
|
`deck A: ${platterA.isPlaying() ? '▶' : '⏸'} rpm ${platterA.currentRpm().toFixed(2)} ` +
|
||||||
|
`deck B: ${platterB.isPlaying() ? '▶' : '⏸'} rpm ${platterB.currentRpm().toFixed(2)}\n` +
|
||||||
|
`pitch A: ${pitchA.getValue().toFixed(2)} repaired: ${machines.quest.count()}/${machines.quest.total}` +
|
||||||
|
(won ? ' <span id="won">◆ MIX RESTORED</span>' : '') + '\n' +
|
||||||
|
`hotbar[${hotbar.active + 1}]: ${activeName} ×${active.count} ` +
|
||||||
|
`crossfader slot ${slotJammed() ? 'JAMMED (mine the dust)' : 'clear'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotJammed(): boolean {
|
||||||
|
for (let x = CROSS_SLOT.min[0]; x <= CROSS_SLOT.max[0]; x++)
|
||||||
|
if (world.isSolid(x, 67, 54)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
278
src/demo/playerDemo.ts
Normal file
278
src/demo/playerDemo.ts
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
// Lane B — standalone player/physics demo. Runs with ZERO code from other
|
||||||
|
// lanes: everything the controller consumes (IVoxelWorld, KinematicColliders)
|
||||||
|
// is mocked here and clearly marked `// DEMO MOCK — not for integration`.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { PlayerController } from '../player';
|
||||||
|
import { FIXED_DT, PLATTER } from '../core/constants';
|
||||||
|
import { blockDef } from '../core/blocks';
|
||||||
|
import type { BlockId } from '../core/blocks';
|
||||||
|
import type { IVoxelWorld, KinematicCollider } from '../core/types';
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DEMO MOCK — not for integration: a hand-shaped voxel world.
|
||||||
|
// floor y=0 across 160×160, with:
|
||||||
|
// - a 1-voxel staircase (auto-step should work)
|
||||||
|
// - a 2-voxel wall (auto-step must NOT work)
|
||||||
|
// - a pit with a 2-wide plywood bridge
|
||||||
|
// - a second pit spanned only by the moving platform
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const SIZE = 160;
|
||||||
|
|
||||||
|
function shapeSolid(x: number, y: number, z: number): boolean {
|
||||||
|
if (y < 0) return true; // sentinel floor
|
||||||
|
if (x < 0 || x >= SIZE || z < 0 || z >= SIZE) return false;
|
||||||
|
|
||||||
|
const inPit1 = x >= 44 && x <= 63 && z >= 8 && z <= 27;
|
||||||
|
const bridge1 = x >= 53 && x <= 54; // 2-wide walkway across pit 1
|
||||||
|
const inPit2 = x >= 40 && x <= 80 && z >= 110 && z <= 140;
|
||||||
|
|
||||||
|
if (y === 0) {
|
||||||
|
if (inPit1 && !bridge1) return false;
|
||||||
|
if (inPit2) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (x >= 10 && x <= 15 && z >= 10 && z <= 19) return y >= 1 && y <= x - 9; // staircase
|
||||||
|
if (x === 30 && z >= 8 && z <= 40 && (y === 1 || y === 2)) return true; // 2-voxel wall
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeBlock(x: number, y: number, z: number): BlockId {
|
||||||
|
if (!shapeSolid(x, y, z)) return 0;
|
||||||
|
if (y === 0) return 1; // plywood floor
|
||||||
|
if (x === 30) return 5; // matte_black wall
|
||||||
|
return 4; // steel_grey stairs
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockWorld implements IVoxelWorld {
|
||||||
|
readonly sizeX = SIZE; readonly sizeY = 64; readonly sizeZ = SIZE;
|
||||||
|
private overlay = new Map<number, BlockId>();
|
||||||
|
private key(x: number, y: number, z: number) { return (x * 512 + y) * 512 + z; }
|
||||||
|
|
||||||
|
getBlock(x: number, y: number, z: number): BlockId {
|
||||||
|
const o = this.overlay.get(this.key(x, y, z));
|
||||||
|
return o !== undefined ? o : shapeBlock(x, y, z);
|
||||||
|
}
|
||||||
|
setBlock(x: number, y: number, z: number, id: BlockId): void {
|
||||||
|
this.overlay.set(this.key(x, y, z), id);
|
||||||
|
}
|
||||||
|
isSolid(x: number, y: number, z: number): boolean {
|
||||||
|
const o = this.overlay.get(this.key(x, y, z));
|
||||||
|
if (o !== undefined) return blockDef(o).solid;
|
||||||
|
return shapeSolid(x, y, z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const world = new MockWorld();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Three.js scene.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
document.body.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x0a0a0c);
|
||||||
|
scene.fog = new THREE.Fog(0x0a0a0c, 60, 220);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(72, window.innerWidth / window.innerHeight, 0.05, 500);
|
||||||
|
|
||||||
|
scene.add(new THREE.HemisphereLight(0xbfd0ff, 0x2a2418, 0.9));
|
||||||
|
const key = new THREE.DirectionalLight(0xfff2d8, 1.1);
|
||||||
|
key.position.set(60, 120, 30);
|
||||||
|
scene.add(key);
|
||||||
|
|
||||||
|
// Instanced voxels (built from the same shapeSolid the physics uses).
|
||||||
|
function buildVoxels(): void {
|
||||||
|
const cells: number[] = [];
|
||||||
|
for (let x = 0; x < SIZE; x++)
|
||||||
|
for (let y = 0; y <= 7; y++)
|
||||||
|
for (let z = 0; z < SIZE; z++)
|
||||||
|
if (shapeSolid(x, y, z)) cells.push(x, y, z);
|
||||||
|
|
||||||
|
const count = cells.length / 3;
|
||||||
|
const geo = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
const mat = new THREE.MeshStandardMaterial({ roughness: 0.95, metalness: 0.0 });
|
||||||
|
const mesh = new THREE.InstancedMesh(geo, mat, count);
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
const color = new THREE.Color();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const x = cells[i * 3], y = cells[i * 3 + 1], z = cells[i * 3 + 2];
|
||||||
|
dummy.position.set(x + 0.5, y + 0.5, z + 0.5);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
mesh.setMatrixAt(i, dummy.matrix);
|
||||||
|
const t = blockDef(shapeBlock(x, y, z)).tint;
|
||||||
|
color.setRGB(t[0] / 255, t[1] / 255, t[2] / 255, THREE.SRGBColorSpace);
|
||||||
|
mesh.setColorAt(i, color);
|
||||||
|
}
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||||
|
scene.add(mesh);
|
||||||
|
}
|
||||||
|
buildVoxels();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DEMO MOCK — not for integration: two kinematic colliders.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// (1) Spinning disc (the "record"). Top surface flush at y = 1 so you can walk
|
||||||
|
// straight on from the floor. velocityAt matches the mesh's +Y rotation.
|
||||||
|
const DISC_CENTER: [number, number, number] = [110, 0.75, 45];
|
||||||
|
const DISC_RADIUS = PLATTER.radius; // 41
|
||||||
|
let discRpm: number = PLATTER.rpm33;
|
||||||
|
let discAngle = 0;
|
||||||
|
const disc: KinematicCollider = {
|
||||||
|
id: 'disc',
|
||||||
|
shape: { kind: 'cylinder', center: DISC_CENTER, radius: DISC_RADIUS, halfHeight: 0.25 },
|
||||||
|
velocityAt(x, _y, z) {
|
||||||
|
const w = (discRpm * Math.PI * 2) / 60;
|
||||||
|
return [w * (z - DISC_CENTER[2]), 0, -w * (x - DISC_CENTER[0])];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const discMesh = new THREE.Group();
|
||||||
|
// Visual only: lift the mesh a hair so it doesn't z-fight the coplanar floor.
|
||||||
|
// The collider top stays at y=1 (halfHeight 0.25 about center 0.75).
|
||||||
|
discMesh.position.set(DISC_CENTER[0], DISC_CENTER[1] + 0.12, DISC_CENTER[2]);
|
||||||
|
{
|
||||||
|
const platter = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(DISC_RADIUS, DISC_RADIUS, 0.5, 64),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x14141a, roughness: 0.6, metalness: 0.2 }),
|
||||||
|
);
|
||||||
|
discMesh.add(platter);
|
||||||
|
const label = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(8, 8, 0.52, 32),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0xe8e0c8, roughness: 0.9 }),
|
||||||
|
);
|
||||||
|
discMesh.add(label);
|
||||||
|
// A radial stripe + rim strobe dot so the spin is obvious.
|
||||||
|
const stripe = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(DISC_RADIUS, 0.53, 1.2),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x4060ff, emissive: 0x2038a0, roughness: 0.5 }),
|
||||||
|
);
|
||||||
|
stripe.position.set(DISC_RADIUS / 2, 0, 0);
|
||||||
|
discMesh.add(stripe);
|
||||||
|
const dot = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(1.4, 1.4, 0.55, 16),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0xff3c32, emissive: 0xff3c32, emissiveIntensity: 1.5 }),
|
||||||
|
);
|
||||||
|
dot.position.set(DISC_RADIUS - 3, 0, 0);
|
||||||
|
discMesh.add(dot);
|
||||||
|
}
|
||||||
|
scene.add(discMesh);
|
||||||
|
|
||||||
|
// (2) Oscillating platform across pit 2.
|
||||||
|
const PLAT_BASE_X = 60, PLAT_AMP = 20, PLAT_W = 0.6, PLAT_HALF = 8;
|
||||||
|
let platVX = 0;
|
||||||
|
const platShape = {
|
||||||
|
kind: 'aabb' as const,
|
||||||
|
min: [PLAT_BASE_X - PLAT_HALF, 0, 106] as [number, number, number],
|
||||||
|
max: [PLAT_BASE_X + PLAT_HALF, 1, 144] as [number, number, number],
|
||||||
|
};
|
||||||
|
const platform: KinematicCollider = {
|
||||||
|
id: 'platform',
|
||||||
|
shape: platShape,
|
||||||
|
velocityAt() { return [platVX, 0, 0]; },
|
||||||
|
};
|
||||||
|
const platMesh = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(PLAT_HALF * 2, 1, 38),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0xbc763e, roughness: 0.7, metalness: 0.3 }),
|
||||||
|
);
|
||||||
|
platMesh.position.set(PLAT_BASE_X, 0.56, 125); // visual lift; collider top stays y=1
|
||||||
|
scene.add(platMesh);
|
||||||
|
|
||||||
|
const colliders = [disc, platform];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Player.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const player = new PlayerController(world, {
|
||||||
|
getColliders: () => colliders,
|
||||||
|
camera,
|
||||||
|
domElement: renderer.domElement,
|
||||||
|
spawn: [20, 1, 5],
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on('player:step', (e) => console.log('player:step', blockDef(e.block).name));
|
||||||
|
bus.on('player:landed', (e) => console.log('player:landed impactSpeed=', e.impactSpeed.toFixed(2)));
|
||||||
|
|
||||||
|
// DEMO MOCK — not for integration: debug handle so headless checks can drive the
|
||||||
|
// input state and read player state without pointer-lock mouse input.
|
||||||
|
(window as { __demo?: unknown }).__demo = {
|
||||||
|
player, world, colliders,
|
||||||
|
setRpm: (r: number) => { discRpm = r; },
|
||||||
|
PLATTER,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const $ = (id: string) => document.getElementById(id)!;
|
||||||
|
const hud = $('hud');
|
||||||
|
const spinBtn = $('spin'), tpBtn = $('tp'), flyBtn = $('fly'), bobBtn = $('bob');
|
||||||
|
|
||||||
|
spinBtn.addEventListener('click', () => {
|
||||||
|
discRpm = discRpm === PLATTER.rpm33 ? PLATTER.rpm45 : PLATTER.rpm33;
|
||||||
|
spinBtn.textContent = `Speed: ${discRpm === PLATTER.rpm45 ? '45' : '33'}`;
|
||||||
|
});
|
||||||
|
tpBtn.addEventListener('click', () => player.teleport([DISC_CENTER[0], 1.0, DISC_CENTER[2]]));
|
||||||
|
flyBtn.addEventListener('click', () => {
|
||||||
|
player.setFlying(!player.isFlying);
|
||||||
|
flyBtn.textContent = `Fly: ${player.isFlying ? 'on' : 'off'}`;
|
||||||
|
});
|
||||||
|
bobBtn.addEventListener('click', () => {
|
||||||
|
player.viewBob = !player.viewBob;
|
||||||
|
bobBtn.textContent = `View-bob: ${player.viewBob ? 'on' : 'off'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
camera.aspect = window.innerWidth / window.innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixed-step loop: advance colliders, then the player, then render.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let simTime = 0;
|
||||||
|
let acc = 0;
|
||||||
|
let last = performance.now();
|
||||||
|
|
||||||
|
function stepColliders(): void {
|
||||||
|
simTime += FIXED_DT;
|
||||||
|
discAngle += ((discRpm * Math.PI * 2) / 60) * FIXED_DT;
|
||||||
|
discMesh.rotation.y = discAngle;
|
||||||
|
|
||||||
|
const cx = PLAT_BASE_X + PLAT_AMP * Math.sin(simTime * PLAT_W);
|
||||||
|
platVX = PLAT_AMP * PLAT_W * Math.cos(simTime * PLAT_W);
|
||||||
|
platShape.min[0] = cx - PLAT_HALF;
|
||||||
|
platShape.max[0] = cx + PLAT_HALF;
|
||||||
|
platMesh.position.x = cx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(now: number): void {
|
||||||
|
const dt = Math.min(0.1, (now - last) / 1000);
|
||||||
|
last = now;
|
||||||
|
acc += dt;
|
||||||
|
let guard = 0;
|
||||||
|
while (acc >= FIXED_DT && guard++ < 8) {
|
||||||
|
stepColliders();
|
||||||
|
player.update(FIXED_DT);
|
||||||
|
acc -= FIXED_DT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = player.position, cv = player.carryVelocity;
|
||||||
|
const cvMag = Math.hypot(cv[0], cv[2]);
|
||||||
|
hud.textContent =
|
||||||
|
`pos ${p[0].toFixed(1)}, ${p[1].toFixed(1)}, ${p[2].toFixed(1)}\n` +
|
||||||
|
`onGround ${player.onGround}\n` +
|
||||||
|
`groundedOn ${player.groundedOn ?? '—'}\n` +
|
||||||
|
`carryVel ${cv[0].toFixed(2)}, ${cv[2].toFixed(2)} (|${cvMag.toFixed(2)}|)\n` +
|
||||||
|
`flying ${player.isFlying}`;
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
264
src/demo/worldgenDemo.ts
Normal file
264
src/demo/worldgenDemo.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
// TURNCRAFT — Lane C standalone demo (worldgen).
|
||||||
|
// Runs with ZERO code from other lanes: a tiny array-backed MockWorld here
|
||||||
|
// stands in for Lane A's VoxelWorld, and a naive InstancedMesh-per-block-id
|
||||||
|
// visualiser (flat tints from core/blocks) lets you eyeball the whole booth.
|
||||||
|
//
|
||||||
|
// HOW TO VERIFY (open /demo-worldgen.html, `npm run dev`):
|
||||||
|
// • The whole DJ booth renders: two grey turntable plinths, a black mixer
|
||||||
|
// massif between them, cable canyon + gold patch-bay wall behind, and the
|
||||||
|
// green PCB Depths under the table. Orbit with the mouse.
|
||||||
|
// • ZONE BUTTONS (top-left) fly the camera to each DESIGN §4 zone.
|
||||||
|
// • "Hide shell" toggles the plywood so you can see inside from any angle.
|
||||||
|
// • STATS panel (top-right): build time (< 500 ms), non-air voxel count,
|
||||||
|
// per-block counts, and the VALIDATION results — every line must be green.
|
||||||
|
// Any failure is also printed to the console with console.error.
|
||||||
|
// • Look for: EMPTY circular platter wells (Lane D fills them), empty fader
|
||||||
|
// slots / button recesses, the spinning-strobe red dot, the empty gold
|
||||||
|
// quest socket (red blink) on the patch bay, and the crate of blue/black
|
||||||
|
// records under the front-left hatch.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
|
import type { BlockId } from '../core/blocks';
|
||||||
|
import { AIR, BLOCKS, blockDef, isSolidId } from '../core/blocks';
|
||||||
|
import { WORLD_X, WORLD_Y, WORLD_Z, Y_PLINTH_TOP, Y_TABLETOP, LAYOUT, PLATTER } from '../core/constants';
|
||||||
|
import type { IVoxelWorld, Vec3 } from '../core/types';
|
||||||
|
import { buildBooth, SPAWN } from '../worldgen/buildBooth';
|
||||||
|
import { QUEST_POS, QUEST_REACH_POINTS, hasAdjacentAir } from '../worldgen/questPositions';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// DEMO MOCK — not for integration. A flat Uint8Array voxel store implementing
|
||||||
|
// IVoxelWorld, exactly the surface Lane A's VoxelWorld will provide.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
class MockWorld implements IVoxelWorld {
|
||||||
|
readonly sizeX = WORLD_X; readonly sizeY = WORLD_Y; readonly sizeZ = WORLD_Z;
|
||||||
|
readonly data = new Uint8Array(WORLD_X * WORLD_Y * WORLD_Z);
|
||||||
|
oob = 0;
|
||||||
|
private idx(x: number, y: number, z: number) { return (y * this.sizeZ + z) * this.sizeX + x; }
|
||||||
|
private inb(x: number, y: number, z: number) {
|
||||||
|
return x >= 0 && y >= 0 && z >= 0 && x < this.sizeX && y < this.sizeY && z < this.sizeZ;
|
||||||
|
}
|
||||||
|
getBlock(x: number, y: number, z: number): BlockId {
|
||||||
|
if (!this.inb(x, y, z)) return y < 0 ? 6 /* rubber sentinel */ : AIR;
|
||||||
|
return this.data[this.idx(x, y, z)];
|
||||||
|
}
|
||||||
|
setBlock(x: number, y: number, z: number, id: BlockId): void {
|
||||||
|
if (!this.inb(x, y, z)) { this.oob++; return; }
|
||||||
|
this.data[this.idx(x, y, z)] = id;
|
||||||
|
}
|
||||||
|
isSolid(x: number, y: number, z: number): boolean { return isSolidId(this.getBlock(x, y, z)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── build ────────────────────────────────────────────────────────────────────
|
||||||
|
const world = new MockWorld();
|
||||||
|
const t0 = performance.now();
|
||||||
|
buildBooth(world);
|
||||||
|
const buildMs = performance.now() - t0;
|
||||||
|
|
||||||
|
// ── validation (mirrors the acceptance criteria; console.error on fail) ───────
|
||||||
|
function validate(): { line: string; pass: boolean }[] {
|
||||||
|
const out: { line: string; pass: boolean }[] = [];
|
||||||
|
const add = (line: string, pass: boolean) => { out.push({ line, pass }); if (!pass) console.error('[worldgen] ' + line); };
|
||||||
|
|
||||||
|
add(`build < 500ms (${buildMs.toFixed(1)}ms)`, buildMs < 500);
|
||||||
|
add(`no out-of-bounds writes (${world.oob})`, world.oob === 0);
|
||||||
|
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sx, sy, sz] = SPAWN;
|
||||||
|
add('spawn stands on solid', isSolidId(world.getBlock(sx, sy - 1, sz)) && world.getBlock(sx, sy - 1, sz) !== AIR);
|
||||||
|
add('spawn has headroom', world.getBlock(sx, sy, sz) === AIR && world.getBlock(sx, sy + 1, sz) === AIR);
|
||||||
|
|
||||||
|
let reach = 0;
|
||||||
|
for (const p of QUEST_REACH_POINTS) if (hasAdjacentAir(world, p)) reach++;
|
||||||
|
add(`quest points reachable (${reach}/${QUEST_REACH_POINTS.length})`, reach === QUEST_REACH_POINTS.length);
|
||||||
|
|
||||||
|
// anchors land on the intended block (not a neighbour)
|
||||||
|
add('fuse anchor is the fuse block', blockDef(world.getBlock(...QUEST_POS.fuse.box)).name === 'rubber');
|
||||||
|
add('stylus anchor is the pickup block', blockDef(world.getBlock(...QUEST_POS.stylus.pickup)).name === 'chrome');
|
||||||
|
|
||||||
|
// record crate is populated (regression guard for the collapsed footprint)
|
||||||
|
let crateVinyl = 0;
|
||||||
|
for (let x = 16; x < 80; x++) for (let y = 2; y < 30; y++) for (let z = 16; z < 48; z++) {
|
||||||
|
const nm = blockDef(world.getBlock(x, y, z)).name;
|
||||||
|
if (nm === 'vinyl_blue' || nm === 'vinyl_black') crateVinyl++;
|
||||||
|
}
|
||||||
|
add(`record crate populated (${crateVinyl})`, crateVinyl > 500);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const validation = validate();
|
||||||
|
|
||||||
|
// ── stats ──────────────────────────────────────────────────────────────────
|
||||||
|
let nonAir = 0; const counts = new Map<number, number>();
|
||||||
|
for (const v of world.data) if (v !== AIR) { nonAir++; counts.set(v, (counts.get(v) ?? 0) + 1); }
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Renderer / scene
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
const app = document.getElementById('app') ?? document.body;
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
app.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x0d0d10);
|
||||||
|
scene.fog = new THREE.Fog(0x1a140e, 220, 640); // warm plywood-brown horizon
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 2000);
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
||||||
|
const key = new THREE.DirectionalLight(0xffe9c8, 1.1); key.position.set(0.4, 1, 0.25); scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0x88aaff, 0.4); fill.position.set(-0.5, 0.4, -0.6); scene.add(fill);
|
||||||
|
|
||||||
|
// ── build one InstancedMesh per block id from surface-visible voxels only ────
|
||||||
|
const geom = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
const shellMeshes: THREE.InstancedMesh[] = [];
|
||||||
|
const idToMesh = new Map<number, THREE.InstancedMesh>();
|
||||||
|
|
||||||
|
function isVisible(x: number, y: number, z: number): boolean {
|
||||||
|
// rendered if any 6-neighbour is AIR (OOB counts as air so outer faces show)
|
||||||
|
return (
|
||||||
|
world.getBlock(x + 1, y, z) === AIR || world.getBlock(x - 1, y, z) === AIR ||
|
||||||
|
world.getBlock(x, y + 1, z) === AIR || world.getBlock(x, y - 1, z) === AIR ||
|
||||||
|
world.getBlock(x, y, z + 1) === AIR || world.getBlock(x, y, z - 1) === AIR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// first pass: count visible voxels per id
|
||||||
|
const visCount = new Map<number, number>();
|
||||||
|
for (let y = 0; y < WORLD_Y; y++)
|
||||||
|
for (let z = 0; z < WORLD_Z; z++)
|
||||||
|
for (let x = 0; x < WORLD_X; x++) {
|
||||||
|
const id = world.getBlock(x, y, z);
|
||||||
|
if (id === AIR) continue;
|
||||||
|
if (!isVisible(x, y, z)) continue;
|
||||||
|
visCount.set(id, (visCount.get(id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// make meshes
|
||||||
|
const cursor = new Map<number, number>();
|
||||||
|
for (const def of BLOCKS) {
|
||||||
|
if (def.id === AIR) continue;
|
||||||
|
const n = visCount.get(def.id) ?? 0;
|
||||||
|
if (n === 0) continue;
|
||||||
|
const col = new THREE.Color(def.tint[0] / 255, def.tint[1] / 255, def.tint[2] / 255);
|
||||||
|
const mat = new THREE.MeshStandardMaterial({ color: col, roughness: 0.85, metalness: 0.05 });
|
||||||
|
if (def.emissive > 0) { mat.emissive = col.clone(); mat.emissiveIntensity = def.emissive * 1.4; }
|
||||||
|
if (def.transparent) { mat.transparent = true; mat.opacity = def.name === 'glass' ? 0.4 : 0.6; mat.depthWrite = false; }
|
||||||
|
const mesh = new THREE.InstancedMesh(geom, mat, n);
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
mesh.name = def.name;
|
||||||
|
idToMesh.set(def.id, mesh);
|
||||||
|
cursor.set(def.id, 0);
|
||||||
|
scene.add(mesh);
|
||||||
|
if (def.name === 'plywood' || def.name === 'ply_edge') shellMeshes.push(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// second pass: place instances (centre voxel at integer + 0.5)
|
||||||
|
const m4 = new THREE.Matrix4();
|
||||||
|
for (let y = 0; y < WORLD_Y; y++)
|
||||||
|
for (let z = 0; z < WORLD_Z; z++)
|
||||||
|
for (let x = 0; x < WORLD_X; x++) {
|
||||||
|
const id = world.getBlock(x, y, z);
|
||||||
|
if (id === AIR) continue;
|
||||||
|
const mesh = idToMesh.get(id);
|
||||||
|
if (!mesh || !isVisible(x, y, z)) continue;
|
||||||
|
const i = cursor.get(id)!; cursor.set(id, i + 1);
|
||||||
|
m4.makeTranslation(x + 0.5, y + 0.5, z + 0.5);
|
||||||
|
mesh.setMatrixAt(i, m4);
|
||||||
|
}
|
||||||
|
for (const mesh of idToMesh.values()) mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
const totalVisible = [...visCount.values()].reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Camera zones (DESIGN §4) — teleport buttons
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
const mixMidZ = (LAYOUT.mixer.minZ + LAYOUT.mixer.maxZ) / 2;
|
||||||
|
const zones: { name: string; target: Vec3; from: Vec3 }[] = [
|
||||||
|
{ name: 'Deck A (spawn)', target: [LAYOUT.deckA.spindleX, Y_PLINTH_TOP, LAYOUT.deckA.spindleZ], from: [LAYOUT.deckA.spindleX - 40, Y_PLINTH_TOP + 55, LAYOUT.deckA.spindleZ - 70] },
|
||||||
|
{ name: 'Mixer Massif', target: [LAYOUT.mixer.minX + 40, LAYOUT.mixer.topY, mixMidZ], from: [LAYOUT.mixer.minX + 40, LAYOUT.mixer.topY + 60, mixMidZ - 80] },
|
||||||
|
{ name: 'Deck B (silent)', target: [LAYOUT.deckB.spindleX, Y_PLINTH_TOP, LAYOUT.deckB.spindleZ], from: [LAYOUT.deckB.spindleX + 40, Y_PLINTH_TOP + 55, LAYOUT.deckB.spindleZ - 70] },
|
||||||
|
{ name: 'Cable Canyon', target: [WORLD_X / 2, Y_TABLETOP + 10, 170], from: [WORLD_X / 2, Y_TABLETOP + 70, 90] },
|
||||||
|
{ name: 'Patch Bay', target: [QUEST_POS.rca.socket[0], QUEST_POS.rca.socket[1], LAYOUT.backWallZ], from: [QUEST_POS.rca.socket[0], QUEST_POS.rca.socket[1] + 20, LAYOUT.backWallZ - 70] },
|
||||||
|
{ name: 'PCB Depths', target: [LAYOUT.mixer.minX + 20, 8, LAYOUT.mixer.minZ + 20], from: [LAYOUT.mixer.minX - 40, 40, LAYOUT.mixer.minZ - 30] },
|
||||||
|
// aims at the crate's actual centre (under-ledge, in front of Deck A); toggle "hide shell" to see in
|
||||||
|
{ name: 'Record Crate', target: [LAYOUT.rimThickness + 30, 15, LAYOUT.rimThickness + 14], from: [LAYOUT.rimThickness + 30, 58, LAYOUT.frontLipZ - 50] },
|
||||||
|
{ name: 'Whole booth', target: [WORLD_X / 2, 40, WORLD_Z / 2], from: [WORLD_X / 2 - 40, 260, -140] },
|
||||||
|
];
|
||||||
|
function goto(z: { target: Vec3; from: Vec3 }) {
|
||||||
|
camera.position.set(z.from[0], z.from[1], z.from[2]);
|
||||||
|
controls.target.set(z.target[0], z.target[1], z.target[2]);
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
goto(zones[0]);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// UI (built in code; HTML stays minimal)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
const css = 'font:12px/1.5 ui-monospace,Menlo,monospace;color:#ddd;background:rgba(16,16,20,.86);' +
|
||||||
|
'border:1px solid #333;border-radius:8px;padding:10px 12px;position:fixed;z-index:10;backdrop-filter:blur(4px)';
|
||||||
|
|
||||||
|
const nav = document.createElement('div');
|
||||||
|
nav.style.cssText = css + ';left:12px;top:12px;max-width:180px';
|
||||||
|
nav.innerHTML = '<b style="color:#8cf">ZONES</b><br>';
|
||||||
|
for (const z of zones) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.textContent = z.name;
|
||||||
|
b.style.cssText = 'display:block;width:100%;margin:3px 0;padding:4px;cursor:pointer;background:#222;color:#ddd;border:1px solid #444;border-radius:5px;font:inherit;text-align:left';
|
||||||
|
b.onclick = () => goto(z);
|
||||||
|
nav.appendChild(b);
|
||||||
|
}
|
||||||
|
const shellToggle = document.createElement('label');
|
||||||
|
shellToggle.style.cssText = 'display:block;margin-top:8px;cursor:pointer';
|
||||||
|
shellToggle.innerHTML = '<input type="checkbox" id="hideShell"> hide plywood shell';
|
||||||
|
nav.appendChild(shellToggle);
|
||||||
|
document.body.appendChild(nav);
|
||||||
|
(shellToggle.querySelector('input') as HTMLInputElement).onchange = (e) => {
|
||||||
|
const hide = (e.target as HTMLInputElement).checked;
|
||||||
|
for (const m of shellMeshes) m.visible = !hide;
|
||||||
|
};
|
||||||
|
|
||||||
|
const topBlocks = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
|
||||||
|
.map(([id, c]) => `${blockDef(id).name}: ${c.toLocaleString()}`).join('<br>');
|
||||||
|
const valHtml = validation.map(v => `<span style="color:${v.pass ? '#5e5' : '#f55'}">${v.pass ? '✓' : '✗'} ${v.line}</span>`).join('<br>');
|
||||||
|
const stats = document.createElement('div');
|
||||||
|
stats.style.cssText = css + ';right:12px;top:12px;max-width:260px';
|
||||||
|
stats.innerHTML =
|
||||||
|
`<b style="color:#8cf">TURNCRAFT · Lane C</b><br>` +
|
||||||
|
`build: <b>${buildMs.toFixed(1)} ms</b><br>` +
|
||||||
|
`non-air voxels: <b>${nonAir.toLocaleString()}</b><br>` +
|
||||||
|
`visible (rendered): <b>${totalVisible.toLocaleString()}</b><br>` +
|
||||||
|
`block types: <b>${counts.size}/30</b><br>` +
|
||||||
|
`<hr style="border-color:#333"><b style="color:#8cf">VALIDATION</b><br>${valHtml}` +
|
||||||
|
`<hr style="border-color:#333"><b style="color:#8cf">TOP BLOCKS</b><br>${topBlocks}`;
|
||||||
|
document.body.appendChild(stats);
|
||||||
|
|
||||||
|
// small axes + a subtle ground shadow-catcher for orientation
|
||||||
|
scene.add(new THREE.AxesHelper(30));
|
||||||
|
|
||||||
|
// ── loop ──────────────────────────────────────────────────────────────────
|
||||||
|
addEventListener('resize', () => {
|
||||||
|
camera.aspect = innerWidth / innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
});
|
||||||
|
function tick() {
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
|
||||||
|
console.info(`[worldgen demo] built in ${buildMs.toFixed(1)}ms · ${nonAir.toLocaleString()} voxels · ${totalVisible.toLocaleString()} rendered · validation ${validation.every(v => v.pass) ? 'PASS' : 'FAIL'}`);
|
||||||
129
src/engine/ChunkManager.ts
Normal file
129
src/engine/ChunkManager.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
// TURNCRAFT — Lane A. Builds and maintains chunk meshes from a VoxelWorld.
|
||||||
|
//
|
||||||
|
// buildAll() meshes every non-empty chunk once (initial world load). update()
|
||||||
|
// drains the world's dirty set with a per-frame budget, remeshing edited
|
||||||
|
// chunks and swapping their geometry in place. Each chunk owns up to two meshes
|
||||||
|
// (opaque + transparent) sharing the atlas's two singleton materials. Vertex
|
||||||
|
// positions are baked in absolute world coords, so each mesh's bounding sphere
|
||||||
|
// drives Three.js frustum culling with no per-chunk matrix work.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { VoxelWorld } from './VoxelWorld';
|
||||||
|
import type { Atlas } from './atlas';
|
||||||
|
import { meshChunk } from './mesher';
|
||||||
|
|
||||||
|
interface ChunkMeshes {
|
||||||
|
opaque: THREE.Mesh | null;
|
||||||
|
transparent: THREE.Mesh | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChunkManager {
|
||||||
|
private readonly meshes = new Map<number, ChunkMeshes>();
|
||||||
|
private readonly dirtyScratch: number[] = []; // reused each frame (cx,cy,cz,...)
|
||||||
|
|
||||||
|
/** ms spent in the most recent remesh batch (for demo/perf HUD). */
|
||||||
|
lastRemeshMs = 0;
|
||||||
|
/** chunks remeshed in the most recent update(). */
|
||||||
|
lastRemeshCount = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly world: VoxelWorld,
|
||||||
|
private readonly scene: THREE.Scene,
|
||||||
|
private readonly atlas: Atlas,
|
||||||
|
/** max chunks remeshed per update() call (per frame). */
|
||||||
|
private readonly remeshBudget = 4,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private key(cx: number, cy: number, cz: number): number {
|
||||||
|
return (cy * this.world.numChunksZ + cz) * this.world.numChunksX + cx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mesh every non-empty chunk. Call once after worldgen fills the world. */
|
||||||
|
buildAll(): void {
|
||||||
|
const t0 = now();
|
||||||
|
for (let cy = 0; cy < this.world.numChunksY; cy++)
|
||||||
|
for (let cz = 0; cz < this.world.numChunksZ; cz++)
|
||||||
|
for (let cx = 0; cx < this.world.numChunksX; cx++) {
|
||||||
|
if (this.world.isChunkEmpty(cx, cy, cz)) continue;
|
||||||
|
this.remeshChunk(cx, cy, cz);
|
||||||
|
this.world.clearDirty(cx, cy, cz);
|
||||||
|
}
|
||||||
|
// Any dirt flagged on empty chunks (e.g. fillBox margins) is now moot.
|
||||||
|
this.world.forEachDirtyChunk((cx, cy, cz) => this.world.clearDirty(cx, cy, cz));
|
||||||
|
this.lastRemeshMs = now() - t0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drain the dirty set, budgeted. Call once per frame. */
|
||||||
|
update(): void {
|
||||||
|
if (this.world.dirtyCount === 0) { this.lastRemeshCount = 0; return; }
|
||||||
|
const t0 = now();
|
||||||
|
const batch = this.dirtyScratch;
|
||||||
|
batch.length = 0;
|
||||||
|
this.world.forEachDirtyChunk((cx, cy, cz) => { batch.push(cx, cy, cz); });
|
||||||
|
|
||||||
|
const limit = Math.min(this.remeshBudget, batch.length / 3) | 0;
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const cx = batch[i * 3], cy = batch[i * 3 + 1], cz = batch[i * 3 + 2];
|
||||||
|
this.remeshChunk(cx, cy, cz);
|
||||||
|
this.world.clearDirty(cx, cy, cz);
|
||||||
|
}
|
||||||
|
this.lastRemeshCount = limit;
|
||||||
|
this.lastRemeshMs = now() - t0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get meshCount(): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const m of this.meshes.values()) {
|
||||||
|
if (m.opaque) n++;
|
||||||
|
if (m.transparent) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private remeshChunk(cx: number, cy: number, cz: number): void {
|
||||||
|
const geo = meshChunk(this.world, cx, cy, cz);
|
||||||
|
const k = this.key(cx, cy, cz);
|
||||||
|
let entry = this.meshes.get(k);
|
||||||
|
if (!entry) { entry = { opaque: null, transparent: null }; this.meshes.set(k, entry); }
|
||||||
|
|
||||||
|
entry.opaque = this.applyGeometry(entry.opaque, geo.opaque, this.atlas.opaqueMaterial);
|
||||||
|
entry.transparent =
|
||||||
|
this.applyGeometry(entry.transparent, geo.transparent, this.atlas.transparentMaterial);
|
||||||
|
|
||||||
|
if (!entry.opaque && !entry.transparent) this.meshes.delete(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyGeometry(
|
||||||
|
mesh: THREE.Mesh | null, geo: THREE.BufferGeometry | null, material: THREE.Material,
|
||||||
|
): THREE.Mesh | null {
|
||||||
|
if (!geo) {
|
||||||
|
if (mesh) { this.scene.remove(mesh); disposeGeom(mesh); }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (mesh) {
|
||||||
|
disposeGeom(mesh);
|
||||||
|
mesh.geometry = geo;
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
const m = new THREE.Mesh(geo, material);
|
||||||
|
m.frustumCulled = true;
|
||||||
|
this.scene.add(m);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const entry of this.meshes.values()) {
|
||||||
|
if (entry.opaque) { this.scene.remove(entry.opaque); disposeGeom(entry.opaque); }
|
||||||
|
if (entry.transparent) { this.scene.remove(entry.transparent); disposeGeom(entry.transparent); }
|
||||||
|
}
|
||||||
|
this.meshes.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeGeom(mesh: THREE.Mesh): void {
|
||||||
|
(mesh.geometry as THREE.BufferGeometry).dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function now(): number {
|
||||||
|
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||||
|
}
|
||||||
94
src/engine/HANDOFF.md
Normal file
94
src/engine/HANDOFF.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Lane A — Voxel Engine & Rendering — HANDOFF
|
||||||
|
|
||||||
|
Status: **complete.** All of A1–A5 implemented, browser-verified, and typechecking
|
||||||
|
clean in isolation. No stubs.
|
||||||
|
|
||||||
|
## Public API (10 lines)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createRenderer, VoxelWorld, ChunkManager, buildAtlas, meshChunk } from './engine';
|
||||||
|
const { renderer, scene, camera, atlas, setEmissiveBoost, render, resize, dispose } = createRenderer(container);
|
||||||
|
const world = new VoxelWorld(WORLD_X, WORLD_Y, WORLD_Z); // implements IVoxelWorld
|
||||||
|
world.fillBox(x0,y0,z0,x1,y1,z1,id); // bulk paint (worldgen)
|
||||||
|
world.setBlock(x,y,z,id); world.getBlock(x,y,z); world.isSolid(x,y,z);
|
||||||
|
const chunks = new ChunkManager(world, scene, atlas, /*remeshBudget*/ 4);
|
||||||
|
chunks.buildAll(); // once, after worldgen fills the world (< 3 s; ~25 ms here)
|
||||||
|
chunks.update(); // once per frame — drains dirty chunks, budgeted
|
||||||
|
setEmissiveBoost(v); // Lane E pulses LED glow 0..3 (default 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
`atlas` also carries `opaqueMaterial` / `transparentMaterial` (ChunkManager uses them).
|
||||||
|
`meshChunk(world,cx,cy,cz)` is exported for anyone who wants raw geometry.
|
||||||
|
|
||||||
|
## What's done
|
||||||
|
|
||||||
|
- **VoxelWorld** — 32³ Uint8Array chunks, lazily allocated; oob reads AIR for y≥0 and a
|
||||||
|
solid sentinel for y<0; `setBlock` marks the chunk + any bordering neighbour dirty;
|
||||||
|
`fillBox` writes raw with a chunk-resolve cache and dirties the touched range +1;
|
||||||
|
`readChunkPadded` gives the mesher a 1-voxel-padded id buffer (fast interior copy,
|
||||||
|
getBlock only on the border shell). Dirty API: `forEachDirtyChunk`/`clearDirty`/`dirtyCount`.
|
||||||
|
- **Atlas** — procedural 8×4 grid of 16×16 tiles, one per block id, all 10 patterns
|
||||||
|
painted deterministically; a parallel emissive atlas (black except LED/strobe). Two
|
||||||
|
`MeshStandardMaterial`s (opaque, transparent) with an `onBeforeCompile` patch that
|
||||||
|
fracts the per-quad tile-repeat UV into the block's atlas cell. `NearestFilter`, no
|
||||||
|
mips, no padding needed (fract keeps the sample strictly inside a cell → no bleed).
|
||||||
|
- **Greedy mesher** — per-chunk, 6 face directions, cells keyed by `(blockId, 4×AO)`,
|
||||||
|
coplanar equal cells merged into one quad; classic 3-neighbour AO baked to vertex
|
||||||
|
colour; quad diagonal flips on AO anisotropy; correct outward winding per direction;
|
||||||
|
opaque and transparent faces split into separate geometries; absolute world coords
|
||||||
|
baked in (bounding sphere → Three.js frustum culling per chunk).
|
||||||
|
- **ChunkManager** — `buildAll()` meshes every non-empty chunk once; `update()` drains
|
||||||
|
the dirty set at `remeshBudget` chunks/frame, disposing+swapping geometry in place;
|
||||||
|
removes emptied meshes; `meshCount`/`lastRemeshMs`/`lastRemeshCount` for HUDs.
|
||||||
|
- **Renderer** — WebGLRenderer (AA off, pixelRatio ≤2), sRGB output, ACES tone mapping,
|
||||||
|
75° camera, warm key `DirectionalLight` + cool `HemisphereLight` + small ambient,
|
||||||
|
warm plywood fog, near-black warm background; self-wiring `ResizeObserver`; the single
|
||||||
|
`setEmissiveBoost` hook Lane E drives.
|
||||||
|
|
||||||
|
## Verified in `demo-engine.html` (all acceptance boxes)
|
||||||
|
|
||||||
|
Full 448×160×256 world + test pattern: **build ~25 ms, 46–47 draw calls, ~1.5 k tris,
|
||||||
|
86–128 fps**. AO visibly darkens the steel cave's inner corners with no chunk-seam cracks.
|
||||||
|
Glass arch is see-through onto the geometry behind it; blue vinyl blends over the cream
|
||||||
|
wall. All 31 block ids render with distinct patterns (labeled pillar row); LEDs glow and
|
||||||
|
the emissive slider scales them 0→3. Torture (500 edits/s): **min fps 128**, dirty set
|
||||||
|
drains, edits appear within ~1 frame.
|
||||||
|
|
||||||
|
## Post-build adversarial review (done)
|
||||||
|
|
||||||
|
A 5-dimension correctness review + programmatic verification caught and fixed two bugs:
|
||||||
|
- **HIGH (fixed):** Z-axis winding table (`AXIS[2]`) had flip flags swapped → every opaque block's
|
||||||
|
+Z/−Z faces were wound backwards and back-face-culled. Verified via a per-triangle geometric-vs-
|
||||||
|
stored-normal check: 520 Z tris inverted before, **0 mismatches after** (all six normals, opaque
|
||||||
|
and transparent). If you ever touch the mesher, re-run that check (see `LANE_A_update.md`).
|
||||||
|
- **LOW (fixed):** `renderer.ts` resize fallback used `??` where `clientWidth` returns `0` (not
|
||||||
|
nullish); switched to `||` so a 0-width container falls back to the window.
|
||||||
|
|
||||||
|
## Contract friction / notes for the integrator
|
||||||
|
|
||||||
|
1. **Repo-wide `npm run typecheck` currently FAILS — but not on Lane A code.** The only
|
||||||
|
error is in Lane C's `src/worldgen/anchors.ts` (a `LAYOUT.deckB` assigned where a
|
||||||
|
`deckA`-typed value is expected). Lane A compiles clean on its own; verified with an
|
||||||
|
isolated tsconfig over `src/core` + `src/engine` + `src/demo/engineDemo.ts`. Not my
|
||||||
|
file to touch — flagging for whoever owns worldgen / integration.
|
||||||
|
2. **Dev-only console warning** "Multiple instances of Three.js" appears on the demo page
|
||||||
|
because `OrbitControls` (demo convenience only, not in the shipped engine API) pulls
|
||||||
|
`three` through a second Vite resolution. Harmless; it will not appear in the
|
||||||
|
integrated game (no OrbitControls there). If desired, add `resolve.dedupe: ['three']`
|
||||||
|
to `vite.config` — but that's a shared config file, so I left it to the integrator.
|
||||||
|
3. **Draw-call budget at full booth scale.** The demo (empty-ish world) is ~47 calls,
|
||||||
|
far under 300. The *dense* authored booth could push non-empty-chunks × 2
|
||||||
|
(opaque+transparent) upward; if it ever nears 300, the cheap wins are merging tiny
|
||||||
|
transparent geometries or worker-threaded meshing. **No web workers in v1** (per brief);
|
||||||
|
meshing is fully synchronous and comfortably inside the per-frame budget so far.
|
||||||
|
4. **Wiring reminders** (match `docs/INTEGRATION.md`): call `chunks.buildAll()` once after
|
||||||
|
`buildBooth(world)`, then `chunks.update()` every frame in the loop. `createRenderer`
|
||||||
|
builds the atlas for you and exposes it as `.atlas`; hand that same object to
|
||||||
|
`ChunkManager` so `setEmissiveBoost` and the meshes share one material set. Do **not**
|
||||||
|
call `buildAtlas()` a second time — you'd get materials `setEmissiveBoost` can't reach.
|
||||||
|
|
||||||
|
## Owned paths (no writes outside these)
|
||||||
|
|
||||||
|
`src/engine/**`, `src/demo/engineDemo.ts`, `demo-engine.html`. Core untouched.
|
||||||
|
`src/demo/engineDemo.ts` exposes a tiny dev-only `window.__demo.look()` camera hook,
|
||||||
|
clearly commented — used for inspection, harmless in normal play.
|
||||||
234
src/engine/VoxelWorld.ts
Normal file
234
src/engine/VoxelWorld.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
// TURNCRAFT — Lane A. Chunked voxel storage implementing IVoxelWorld.
|
||||||
|
//
|
||||||
|
// One Uint8Array per 32^3 chunk, allocated lazily (an unallocated chunk reads
|
||||||
|
// as all-AIR). Coordinates are Y-up, 1 voxel = 1 unit; voxel (x,y,z) occupies
|
||||||
|
// [x,x+1)x[y,y+1)x[z,z+1). All world sizes come in as multiples of CHUNK.
|
||||||
|
|
||||||
|
import { CHUNK } from '../core/constants';
|
||||||
|
import { AIR, isSolidId, type BlockId } from '../core/blocks';
|
||||||
|
import type { IVoxelWorld } from '../core/types';
|
||||||
|
|
||||||
|
// Out-of-bounds below y=0 reads as a solid sentinel so the player never falls
|
||||||
|
// out of the world and the floor's underside is culled. Value is arbitrary as
|
||||||
|
// long as it is a solid, opaque block id (never rendered — nothing meshes y<0).
|
||||||
|
const OOB_SOLID: BlockId = 4; // steel_grey (solid, opaque)
|
||||||
|
|
||||||
|
const CH = CHUNK;
|
||||||
|
const CH2 = CH * CH;
|
||||||
|
const CH3 = CH * CH * CH;
|
||||||
|
const P = CH + 2; // padded chunk edge (1-voxel border)
|
||||||
|
const P3 = P * P * P;
|
||||||
|
|
||||||
|
/** local voxel index within a chunk array: y outer, z middle, x inner. */
|
||||||
|
function localIdx(lx: number, ly: number, lz: number): number {
|
||||||
|
return (ly * CH + lz) * CH + lx;
|
||||||
|
}
|
||||||
|
/** padded index for coords in [-1, CH]; matches localIdx ordering (+1 shift). */
|
||||||
|
function padIdx(px: number, py: number, pz: number): number {
|
||||||
|
return ((py + 1) * P + (pz + 1)) * P + (px + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VoxelWorld implements IVoxelWorld {
|
||||||
|
readonly sizeX: number;
|
||||||
|
readonly sizeY: number;
|
||||||
|
readonly sizeZ: number;
|
||||||
|
|
||||||
|
readonly numChunksX: number;
|
||||||
|
readonly numChunksY: number;
|
||||||
|
readonly numChunksZ: number;
|
||||||
|
|
||||||
|
private readonly chunks: (Uint8Array | undefined)[];
|
||||||
|
private readonly dirty = new Set<number>();
|
||||||
|
|
||||||
|
// fillBox chunk-resolve cache (fillBox is worldgen's hot path).
|
||||||
|
private _lastCx = -1;
|
||||||
|
private _lastCy = -1;
|
||||||
|
private _lastCz = -1;
|
||||||
|
private _lastArr: Uint8Array | null = null;
|
||||||
|
|
||||||
|
constructor(sizeX: number, sizeY: number, sizeZ: number) {
|
||||||
|
if (sizeX % CH || sizeY % CH || sizeZ % CH) {
|
||||||
|
throw new Error(`world size must be a multiple of CHUNK=${CH}`);
|
||||||
|
}
|
||||||
|
this.sizeX = sizeX;
|
||||||
|
this.sizeY = sizeY;
|
||||||
|
this.sizeZ = sizeZ;
|
||||||
|
this.numChunksX = sizeX / CH;
|
||||||
|
this.numChunksY = sizeY / CH;
|
||||||
|
this.numChunksZ = sizeZ / CH;
|
||||||
|
this.chunks = new Array(this.numChunksX * this.numChunksY * this.numChunksZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
private chunkIndex(cx: number, cy: number, cz: number): number {
|
||||||
|
return (cy * this.numChunksZ + cz) * this.numChunksX + cx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private chunkAt(cx: number, cy: number, cz: number): Uint8Array | undefined {
|
||||||
|
return this.chunks[this.chunkIndex(cx, cy, cz)];
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlock(x: number, y: number, z: number): BlockId {
|
||||||
|
if (y < 0) return OOB_SOLID;
|
||||||
|
if (x < 0 || z < 0 || x >= this.sizeX || y >= this.sizeY || z >= this.sizeZ) {
|
||||||
|
return AIR;
|
||||||
|
}
|
||||||
|
const cx = (x / CH) | 0, cy = (y / CH) | 0, cz = (z / CH) | 0;
|
||||||
|
const arr = this.chunks[this.chunkIndex(cx, cy, cz)];
|
||||||
|
if (!arr) return AIR;
|
||||||
|
return arr[localIdx(x - cx * CH, y - cy * CH, z - cz * CH)];
|
||||||
|
}
|
||||||
|
|
||||||
|
isSolid(x: number, y: number, z: number): boolean {
|
||||||
|
return isSolidId(this.getBlock(x, y, z));
|
||||||
|
}
|
||||||
|
|
||||||
|
setBlock(x: number, y: number, z: number, id: BlockId): void {
|
||||||
|
if (x < 0 || y < 0 || z < 0 || x >= this.sizeX || y >= this.sizeY || z >= this.sizeZ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cx = (x / CH) | 0, cy = (y / CH) | 0, cz = (z / CH) | 0;
|
||||||
|
const ci = this.chunkIndex(cx, cy, cz);
|
||||||
|
let arr = this.chunks[ci];
|
||||||
|
if (!arr) {
|
||||||
|
if (id === AIR) return; // writing air into empty chunk: nothing to do
|
||||||
|
arr = this.chunks[ci] = new Uint8Array(CH3);
|
||||||
|
}
|
||||||
|
const lx = x - cx * CH, ly = y - cy * CH, lz = z - cz * CH;
|
||||||
|
const li = localIdx(lx, ly, lz);
|
||||||
|
if (arr[li] === id) return; // no change → no remesh
|
||||||
|
arr[li] = id;
|
||||||
|
|
||||||
|
this.markDirty(cx, cy, cz);
|
||||||
|
// Neighbouring chunk faces go stale when the edited voxel borders them.
|
||||||
|
if (lx === 0) this.markDirty(cx - 1, cy, cz);
|
||||||
|
else if (lx === CH - 1) this.markDirty(cx + 1, cy, cz);
|
||||||
|
if (ly === 0) this.markDirty(cx, cy - 1, cz);
|
||||||
|
else if (ly === CH - 1) this.markDirty(cx, cy + 1, cz);
|
||||||
|
if (lz === 0) this.markDirty(cx, cy, cz - 1);
|
||||||
|
else if (lz === CH - 1) this.markDirty(cx, cy, cz + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk fill [x0..x1] x [y0..y1] x [z0..z1] (inclusive) with `id`, writing raw
|
||||||
|
* into chunk arrays without per-voxel dirty bookkeeping. Dirties every touched
|
||||||
|
* chunk plus a 1-chunk margin so cross-chunk border faces re-cull correctly.
|
||||||
|
* Worldgen (Lane C) paints millions of voxels through this path.
|
||||||
|
*/
|
||||||
|
fillBox(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, id: BlockId): void {
|
||||||
|
let ax0 = Math.min(x0, x1), ax1 = Math.max(x0, x1);
|
||||||
|
let ay0 = Math.min(y0, y1), ay1 = Math.max(y0, y1);
|
||||||
|
let az0 = Math.min(z0, z1), az1 = Math.max(z0, z1);
|
||||||
|
ax0 = Math.max(0, ax0); ay0 = Math.max(0, ay0); az0 = Math.max(0, az0);
|
||||||
|
ax1 = Math.min(this.sizeX - 1, ax1);
|
||||||
|
ay1 = Math.min(this.sizeY - 1, ay1);
|
||||||
|
az1 = Math.min(this.sizeZ - 1, az1);
|
||||||
|
if (ax0 > ax1 || ay0 > ay1 || az0 > az1) return;
|
||||||
|
|
||||||
|
for (let y = ay0; y <= ay1; y++) {
|
||||||
|
const cy = (y / CH) | 0, ly = y - cy * CH;
|
||||||
|
for (let z = az0; z <= az1; z++) {
|
||||||
|
const cz = (z / CH) | 0, lz = z - cz * CH;
|
||||||
|
for (let x = ax0; x <= ax1; x++) {
|
||||||
|
const cx = (x / CH) | 0;
|
||||||
|
let arr = this._lastArr;
|
||||||
|
if (cx !== this._lastCx || cy !== this._lastCy || cz !== this._lastCz || !arr) {
|
||||||
|
const ci = this.chunkIndex(cx, cy, cz);
|
||||||
|
arr = this.chunks[ci] ?? null;
|
||||||
|
if (!arr) {
|
||||||
|
if (id === AIR) { // don't allocate a chunk just to write air
|
||||||
|
this._lastCx = cx; this._lastCy = cy; this._lastCz = cz;
|
||||||
|
this._lastArr = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
arr = this.chunks[ci] = new Uint8Array(CH3);
|
||||||
|
}
|
||||||
|
this._lastCx = cx; this._lastCy = cy; this._lastCz = cz;
|
||||||
|
this._lastArr = arr;
|
||||||
|
}
|
||||||
|
arr[localIdx(x - cx * CH, ly, lz)] = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._lastArr = null; // invalidate cache (arrays may be re-fetched next call)
|
||||||
|
|
||||||
|
// Dirty the touched chunk range, expanded by one chunk for border re-cull.
|
||||||
|
const cx0 = Math.max(0, ((ax0 / CH) | 0) - 1);
|
||||||
|
const cy0 = Math.max(0, ((ay0 / CH) | 0) - 1);
|
||||||
|
const cz0 = Math.max(0, ((az0 / CH) | 0) - 1);
|
||||||
|
const cx1 = Math.min(this.numChunksX - 1, ((ax1 / CH) | 0) + 1);
|
||||||
|
const cy1 = Math.min(this.numChunksY - 1, ((ay1 / CH) | 0) + 1);
|
||||||
|
const cz1 = Math.min(this.numChunksZ - 1, ((az1 / CH) | 0) + 1);
|
||||||
|
for (let cy = cy0; cy <= cy1; cy++)
|
||||||
|
for (let cz = cz0; cz <= cz1; cz++)
|
||||||
|
for (let cx = cx0; cx <= cx1; cx++)
|
||||||
|
this.dirty.add(this.chunkIndex(cx, cy, cz));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dirty tracking (consumed by ChunkManager, both Lane A) ---
|
||||||
|
|
||||||
|
private markDirty(cx: number, cy: number, cz: number): void {
|
||||||
|
if (cx < 0 || cy < 0 || cz < 0 ||
|
||||||
|
cx >= this.numChunksX || cy >= this.numChunksY || cz >= this.numChunksZ) return;
|
||||||
|
this.dirty.add(this.chunkIndex(cx, cy, cz));
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachDirtyChunk(cb: (cx: number, cy: number, cz: number) => void): void {
|
||||||
|
// Snapshot: the callback may clearDirty as it goes.
|
||||||
|
const nx = this.numChunksX, nz = this.numChunksZ;
|
||||||
|
for (const ci of this.dirty) {
|
||||||
|
const cx = ci % nx;
|
||||||
|
const cy = (ci / (nx * nz)) | 0;
|
||||||
|
const cz = ((ci - cy * nx * nz) / nx) | 0;
|
||||||
|
cb(cx, cy, cz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearDirty(cx: number, cy: number, cz: number): void {
|
||||||
|
this.dirty.delete(this.chunkIndex(cx, cy, cz));
|
||||||
|
}
|
||||||
|
get dirtyCount(): number { return this.dirty.size; }
|
||||||
|
|
||||||
|
isChunkEmpty(cx: number, cy: number, cz: number): boolean {
|
||||||
|
return this.chunkAt(cx, cy, cz) === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill `out` (length (CHUNK+2)^3) with the block ids of chunk (cx,cy,cz) plus a
|
||||||
|
* 1-voxel border read from neighbouring chunks / world bounds. Interior is a
|
||||||
|
* fast direct array copy; only the ~6*(CHUNK+2)^2 border cells go through
|
||||||
|
* getBlock. The mesher indexes this with padIdx() (coords -1..CHUNK).
|
||||||
|
*/
|
||||||
|
readChunkPadded(cx: number, cy: number, cz: number, out: Uint8Array): void {
|
||||||
|
if (out.length !== P3) throw new Error('readChunkPadded: bad buffer size');
|
||||||
|
const arr = this.chunkAt(cx, cy, cz);
|
||||||
|
// interior [0..CH-1]^3
|
||||||
|
if (arr) {
|
||||||
|
for (let ly = 0; ly < CH; ly++)
|
||||||
|
for (let lz = 0; lz < CH; lz++) {
|
||||||
|
let src = (ly * CH + lz) * CH;
|
||||||
|
let dst = ((ly + 1) * P + (lz + 1)) * P + 1;
|
||||||
|
for (let lx = 0; lx < CH; lx++) out[dst + lx] = arr[src + lx];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.fill(0); // whole chunk (incl. interior) starts as AIR
|
||||||
|
}
|
||||||
|
const bx = cx * CH, by = cy * CH, bz = cz * CH;
|
||||||
|
// x = -1 and x = CH planes
|
||||||
|
for (let py = -1; py <= CH; py++)
|
||||||
|
for (let pz = -1; pz <= CH; pz++) {
|
||||||
|
out[padIdx(-1, py, pz)] = this.getBlock(bx - 1, by + py, bz + pz);
|
||||||
|
out[padIdx(CH, py, pz)] = this.getBlock(bx + CH, by + py, bz + pz);
|
||||||
|
}
|
||||||
|
// y = -1 and y = CH planes
|
||||||
|
for (let px = -1; px <= CH; px++)
|
||||||
|
for (let pz = -1; pz <= CH; pz++) {
|
||||||
|
out[padIdx(px, -1, pz)] = this.getBlock(bx + px, by - 1, bz + pz);
|
||||||
|
out[padIdx(px, CH, pz)] = this.getBlock(bx + px, by + CH, bz + pz);
|
||||||
|
}
|
||||||
|
// z = -1 and z = CH planes
|
||||||
|
for (let px = -1; px <= CH; px++)
|
||||||
|
for (let py = -1; py <= CH; py++) {
|
||||||
|
out[padIdx(px, py, -1)] = this.getBlock(bx + px, by + py, bz - 1);
|
||||||
|
out[padIdx(px, py, CH)] = this.getBlock(bx + px, by + py, bz + CH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
291
src/engine/atlas.ts
Normal file
291
src/engine/atlas.ts
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
// TURNCRAFT — Lane A. Procedural texture atlas + block materials.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { BLOCKS, BLOCK_BY_ID, type BlockDef, type Pattern } from '../core/blocks';
|
||||||
|
|
||||||
|
const TILE = 16;
|
||||||
|
const COLS = 8;
|
||||||
|
const ROWS = Math.ceil(BLOCKS.length / COLS); // 31 blocks -> 4 rows
|
||||||
|
|
||||||
|
export interface Atlas {
|
||||||
|
texture: THREE.CanvasTexture;
|
||||||
|
emissiveTexture: THREE.CanvasTexture;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
opaqueMaterial: THREE.Material;
|
||||||
|
transparentMaterial: THREE.Material;
|
||||||
|
/** Multiply emissive intensity (Lane E pulses this to the beat; default 1). */
|
||||||
|
setEmissiveBoost(v: number): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- deterministic per-tile noise (stable atlas across reloads) ---
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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 pattern: Pattern = def.pattern;
|
||||||
|
switch (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]);
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAtlas(): Atlas {
|
||||||
|
const w = COLS * 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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
emisCtx.putImageData(new ImageData(em, TILE, TILE), ox, oy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(baseCanvas);
|
||||||
|
const emissiveTexture = new THREE.CanvasTexture(emisCanvas);
|
||||||
|
for (const t of [texture, emissiveTexture]) {
|
||||||
|
t.magFilter = THREE.NearestFilter;
|
||||||
|
t.minFilter = THREE.NearestFilter;
|
||||||
|
t.generateMipmaps = false;
|
||||||
|
t.flipY = false; // cell (col,row) is top-based; keeps atlas UV math direct
|
||||||
|
t.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
t.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opaqueMaterial = makeBlockMaterial(texture, emissiveTexture, false);
|
||||||
|
const transparentMaterial = makeBlockMaterial(texture, emissiveTexture, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
texture, emissiveTexture, cols: COLS, rows: ROWS,
|
||||||
|
opaqueMaterial, transparentMaterial,
|
||||||
|
setEmissiveBoost(v: number) {
|
||||||
|
(opaqueMaterial as THREE.MeshStandardMaterial).emissiveIntensity = v;
|
||||||
|
(transparentMaterial as THREE.MeshStandardMaterial).emissiveIntensity = v;
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
texture.dispose(); emissiveTexture.dispose();
|
||||||
|
opaqueMaterial.dispose(); transparentMaterial.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCanvas(w: number, h: number): HTMLCanvasElement {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = w; c.height = h;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBlockMaterial(
|
||||||
|
map: THREE.Texture, emissiveMap: THREE.Texture, transparent: boolean,
|
||||||
|
): THREE.MeshStandardMaterial {
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
map,
|
||||||
|
emissiveMap,
|
||||||
|
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,
|
||||||
|
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).
|
||||||
|
mat.onBeforeCompile = (shader) => {
|
||||||
|
shader.uniforms.uAtlasGrid = { value: new THREE.Vector2(COLS, ROWS) };
|
||||||
|
|
||||||
|
shader.vertexShader = shader.vertexShader
|
||||||
|
.replace('#include <common>',
|
||||||
|
`#include <common>
|
||||||
|
attribute vec2 aTile;
|
||||||
|
varying vec2 vTileRepeat;
|
||||||
|
varying vec2 vTileCell;`)
|
||||||
|
.replace('#include <uv_vertex>',
|
||||||
|
`#include <uv_vertex>
|
||||||
|
vTileRepeat = uv;
|
||||||
|
vTileCell = aTile;`);
|
||||||
|
|
||||||
|
shader.fragmentShader = shader.fragmentShader
|
||||||
|
.replace('#include <common>',
|
||||||
|
`#include <common>
|
||||||
|
uniform vec2 uAtlasGrid;
|
||||||
|
varying vec2 vTileRepeat;
|
||||||
|
varying vec2 vTileCell;
|
||||||
|
vec2 turncraftAtlasUV() {
|
||||||
|
return (vTileCell + 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'}`;
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
8
src/engine/index.ts
Normal file
8
src/engine/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// TURNCRAFT — Lane A public API. Other lanes/integration import from here.
|
||||||
|
// (Cross-lane code should depend on IVoxelWorld from core, not on VoxelWorld.)
|
||||||
|
|
||||||
|
export { VoxelWorld } from './VoxelWorld';
|
||||||
|
export { buildAtlas, type Atlas } from './atlas';
|
||||||
|
export { ChunkManager } from './ChunkManager';
|
||||||
|
export { meshChunk, type ChunkGeometry } from './mesher';
|
||||||
|
export { createRenderer, type Renderer } from './renderer';
|
||||||
248
src/engine/mesher.ts
Normal file
248
src/engine/mesher.ts
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
// TURNCRAFT — Lane A. Greedy voxel mesher with baked ambient occlusion.
|
||||||
|
//
|
||||||
|
// For each chunk we read a 1-voxel-padded id buffer (so chunk borders cull
|
||||||
|
// against neighbours), then greedy-mesh each of the 6 face directions: a face
|
||||||
|
// is a mask cell keyed by (blockId, 4-corner AO); coplanar cells with an equal
|
||||||
|
// key merge into one quad. AO is the classic Minecraft 3-neighbour corner
|
||||||
|
// darkening baked into vertex colours; quad triangulation flips on the AO
|
||||||
|
// anisotropy case. Opaque and transparent faces go to separate geometries.
|
||||||
|
//
|
||||||
|
// UVs: each quad's `uv` runs 0..w, 0..h (tiles = voxels); the material shader
|
||||||
|
// fracts that per fragment and offsets into the block's atlas cell carried by
|
||||||
|
// the `aTile` attribute. See atlas.ts.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { CHUNK } from '../core/constants';
|
||||||
|
import { AIR, blockDef, type BlockId } from '../core/blocks';
|
||||||
|
import type { VoxelWorld } from './VoxelWorld';
|
||||||
|
|
||||||
|
const CH = CHUNK;
|
||||||
|
const P = CH + 2;
|
||||||
|
|
||||||
|
// AO level (0..3) -> brightness multiplier baked into vertex colour.
|
||||||
|
const AO_LUT = [0.42, 0.66, 0.84, 1.0];
|
||||||
|
|
||||||
|
// Reused scratch — meshing is synchronous, so a single shared buffer is safe.
|
||||||
|
const pad = new Uint8Array(P * P * P);
|
||||||
|
const mask = new Int32Array(CH * CH);
|
||||||
|
|
||||||
|
// unit axis vectors, indexed by axis 0=x,1=y,2=z
|
||||||
|
const UX = [1, 0, 0], UY = [0, 1, 0], UZ = [0, 0, 1];
|
||||||
|
const UNIT = [UX, UY, UZ];
|
||||||
|
|
||||||
|
// Per axis d: the two in-plane axes (a,b) and whether +d / -d faces need their
|
||||||
|
// triangle winding reversed so front faces point outward. The base (unflipped)
|
||||||
|
// quad's geometric normal is a_dir x b_dir; flip the +d face when that equals
|
||||||
|
// -d, and the -d face when it equals +d.
|
||||||
|
// +X: a=Y,b=Z a×b=Y×Z=+X -> +X no flip, -X flip
|
||||||
|
// +Y: a=X,b=Z a×b=X×Z=-Y -> +Y flip, -Y no flip
|
||||||
|
// +Z: a=X,b=Y a×b=X×Y=+Z -> +Z no flip, -Z flip (same shape as X)
|
||||||
|
const AXIS = [
|
||||||
|
{ a: 1, b: 2, flipPlus: false, flipMinus: true }, // d=0 (X)
|
||||||
|
{ a: 0, b: 2, flipPlus: true, flipMinus: false }, // d=1 (Y)
|
||||||
|
{ a: 0, b: 1, flipPlus: false, flipMinus: true }, // d=2 (Z)
|
||||||
|
];
|
||||||
|
|
||||||
|
function padAt(px: number, py: number, pz: number): number {
|
||||||
|
return pad[((py + 1) * P + (pz + 1)) * P + (px + 1)];
|
||||||
|
}
|
||||||
|
function opaqueSolidAt(px: number, py: number, pz: number): boolean {
|
||||||
|
const d = blockDef(padAt(px, py, pz));
|
||||||
|
return d.solid && !d.transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Growable typed-array-backed vertex/index accumulator for one geometry. */
|
||||||
|
class Builder {
|
||||||
|
pos: number[] = [];
|
||||||
|
norm: number[] = [];
|
||||||
|
col: number[] = [];
|
||||||
|
uv: number[] = [];
|
||||||
|
tile: number[] = [];
|
||||||
|
idx: number[] = [];
|
||||||
|
vcount = 0;
|
||||||
|
|
||||||
|
quad(
|
||||||
|
// 4 corners c00,c10,c11,c01 as [x,y,z]
|
||||||
|
c00: number[], c10: number[], c11: number[], c01: number[],
|
||||||
|
nx: number, ny: number, nz: number,
|
||||||
|
w: number, h: number,
|
||||||
|
ao0: number, ao1: number, ao2: number, ao3: number,
|
||||||
|
tileCol: number, tileRow: number,
|
||||||
|
windingFlip: boolean,
|
||||||
|
): void {
|
||||||
|
const base = this.vcount;
|
||||||
|
this.push(c00, nx, ny, nz, 0, 0, AO_LUT[ao0], tileCol, tileRow);
|
||||||
|
this.push(c10, nx, ny, nz, w, 0, AO_LUT[ao1], tileCol, tileRow);
|
||||||
|
this.push(c11, nx, ny, nz, w, h, AO_LUT[ao2], tileCol, tileRow);
|
||||||
|
this.push(c01, nx, ny, nz, 0, h, AO_LUT[ao3], tileCol, tileRow);
|
||||||
|
|
||||||
|
// Diagonal flip on AO anisotropy so the darker corner keeps its gradient.
|
||||||
|
const aoFlip = (ao0 + ao2) > (ao1 + ao3);
|
||||||
|
let t0: number, t1: number, t2: number, t3: number, t4: number, t5: number;
|
||||||
|
if (aoFlip) { t0 = 1; t1 = 2; t2 = 3; t3 = 1; t4 = 3; t5 = 0; }
|
||||||
|
else { t0 = 0; t1 = 1; t2 = 2; t3 = 0; t4 = 2; t5 = 3; }
|
||||||
|
if (windingFlip) {
|
||||||
|
this.idx.push(base + t0, base + t2, base + t1, base + t3, base + t5, base + t4);
|
||||||
|
} else {
|
||||||
|
this.idx.push(base + t0, base + t1, base + t2, base + t3, base + t4, base + t5);
|
||||||
|
}
|
||||||
|
this.vcount += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private push(
|
||||||
|
c: number[], nx: number, ny: number, nz: number,
|
||||||
|
u: number, v: number, bright: number, tileCol: number, tileRow: number,
|
||||||
|
): void {
|
||||||
|
this.pos.push(c[0], c[1], c[2]);
|
||||||
|
this.norm.push(nx, ny, nz);
|
||||||
|
this.col.push(bright, bright, bright);
|
||||||
|
this.uv.push(u, v);
|
||||||
|
this.tile.push(tileCol, tileRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
toGeometry(): THREE.BufferGeometry | null {
|
||||||
|
if (this.vcount === 0) return null;
|
||||||
|
const g = new THREE.BufferGeometry();
|
||||||
|
g.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3));
|
||||||
|
g.setAttribute('normal', new THREE.Float32BufferAttribute(this.norm, 3));
|
||||||
|
g.setAttribute('color', new THREE.Float32BufferAttribute(this.col, 3));
|
||||||
|
g.setAttribute('uv', new THREE.Float32BufferAttribute(this.uv, 2));
|
||||||
|
g.setAttribute('aTile', new THREE.Float32BufferAttribute(this.tile, 2));
|
||||||
|
g.setIndex(this.idx);
|
||||||
|
g.computeBoundingSphere();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChunkGeometry {
|
||||||
|
opaque: THREE.BufferGeometry | null;
|
||||||
|
transparent: THREE.BufferGeometry | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Should voxel `id`'s face toward neighbour `nId` be drawn? */
|
||||||
|
function faceVisible(id: BlockId, nId: BlockId): boolean {
|
||||||
|
const nd = blockDef(nId);
|
||||||
|
if (nd.solid && !nd.transparent) return false; // opaque neighbour hides it
|
||||||
|
if (nId === AIR) return true;
|
||||||
|
const md = blockDef(id);
|
||||||
|
if (!md.transparent) return true; // opaque me behind glass: visible
|
||||||
|
return nId !== id; // glass vs same glass: cull internal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meshChunk(world: VoxelWorld, cx: number, cy: number, cz: number): ChunkGeometry {
|
||||||
|
world.readChunkPadded(cx, cy, cz, pad);
|
||||||
|
const baseX = cx * CH, baseY = cy * CH, baseZ = cz * CH;
|
||||||
|
const opaque = new Builder();
|
||||||
|
const transparent = new Builder();
|
||||||
|
|
||||||
|
// scratch position arrays reused across quads
|
||||||
|
const c00 = [0, 0, 0], c10 = [0, 0, 0], c11 = [0, 0, 0], c01 = [0, 0, 0];
|
||||||
|
|
||||||
|
for (let d = 0; d < 3; d++) {
|
||||||
|
const { a, b, flipPlus, flipMinus } = AXIS[d];
|
||||||
|
const ud = UNIT[d], ua = UNIT[a], ub = UNIT[b];
|
||||||
|
const udx = ud[0], udy = ud[1], udz = ud[2];
|
||||||
|
const uax = ua[0], uay = ua[1], uaz = ua[2];
|
||||||
|
const ubx = ub[0], uby = ub[1], ubz = ub[2];
|
||||||
|
|
||||||
|
for (let s = 0; s < 2; s++) {
|
||||||
|
const sign = s === 0 ? 1 : -1;
|
||||||
|
const windingFlip = sign === 1 ? flipPlus : flipMinus;
|
||||||
|
|
||||||
|
for (let L = 0; L < CH; L++) {
|
||||||
|
// Build the mask for this layer/plane.
|
||||||
|
let any = false;
|
||||||
|
for (let bi = 0; bi < CH; bi++) {
|
||||||
|
for (let ai = 0; ai < CH; ai++) {
|
||||||
|
const lx = L * udx + ai * uax + bi * ubx;
|
||||||
|
const ly = L * udy + ai * uay + bi * uby;
|
||||||
|
const lz = L * udz + ai * uaz + bi * ubz;
|
||||||
|
const id = padAt(lx, ly, lz);
|
||||||
|
if (id === AIR) { mask[bi * CH + ai] = 0; continue; }
|
||||||
|
const nId = padAt(lx + sign * udx, ly + sign * udy, lz + sign * udz);
|
||||||
|
if (!faceVisible(id, nId)) { mask[bi * CH + ai] = 0; continue; }
|
||||||
|
|
||||||
|
// AO for the 4 corners (order c00,c10,c11,c01), sampled in the
|
||||||
|
// empty cell in front of the face.
|
||||||
|
const ox = lx + sign * udx, oy = ly + sign * udy, oz = lz + sign * udz;
|
||||||
|
const ao0 = cornerAO(ox, oy, oz, -1, -1, uax, uay, uaz, ubx, uby, ubz);
|
||||||
|
const ao1 = cornerAO(ox, oy, oz, +1, -1, uax, uay, uaz, ubx, uby, ubz);
|
||||||
|
const ao2 = cornerAO(ox, oy, oz, +1, +1, uax, uay, uaz, ubx, uby, ubz);
|
||||||
|
const ao3 = cornerAO(ox, oy, oz, -1, +1, uax, uay, uaz, ubx, uby, ubz);
|
||||||
|
mask[bi * CH + ai] = id | (ao0 << 5) | (ao1 << 7) | (ao2 << 9) | (ao3 << 11);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!any) continue;
|
||||||
|
|
||||||
|
// Greedy-merge the mask into rectangles.
|
||||||
|
const planeD = sign === 1 ? L + 1 : L;
|
||||||
|
for (let bi = 0; bi < CH; bi++) {
|
||||||
|
for (let ai = 0; ai < CH;) {
|
||||||
|
const packed = mask[bi * CH + ai];
|
||||||
|
if (packed === 0) { ai++; continue; }
|
||||||
|
// width along a
|
||||||
|
let w = 1;
|
||||||
|
while (ai + w < CH && mask[bi * CH + ai + w] === packed) w++;
|
||||||
|
// height along b
|
||||||
|
let h = 1;
|
||||||
|
grow: while (bi + h < CH) {
|
||||||
|
for (let k = 0; k < w; k++) {
|
||||||
|
if (mask[(bi + h) * CH + ai + k] !== packed) break grow;
|
||||||
|
}
|
||||||
|
h++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = packed & 31;
|
||||||
|
const ao0 = (packed >> 5) & 3, ao1 = (packed >> 7) & 3;
|
||||||
|
const ao2 = (packed >> 9) & 3, ao3 = (packed >> 11) & 3;
|
||||||
|
const def = blockDef(id);
|
||||||
|
|
||||||
|
// Corner world positions: component d=planeD, a in [ai,ai+w], b in [bi,bi+h]
|
||||||
|
setCorner(c00, baseX, baseY, baseZ, planeD, udx, udy, udz, ai, uax, uay, uaz, bi, ubx, uby, ubz);
|
||||||
|
setCorner(c10, baseX, baseY, baseZ, planeD, udx, udy, udz, ai + w, uax, uay, uaz, bi, ubx, uby, ubz);
|
||||||
|
setCorner(c11, baseX, baseY, baseZ, planeD, udx, udy, udz, ai + w, uax, uay, uaz, bi + h, ubx, uby, ubz);
|
||||||
|
setCorner(c01, baseX, baseY, baseZ, planeD, udx, udy, udz, ai, uax, uay, uaz, bi + h, ubx, uby, ubz);
|
||||||
|
|
||||||
|
const nx = udx * sign, ny = udy * sign, nz = udz * sign;
|
||||||
|
const col = id % 8, row = (id / 8) | 0;
|
||||||
|
const builder = def.transparent ? transparent : opaque;
|
||||||
|
builder.quad(c00, c10, c11, c01, nx, ny, nz, w, h,
|
||||||
|
ao0, ao1, ao2, ao3, col, row, windingFlip);
|
||||||
|
|
||||||
|
// clear the consumed cells
|
||||||
|
for (let hh = 0; hh < h; hh++)
|
||||||
|
for (let ww = 0; ww < w; ww++) mask[(bi + hh) * CH + ai + ww] = 0;
|
||||||
|
ai += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { opaque: opaque.toGeometry(), transparent: transparent.toGeometry() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cornerAO(
|
||||||
|
ox: number, oy: number, oz: number, sa: number, sb: number,
|
||||||
|
uax: number, uay: number, uaz: number, ubx: number, uby: number, ubz: number,
|
||||||
|
): number {
|
||||||
|
const s1 = opaqueSolidAt(ox + sa * uax, oy + sa * uay, oz + sa * uaz) ? 1 : 0;
|
||||||
|
const s2 = opaqueSolidAt(ox + sb * ubx, oy + sb * uby, oz + sb * ubz) ? 1 : 0;
|
||||||
|
if (s1 && s2) return 0;
|
||||||
|
const cc = opaqueSolidAt(
|
||||||
|
ox + sa * uax + sb * ubx, oy + sa * uay + sb * uby, oz + sa * uaz + sb * ubz,
|
||||||
|
) ? 1 : 0;
|
||||||
|
return 3 - s1 - s2 - cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCorner(
|
||||||
|
out: number[], baseX: number, baseY: number, baseZ: number,
|
||||||
|
planeD: number, udx: number, udy: number, udz: number,
|
||||||
|
av: number, uax: number, uay: number, uaz: number,
|
||||||
|
bv: number, ubx: number, uby: number, ubz: number,
|
||||||
|
): void {
|
||||||
|
out[0] = baseX + planeD * udx + av * uax + bv * ubx;
|
||||||
|
out[1] = baseY + planeD * udy + av * uay + bv * uby;
|
||||||
|
out[2] = baseZ + planeD * udz + av * uaz + bv * ubz;
|
||||||
|
}
|
||||||
83
src/engine/renderer.ts
Normal file
83
src/engine/renderer.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
// 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).
|
||||||
|
//
|
||||||
|
// 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 { buildAtlas, type Atlas } from './atlas';
|
||||||
|
|
||||||
|
export interface Renderer {
|
||||||
|
renderer: THREE.WebGLRenderer;
|
||||||
|
scene: THREE.Scene;
|
||||||
|
camera: THREE.PerspectiveCamera;
|
||||||
|
atlas: Atlas;
|
||||||
|
/** Scale emissive (LED) glow; Lane E pulses this to the beat. Default 1. */
|
||||||
|
setEmissiveBoost(v: number): void;
|
||||||
|
render(): void;
|
||||||
|
resize(width?: number, height?: number): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRenderer(container: HTMLElement): Renderer {
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance' });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1.05;
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const bg = new THREE.Color(0x0a0908); // near-black, warm
|
||||||
|
scene.background = bg;
|
||||||
|
scene.fog = new THREE.Fog(0x1a1310, 90, 420); // warm plywood-brown horizon
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
|
||||||
|
camera.position.set(0, 4, 8);
|
||||||
|
|
||||||
|
// Warm key light (parallel; direction = position, target at origin).
|
||||||
|
const key = new THREE.DirectionalLight(0xfff1dd, 2.2);
|
||||||
|
key.position.set(70, 130, 45);
|
||||||
|
scene.add(key);
|
||||||
|
// Cool sky / warm ground hemispheric fill.
|
||||||
|
const hemi = new THREE.HemisphereLight(0x9fb4d8, 0x4a3a28, 0.65);
|
||||||
|
scene.add(hemi);
|
||||||
|
// Small ambient lift so deep AO corners never go pure black.
|
||||||
|
scene.add(new THREE.AmbientLight(0x20202a, 0.25));
|
||||||
|
|
||||||
|
const atlas = buildAtlas();
|
||||||
|
|
||||||
|
const setEmissiveBoost = (v: number) => atlas.setEmissiveBoost(v);
|
||||||
|
|
||||||
|
const resize = (width?: number, height?: number) => {
|
||||||
|
// `||` (not `??`): clientWidth is 0 — not nullish — for an unlaid-out
|
||||||
|
// container, so fall back to the window size in that case too.
|
||||||
|
const w = width || container.clientWidth || window.innerWidth;
|
||||||
|
const h = height || container.clientHeight || window.innerHeight;
|
||||||
|
if (w === 0 || h === 0) return;
|
||||||
|
renderer.setSize(w, h, true);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
|
||||||
|
// Self-contained resize handling so callers don't have to wire it up.
|
||||||
|
const ro = new ResizeObserver(() => resize());
|
||||||
|
ro.observe(container);
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderer, scene, camera, atlas, setEmissiveBoost,
|
||||||
|
render: () => renderer.render(scene, camera),
|
||||||
|
resize,
|
||||||
|
dispose() {
|
||||||
|
ro.disconnect();
|
||||||
|
atlas.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
if (renderer.domElement.parentElement === container) {
|
||||||
|
container.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
181
src/fx/FxSystem.ts
Normal file
181
src/fx/FxSystem.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
// TURNCRAFT — Lane E. FX orchestrator. Owns every particle system and emissive
|
||||||
|
// sprite overlay, drives the global LED pulse through Lane A's injected
|
||||||
|
// `setEmissiveBoost` hook, and runs the win visual timeline. Reacts to bus
|
||||||
|
// events; never calls setBlock and never touches another lane's materials.
|
||||||
|
//
|
||||||
|
// Inject `{ scene, setEmissiveBoost, getLevels }`. Call `update(dt)` each tick.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import { PLATTER } from '../core/constants';
|
||||||
|
import { blockDef } from '../core/blocks';
|
||||||
|
import { makeGlowTexture, Burst, DustField } from './particles';
|
||||||
|
import { VuBank, SignalTrace, PatchBlink } from './overlays';
|
||||||
|
import { DUST_BOX, DECK_RIM, PATCH_BAY_POS, SIGNAL_PATH } from './layout';
|
||||||
|
|
||||||
|
export interface FxOpts {
|
||||||
|
scene: THREE.Object3D;
|
||||||
|
setEmissiveBoost: (v: number) => void;
|
||||||
|
getLevels?: () => { low: number; mid: number; high: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Deck = 'A' | 'B';
|
||||||
|
|
||||||
|
export class FxSystem {
|
||||||
|
private scene: THREE.Object3D;
|
||||||
|
private setBoost: (v: number) => void;
|
||||||
|
private getLevels: () => { low: number; mid: number; high: number };
|
||||||
|
|
||||||
|
private dust: DustField;
|
||||||
|
private puff: Burst;
|
||||||
|
private confetti: Burst;
|
||||||
|
private sparkle: Burst;
|
||||||
|
private vu: VuBank;
|
||||||
|
private trace: SignalTrace;
|
||||||
|
private patch: PatchBlink;
|
||||||
|
|
||||||
|
// LED pulse
|
||||||
|
private boostBase = 0.35;
|
||||||
|
private pulse = 0;
|
||||||
|
private readonly pulseTau = 0.09; // ~150 ms visible decay
|
||||||
|
private repairs = 0;
|
||||||
|
|
||||||
|
// rim sparkle
|
||||||
|
private deckSpin: Record<Deck, { playing: boolean; rpm: number }> = {
|
||||||
|
A: { playing: false, rpm: 0 }, B: { playing: false, rpm: 0 },
|
||||||
|
};
|
||||||
|
private sparkleTimer = 0;
|
||||||
|
|
||||||
|
// win timeline
|
||||||
|
private won = false;
|
||||||
|
private winClock = 0;
|
||||||
|
private dropped = false;
|
||||||
|
|
||||||
|
constructor(opts: FxOpts) {
|
||||||
|
this.scene = opts.scene;
|
||||||
|
this.setBoost = opts.setEmissiveBoost;
|
||||||
|
this.getLevels = opts.getLevels ?? (() => ({ low: 0, mid: 0, high: 0 }));
|
||||||
|
|
||||||
|
const tex = makeGlowTexture();
|
||||||
|
this.dust = new DustField(280, tex, DUST_BOX.min, DUST_BOX.max);
|
||||||
|
this.puff = new Burst(160, 1.4, tex, 6, 3.5);
|
||||||
|
this.confetti = new Burst(220, 2.2, tex, 9, 0.6);
|
||||||
|
this.sparkle = new Burst(120, 1.0, tex, 0, 1.5);
|
||||||
|
this.vu = new VuBank(tex);
|
||||||
|
this.trace = new SignalTrace(tex);
|
||||||
|
this.patch = new PatchBlink(tex);
|
||||||
|
|
||||||
|
this.scene.add(this.dust.points, this.puff.points, this.confetti.points,
|
||||||
|
this.sparkle.points, this.vu.group, this.trace.sprite, this.patch.sprite);
|
||||||
|
|
||||||
|
this.setBoost(this.boostBase);
|
||||||
|
this.wireBus();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
this.dust.update(dt);
|
||||||
|
this.puff.update(dt);
|
||||||
|
this.confetti.update(dt);
|
||||||
|
this.sparkle.update(dt);
|
||||||
|
this.trace.update(dt);
|
||||||
|
this.patch.update(dt);
|
||||||
|
this.vu.update(this.getLevels());
|
||||||
|
this.updateRimSparkle(dt);
|
||||||
|
|
||||||
|
// LED pulse decay
|
||||||
|
this.pulse *= Math.exp(-dt / this.pulseTau);
|
||||||
|
if (this.pulse < 0.001) this.pulse = 0;
|
||||||
|
|
||||||
|
let boost: number;
|
||||||
|
if (this.won && this.winClock < 1.8) {
|
||||||
|
// pre-drop: booth goes dark and silent
|
||||||
|
this.winClock += dt;
|
||||||
|
boost = 0.04;
|
||||||
|
if (this.winClock >= 1.8 && !this.dropped) this.onNeedleDrop();
|
||||||
|
} else {
|
||||||
|
if (this.won) this.winClock += dt;
|
||||||
|
boost = this.boostBase + this.pulse;
|
||||||
|
}
|
||||||
|
this.setBoost(Math.max(0, Math.min(2, boost)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── win visuals ─────────────────────────────────────────────────────────
|
||||||
|
private onNeedleDrop(): void {
|
||||||
|
this.dropped = true;
|
||||||
|
this.boostBase = 1.0; // living booth from here on
|
||||||
|
this.pulse = 1.2; // slam flash
|
||||||
|
// confetti over both decks
|
||||||
|
for (const d of ['A', 'B'] as Deck[]) {
|
||||||
|
const r = DECK_RIM[d];
|
||||||
|
this.confetti.spawn(80, r.x, r.y + 20, r.z, {
|
||||||
|
spread: 24, speed: 10, up: 6, life: 3.2, jitter: 20,
|
||||||
|
r: 0.5, g: 0.7, b: 1.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateRimSparkle(dt: number): void {
|
||||||
|
let any = false;
|
||||||
|
for (const d of ['A', 'B'] as Deck[]) {
|
||||||
|
const s = this.deckSpin[d];
|
||||||
|
if (s.playing && Math.abs(s.rpm - PLATTER.rpm45) < 0.15) any = true;
|
||||||
|
}
|
||||||
|
if (!any) return;
|
||||||
|
this.sparkleTimer -= dt;
|
||||||
|
if (this.sparkleTimer > 0) return;
|
||||||
|
this.sparkleTimer = 0.04;
|
||||||
|
for (const d of ['A', 'B'] as Deck[]) {
|
||||||
|
const s = this.deckSpin[d];
|
||||||
|
if (!(s.playing && Math.abs(s.rpm - PLATTER.rpm45) < 0.15)) continue;
|
||||||
|
const rim = DECK_RIM[d];
|
||||||
|
const ang = Math.random() * Math.PI * 2;
|
||||||
|
const x = rim.x + Math.cos(ang) * rim.r;
|
||||||
|
const z = rim.z + Math.sin(ang) * rim.r;
|
||||||
|
this.sparkle.spawn(2, x, rim.y, z, {
|
||||||
|
spread: 1, speed: 3, up: 3, life: 0.4, jitter: 0.5,
|
||||||
|
r: 1.0, g: 0.35, b: 0.28,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── bus wiring ─────────────────────────────────────────────────────────
|
||||||
|
private wireBus(): void {
|
||||||
|
bus.on('audio:beat', (p) => {
|
||||||
|
if (this.repairs < 1 && !this.won) return; // dead booth stays dim
|
||||||
|
const amp = 0.85 * p.energy;
|
||||||
|
if (amp > this.pulse) this.pulse = amp;
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on('signal:repair', (p) => {
|
||||||
|
this.repairs = p.repaired;
|
||||||
|
this.boostBase = Math.min(1.0, 0.35 + 0.16 * p.repaired);
|
||||||
|
this.trace.trigger();
|
||||||
|
if (p.node === 'rca') this.patch.setLevel(0.9);
|
||||||
|
// a little burst of sparkle at the mixer output as feedback
|
||||||
|
const out = SIGNAL_PATH[SIGNAL_PATH.length - 1];
|
||||||
|
this.sparkle.spawn(16, out[0], out[1], out[2], {
|
||||||
|
spread: 6, speed: 6, up: 4, life: 0.7, jitter: 2,
|
||||||
|
r: 0.5, g: 1.0, b: 0.55,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on('game:win', () => {
|
||||||
|
if (this.won) return; // idempotent, mirrors AudioEngine — a second win is ignored
|
||||||
|
this.won = true;
|
||||||
|
this.winClock = 0;
|
||||||
|
this.dropped = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on('platter:state', (p) => {
|
||||||
|
this.deckSpin[p.deck] = { playing: p.playing, rpm: p.rpm };
|
||||||
|
});
|
||||||
|
|
||||||
|
bus.on('block:break', (p) => {
|
||||||
|
const tint = blockDef(p.id).tint;
|
||||||
|
this.puff.spawn(14, p.x + 0.5, p.y + 0.5, p.z + 0.5, {
|
||||||
|
spread: 4, speed: 5, up: 3, life: 0.55, jitter: 0.5,
|
||||||
|
r: tint[0] / 255, g: tint[1] / 255, b: tint[2] / 255,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/fx/HANDOFF.md
Normal file
42
src/fx/HANDOFF.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Lane E — FX HANDOFF (`src/fx/**`)
|
||||||
|
|
||||||
|
Owned by Lane E. See `src/audio/HANDOFF.md` for the full lane summary.
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const fx = new FxSystem({
|
||||||
|
scene, // THREE.Object3D to add fx to (Lane A's scene)
|
||||||
|
setEmissiveBoost, // Lane A's injected hook (v:number)=>void
|
||||||
|
getLevels: () => audio.getLevels(),
|
||||||
|
});
|
||||||
|
fx.update(dt); // each fixed tick
|
||||||
|
```
|
||||||
|
|
||||||
|
`FxSystem` self-wires to the bus (`audio:beat`, `signal:repair`, `game:win`,
|
||||||
|
`platter:state`, `block:break`) and adds all its own objects to `scene`.
|
||||||
|
|
||||||
|
## What's in here
|
||||||
|
|
||||||
|
- `particles.ts` — `Burst` (round-robin, finite-life pool: break puffs, win
|
||||||
|
confetti, 45-rpm rim sparkle) and `DustField` (continuous brownian motes).
|
||||||
|
Both preallocate typed arrays and mutate them in place — **zero per-frame
|
||||||
|
allocation**; a shared canvas glow texture, no external assets.
|
||||||
|
- `overlays.ts` — emissive sprite overlays we own (never touching Lane A/C
|
||||||
|
materials): `VuBank` (mixer VU towers, LED-segment stacks keyed to
|
||||||
|
`getLevels()`), `SignalTrace` (a bright sprite that runs the signal-path
|
||||||
|
polyline on each repair), `PatchBlink` (patch-bay socket blink).
|
||||||
|
- `layout.ts` — all overlay anchor points derived from `src/core/constants.ts`
|
||||||
|
`LAYOUT` (VU towers, `SIGNAL_PATH`, patch bay, dust box, deck rims).
|
||||||
|
- `FxSystem.ts` — orchestrator: the global LED emissive pulse (base steps up
|
||||||
|
per repair, pulses on beats), rim sparkle, and the win visual timeline
|
||||||
|
(1.8 s dark → needle-drop flash + confetti → living-booth steady state).
|
||||||
|
|
||||||
|
## Notes for integration
|
||||||
|
|
||||||
|
- The only material Lane E touches is via the injected `setEmissiveBoost`.
|
||||||
|
- VU/trace/patch positions come from `LAYOUT`; if Lane C's real LED towers sit
|
||||||
|
elsewhere, adjust `VU_TOWERS` in `layout.ts` (see friction #5 in the audio
|
||||||
|
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).
|
||||||
49
src/fx/layout.ts
Normal file
49
src/fx/layout.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// TURNCRAFT — Lane E. Fixed world-space geometry the FX overlays sit on, all
|
||||||
|
// derived from `src/core/constants.ts` LAYOUT (never a magic literal that isn't
|
||||||
|
// anchored to a gear position). These are the sprite anchor points: VU towers
|
||||||
|
// on the mixer, the signal-path polyline lit on each repair, the patch bay.
|
||||||
|
|
||||||
|
import { LAYOUT, PLATTER, Y_RECORD_TOP } from '../core/constants';
|
||||||
|
|
||||||
|
export interface VuTower {
|
||||||
|
x: number; z: number; baseY: number; segments: number; segH: number;
|
||||||
|
band: 'low' | 'mid' | 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = LAYOUT.mixer;
|
||||||
|
const midX = (m.minX + m.maxX) / 2;
|
||||||
|
|
||||||
|
// Two LED VU towers at the back of the mixer face, driven by different bands.
|
||||||
|
export const VU_TOWERS: VuTower[] = [
|
||||||
|
{ x: m.minX + 14, z: m.maxZ - 6, baseY: m.topY + 3, segments: 14, segH: 2.0, band: 'low' },
|
||||||
|
{ x: m.maxX - 14, z: m.maxZ - 6, baseY: m.topY + 3, segments: 14, segH: 2.0, band: 'mid' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// The signal chain, as a polyline the trace sprite runs on each repair:
|
||||||
|
// cartridge -> tonearm pivot -> cable canyon -> patch bay -> mixer -> master out.
|
||||||
|
export const SIGNAL_PATH: [number, number, number][] = [
|
||||||
|
[LAYOUT.deckA.spindleX + 30, Y_RECORD_TOP + 2, LAYOUT.deckA.spindleZ - 18], // cartridge
|
||||||
|
[LAYOUT.deckA.maxX - 6, 92, LAYOUT.deckA.maxZ - 6], // tonearm pivot
|
||||||
|
[150, 96, 160], // cable canyon
|
||||||
|
[midX, (LAYOUT.patchBay.minY + LAYOUT.patchBay.maxY) / 2, LAYOUT.backWallZ], // patch bay
|
||||||
|
[midX, m.topY + 6, (m.minZ + m.maxZ) / 2], // mixer
|
||||||
|
[midX, m.topY - 8, LAYOUT.frontLipZ + 8], // master out (front)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PATCH_BAY_POS: [number, number, number] = [
|
||||||
|
midX,
|
||||||
|
(LAYOUT.patchBay.minY + LAYOUT.patchBay.maxY) / 2,
|
||||||
|
LAYOUT.backWallZ,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Deck rim points for the 45-rpm sparkle.
|
||||||
|
export const DECK_RIM = {
|
||||||
|
A: { x: LAYOUT.deckA.spindleX, z: LAYOUT.deckA.spindleZ, y: Y_RECORD_TOP + 0.5, r: PLATTER.recordRadius },
|
||||||
|
B: { x: LAYOUT.deckB.spindleX, z: LAYOUT.deckB.spindleZ, y: Y_RECORD_TOP + 0.5, r: PLATTER.recordRadius },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Booth-volume box the ambient dust motes drift within. */
|
||||||
|
export const DUST_BOX = {
|
||||||
|
min: [10, 42, 44] as [number, number, number],
|
||||||
|
max: [438, 128, 198] as [number, number, number],
|
||||||
|
};
|
||||||
137
src/fx/overlays.ts
Normal file
137
src/fx/overlays.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
// TURNCRAFT — Lane E. Emissive sprite overlays we own and animate directly
|
||||||
|
// (never mutating Lane A/C block materials): the mixer VU towers, the moving
|
||||||
|
// signal-path trace lit on each repair, and the patch-bay blink. Sprites so
|
||||||
|
// they always face the camera and read as glowing LEDs from any angle.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { VU_TOWERS, SIGNAL_PATH, PATCH_BAY_POS, type VuTower } from './layout';
|
||||||
|
|
||||||
|
const GREEN = new THREE.Color(0.24, 1.0, 0.35);
|
||||||
|
const AMBER = new THREE.Color(1.0, 0.69, 0.16);
|
||||||
|
const RED = new THREE.Color(1.0, 0.19, 0.16);
|
||||||
|
|
||||||
|
function segColor(i: number, n: number): THREE.Color {
|
||||||
|
const f = i / n;
|
||||||
|
if (f > 0.85) return RED;
|
||||||
|
if (f > 0.65) return AMBER;
|
||||||
|
return GREEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Seg { mat: THREE.SpriteMaterial; base: THREE.Color; }
|
||||||
|
|
||||||
|
/** One VU tower: a vertical stack of LED segment sprites keyed to a level. */
|
||||||
|
class Tower {
|
||||||
|
readonly group: THREE.Group;
|
||||||
|
private segs: Seg[] = [];
|
||||||
|
private n: number;
|
||||||
|
constructor(t: VuTower, tex: THREE.Texture) {
|
||||||
|
this.group = new THREE.Group();
|
||||||
|
this.n = t.segments;
|
||||||
|
for (let i = 0; i < t.segments; i++) {
|
||||||
|
const base = segColor(i, t.segments);
|
||||||
|
const mat = new THREE.SpriteMaterial({
|
||||||
|
map: tex, color: base.clone(), transparent: true, opacity: 0.08,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
});
|
||||||
|
const sp = new THREE.Sprite(mat);
|
||||||
|
sp.scale.set(3.2, t.segH * 0.9, 1);
|
||||||
|
sp.position.set(t.x, t.baseY + (i + 0.5) * t.segH, t.z);
|
||||||
|
this.group.add(sp);
|
||||||
|
this.segs.push({ mat, base });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLevel(v: number): void {
|
||||||
|
const lit = Math.round(Math.max(0, Math.min(1, v)) * this.n);
|
||||||
|
for (let i = 0; i < this.n; i++) {
|
||||||
|
this.segs[i].mat.opacity = i < lit ? 0.95 : 0.06;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VuBank {
|
||||||
|
readonly group = new THREE.Group();
|
||||||
|
private towers: Tower[] = [];
|
||||||
|
constructor(tex: THREE.Texture) {
|
||||||
|
for (const t of VU_TOWERS) {
|
||||||
|
const tw = new Tower(t, tex);
|
||||||
|
this.towers.push(tw);
|
||||||
|
this.group.add(tw.group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update(levels: { low: number; mid: number; high: number }): void {
|
||||||
|
for (let i = 0; i < this.towers.length; i++) {
|
||||||
|
const band = VU_TOWERS[i].band;
|
||||||
|
const v = band === 'low' ? levels.low
|
||||||
|
: band === 'mid' ? levels.mid * 0.7 + levels.high * 0.3
|
||||||
|
: levels.high;
|
||||||
|
this.towers[i].setLevel(Math.min(1, v * 1.4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bright sprite that runs the signal path once per repair. */
|
||||||
|
export class SignalTrace {
|
||||||
|
readonly sprite: THREE.Sprite;
|
||||||
|
private pts = SIGNAL_PATH;
|
||||||
|
private segLen: number[] = [];
|
||||||
|
private total = 0;
|
||||||
|
private t = -1; // <0 = idle
|
||||||
|
private dur = 1.3;
|
||||||
|
constructor(tex: THREE.Texture) {
|
||||||
|
const mat = new THREE.SpriteMaterial({
|
||||||
|
map: tex, color: new THREE.Color(0.5, 0.8, 1.0), transparent: true,
|
||||||
|
opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
});
|
||||||
|
this.sprite = new THREE.Sprite(mat);
|
||||||
|
this.sprite.scale.set(7, 7, 1);
|
||||||
|
for (let i = 0; i < this.pts.length - 1; i++) {
|
||||||
|
const a = this.pts[i], b = this.pts[i + 1];
|
||||||
|
const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
|
||||||
|
const len = Math.hypot(dx, dy, dz);
|
||||||
|
this.segLen.push(len);
|
||||||
|
this.total += len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trigger(): void { this.t = 0; }
|
||||||
|
update(dt: number): void {
|
||||||
|
const mat = this.sprite.material as THREE.SpriteMaterial;
|
||||||
|
if (this.t < 0) { mat.opacity = 0; return; }
|
||||||
|
this.t += dt / this.dur;
|
||||||
|
if (this.t >= 1) { this.t = -1; mat.opacity = 0; return; }
|
||||||
|
// position along polyline at fraction t
|
||||||
|
let d = this.t * this.total;
|
||||||
|
let i = 0;
|
||||||
|
while (i < this.segLen.length && d > this.segLen[i]) { d -= this.segLen[i]; i++; }
|
||||||
|
if (i >= this.segLen.length) i = this.segLen.length - 1;
|
||||||
|
const a = this.pts[i], b = this.pts[i + 1];
|
||||||
|
const f = this.segLen[i] > 0 ? d / this.segLen[i] : 0;
|
||||||
|
this.sprite.position.set(
|
||||||
|
a[0] + (b[0] - a[0]) * f,
|
||||||
|
a[1] + (b[1] - a[1]) * f,
|
||||||
|
a[2] + (b[2] - a[2]) * f,
|
||||||
|
);
|
||||||
|
mat.opacity = Math.sin(Math.PI * this.t) * 0.9 + 0.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch-bay socket blink; brightens once RCA is repaired. */
|
||||||
|
export class PatchBlink {
|
||||||
|
readonly sprite: THREE.Sprite;
|
||||||
|
private phase = 0;
|
||||||
|
private level = 0.25;
|
||||||
|
constructor(tex: THREE.Texture) {
|
||||||
|
const mat = new THREE.SpriteMaterial({
|
||||||
|
map: tex, color: new THREE.Color(1.0, 0.69, 0.16), transparent: true,
|
||||||
|
opacity: 0.2, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
});
|
||||||
|
this.sprite = new THREE.Sprite(mat);
|
||||||
|
this.sprite.scale.set(5, 5, 1);
|
||||||
|
this.sprite.position.set(PATCH_BAY_POS[0], PATCH_BAY_POS[1], PATCH_BAY_POS[2]);
|
||||||
|
}
|
||||||
|
setLevel(v: number): void { this.level = v; }
|
||||||
|
update(dt: number): void {
|
||||||
|
this.phase += dt * 3;
|
||||||
|
const mat = this.sprite.material as THREE.SpriteMaterial;
|
||||||
|
mat.opacity = (0.5 + 0.5 * Math.sin(this.phase)) * this.level;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/fx/particles.ts
Normal file
186
src/fx/particles.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
// TURNCRAFT — Lane E. Particle systems, all THREE.Points with preallocated,
|
||||||
|
// reused typed arrays (zero per-frame allocation in update). A round-robin
|
||||||
|
// `Burst` pool powers break puffs, win confetti and rim sparkle; `DustField`
|
||||||
|
// is the continuous ambient motes drifting in the booth's light.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/** Soft round particle sprite, painted once on a canvas (no external assets). */
|
||||||
|
export function makeGlowTexture(): THREE.CanvasTexture {
|
||||||
|
const s = 64;
|
||||||
|
const cv = document.createElement('canvas');
|
||||||
|
cv.width = cv.height = s;
|
||||||
|
const g = cv.getContext('2d')!;
|
||||||
|
const grad = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
|
||||||
|
grad.addColorStop(0, 'rgba(255,255,255,1)');
|
||||||
|
grad.addColorStop(0.35, 'rgba(255,255,255,0.65)');
|
||||||
|
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||||
|
g.fillStyle = grad;
|
||||||
|
g.fillRect(0, 0, s, s);
|
||||||
|
const tex = new THREE.CanvasTexture(cv);
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round-robin particle pool with a finite life per point. Fades to black over
|
||||||
|
* its life (additive blending → fades to invisible). Reused for puffs,
|
||||||
|
* confetti and sparkle by varying spawn parameters.
|
||||||
|
*/
|
||||||
|
export class Burst {
|
||||||
|
readonly points: THREE.Points;
|
||||||
|
private cap: number;
|
||||||
|
private pos: Float32Array;
|
||||||
|
private col: Float32Array;
|
||||||
|
private base: Float32Array; // spawn color, for fade
|
||||||
|
private vel: Float32Array;
|
||||||
|
private age: Float32Array;
|
||||||
|
private life: Float32Array;
|
||||||
|
private gravity: number;
|
||||||
|
private drag: number;
|
||||||
|
private cursor = 0;
|
||||||
|
private geo: THREE.BufferGeometry;
|
||||||
|
|
||||||
|
constructor(cap: number, size: number, tex: THREE.Texture, gravity = 0, drag = 2.5) {
|
||||||
|
this.cap = cap;
|
||||||
|
this.gravity = gravity;
|
||||||
|
this.drag = drag;
|
||||||
|
this.pos = new Float32Array(cap * 3);
|
||||||
|
this.col = new Float32Array(cap * 3);
|
||||||
|
this.base = new Float32Array(cap * 3);
|
||||||
|
this.vel = new Float32Array(cap * 3);
|
||||||
|
this.age = new Float32Array(cap);
|
||||||
|
this.life = new Float32Array(cap); // 0 = free slot
|
||||||
|
this.geo = new THREE.BufferGeometry();
|
||||||
|
this.geo.setAttribute('position', new THREE.BufferAttribute(this.pos, 3));
|
||||||
|
this.geo.setAttribute('color', new THREE.BufferAttribute(this.col, 3));
|
||||||
|
const mat = new THREE.PointsMaterial({
|
||||||
|
size, map: tex, vertexColors: true, transparent: true,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true,
|
||||||
|
});
|
||||||
|
this.points = new THREE.Points(this.geo, mat);
|
||||||
|
this.points.frustumCulled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn(
|
||||||
|
n: number, x: number, y: number, z: number,
|
||||||
|
o: { spread?: number; speed?: number; up?: number; life?: number;
|
||||||
|
r: number; g: number; b: number; jitter?: number },
|
||||||
|
): void {
|
||||||
|
const spread = o.spread ?? 3;
|
||||||
|
const speed = o.speed ?? 4;
|
||||||
|
const up = o.up ?? 2;
|
||||||
|
const life = o.life ?? 0.6;
|
||||||
|
const jit = o.jitter ?? 0.2;
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
const i = this.cursor;
|
||||||
|
this.cursor = (this.cursor + 1) % this.cap;
|
||||||
|
const i3 = i * 3;
|
||||||
|
this.pos[i3] = x + (Math.random() * 2 - 1) * jit;
|
||||||
|
this.pos[i3 + 1] = y + (Math.random() * 2 - 1) * jit;
|
||||||
|
this.pos[i3 + 2] = z + (Math.random() * 2 - 1) * jit;
|
||||||
|
this.vel[i3] = (Math.random() * 2 - 1) * spread;
|
||||||
|
this.vel[i3 + 1] = up + (Math.random() * 2 - 1) * spread * 0.5;
|
||||||
|
this.vel[i3 + 2] = (Math.random() * 2 - 1) * spread;
|
||||||
|
const sp = 0.5 + Math.random() * 0.5;
|
||||||
|
this.vel[i3] *= sp * speed * 0.25;
|
||||||
|
this.vel[i3 + 2] *= sp * speed * 0.25;
|
||||||
|
this.base[i3] = o.r; this.base[i3 + 1] = o.g; this.base[i3 + 2] = o.b;
|
||||||
|
this.col[i3] = o.r; this.col[i3 + 1] = o.g; this.col[i3 + 2] = o.b;
|
||||||
|
this.age[i] = 0;
|
||||||
|
this.life[i] = life;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
let dirty = false;
|
||||||
|
for (let i = 0; i < this.cap; i++) {
|
||||||
|
const L = this.life[i];
|
||||||
|
if (L <= 0) continue;
|
||||||
|
dirty = true;
|
||||||
|
let a = this.age[i] + dt;
|
||||||
|
const i3 = i * 3;
|
||||||
|
if (a >= L) {
|
||||||
|
this.life[i] = 0; this.age[i] = 0;
|
||||||
|
this.col[i3] = this.col[i3 + 1] = this.col[i3 + 2] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.age[i] = a;
|
||||||
|
// integrate
|
||||||
|
this.vel[i3 + 1] -= this.gravity * dt;
|
||||||
|
const d = Math.max(0, 1 - this.drag * dt);
|
||||||
|
this.vel[i3] *= d; this.vel[i3 + 2] *= d;
|
||||||
|
this.pos[i3] += this.vel[i3] * dt;
|
||||||
|
this.pos[i3 + 1] += this.vel[i3 + 1] * dt;
|
||||||
|
this.pos[i3 + 2] += this.vel[i3 + 2] * dt;
|
||||||
|
// fade
|
||||||
|
const f = 1 - a / L;
|
||||||
|
this.col[i3] = this.base[i3] * f;
|
||||||
|
this.col[i3 + 1] = this.base[i3 + 1] * f;
|
||||||
|
this.col[i3 + 2] = this.base[i3 + 2] * f;
|
||||||
|
}
|
||||||
|
if (dirty) {
|
||||||
|
(this.geo.getAttribute('position') as THREE.BufferAttribute).needsUpdate = true;
|
||||||
|
(this.geo.getAttribute('color') as THREE.BufferAttribute).needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Continuous ambient dust motes drifting (brownian) inside a booth-volume box. */
|
||||||
|
export class DustField {
|
||||||
|
readonly points: THREE.Points;
|
||||||
|
private cap: number;
|
||||||
|
private pos: Float32Array;
|
||||||
|
private vel: Float32Array;
|
||||||
|
private min: [number, number, number];
|
||||||
|
private max: [number, number, number];
|
||||||
|
private geo: THREE.BufferGeometry;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
cap: number, tex: THREE.Texture,
|
||||||
|
min: [number, number, number], max: [number, number, number],
|
||||||
|
) {
|
||||||
|
this.cap = cap;
|
||||||
|
this.min = min; this.max = max;
|
||||||
|
this.pos = new Float32Array(cap * 3);
|
||||||
|
this.vel = new Float32Array(cap * 3);
|
||||||
|
for (let i = 0; i < cap; i++) {
|
||||||
|
const i3 = i * 3;
|
||||||
|
this.pos[i3] = min[0] + Math.random() * (max[0] - min[0]);
|
||||||
|
this.pos[i3 + 1] = min[1] + Math.random() * (max[1] - min[1]);
|
||||||
|
this.pos[i3 + 2] = min[2] + Math.random() * (max[2] - min[2]);
|
||||||
|
this.vel[i3] = (Math.random() * 2 - 1) * 0.6;
|
||||||
|
this.vel[i3 + 1] = (Math.random() * 2 - 1) * 0.25;
|
||||||
|
this.vel[i3 + 2] = (Math.random() * 2 - 1) * 0.6;
|
||||||
|
}
|
||||||
|
this.geo = new THREE.BufferGeometry();
|
||||||
|
this.geo.setAttribute('position', new THREE.BufferAttribute(this.pos, 3));
|
||||||
|
const mat = new THREE.PointsMaterial({
|
||||||
|
size: 0.9, map: tex, color: new THREE.Color(0.9, 0.82, 0.62),
|
||||||
|
transparent: true, opacity: 0.32, blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false, sizeAttenuation: true,
|
||||||
|
});
|
||||||
|
this.points = new THREE.Points(this.geo, mat);
|
||||||
|
this.points.frustumCulled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
const { min, max } = this;
|
||||||
|
for (let i = 0; i < this.cap; i++) {
|
||||||
|
const i3 = i * 3;
|
||||||
|
// gentle brownian nudge
|
||||||
|
this.vel[i3] += (Math.random() * 2 - 1) * 0.5 * dt;
|
||||||
|
this.vel[i3 + 1] += (Math.random() * 2 - 1) * 0.2 * dt;
|
||||||
|
this.vel[i3 + 2] += (Math.random() * 2 - 1) * 0.5 * dt;
|
||||||
|
this.vel[i3] *= 0.98; this.vel[i3 + 1] *= 0.98; this.vel[i3 + 2] *= 0.98;
|
||||||
|
for (let a = 0; a < 3; a++) {
|
||||||
|
let v = this.pos[i3 + a] + this.vel[i3 + a] * dt;
|
||||||
|
const lo = min[a], hi = max[a];
|
||||||
|
if (v < lo) v = hi - (lo - v);
|
||||||
|
else if (v > hi) v = lo + (v - hi);
|
||||||
|
this.pos[i3 + a] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(this.geo.getAttribute('position') as THREE.BufferAttribute).needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/interact/hotbar.ts
Normal file
78
src/interact/hotbar.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// LANE D — hotbar model (D5). A pure 9-slot inventory: block id + count per
|
||||||
|
// slot, one active slot. No rendering (Lane E draws it). Emits change
|
||||||
|
// notifications via onChange (the core bus has no hotbar events by contract).
|
||||||
|
|
||||||
|
import { AIR, type BlockId } from '../core/blocks';
|
||||||
|
|
||||||
|
export interface HotSlot {
|
||||||
|
id: BlockId; // AIR (0) when empty
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOTBAR_SLOTS = 9;
|
||||||
|
|
||||||
|
export class Hotbar {
|
||||||
|
readonly slots: HotSlot[];
|
||||||
|
private activeIndex = 0;
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.slots = Array.from({ length: HOTBAR_SLOTS }, () => ({ id: AIR, count: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to any change (selection or contents). Returns an unsubscribe. */
|
||||||
|
onChange(fn: () => void): () => void {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private changed(): void {
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
get active(): number { return this.activeIndex; }
|
||||||
|
|
||||||
|
setActive(i: number): void {
|
||||||
|
const n = ((i % HOTBAR_SLOTS) + HOTBAR_SLOTS) % HOTBAR_SLOTS;
|
||||||
|
if (n === this.activeIndex) return;
|
||||||
|
this.activeIndex = n;
|
||||||
|
this.changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block id in the active slot, or AIR if empty. */
|
||||||
|
activeBlock(): BlockId {
|
||||||
|
const s = this.slots[this.activeIndex];
|
||||||
|
return s.count > 0 ? s.id : AIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total count of a block id across all slots. */
|
||||||
|
count(id: BlockId): number {
|
||||||
|
let n = 0;
|
||||||
|
for (const s of this.slots) if (s.id === id) n += s.count;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id: BlockId, n = 1): boolean { return id !== AIR && this.count(id) >= n; }
|
||||||
|
|
||||||
|
/** Add n of a block: stack into an existing slot of that id, else first empty. */
|
||||||
|
add(id: BlockId, n = 1): void {
|
||||||
|
if (id === AIR || n <= 0) return;
|
||||||
|
for (const s of this.slots) {
|
||||||
|
if (s.id === id && s.count > 0) { s.count += n; this.changed(); return; }
|
||||||
|
}
|
||||||
|
for (const s of this.slots) {
|
||||||
|
if (s.count === 0) { s.id = id; s.count = n; this.changed(); return; }
|
||||||
|
}
|
||||||
|
// No room: silently drop the overflow (v1 has no full-inventory UX).
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove n of the active block. Returns false if the slot lacks that many. */
|
||||||
|
consumeActive(n = 1): boolean {
|
||||||
|
const s = this.slots[this.activeIndex];
|
||||||
|
if (s.count < n) return false;
|
||||||
|
s.count -= n;
|
||||||
|
if (s.count === 0) s.id = AIR;
|
||||||
|
this.changed();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/interact/index.ts
Normal file
4
src/interact/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// LANE D — interaction layer public surface.
|
||||||
|
export { Hotbar, HOTBAR_SLOTS, type HotSlot } from './hotbar';
|
||||||
|
export { Interaction } from './interaction';
|
||||||
|
export { raycastVoxel, raycastColliders, type VoxelHit, type ColliderHit } from './raycast';
|
||||||
206
src/interact/interaction.ts
Normal file
206
src/interact/interaction.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
// LANE D — interaction layer (D5).
|
||||||
|
// Raycasts from the player's eye (voxel DDA + analytic machine tests, nearest
|
||||||
|
// wins), then handles break (LMB hold), place (RMB), and use (E). Use routes
|
||||||
|
// the two hotbar-dependent quest fixes (stylus into the deck-A headshell, fresh
|
||||||
|
// fuse into the fuse box); everything else mechanical goes through the machine's
|
||||||
|
// own onInteract. Owns the aimed-block selection wireframe.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
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 type { Hotbar } from './hotbar';
|
||||||
|
import { raycastColliders, raycastVoxel, type VoxelHit } from './raycast';
|
||||||
|
|
||||||
|
const BREAK_TIME = 0.4; // seconds to mine a block
|
||||||
|
|
||||||
|
export class Interaction {
|
||||||
|
private readonly world: IVoxelWorld;
|
||||||
|
private readonly player: IPlayerView;
|
||||||
|
private readonly machines: MachineSet;
|
||||||
|
private readonly hotbar: Hotbar;
|
||||||
|
private readonly colliderOwner = new Map<string, IMachine>();
|
||||||
|
// Colliders the interaction ray tests. EXCLUDES fader caps: faders are
|
||||||
|
// push-operated (walk into them), never aimed at, and their caps sit in the
|
||||||
|
// slots where breakable dust lives — including them would occlude that dust
|
||||||
|
// (e.g. the crossfader's jam) and make the quest node un-mineable.
|
||||||
|
private readonly rayColliders: KinematicCollider[] = [];
|
||||||
|
|
||||||
|
private mining = false;
|
||||||
|
private breakTarget: { x: number; y: number; z: number } | null = null;
|
||||||
|
private breakElapsed = 0;
|
||||||
|
private lastHit: RayHit = null;
|
||||||
|
|
||||||
|
private readonly selection: THREE.LineSegments;
|
||||||
|
|
||||||
|
constructor(world: IVoxelWorld, player: IPlayerView, machines: MachineSet, hotbar: Hotbar) {
|
||||||
|
this.world = world;
|
||||||
|
this.player = player;
|
||||||
|
this.machines = machines;
|
||||||
|
this.hotbar = hotbar;
|
||||||
|
|
||||||
|
for (const m of machines.machines) {
|
||||||
|
const isFader = m instanceof Fader;
|
||||||
|
for (const c of m.getColliders()) {
|
||||||
|
this.colliderOwner.set(c.id, m);
|
||||||
|
if (!isFader) this.rayColliders.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = new THREE.EdgesGeometry(new THREE.BoxGeometry(1.002, 1.002, 1.002));
|
||||||
|
const mat = new THREE.LineBasicMaterial({ color: 0x111111, transparent: true, opacity: 0.85 });
|
||||||
|
this.selection = new THREE.LineSegments(geo, mat);
|
||||||
|
this.selection.visible = false;
|
||||||
|
this.selection.renderOrder = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selection wireframe; add to the scene. */
|
||||||
|
getObject3D(): THREE.Object3D { return this.selection; }
|
||||||
|
|
||||||
|
// ---- Input surface (host wires DOM events to these) ----
|
||||||
|
startMining(): void { this.mining = true; }
|
||||||
|
stopMining(): void { this.mining = false; this.breakTarget = null; this.breakElapsed = 0; }
|
||||||
|
|
||||||
|
place(): void {
|
||||||
|
const hit = this.raycast();
|
||||||
|
if (!hit || hit.kind !== 'block') return;
|
||||||
|
if (hit.face[0] === 0 && hit.face[1] === 0 && hit.face[2] === 0) return; // inside a block
|
||||||
|
const id = this.hotbar.activeBlock();
|
||||||
|
if (id === AIR) return;
|
||||||
|
|
||||||
|
const tx = hit.x + hit.face[0], ty = hit.y + hit.face[1], tz = hit.z + hit.face[2];
|
||||||
|
if (this.world.getBlock(tx, ty, tz) !== AIR) return;
|
||||||
|
if (voxelHitsPlayer(this.player, tx, ty, tz)) return;
|
||||||
|
if (this.voxelHitsCollider(tx, ty, tz)) return;
|
||||||
|
|
||||||
|
this.world.setBlock(tx, ty, tz, id);
|
||||||
|
this.hotbar.consumeActive(1);
|
||||||
|
bus.emit('block:place', { x: tx, y: ty, z: tz, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
use(): void {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m.onInteract?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block use: seat a fresh fuse into the (already-cleared) fuse box.
|
||||||
|
const q = this.machines.quest;
|
||||||
|
if (!q.isRepaired('fuse') && this.hotbar.activeBlock() === QUEST_ITEMS.fuse) {
|
||||||
|
const f = q.pos.fuseBox;
|
||||||
|
const fx = Math.floor(f[0]), fy = Math.floor(f[1]), fz = Math.floor(f[2]);
|
||||||
|
const near = Math.abs(hit.x - fx) <= 1 && Math.abs(hit.y - fy) <= 1 && Math.abs(hit.z - fz) <= 1;
|
||||||
|
if (near && this.world.getBlock(fx, fy, fz) === AIR) {
|
||||||
|
this.world.setBlock(fx, fy, fz, QUEST_ITEMS.fuse);
|
||||||
|
bus.emit('block:place', { x: fx, y: fy, z: fz, id: QUEST_ITEMS.fuse });
|
||||||
|
this.hotbar.consumeActive(1);
|
||||||
|
q.repair('fuse');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
const hit = this.raycast();
|
||||||
|
this.lastHit = hit;
|
||||||
|
this.advanceMining(dt, hit);
|
||||||
|
this.updateSelection(hit);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHit(): RayHit { return this.lastHit; }
|
||||||
|
|
||||||
|
// ---- internals ----
|
||||||
|
|
||||||
|
private advanceMining(dt: number, hit: RayHit): void {
|
||||||
|
if (!this.mining || !hit || hit.kind !== 'block' || !blockDef(hit.id).breakable) {
|
||||||
|
this.breakTarget = null; this.breakElapsed = 0; return;
|
||||||
|
}
|
||||||
|
if (!this.breakTarget || this.breakTarget.x !== hit.x || this.breakTarget.y !== hit.y || this.breakTarget.z !== hit.z) {
|
||||||
|
this.breakTarget = { x: hit.x, y: hit.y, z: hit.z };
|
||||||
|
this.breakElapsed = 0;
|
||||||
|
}
|
||||||
|
this.breakElapsed += dt;
|
||||||
|
if (this.breakElapsed >= BREAK_TIME) {
|
||||||
|
const id: BlockId = hit.id;
|
||||||
|
this.hotbar.add(id, 1);
|
||||||
|
this.world.setBlock(hit.x, hit.y, hit.z, AIR);
|
||||||
|
bus.emit('block:break', { x: hit.x, y: hit.y, z: hit.z, id });
|
||||||
|
this.breakTarget = null; this.breakElapsed = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSelection(hit: RayHit): void {
|
||||||
|
if (hit && hit.kind === 'block') {
|
||||||
|
this.selection.visible = true;
|
||||||
|
this.selection.position.set(hit.x + 0.5, hit.y + 0.5, hit.z + 0.5);
|
||||||
|
} else {
|
||||||
|
this.selection.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private raycast(): RayHit {
|
||||||
|
const [ox, oy, oz] = this.player.eye;
|
||||||
|
const [dx, dy, dz] = this.player.lookDir;
|
||||||
|
const reach = PLAYER.reach;
|
||||||
|
|
||||||
|
const vox = raycastVoxel(this.world, ox, oy, oz, dx, dy, dz, reach);
|
||||||
|
const col = raycastColliders(this.rayColliders, ox, oy, oz, dx, dy, dz, reach);
|
||||||
|
|
||||||
|
const voxDist = vox ? vox.dist : Infinity;
|
||||||
|
const colDist = col ? col.dist : Infinity;
|
||||||
|
|
||||||
|
if (col && colDist <= voxDist) {
|
||||||
|
const m = this.colliderOwner.get(col.collider.id);
|
||||||
|
if (m) return { kind: 'machine', machine: m, point: col.point };
|
||||||
|
}
|
||||||
|
if (vox) return blockHit(vox);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private voxelHitsCollider(x: number, y: number, z: number): boolean {
|
||||||
|
// Voxel box [x,x+1]×[y,y+1]×[z,z+1] vs each collider (conservative).
|
||||||
|
const bx0 = x, bx1 = x + 1, by0 = y, by1 = y + 1, bz0 = z, bz1 = z + 1;
|
||||||
|
for (const c of this.machines.getColliders()) {
|
||||||
|
if (c.shape.kind === 'aabb') {
|
||||||
|
const s = c.shape;
|
||||||
|
if (bx0 < s.max[0] && bx1 > s.min[0] && by0 < s.max[1] && by1 > s.min[1] && bz0 < s.max[2] && bz1 > s.min[2]) return true;
|
||||||
|
} else {
|
||||||
|
const s = c.shape;
|
||||||
|
const cyTop = s.center[1] + s.halfHeight, cyBot = s.center[1] - s.halfHeight;
|
||||||
|
if (by1 <= cyBot || by0 >= cyTop) continue;
|
||||||
|
// horizontal distance from the cylinder axis to the voxel-box nearest point.
|
||||||
|
const nx = clampN(s.center[0], bx0, bx1), nz = clampN(s.center[2], bz0, bz1);
|
||||||
|
const ddx = nx - s.center[0], ddz = nz - s.center[2];
|
||||||
|
if (ddx * ddx + ddz * ddz < s.radius * s.radius) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockHit(v: VoxelHit): RayHit {
|
||||||
|
return { kind: 'block', x: v.x, y: v.y, z: v.z, id: v.id, face: v.face };
|
||||||
|
}
|
||||||
|
|
||||||
|
function voxelHitsPlayer(p: IPlayerView, x: number, y: number, z: number): boolean {
|
||||||
|
const [px, py, pz] = p.position;
|
||||||
|
const hw = PLAYER.width / 2;
|
||||||
|
const pminX = px - hw, pmaxX = px + hw, pminY = py, pmaxY = py + PLAYER.height, pminZ = pz - hw, pmaxZ = pz + hw;
|
||||||
|
return x < pmaxX && x + 1 > pminX && y < pmaxY && y + 1 > pminY && z < pmaxZ && z + 1 > pminZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampN(v: number, a: number, b: number): number { return v < a ? a : v > b ? b : v; }
|
||||||
180
src/interact/raycast.ts
Normal file
180
src/interact/raycast.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
// LANE D — raycasting for interaction (D5).
|
||||||
|
// Voxel traversal is Amanatides & Woo DDA (no tunneling: it visits every voxel
|
||||||
|
// the ray crosses, in order). Machine colliders are tested analytically
|
||||||
|
// (ray↔cylinder, ray↔aabb). The nearest hit of either kind wins.
|
||||||
|
|
||||||
|
import type { BlockId } from '../core/blocks';
|
||||||
|
import type { IVoxelWorld, KinematicCollider, Vec3 } from '../core/types';
|
||||||
|
|
||||||
|
export interface VoxelHit {
|
||||||
|
x: number; y: number; z: number;
|
||||||
|
id: BlockId;
|
||||||
|
face: Vec3; // outward normal of the hit face (for placement)
|
||||||
|
dist: number; // world-unit distance from origin (dir assumed to be normalized here)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColliderHit {
|
||||||
|
collider: KinematicCollider;
|
||||||
|
point: Vec3;
|
||||||
|
dist: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First solid voxel along the ray within maxDist, or null. Dir is normalized here. */
|
||||||
|
export function raycastVoxel(
|
||||||
|
world: IVoxelWorld,
|
||||||
|
ox: number, oy: number, oz: number,
|
||||||
|
dx: number, dy: number, dz: number,
|
||||||
|
maxDist: number,
|
||||||
|
): VoxelHit | null {
|
||||||
|
const len = Math.hypot(dx, dy, dz);
|
||||||
|
if (len === 0) return null;
|
||||||
|
dx /= len; dy /= len; dz /= len;
|
||||||
|
|
||||||
|
let x = Math.floor(ox), y = Math.floor(oy), z = Math.floor(oz);
|
||||||
|
if (world.isSolid(x, y, z)) {
|
||||||
|
return { x, y, z, id: world.getBlock(x, y, z), face: [0, 0, 0], dist: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepX = dx > 0 ? 1 : dx < 0 ? -1 : 0;
|
||||||
|
const stepY = dy > 0 ? 1 : dy < 0 ? -1 : 0;
|
||||||
|
const stepZ = dz > 0 ? 1 : dz < 0 ? -1 : 0;
|
||||||
|
const invX = dx !== 0 ? Math.abs(1 / dx) : Infinity;
|
||||||
|
const invY = dy !== 0 ? Math.abs(1 / dy) : Infinity;
|
||||||
|
const invZ = dz !== 0 ? Math.abs(1 / dz) : Infinity;
|
||||||
|
let tMaxX = stepX === 0 ? Infinity : stepX > 0 ? (x + 1 - ox) * invX : (ox - x) * invX;
|
||||||
|
let tMaxY = stepY === 0 ? Infinity : stepY > 0 ? (y + 1 - oy) * invY : (oy - y) * invY;
|
||||||
|
let tMaxZ = stepZ === 0 ? Infinity : stepZ > 0 ? (z + 1 - oz) * invZ : (oz - z) * invZ;
|
||||||
|
|
||||||
|
for (let i = 0; i < 2048; i++) {
|
||||||
|
let axis: 0 | 1 | 2;
|
||||||
|
let t: number;
|
||||||
|
if (tMaxX <= tMaxY && tMaxX <= tMaxZ) { axis = 0; t = tMaxX; x += stepX; tMaxX += invX; }
|
||||||
|
else if (tMaxY <= tMaxZ) { axis = 1; t = tMaxY; y += stepY; tMaxY += invY; }
|
||||||
|
else { axis = 2; t = tMaxZ; z += stepZ; tMaxZ += invZ; }
|
||||||
|
if (t > maxDist) return null;
|
||||||
|
if (world.isSolid(x, y, z)) {
|
||||||
|
const face: Vec3 = axis === 0 ? [-stepX, 0, 0] : axis === 1 ? [0, -stepY, 0] : [0, 0, -stepZ];
|
||||||
|
return { x, y, z, id: world.getBlock(x, y, z), face, dist: t };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest machine collider along the ray within maxDist, or null. */
|
||||||
|
export function raycastColliders(
|
||||||
|
colliders: readonly KinematicCollider[],
|
||||||
|
ox: number, oy: number, oz: number,
|
||||||
|
dx: number, dy: number, dz: number,
|
||||||
|
maxDist: number,
|
||||||
|
): ColliderHit | null {
|
||||||
|
const len = Math.hypot(dx, dy, dz);
|
||||||
|
if (len === 0) return null;
|
||||||
|
dx /= len; dy /= len; dz /= len;
|
||||||
|
|
||||||
|
let best: ColliderHit | null = null;
|
||||||
|
for (const c of colliders) {
|
||||||
|
let t: number | null = null;
|
||||||
|
if (c.shape.kind === 'cylinder') {
|
||||||
|
const s = c.shape;
|
||||||
|
t = rayCylinder(ox, oy, oz, dx, dy, dz, s.center, s.radius, s.halfHeight, maxDist);
|
||||||
|
} else {
|
||||||
|
const s = c.shape;
|
||||||
|
t = rayAabb(ox, oy, oz, dx, dy, dz, s.min, s.max, maxDist);
|
||||||
|
}
|
||||||
|
if (t !== null && t >= 0 && t <= maxDist && (best === null || t < best.dist)) {
|
||||||
|
best = { collider: c, point: [ox + dx * t, oy + dy * t, oz + dz * t], dist: t };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entry distance of the ray into an AABB, or null. 0 if the origin is inside. */
|
||||||
|
function rayAabb(
|
||||||
|
ox: number, oy: number, oz: number,
|
||||||
|
dx: number, dy: number, dz: number,
|
||||||
|
min: Vec3, max: Vec3, maxDist: number,
|
||||||
|
): number | null {
|
||||||
|
// Slab method, axes unrolled to avoid per-call array allocation (hot path).
|
||||||
|
let tmin = 0;
|
||||||
|
let tmax = maxDist;
|
||||||
|
// X
|
||||||
|
if (Math.abs(dx) < 1e-9) { if (ox < min[0] || ox > max[0]) return null; }
|
||||||
|
else {
|
||||||
|
let t1 = (min[0] - ox) / dx, t2 = (max[0] - ox) / dx;
|
||||||
|
if (t1 > t2) { const t = t1; t1 = t2; t2 = t; }
|
||||||
|
if (t1 > tmin) tmin = t1;
|
||||||
|
if (t2 < tmax) tmax = t2;
|
||||||
|
if (tmin > tmax) return null;
|
||||||
|
}
|
||||||
|
// Y
|
||||||
|
if (Math.abs(dy) < 1e-9) { if (oy < min[1] || oy > max[1]) return null; }
|
||||||
|
else {
|
||||||
|
let t1 = (min[1] - oy) / dy, t2 = (max[1] - oy) / dy;
|
||||||
|
if (t1 > t2) { const t = t1; t1 = t2; t2 = t; }
|
||||||
|
if (t1 > tmin) tmin = t1;
|
||||||
|
if (t2 < tmax) tmax = t2;
|
||||||
|
if (tmin > tmax) return null;
|
||||||
|
}
|
||||||
|
// Z
|
||||||
|
if (Math.abs(dz) < 1e-9) { if (oz < min[2] || oz > max[2]) return null; }
|
||||||
|
else {
|
||||||
|
let t1 = (min[2] - oz) / dz, t2 = (max[2] - oz) / dz;
|
||||||
|
if (t1 > t2) { const t = t1; t1 = t2; t2 = t; }
|
||||||
|
if (t1 > tmin) tmin = t1;
|
||||||
|
if (t2 < tmax) tmax = t2;
|
||||||
|
if (tmin > tmax) return null;
|
||||||
|
}
|
||||||
|
return tmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest entry distance of the ray into a finite +Y cylinder, or null. */
|
||||||
|
function rayCylinder(
|
||||||
|
ox: number, oy: number, oz: number,
|
||||||
|
dx: number, dy: number, dz: number,
|
||||||
|
center: Vec3, radius: number, halfHeight: number, maxDist: number,
|
||||||
|
): number | null {
|
||||||
|
const cx = center[0], cy = center[1], cz = center[2];
|
||||||
|
const yTop = cy + halfHeight, yBot = cy - halfHeight;
|
||||||
|
let best: number | null = null;
|
||||||
|
|
||||||
|
const consider = (t: number) => {
|
||||||
|
if (t < 0 || t > maxDist) return;
|
||||||
|
if (best === null || t < best) best = t;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Side (infinite cylinder in xz), then clamp y to the finite height.
|
||||||
|
const a = dx * dx + dz * dz;
|
||||||
|
if (a > 1e-12) {
|
||||||
|
const rox = ox - cx, roz = oz - cz;
|
||||||
|
const b = 2 * (rox * dx + roz * dz);
|
||||||
|
const c = rox * rox + roz * roz - radius * radius;
|
||||||
|
const disc = b * b - 4 * a * c;
|
||||||
|
if (disc >= 0) {
|
||||||
|
const sq = Math.sqrt(disc);
|
||||||
|
const inv = 1 / (2 * a);
|
||||||
|
considerSide((-b - sq) * inv, oy, dy, yBot, yTop, maxDist, consider);
|
||||||
|
considerSide((-b + sq) * inv, oy, dy, yBot, yTop, maxDist, consider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caps (top/bottom disks).
|
||||||
|
if (Math.abs(dy) > 1e-12) {
|
||||||
|
considerCap(yTop, ox, oy, oz, dx, dy, dz, cx, cz, radius, maxDist, consider);
|
||||||
|
considerCap(yBot, ox, oy, oz, dx, dy, dz, cx, cz, radius, maxDist, consider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function considerSide(t: number, oy: number, dy: number, yBot: number, yTop: number, maxDist: number, consider: (t: number) => void): void {
|
||||||
|
if (t < 0 || t > maxDist) return;
|
||||||
|
const hy = oy + dy * t;
|
||||||
|
if (hy >= yBot && hy <= yTop) consider(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function considerCap(planeY: number, ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, cx: number, cz: number, radius: number, maxDist: number, consider: (t: number) => void): void {
|
||||||
|
const t = (planeY - oy) / dy;
|
||||||
|
if (t < 0 || t > maxDist) return;
|
||||||
|
const hx = ox + dx * t - cx, hz = oz + dz * t - cz;
|
||||||
|
if (hx * hx + hz * hz <= radius * radius) consider(t);
|
||||||
|
}
|
||||||
91
src/machines/HANDOFF.md
Normal file
91
src/machines/HANDOFF.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# LANE D — Machines, Interaction & Quest — HANDOFF
|
||||||
|
|
||||||
|
Owns `src/machines/**`, `src/interact/**`, `src/demo/machinesDemo.ts`,
|
||||||
|
`demo-machines.html`. `npm run typecheck` is clean. Demo runs with zero code
|
||||||
|
from other lanes.
|
||||||
|
|
||||||
|
## Public API (the 10-line surface)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/machines
|
||||||
|
createMachines(world: IVoxelWorld, questPos?: Partial<QuestPositions>): MachineSet
|
||||||
|
// MachineSet = { machines, quest, tonearmA, getColliders(), update(dt),
|
||||||
|
// getObject3Ds(), attachPlayer(player) }
|
||||||
|
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)
|
||||||
|
new Hotbar() // .slots .active .setActive(i) .activeBlock() .add(id,n) .consumeActive(n) .onChange(fn)
|
||||||
|
raycastVoxel(...) / raycastColliders(...) // DDA + analytic, exported for reuse
|
||||||
|
```
|
||||||
|
|
||||||
|
## Done (all acceptance criteria verified — see LANE_D_update.md)
|
||||||
|
|
||||||
|
- **Platters ×2**: rotating cylinder collider, `velocityAt = ω×r` (tangential,
|
||||||
|
zero-y, zero when stopped). Exponential spin-up/brake. Runtime-measured
|
||||||
|
33→8.52 v/s @ rim / 2.08 v/s @ r=10, 45→11.57 v/s. Blue/black records with a
|
||||||
|
procedural groove+label canvas texture ("TC-001 · TURNCRAFT"), strobe-dot rim,
|
||||||
|
chrome spindle. Emits `platter:state` on play/speed/pitch change.
|
||||||
|
- **Tonearms ×2**: rigid arm pivoting about a rear pedestal; cue → inward track
|
||||||
|
(~4 min) → auto-return. Headshell is a moving kinematic aabb ("needle plow")
|
||||||
|
with real finite-difference velocity. Deck A starts with an empty stylus
|
||||||
|
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.
|
||||||
|
- **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) +
|
||||||
|
analytic ray↔cylinder/aabb, nearest wins. Break (0.4 s hold → hotbar + `block:break`),
|
||||||
|
place (blocked by player/collider/occupied + `block:place`), use (routes stylus &
|
||||||
|
fuse via hotbar+quest, else `machine.onInteract()`). Selection wireframe.
|
||||||
|
- **Quest**: pure state machine over `SIGNAL_NODES`; `signal:repair` per fix,
|
||||||
|
`game:win` exactly once on the fifth, both platters auto-start on win.
|
||||||
|
|
||||||
|
## Contract friction / deviations (for the integrator)
|
||||||
|
|
||||||
|
1. **`createMachines` takes a 2nd arg** `questPos?: Partial<QuestPositions>`.
|
||||||
|
The brief's *Provides* line shows `createMachines(world)`; D6 + INTEGRATION
|
||||||
|
both require quest positions, so I made it optional — `createMachines(world)`
|
||||||
|
works (LAYOUT-derived defaults via `defaultQuestPositions()`), and integration
|
||||||
|
passes real `QUEST_POS`. **`QuestPositions` is defined in `src/machines/quest.ts`**
|
||||||
|
— Lane C's `src/worldgen/questPositions.ts` should export a `QUEST_POS` of that
|
||||||
|
shape (I do not import Lane C).
|
||||||
|
2. **`MachineSet.attachPlayer(player)`** is an addition beyond the brief. Faders
|
||||||
|
(walk-to-push) and walk-press buttons need the player view, which doesn't exist
|
||||||
|
at `createMachines` time. **Integrator: call `machines.attachPlayer(player)`
|
||||||
|
right after constructing the Lane B player (step 6.5).** Without it, faders
|
||||||
|
still work via `setValue` and buttons via E; only walk-interaction is inert.
|
||||||
|
3. **Quest item blocks** (`QUEST_ITEMS`): the stylus pickup is `chrome` (11) and
|
||||||
|
the fuse pickup is `glass` (22). Lane C should place these as the parts-bin
|
||||||
|
pickups. Stylus fit = `use` on Deck A's headshell holding chrome; fuse = break
|
||||||
|
the blown fuse at `pos.fuseBox`, then `use` nearby holding glass.
|
||||||
|
4. **Hotbar change events** use `hotbar.onChange(fn)` (not the core bus — the bus
|
||||||
|
has no hotbar events by contract). Lane E's HUD subscribes to that.
|
||||||
|
5. **Emissive pulse hook**: I don't touch Lane A materials. The master power /
|
||||||
|
cue lamps swap their own emissive material; Lane E's global emissive boost
|
||||||
|
(`setEmissiveBoost`) is orthogonal.
|
||||||
|
6. **45-mode**: kept available at all times (INTEGRATION smoke-test 2 rim-launches
|
||||||
|
at 45 pre-win). DESIGN §6's "45 unlocked at win" is treated as flavor. Flag
|
||||||
|
`Platter.unlocked45` exists if you want to gate it.
|
||||||
|
|
||||||
|
## Stubbed / out of scope (by contract)
|
||||||
|
|
||||||
|
- No player physics, no voxel meshing, no booth geometry, no audio/UI rendering.
|
||||||
|
I emit events; Lane E renders hotbar/lights/sound from them.
|
||||||
|
- The demo's `MockWorld`, naive block viz, and `MockPlayer` are `// DEMO MOCK`
|
||||||
|
and never used in integration.
|
||||||
|
- Demo exposes `window.TURNCRAFT_D` (debug handle: `machines/quest/step(n)`);
|
||||||
|
harmless, demo-only.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
`npm run dev` → open `/demo-machines.html`. Header comment in
|
||||||
|
`src/demo/machinesDemo.ts` lists exactly what to press for each criterion.
|
||||||
|
Note: in a **hidden/background tab** browsers pause `requestAnimationFrame`, so
|
||||||
|
the sim appears frozen — use `TURNCRAFT_D.step(n)` from the console to advance,
|
||||||
|
or just focus the tab.
|
||||||
94
src/machines/buttons.ts
Normal file
94
src/machines/buttons.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
// LANE D — button machines (D4): start/stop plates, 33/45 rockers, cue lamps,
|
||||||
|
// master power switch. A button is a small aabb machine. Pressing "use" (E)
|
||||||
|
// while aiming at it fires onUse; the start/stop plate can also be walk-pressed
|
||||||
|
// (stepping on the plate). The master power switch stays inert (dark) until the
|
||||||
|
// other four nodes are repaired, then glows and its single press is the finale.
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
export interface ButtonOpts {
|
||||||
|
id: string;
|
||||||
|
pos: Vec3; // center
|
||||||
|
size?: [number, number, number];
|
||||||
|
block?: BlockId; // visual block, default matte_black (5)
|
||||||
|
action: string; // machine:interact action label
|
||||||
|
onUse: () => void;
|
||||||
|
/** Walk-press: firing onUse when the player stands on the plate. */
|
||||||
|
walkPress?: boolean;
|
||||||
|
/** Optional glow color id used by setGlow (e.g. led_green for armed power). */
|
||||||
|
glowBlock?: BlockId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Button extends MachineBase {
|
||||||
|
private readonly action: string;
|
||||||
|
private readonly onUse: () => void;
|
||||||
|
private readonly walkPress: boolean;
|
||||||
|
private readonly mesh: THREE.Mesh;
|
||||||
|
private readonly baseMat: THREE.MeshStandardMaterial;
|
||||||
|
private readonly glowMat: THREE.MeshStandardMaterial | null;
|
||||||
|
private readonly shape: { kind: 'aabb'; min: Vec3; max: Vec3 };
|
||||||
|
private readonly aabb: AABB = { minX: 0, minY: 0, minZ: 0, maxX: 0, maxY: 0, maxZ: 0 };
|
||||||
|
private wasPressed = false;
|
||||||
|
private glowing = false;
|
||||||
|
|
||||||
|
constructor(o: ButtonOpts) {
|
||||||
|
super(o.id);
|
||||||
|
this.action = o.action;
|
||||||
|
this.onUse = o.onUse;
|
||||||
|
this.walkPress = o.walkPress ?? false;
|
||||||
|
|
||||||
|
const size = o.size ?? [5, 2, 5];
|
||||||
|
this.baseMat = blockMaterial(o.block ?? 5);
|
||||||
|
this.glowMat = o.glowBlock != null ? blockMaterial(o.glowBlock, { emissive: 1.1 }) : null;
|
||||||
|
this.mesh = box(size[0], size[1], size[2], this.baseMat, o.pos[0], o.pos[1], o.pos[2]);
|
||||||
|
this.group.add(this.mesh);
|
||||||
|
|
||||||
|
const h: Vec3 = [size[0] / 2, size[1] / 2, size[2] / 2];
|
||||||
|
this.shape = {
|
||||||
|
kind: 'aabb',
|
||||||
|
min: [o.pos[0] - h[0], o.pos[1] - h[1], o.pos[2] - h[2]],
|
||||||
|
max: [o.pos[0] + h[0], o.pos[1] + h[1], o.pos[2] + h[2]],
|
||||||
|
};
|
||||||
|
const collider: KinematicCollider = {
|
||||||
|
id: `${o.id}_c`,
|
||||||
|
shape: this.shape,
|
||||||
|
velocityAt: () => [0, 0, 0],
|
||||||
|
};
|
||||||
|
this.colliders = [collider];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swap to the glow material (armed power switch, cue lamp on). */
|
||||||
|
setGlow(on: boolean): void {
|
||||||
|
if (on === this.glowing || !this.glowMat) return;
|
||||||
|
this.glowing = on;
|
||||||
|
this.mesh.material = on ? this.glowMat : this.baseMat;
|
||||||
|
}
|
||||||
|
|
||||||
|
isGlowing(): boolean { return this.glowing; }
|
||||||
|
|
||||||
|
onInteract(): void {
|
||||||
|
bus.emit('machine:interact', { machineId: this.id, action: this.action });
|
||||||
|
this.onUse();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_dt: number): void {
|
||||||
|
if (!this.walkPress || !this.player) return;
|
||||||
|
// Edge-triggered walk press: player AABB overlaps the plate from above.
|
||||||
|
const pb = playerAABB(this.player, this.aabb);
|
||||||
|
const over = aabbOverlap(pb, {
|
||||||
|
minX: this.shape.min[0], minY: this.shape.min[1], minZ: this.shape.min[2],
|
||||||
|
maxX: this.shape.max[0], maxY: this.shape.max[1], maxZ: this.shape.max[2],
|
||||||
|
}, 0.1);
|
||||||
|
if (over && !this.wasPressed) {
|
||||||
|
this.wasPressed = true;
|
||||||
|
this.onInteract();
|
||||||
|
} else if (!over) {
|
||||||
|
this.wasPressed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/machines/faders.ts
Normal file
165
src/machines/faders.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
export interface FaderOpts {
|
||||||
|
id: string;
|
||||||
|
/** Sled-center travel endpoints (value 0 → start, value 1 → end). */
|
||||||
|
start: Vec3;
|
||||||
|
end: Vec3;
|
||||||
|
axis: 'x' | 'z';
|
||||||
|
detents?: number; // default 11
|
||||||
|
capBlock?: BlockId; // visual, default fader_cap (14)
|
||||||
|
capSize?: [number, number, number];
|
||||||
|
initial?: number; // default 0.5 (center)
|
||||||
|
/** 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. */
|
||||||
|
onFullSlide?: () => void;
|
||||||
|
/** Called whenever the value changes (e.g. pitch fader → platter). */
|
||||||
|
onChange?: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Fader extends MachineBase {
|
||||||
|
private value: number;
|
||||||
|
private readonly axis: 'x' | 'z';
|
||||||
|
private readonly start: Vec3;
|
||||||
|
private readonly end: Vec3;
|
||||||
|
private readonly detents: number;
|
||||||
|
private readonly gate?: () => boolean;
|
||||||
|
private readonly onFullSlide?: () => void;
|
||||||
|
private readonly onChange?: (value: number) => void;
|
||||||
|
|
||||||
|
private readonly cap: THREE.Mesh;
|
||||||
|
private readonly capHalf: Vec3;
|
||||||
|
private readonly shape: { kind: 'aabb'; min: Vec3; max: Vec3 };
|
||||||
|
private readonly aabb: AABB = { minX: 0, minY: 0, minZ: 0, maxX: 0, maxY: 0, maxZ: 0 };
|
||||||
|
|
||||||
|
private sawLow = false;
|
||||||
|
private sawHigh = false;
|
||||||
|
private fullSlideFired = false;
|
||||||
|
|
||||||
|
constructor(o: FaderOpts) {
|
||||||
|
super(o.id);
|
||||||
|
this.axis = o.axis;
|
||||||
|
this.start = o.start;
|
||||||
|
this.end = o.end;
|
||||||
|
this.detents = o.detents ?? 11;
|
||||||
|
this.gate = o.gate;
|
||||||
|
this.onFullSlide = o.onFullSlide;
|
||||||
|
this.onChange = o.onChange;
|
||||||
|
this.value = clamp01(o.initial ?? 0.5);
|
||||||
|
|
||||||
|
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.group.add(this.cap);
|
||||||
|
|
||||||
|
this.shape = { kind: 'aabb', min: [0, 0, 0], max: [0, 0, 0] };
|
||||||
|
const collider: KinematicCollider = {
|
||||||
|
id: `${o.id}_cap`,
|
||||||
|
shape: this.shape,
|
||||||
|
velocityAt: () => [0, 0, 0], // faders move only when pushed; treat as static surface
|
||||||
|
};
|
||||||
|
this.colliders = [collider];
|
||||||
|
|
||||||
|
this.applyValue(this.value, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
getValue(): number { return this.value; }
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackFullSlide(): void {
|
||||||
|
if (this.fullSlideFired) return;
|
||||||
|
if (this.value <= 0.1) this.sawLow = true;
|
||||||
|
if (this.value >= 0.9) this.sawHigh = true;
|
||||||
|
if (this.sawLow && this.sawHigh) {
|
||||||
|
this.fullSlideFired = true;
|
||||||
|
this.onFullSlide?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_dt: number): void {
|
||||||
|
if (!this.player) return;
|
||||||
|
if (this.gate && !this.gate()) return;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
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]);
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(v: number): number { return v < 0 ? 0 : v > 1 ? 1 : v; }
|
||||||
|
function clampNum(v: number, a: number, b: number): number {
|
||||||
|
const lo = Math.min(a, b), hi = Math.max(a, b);
|
||||||
|
return v < lo ? lo : v > hi ? hi : v;
|
||||||
|
}
|
||||||
|
function snapDetent(v: number, detents: number): number {
|
||||||
|
if (detents <= 1) return v;
|
||||||
|
const step = 1 / (detents - 1);
|
||||||
|
return Math.round(v / step) * step;
|
||||||
|
}
|
||||||
|
function lerpVec(a: Vec3, b: Vec3, t: number): Vec3 {
|
||||||
|
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||||
|
}
|
||||||
206
src/machines/index.ts
Normal file
206
src/machines/index.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
// LANE D — machine set assembly + wiring (D6).
|
||||||
|
// createMachines() builds every moving/interactive machine at its LAYOUT-derived
|
||||||
|
// position, wires the control relationships (buttons→platters, faders→platters,
|
||||||
|
// crossfader/rca/power→quest), and returns a MachineSet the integrator drives.
|
||||||
|
//
|
||||||
|
// All positions come from src/core/constants.ts (LAYOUT/PLATTER) or the supplied
|
||||||
|
// QuestPositions — never a hardcoded magic coordinate.
|
||||||
|
|
||||||
|
import {
|
||||||
|
LAYOUT, PLATTER, Y_PLINTH_TOP, Y_RECORD_TOP, SIGNAL_NODES,
|
||||||
|
} from '../core/constants';
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import type { IMachine, IPlayerView, IVoxelWorld, KinematicCollider, Vec3 } from '../core/types';
|
||||||
|
import { Platter } from './platter';
|
||||||
|
import { Tonearm } from './tonearm';
|
||||||
|
import { Fader } from './faders';
|
||||||
|
import { Button } from './buttons';
|
||||||
|
import { RcaPlug } from './rca';
|
||||||
|
import { Quest, type QuestPositions } from './quest';
|
||||||
|
|
||||||
|
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 interface MachineSet {
|
||||||
|
/** Every machine (for iteration / collider lookup). */
|
||||||
|
machines: IMachine[];
|
||||||
|
/** The quest state machine (Lane E queries isRepaired; Interaction repairs). */
|
||||||
|
quest: Quest;
|
||||||
|
/** Deck A tonearm handle — Interaction routes the stylus fit here. */
|
||||||
|
tonearmA: Tonearm;
|
||||||
|
/** Flattened kinematic colliders (stable array; shapes mutate in place). */
|
||||||
|
getColliders(): KinematicCollider[];
|
||||||
|
/** Advance all machines one fixed tick. */
|
||||||
|
update(dt: number): void;
|
||||||
|
/** THREE.Object3D per machine, to add to the scene. */
|
||||||
|
getObject3Ds(): unknown[];
|
||||||
|
/** Give machines the player view (fader push / walk-press). Call after Lane B. */
|
||||||
|
attachPlayer(player: IPlayerView): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sensible LAYOUT-derived quest positions, used when the integrator omits some. */
|
||||||
|
export function defaultQuestPositions(): QuestPositions {
|
||||||
|
const mixCx = (LAYOUT.mixer.minX + LAYOUT.mixer.maxX) / 2;
|
||||||
|
const mixCz = (LAYOUT.mixer.minZ + LAYOUT.mixer.maxZ) / 2;
|
||||||
|
const pbCx = (LAYOUT.patchBay.minX + LAYOUT.patchBay.maxX) / 2;
|
||||||
|
const pbCy = (LAYOUT.patchBay.minY + LAYOUT.patchBay.maxY) / 2;
|
||||||
|
return {
|
||||||
|
stylusSocket: [LAYOUT.deckA.spindleX, Y_RECORD_TOP + 2, LAYOUT.deckA.spindleZ],
|
||||||
|
rcaPlug: [pbCx, 95, 170],
|
||||||
|
rcaSocket: [pbCx, pbCy, LAYOUT.backWallZ],
|
||||||
|
crossfaderSlot: { min: [196, Y_PLINTH_TOP - 14, 53], max: [252, Y_PLINTH_TOP - 12, 55] },
|
||||||
|
fuseBox: [mixCx, 18, mixCz],
|
||||||
|
masterSwitch: [mixCx, LAYOUT.mixer.topY + 2, LAYOUT.mixer.maxZ - 3],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Are all voxels in [min,max] (inclusive) non-solid? (crossfader gate) */
|
||||||
|
function slotClear(world: IVoxelWorld, slot: { min: Vec3; max: Vec3 }): boolean {
|
||||||
|
const [x0, y0, z0] = slot.min;
|
||||||
|
const [x1, y1, z1] = slot.max;
|
||||||
|
for (let x = Math.floor(x0); x <= Math.floor(x1); x++)
|
||||||
|
for (let y = Math.floor(y0); y <= Math.floor(y1); y++)
|
||||||
|
for (let z = Math.floor(z0); z <= Math.floor(z1); z++)
|
||||||
|
if (world.isSolid(x, y, z)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMachines(world: IVoxelWorld, questPos?: Partial<QuestPositions>): MachineSet {
|
||||||
|
const pos: QuestPositions = { ...defaultQuestPositions(), ...questPos };
|
||||||
|
const quest = new Quest(pos);
|
||||||
|
const machines: IMachine[] = [];
|
||||||
|
|
||||||
|
// ---- Per-deck rig (platter, tonearm, pitch fader, buttons) ----
|
||||||
|
interface DeckFootprint {
|
||||||
|
minX: number; maxX: number; minZ: number; maxZ: number; spindleX: number; spindleZ: number;
|
||||||
|
}
|
||||||
|
const buildDeck = (deck: 'A' | 'B', L: DeckFootprint) => {
|
||||||
|
const platter = new Platter(deck, L.spindleX, L.spindleZ);
|
||||||
|
machines.push(platter);
|
||||||
|
|
||||||
|
const pivot: Vec3 = [L.spindleX + PLATTER.radius * 0.8, Y_RECORD_TOP + 1, L.spindleZ + PLATTER.radius * 0.72];
|
||||||
|
const tonearm = new Tonearm(deck, L.spindleX, L.spindleZ, pivot, /*stylus*/ deck === 'B');
|
||||||
|
tonearm.deckPlaying = () => platter.isPlaying();
|
||||||
|
machines.push(tonearm);
|
||||||
|
|
||||||
|
// Pitch fader: vertical slot just right of the platter, along +Z.
|
||||||
|
const pfx = L.spindleX + PLATTER.radius + 6;
|
||||||
|
const pitch = new Fader({
|
||||||
|
id: `fader_pitch${deck}`,
|
||||||
|
axis: 'z',
|
||||||
|
start: [pfx, Y_PLINTH_TOP + 1, L.spindleZ - 16],
|
||||||
|
end: [pfx, Y_PLINTH_TOP + 1, L.spindleZ + 16],
|
||||||
|
capSize: [5, 2.4, 7],
|
||||||
|
initial: 0.5,
|
||||||
|
onChange: (v) => platter.setPitchFromFader(v),
|
||||||
|
});
|
||||||
|
machines.push(pitch);
|
||||||
|
|
||||||
|
// Buttons on the front-left plinth top.
|
||||||
|
const bx = L.minX + 12, by = Y_PLINTH_TOP + 1;
|
||||||
|
const startStop = new Button({
|
||||||
|
id: `btn_start${deck}`, pos: [bx, by, L.minZ + 12], size: [6, 2, 6],
|
||||||
|
action: 'start_stop', walkPress: true, block: 5, glowBlock: 18,
|
||||||
|
onUse: () => platter.togglePlay(),
|
||||||
|
});
|
||||||
|
const r33 = new Button({
|
||||||
|
id: `btn_33${deck}`, pos: [bx, by, L.minZ + 24], size: [3.4, 1.6, 3.4],
|
||||||
|
action: 'speed_33', block: 13, onUse: () => platter.setSpeed(33),
|
||||||
|
});
|
||||||
|
const r45 = new Button({
|
||||||
|
id: `btn_45${deck}`, pos: [bx + 6, by, L.minZ + 24], size: [3.4, 1.6, 3.4],
|
||||||
|
action: 'speed_45', block: 13, onUse: () => platter.setSpeed(45),
|
||||||
|
});
|
||||||
|
const cue = new Button({
|
||||||
|
id: `btn_cue${deck}`, pos: [bx, by, L.minZ + 34], size: [3, 1.6, 3],
|
||||||
|
action: 'cue', block: 12, glowBlock: 20,
|
||||||
|
onUse: () => cue.setGlow(!cue.isGlowing()),
|
||||||
|
});
|
||||||
|
machines.push(startStop, r33, r45, cue);
|
||||||
|
|
||||||
|
// Deck comes alive on win: both platters auto-start.
|
||||||
|
bus.on('game:win', () => platter.setPlaying(true));
|
||||||
|
|
||||||
|
return { platter, tonearm };
|
||||||
|
};
|
||||||
|
|
||||||
|
const a = buildDeck('A', LAYOUT.deckA);
|
||||||
|
buildDeck('B', LAYOUT.deckB);
|
||||||
|
|
||||||
|
// ---- 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).
|
||||||
|
const mixTopY = LAYOUT.mixer.topY;
|
||||||
|
const mixW = LAYOUT.mixer.maxX - LAYOUT.mixer.minX;
|
||||||
|
const chZ0 = LAYOUT.mixer.minZ + 12, chZ1 = LAYOUT.mixer.minZ + 40; // fader alley depth
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const x = LAYOUT.mixer.minX + (mixW * (i + 1)) / 5; // 4 alleys evenly across the face
|
||||||
|
machines.push(new Fader({
|
||||||
|
id: `fader_ch${i + 1}`,
|
||||||
|
axis: 'z',
|
||||||
|
start: [x, mixTopY + 0.8, chZ0],
|
||||||
|
end: [x, mixTopY + 0.8, chZ1],
|
||||||
|
capSize: [4, 2.2, 6],
|
||||||
|
initial: 0.7,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cs = pos.crossfaderSlot;
|
||||||
|
const csY = (cs.min[1] + cs.max[1]) / 2 + 0.8; // sit the cap on the slot
|
||||||
|
const csZ = (cs.min[2] + cs.max[2]) / 2;
|
||||||
|
const crossfader = new Fader({
|
||||||
|
id: 'fader_cross',
|
||||||
|
axis: 'x',
|
||||||
|
start: [cs.min[0], csY, csZ],
|
||||||
|
end: [cs.max[0], csY, csZ],
|
||||||
|
capSize: [6, 2.2, 5],
|
||||||
|
initial: 0.5,
|
||||||
|
gate: () => slotClear(world, cs),
|
||||||
|
onFullSlide: () => quest.repair('crossfader'),
|
||||||
|
});
|
||||||
|
machines.push(crossfader);
|
||||||
|
|
||||||
|
const power = new Button({
|
||||||
|
id: 'btn_power',
|
||||||
|
pos: pos.masterSwitch,
|
||||||
|
size: [4, 3, 3],
|
||||||
|
action: 'power',
|
||||||
|
block: 5,
|
||||||
|
glowBlock: 19, // led_green when armed
|
||||||
|
onUse: () => {
|
||||||
|
if (quest.canPowerOn() && !quest.isRepaired('power')) quest.repair('power');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
machines.push(power);
|
||||||
|
|
||||||
|
// Arm the power switch (glow) once the other four nodes are repaired.
|
||||||
|
bus.on('signal:repair', () => power.setGlow(quest.canPowerOn()));
|
||||||
|
|
||||||
|
// ---- RCA plug ----
|
||||||
|
machines.push(new RcaPlug(quest, pos.rcaPlug, pos.rcaSocket));
|
||||||
|
|
||||||
|
// ---- MachineSet facade ----
|
||||||
|
const colliders: KinematicCollider[] = [];
|
||||||
|
for (const m of machines) for (const c of m.getColliders()) colliders.push(c);
|
||||||
|
|
||||||
|
return {
|
||||||
|
machines,
|
||||||
|
quest,
|
||||||
|
tonearmA: a.tonearm,
|
||||||
|
getColliders: () => colliders,
|
||||||
|
update: (dt: number) => { for (const m of machines) m.update(dt); },
|
||||||
|
getObject3Ds: () => machines.map((m) => m.getObject3D()),
|
||||||
|
attachPlayer: (player: IPlayerView) => {
|
||||||
|
for (const m of machines) {
|
||||||
|
// MachineBase.attachPlayer exists on all lane-D machines.
|
||||||
|
(m as { attachPlayer?: (p: IPlayerView) => void }).attachPlayer?.(player);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the node list for the demo's quest panel.
|
||||||
|
export const NODES = SIGNAL_NODES;
|
||||||
63
src/machines/machine.ts
Normal file
63
src/machines/machine.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// LANE D — machine framework (D1).
|
||||||
|
// Base helper every machine extends: owns a THREE.Group (its visuals) and a
|
||||||
|
// list of KinematicColliders (what Lane B rides/pushes against). Machines are
|
||||||
|
// dynamic non-voxel entities; the integrator adds getObject3D() to the scene
|
||||||
|
// and calls update(dt) each fixed tick.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import type { IMachine, IPlayerView, KinematicCollider, Vec3 } from '../core/types';
|
||||||
|
import { PLAYER } from '../core/constants';
|
||||||
|
|
||||||
|
export abstract class MachineBase implements IMachine {
|
||||||
|
readonly id: string;
|
||||||
|
readonly group = new THREE.Group();
|
||||||
|
protected colliders: KinematicCollider[] = [];
|
||||||
|
/** Set by MachineSet.attachPlayer once the player exists (Lane B). */
|
||||||
|
protected player: IPlayerView | null = null;
|
||||||
|
|
||||||
|
constructor(id: string) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
getObject3D(): unknown {
|
||||||
|
return this.group;
|
||||||
|
}
|
||||||
|
|
||||||
|
getColliders(): KinematicCollider[] {
|
||||||
|
return this.colliders;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachPlayer(p: IPlayerView): void {
|
||||||
|
this.player = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract update(dt: number): void;
|
||||||
|
onInteract?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Player AABB (world-space) from an IPlayerView. Reused scratch, no alloc. */
|
||||||
|
export interface AABB {
|
||||||
|
minX: number; minY: number; minZ: number;
|
||||||
|
maxX: number; maxY: number; maxZ: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _pbox: AABB = { minX: 0, minY: 0, minZ: 0, maxX: 0, maxY: 0, maxZ: 0 };
|
||||||
|
|
||||||
|
/** Fill an AABB from the player view (position = feet center). */
|
||||||
|
export function playerAABB(p: IPlayerView, out: AABB = _pbox): AABB {
|
||||||
|
const [px, py, pz] = p.position;
|
||||||
|
const hw = PLAYER.width / 2;
|
||||||
|
out.minX = px - hw; out.maxX = px + hw;
|
||||||
|
out.minY = py; out.maxY = py + PLAYER.height;
|
||||||
|
out.minZ = pz - hw; out.maxZ = pz + hw;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Do two AABBs overlap (with an optional per-axis margin)? */
|
||||||
|
export function aabbOverlap(a: AABB, b: AABB, m = 0): boolean {
|
||||||
|
return a.minX - m <= b.maxX && a.maxX + m >= b.minX &&
|
||||||
|
a.minY - m <= b.maxY && a.maxY + m >= b.minY &&
|
||||||
|
a.minZ - m <= b.maxZ && a.maxZ + m >= b.minZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ZERO: Vec3 = [0, 0, 0];
|
||||||
182
src/machines/platter.ts
Normal file
182
src/machines/platter.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
// LANE D — platter machine ×2 (D2), the signature ride.
|
||||||
|
// A rotating kinematic cylinder carrying a translucent record. Standing on it
|
||||||
|
// adds the surface velocity ω×r to the player (Lane B reads velocityAt). Runs
|
||||||
|
// at "shrunk-time" game-RPM (constants.PLATTER) with a torque-y Technics
|
||||||
|
// spin-up/brake.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { PLATTER, Y_PLINTH_TOP, Y_RECORD_TOP } from '../core/constants';
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import type { KinematicCollider, Vec3 } from '../core/types';
|
||||||
|
import { MachineBase } from './machine';
|
||||||
|
import { blockMaterial, cylinderY, recordTopTexture } from './util';
|
||||||
|
import { BLOCK_BY_NAME } from '../core/blocks';
|
||||||
|
|
||||||
|
/** game-RPM (revolutions per minute) → angular speed (rad/s). */
|
||||||
|
const RPM_TO_OMEGA = Math.PI / 30; // 2π / 60
|
||||||
|
|
||||||
|
// Exponential approach time constant; smaller = snappier lurch. Tuned so the
|
||||||
|
// platter reaches ~90% of target in PLATTER.spinUpSeconds.
|
||||||
|
const TAU = PLATTER.spinUpSeconds * 0.43;
|
||||||
|
|
||||||
|
export type Speed = 33 | 45;
|
||||||
|
|
||||||
|
export class Platter extends MachineBase {
|
||||||
|
readonly deck: 'A' | 'B';
|
||||||
|
private readonly cx: number;
|
||||||
|
private readonly cz: number;
|
||||||
|
|
||||||
|
private omega = 0; // current signed angular speed (rad/s), - = CW from above
|
||||||
|
private targetOmega = 0;
|
||||||
|
private playing = false;
|
||||||
|
private speed: Speed = 33;
|
||||||
|
private pitch = 1; // 0.5..1.5 multiplier from the pitch fader
|
||||||
|
unlocked45 = true; // 45 is available for rim-launch traversal from the start
|
||||||
|
|
||||||
|
private readonly spin = new THREE.Group(); // everything that rotates about the spindle
|
||||||
|
private readonly collider: KinematicCollider;
|
||||||
|
private readonly _vel: Vec3 = [0, 0, 0]; // velocityAt scratch (reused, no alloc)
|
||||||
|
|
||||||
|
constructor(deck: 'A' | 'B', spindleX: number, spindleZ: number) {
|
||||||
|
super(`platter${deck}`);
|
||||||
|
this.deck = deck;
|
||||||
|
this.cx = spindleX;
|
||||||
|
this.cz = spindleZ;
|
||||||
|
|
||||||
|
// ---- Collider: one +Y cylinder spanning plinth top → vinyl surface. ----
|
||||||
|
const halfH = (Y_RECORD_TOP - Y_PLINTH_TOP) / 2; // 2.5
|
||||||
|
const cy = Y_PLINTH_TOP + halfH; // 82.5
|
||||||
|
this.collider = {
|
||||||
|
id: this.id,
|
||||||
|
shape: { kind: 'cylinder', center: [this.cx, cy, this.cz], radius: PLATTER.radius, halfHeight: halfH },
|
||||||
|
// Surface velocity = ω × r (rigid rotation about +Y). Zero when stopped.
|
||||||
|
// Writes into a reused scratch (no per-call alloc in the ride hot path).
|
||||||
|
// Contract: consume the value before the next velocityAt call on THIS
|
||||||
|
// collider — Lane B reads it immediately each tick (CONTRACTS §4).
|
||||||
|
velocityAt: (x: number, _y: number, z: number): Vec3 => {
|
||||||
|
const v = this._vel;
|
||||||
|
if (this.omega === 0) { v[0] = v[1] = v[2] = 0; return v; }
|
||||||
|
v[0] = this.omega * (z - this.cz);
|
||||||
|
v[1] = 0;
|
||||||
|
v[2] = -this.omega * (x - this.cx);
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.colliders = [this.collider];
|
||||||
|
|
||||||
|
this.buildVisuals(deck);
|
||||||
|
this.spin.position.set(this.cx, 0, this.cz);
|
||||||
|
this.group.add(this.spin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildVisuals(deck: 'A' | 'B'): void {
|
||||||
|
const r = PLATTER.radius;
|
||||||
|
const rr = PLATTER.recordRadius;
|
||||||
|
|
||||||
|
// Platter: brushed dark alu disc just under the record.
|
||||||
|
const platterMat = blockMaterial(3, { scale: 0.62, roughness: 0.4 }); // brushed_alu, darkened
|
||||||
|
const platter = cylinderY(r, 1.6, platterMat, 0, Y_PLINTH_TOP + 0.8, 0);
|
||||||
|
this.spin.add(platter);
|
||||||
|
|
||||||
|
// Strobe-dot ring: small emissive red studs around the platter rim.
|
||||||
|
const dotMat = blockMaterial(28, { emissive: 0.9 }); // strobe_dot
|
||||||
|
const dotCount = 36;
|
||||||
|
for (let i = 0; i < dotCount; i++) {
|
||||||
|
const a = (i / dotCount) * Math.PI * 2;
|
||||||
|
const d = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.5, 0.7), dotMat);
|
||||||
|
d.position.set(Math.cos(a) * (r - 0.9), Y_PLINTH_TOP + 1.6, Math.sin(a) * (r - 0.9));
|
||||||
|
d.rotation.y = -a;
|
||||||
|
this.spin.add(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slipmat.
|
||||||
|
const slipMat = blockMaterial(7); // slipmat_felt
|
||||||
|
this.spin.add(cylinderY(rr + 1.5, 0.5, slipMat, 0, Y_PLINTH_TOP + 1.9, 0));
|
||||||
|
|
||||||
|
// Record body: blue translucent (deck A) / black (deck B).
|
||||||
|
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.5 : 1 });
|
||||||
|
const recordBody = cylinderY(rr, 1.4, sideMat, 0, Y_RECORD_TOP - 0.7, 0);
|
||||||
|
this.spin.add(recordBody);
|
||||||
|
|
||||||
|
// Record top face: grooves + printed cream label (canvas texture).
|
||||||
|
const label = BLOCK_BY_NAME.get('label_cream')!.tint;
|
||||||
|
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,
|
||||||
|
transparent: deck === 'A', opacity: deck === 'A' ? 0.94 : 1,
|
||||||
|
});
|
||||||
|
const top = new THREE.Mesh(new THREE.CircleGeometry(rr, 64), topMat);
|
||||||
|
top.rotation.x = -Math.PI / 2; // face +Y
|
||||||
|
top.position.y = Y_RECORD_TOP + 0.01;
|
||||||
|
this.spin.add(top);
|
||||||
|
|
||||||
|
// Chrome spindle pin (does not rotate meaningfully; ride onto it to climb).
|
||||||
|
const chrome = blockMaterial(11); // chrome
|
||||||
|
const pin = cylinderY(0.8, 8, chrome, 0, Y_RECORD_TOP + 3.5, 0, 16);
|
||||||
|
this.spin.add(pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Control surface (called by buttons / faders / win wiring) ----
|
||||||
|
|
||||||
|
togglePlay(): void { this.setPlaying(!this.playing); }
|
||||||
|
|
||||||
|
setPlaying(on: boolean): void {
|
||||||
|
if (this.playing === on) return;
|
||||||
|
this.playing = on;
|
||||||
|
this.recomputeTarget();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpeed(s: Speed): void {
|
||||||
|
if (s === 45 && !this.unlocked45) return;
|
||||||
|
if (this.speed === s) return;
|
||||||
|
this.speed = s;
|
||||||
|
this.recomputeTarget();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pitch multiplier 0.5..1.5 from the deck's pitch fader value 0..1. */
|
||||||
|
setPitchFromFader(value01: number): void {
|
||||||
|
const p = 0.5 + value01; // 0..1 -> 0.5..1.5
|
||||||
|
if (Math.abs(p - this.pitch) < 1e-4) return;
|
||||||
|
this.pitch = p;
|
||||||
|
this.recomputeTarget();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying(): boolean { return this.playing; }
|
||||||
|
currentRpm(): number { return this.playing ? this.baseRpm() * this.pitch : 0; }
|
||||||
|
|
||||||
|
private baseRpm(): number { return this.speed === 33 ? PLATTER.rpm33 : PLATTER.rpm45; }
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private lastEmitPlaying = false;
|
||||||
|
private lastEmitRpm = -1;
|
||||||
|
private emitState(): void {
|
||||||
|
// Emit on any meaningful play/speed/pitch change, but never a byte-identical
|
||||||
|
// duplicate (e.g. flicking 33/45 while stopped keeps {playing:false, rpm:0}).
|
||||||
|
const rpm = this.currentRpm();
|
||||||
|
if (this.playing === this.lastEmitPlaying && rpm === this.lastEmitRpm) return;
|
||||||
|
this.lastEmitPlaying = this.playing;
|
||||||
|
this.lastEmitRpm = rpm;
|
||||||
|
bus.emit('platter:state', { deck: this.deck, playing: this.playing, rpm });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
if (this.omega !== this.targetOmega) {
|
||||||
|
// Exponential approach — the Technics spin-up/brake lurch.
|
||||||
|
const k = 1 - Math.exp(-dt / TAU);
|
||||||
|
this.omega += (this.targetOmega - this.omega) * k;
|
||||||
|
if (Math.abs(this.omega - this.targetOmega) < 1e-4) this.omega = this.targetOmega;
|
||||||
|
}
|
||||||
|
if (this.omega !== 0) {
|
||||||
|
this.spin.rotation.y += this.omega * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/machines/quest.ts
Normal file
92
src/machines/quest.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
// LANE D — "Bring Back the Mix" quest state machine (D6).
|
||||||
|
// Pure state: which SIGNAL_NODES are repaired, the running count, and the win.
|
||||||
|
// It knows NOTHING about machines or the hotbar — machines and the interaction
|
||||||
|
// layer call repair() at the right moment. This keeps it trivially testable.
|
||||||
|
|
||||||
|
import { SIGNAL_NODES, type SignalNode } from '../core/constants';
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import type { Vec3 } from '../core/types';
|
||||||
|
import type { BlockId } from '../core/blocks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the five quest fixtures live, in voxel coordinates. Lane D defines the
|
||||||
|
* SHAPE; the integrator supplies real coords from src/worldgen/questPositions.ts
|
||||||
|
* (QUEST_POS) — Lane D never imports Lane C. createMachines() fills sensible
|
||||||
|
* LAYOUT-derived defaults so the machine set works even with no positions given.
|
||||||
|
*/
|
||||||
|
export interface QuestPositions {
|
||||||
|
/** Deck A headshell socket (where the chrome stylus goes). */
|
||||||
|
stylusSocket: Vec3;
|
||||||
|
/** Loose gold RCA plug start (cable canyon). */
|
||||||
|
rcaPlug: Vec3;
|
||||||
|
/** Patch-bay gold socket the plug must reach. */
|
||||||
|
rcaSocket: Vec3;
|
||||||
|
/** Crossfader tram slot voxel span that must be clear of dust to slide. */
|
||||||
|
crossfaderSlot: { min: Vec3; max: Vec3 };
|
||||||
|
/** Fuse-box socket voxel: the blown fuse sits here; break it, seat a fresh one. */
|
||||||
|
fuseBox: Vec3;
|
||||||
|
/** Master power switch (mixer rear). */
|
||||||
|
masterSwitch: Vec3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block ids used as quest "items" the player carries. Lane C should place these
|
||||||
|
* in the PCB-Depths parts bin as the pickups; the interaction layer consumes
|
||||||
|
* them from the hotbar to fix nodes. Exported so integration can align.
|
||||||
|
*/
|
||||||
|
export const QUEST_ITEMS = {
|
||||||
|
/** Chrome stylus block, seated into Deck A's headshell (fixes `stylus`). */
|
||||||
|
stylus: 11 as BlockId, // 'chrome'
|
||||||
|
/** Glass fuse block, seated into the fuse box (fixes `fuse`). */
|
||||||
|
fuse: 22 as BlockId, // 'glass'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export class Quest {
|
||||||
|
readonly total = SIGNAL_NODES.length;
|
||||||
|
readonly pos: QuestPositions;
|
||||||
|
private repaired = new Set<SignalNode>();
|
||||||
|
private won = false;
|
||||||
|
|
||||||
|
constructor(pos: QuestPositions) {
|
||||||
|
this.pos = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRepaired(node: SignalNode): boolean {
|
||||||
|
return this.repaired.has(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
count(): number {
|
||||||
|
return this.repaired.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
isWon(): boolean {
|
||||||
|
return this.won;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once every node except `power` is repaired (master switch arms). */
|
||||||
|
canPowerOn(): boolean {
|
||||||
|
for (const n of SIGNAL_NODES) {
|
||||||
|
if (n !== 'power' && !this.repaired.has(n)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repair a node. Idempotent; `power` is refused until canPowerOn(). Emits
|
||||||
|
* `signal:repair` on success and, when the last node lands, `game:win` once.
|
||||||
|
* Returns true if this call actually changed state.
|
||||||
|
*/
|
||||||
|
repair(node: SignalNode): boolean {
|
||||||
|
if (this.repaired.has(node)) return false;
|
||||||
|
if (node === 'power' && !this.canPowerOn()) return false;
|
||||||
|
|
||||||
|
this.repaired.add(node);
|
||||||
|
bus.emit('signal:repair', { node, repaired: this.repaired.size, total: this.total });
|
||||||
|
|
||||||
|
if (this.repaired.size === this.total && !this.won) {
|
||||||
|
this.won = true;
|
||||||
|
bus.emit('game:win', {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/machines/rca.ts
Normal file
66
src/machines/rca.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// LANE D — the loose RCA plug (quest node `rca`).
|
||||||
|
// A small gold plug in the cable canyon. Each interaction shoves it one step
|
||||||
|
// toward its patch-bay socket (clunk-clunk-click); the third shove seats it and
|
||||||
|
// repairs the node. Self-contained: it holds the quest reference and calls
|
||||||
|
// repair() itself (no hotbar needed).
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import type { KinematicCollider, Vec3 } from '../core/types';
|
||||||
|
import { MachineBase } from './machine';
|
||||||
|
import { blockMaterial, box } from './util';
|
||||||
|
import type { Quest } from './quest';
|
||||||
|
|
||||||
|
const SHOVES = 3;
|
||||||
|
|
||||||
|
export class RcaPlug extends MachineBase {
|
||||||
|
private readonly quest: Quest;
|
||||||
|
private readonly from: Vec3;
|
||||||
|
private readonly step: Vec3;
|
||||||
|
private readonly plug: THREE.Mesh;
|
||||||
|
private readonly shape: { kind: 'aabb'; min: Vec3; max: Vec3 };
|
||||||
|
private shoves = 0;
|
||||||
|
|
||||||
|
constructor(quest: Quest, from: Vec3, socket: Vec3) {
|
||||||
|
super('rcaPlug');
|
||||||
|
this.quest = quest;
|
||||||
|
this.from = from;
|
||||||
|
this.step = [(socket[0] - from[0]) / SHOVES, (socket[1] - from[1]) / SHOVES, (socket[2] - from[2]) / SHOVES];
|
||||||
|
|
||||||
|
this.plug = box(2.4, 2.4, 4, blockMaterial(29), from[0], from[1], from[2]); // rca_gold
|
||||||
|
this.group.add(this.plug);
|
||||||
|
|
||||||
|
this.shape = { kind: 'aabb', min: [0, 0, 0], max: [0, 0, 0] };
|
||||||
|
const collider: KinematicCollider = {
|
||||||
|
id: 'rcaPlug_c',
|
||||||
|
shape: this.shape,
|
||||||
|
velocityAt: () => [0, 0, 0],
|
||||||
|
};
|
||||||
|
this.colliders = [collider];
|
||||||
|
this.place();
|
||||||
|
}
|
||||||
|
|
||||||
|
private place(): void {
|
||||||
|
const x = this.from[0] + this.step[0] * this.shoves;
|
||||||
|
const y = this.from[1] + this.step[1] * this.shoves;
|
||||||
|
const z = this.from[2] + this.step[2] * this.shoves;
|
||||||
|
this.plug.position.set(x, y, z);
|
||||||
|
this.shape.min[0] = x - 1.6; this.shape.max[0] = x + 1.6;
|
||||||
|
this.shape.min[1] = y - 1.6; this.shape.max[1] = y + 1.6;
|
||||||
|
this.shape.min[2] = z - 2.4; this.shape.max[2] = z + 2.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
onInteract(): void {
|
||||||
|
if (this.quest.isRepaired('rca')) return;
|
||||||
|
this.shoves = Math.min(SHOVES, this.shoves + 1);
|
||||||
|
this.place();
|
||||||
|
if (this.shoves >= SHOVES) {
|
||||||
|
bus.emit('machine:interact', { machineId: this.id, action: 'rca_seated' });
|
||||||
|
this.quest.repair('rca');
|
||||||
|
} else {
|
||||||
|
bus.emit('machine:interact', { machineId: this.id, action: 'rca_shove' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(_dt: number): void { /* static except when shoved */ }
|
||||||
|
}
|
||||||
234
src/machines/tonearm.ts
Normal file
234
src/machines/tonearm.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
// LANE D — tonearm machine ×2 (D3).
|
||||||
|
// A rigid arm pivoting about a rear pedestal. While the deck plays AND the
|
||||||
|
// stylus is fitted, it cues to the outer groove then tracks slowly inward
|
||||||
|
// (~TRACK_SECONDS for a full pass) before auto-returning to rest. The headshell
|
||||||
|
// is the "needle plow": a slow kinematic aabb that pushes the player along.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { PLATTER, Y_RECORD_TOP } from '../core/constants';
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import type { ColliderShape, KinematicCollider, Vec3 } from '../core/types';
|
||||||
|
import { MachineBase } from './machine';
|
||||||
|
import { blockMaterial } from './util';
|
||||||
|
|
||||||
|
const HEAD_Y = Y_RECORD_TOP + 1.5; // headshell hovers just above the vinyl
|
||||||
|
const R_INNER = 9; // headshell radius-from-spindle at inner groove
|
||||||
|
const R_OUTER = PLATTER.recordRadius - 3; // outer groove
|
||||||
|
const R_REST = PLATTER.radius + 1; // parked just off the platter rim
|
||||||
|
const TRACK_SECONDS = 240; // ~4 min for a full inward pass
|
||||||
|
const CUE_SECONDS = 1.6; // drop-to-outer-groove cue time
|
||||||
|
const RETURN_RATE = 0.6; // rad/s auto-return sweep
|
||||||
|
|
||||||
|
type State = 'rest' | 'cueing' | 'tracking' | 'returning';
|
||||||
|
|
||||||
|
export class Tonearm extends MachineBase {
|
||||||
|
readonly deck: 'A' | 'B';
|
||||||
|
/** Wired by createMachines to the deck's platter. */
|
||||||
|
deckPlaying: () => boolean = () => false;
|
||||||
|
|
||||||
|
private readonly px: number;
|
||||||
|
private readonly pz: number;
|
||||||
|
private readonly L: number; // rigid arm length (pivot → headshell)
|
||||||
|
private readonly baseX: number; // unit baseline dir (pivot → spindle), xz
|
||||||
|
private readonly baseZ: number;
|
||||||
|
private readonly sign: 1 | -1; // which side of the baseline the arm swings
|
||||||
|
private readonly phiRest: number;
|
||||||
|
private readonly phiOuter: number;
|
||||||
|
|
||||||
|
private state: State = 'rest';
|
||||||
|
private phi: number; // current sweep angle from baseline
|
||||||
|
private trackT = 0; // 0..1 inward progress while tracking
|
||||||
|
private stylus: boolean;
|
||||||
|
|
||||||
|
private readonly head: Vec3 = [0, 0, 0];
|
||||||
|
private readonly prevHead: Vec3 = [0, 0, 0];
|
||||||
|
private readonly headVel: Vec3 = [0, 0, 0];
|
||||||
|
private headInit = false;
|
||||||
|
|
||||||
|
private readonly tube: THREE.Mesh;
|
||||||
|
private readonly headGroup = new THREE.Group();
|
||||||
|
private readonly stylusMesh: THREE.Mesh;
|
||||||
|
private readonly counterweight: THREE.Mesh;
|
||||||
|
private readonly headShape: { kind: 'aabb'; min: Vec3; max: Vec3 };
|
||||||
|
// The arm is a chain of small AABBs tiling the pivot→head line (the brief's
|
||||||
|
// "2–3 segment aabb chain"). A single endpoint-bounding box would balloon into
|
||||||
|
// a huge phantom collider over the rideable record when the arm is diagonal.
|
||||||
|
private readonly armSegs: { kind: 'aabb'; min: Vec3; max: Vec3 }[] = [];
|
||||||
|
private static readonly ARM_SEGMENTS = 3;
|
||||||
|
|
||||||
|
constructor(deck: 'A' | 'B', spindleX: number, spindleZ: number, pivot: Vec3, initialStylus: boolean) {
|
||||||
|
super(`tonearm${deck}`);
|
||||||
|
this.deck = deck;
|
||||||
|
this.px = pivot[0];
|
||||||
|
this.pz = pivot[2];
|
||||||
|
this.stylus = initialStylus;
|
||||||
|
|
||||||
|
const dx = spindleX - this.px;
|
||||||
|
const dz = spindleZ - this.pz;
|
||||||
|
const D = Math.hypot(dx, dz);
|
||||||
|
this.baseX = dx / D;
|
||||||
|
this.baseZ = dz / D;
|
||||||
|
this.L = D - R_INNER; // φ=0 lands the headshell at the inner groove
|
||||||
|
this.phiRest = this.solvePhi(D, R_REST);
|
||||||
|
this.phiOuter = this.solvePhi(D, R_OUTER);
|
||||||
|
|
||||||
|
// Pick the swing side that carries the headshell toward the front (−z).
|
||||||
|
const testZ = (s: 1 | -1) => this.rotZ(this.baseX, this.baseZ, s * this.phiOuter);
|
||||||
|
this.sign = testZ(1) < testZ(-1) ? 1 : -1;
|
||||||
|
|
||||||
|
this.phi = this.phiRest;
|
||||||
|
|
||||||
|
// ---- Colliders (positions filled by first update) ----
|
||||||
|
this.headShape = { kind: 'aabb', min: [0, 0, 0], max: [0, 0, 0] };
|
||||||
|
const headCollider: KinematicCollider = {
|
||||||
|
id: `${this.id}_head`,
|
||||||
|
shape: this.headShape as ColliderShape,
|
||||||
|
velocityAt: () => [this.headVel[0], this.headVel[1], this.headVel[2]],
|
||||||
|
};
|
||||||
|
this.colliders = [headCollider];
|
||||||
|
for (let i = 0; i < Tonearm.ARM_SEGMENTS; i++) {
|
||||||
|
const seg = { kind: 'aabb' as const, min: [0, 0, 0] as Vec3, max: [0, 0, 0] as Vec3 };
|
||||||
|
this.armSegs.push(seg);
|
||||||
|
this.colliders.push({
|
||||||
|
id: `${this.id}_arm${i}`,
|
||||||
|
shape: seg as ColliderShape,
|
||||||
|
velocityAt: () => [this.headVel[0] * 0.4, 0, this.headVel[2] * 0.4],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Visuals ----
|
||||||
|
const chrome = blockMaterial(11); // chrome
|
||||||
|
const dark = blockMaterial(5); // matte_black counterweight
|
||||||
|
|
||||||
|
const pedestal = new THREE.Mesh(new THREE.CylinderGeometry(2.2, 2.6, 8, 16), chrome);
|
||||||
|
pedestal.position.set(this.px, HEAD_Y - 3, this.pz);
|
||||||
|
this.group.add(pedestal);
|
||||||
|
|
||||||
|
this.tube = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.7, 1, 12), chrome);
|
||||||
|
this.group.add(this.tube);
|
||||||
|
|
||||||
|
const cw = new THREE.Mesh(new THREE.CylinderGeometry(2.6, 2.6, 4, 16), dark);
|
||||||
|
cw.rotation.z = Math.PI / 2; // counterweight, positioned each frame with the arm
|
||||||
|
|
||||||
|
const headBody = new THREE.Mesh(new THREE.BoxGeometry(3, 2.4, 2.4), dark);
|
||||||
|
this.headGroup.add(headBody);
|
||||||
|
this.stylusMesh = new THREE.Mesh(new THREE.ConeGeometry(0.5, 2.2, 10), chrome);
|
||||||
|
this.stylusMesh.position.y = -1.8;
|
||||||
|
this.stylusMesh.visible = this.stylus;
|
||||||
|
this.headGroup.add(this.stylusMesh);
|
||||||
|
this.group.add(this.headGroup);
|
||||||
|
|
||||||
|
this.counterweight = cw;
|
||||||
|
this.group.add(cw);
|
||||||
|
|
||||||
|
this.tickGeometry(0); // place everything for frame 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Angle from the baseline so the headshell sits at radius r from the spindle. */
|
||||||
|
private solvePhi(D: number, r: number): number {
|
||||||
|
const c = (D * D + this.L * this.L - r * r) / (2 * D * this.L);
|
||||||
|
return Math.acos(Math.max(-1, Math.min(1, c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** z-component of baseline rotated by angle a about +Y (for side selection). */
|
||||||
|
private rotZ(vx: number, vz: number, a: number): number {
|
||||||
|
return -vx * Math.sin(a) + vz * Math.cos(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStylus(on: boolean): void {
|
||||||
|
this.stylus = on;
|
||||||
|
this.stylusMesh.visible = on;
|
||||||
|
if (on) bus.emit('machine:interact', { machineId: this.id, action: 'stylus_fitted' });
|
||||||
|
}
|
||||||
|
|
||||||
|
hasStylus(): boolean { return this.stylus; }
|
||||||
|
|
||||||
|
private enabled(): boolean { return this.stylus && this.deckPlaying(); }
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
// ---- State machine over the sweep angle φ ----
|
||||||
|
switch (this.state) {
|
||||||
|
case 'rest':
|
||||||
|
if (this.enabled()) { this.state = 'cueing'; }
|
||||||
|
break;
|
||||||
|
case 'cueing': {
|
||||||
|
// Lerp rest → outer over CUE_SECONDS.
|
||||||
|
const step = (this.phiRest - this.phiOuter) * (dt / CUE_SECONDS);
|
||||||
|
this.phi -= step;
|
||||||
|
if (this.phi <= this.phiOuter) { this.phi = this.phiOuter; this.state = 'tracking'; this.trackT = 0; }
|
||||||
|
if (!this.enabled()) this.state = 'returning';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tracking':
|
||||||
|
this.trackT = Math.min(1, this.trackT + dt / TRACK_SECONDS);
|
||||||
|
this.phi = this.phiOuter * (1 - this.trackT); // → 0 (inner) as trackT → 1
|
||||||
|
if (!this.enabled() || this.trackT >= 1) this.state = 'returning';
|
||||||
|
break;
|
||||||
|
case 'returning':
|
||||||
|
this.phi += RETURN_RATE * dt;
|
||||||
|
if (this.phi >= this.phiRest) { this.phi = this.phiRest; this.state = 'rest'; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.tickGeometry(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute headshell world pos, arm transform, colliders, and velocity. */
|
||||||
|
private tickGeometry(dt: number): void {
|
||||||
|
const a = this.sign * this.phi;
|
||||||
|
// Direction pivot→headshell = baseline rotated by a.
|
||||||
|
const dirX = this.baseX * Math.cos(a) + this.baseZ * Math.sin(a);
|
||||||
|
const dirZ = -this.baseX * Math.sin(a) + this.baseZ * Math.cos(a);
|
||||||
|
const hx = this.px + dirX * this.L;
|
||||||
|
const hz = this.pz + dirZ * this.L;
|
||||||
|
this.head[0] = hx; this.head[1] = HEAD_Y; this.head[2] = hz;
|
||||||
|
|
||||||
|
// Headshell velocity (finite difference; zero on first frame).
|
||||||
|
if (this.headInit && dt > 0) {
|
||||||
|
this.headVel[0] = (hx - this.prevHead[0]) / dt;
|
||||||
|
this.headVel[1] = 0;
|
||||||
|
this.headVel[2] = (hz - this.prevHead[2]) / dt;
|
||||||
|
} else {
|
||||||
|
this.headVel[0] = this.headVel[1] = this.headVel[2] = 0;
|
||||||
|
}
|
||||||
|
this.prevHead[0] = hx; this.prevHead[1] = HEAD_Y; this.prevHead[2] = hz;
|
||||||
|
this.headInit = true;
|
||||||
|
|
||||||
|
// Headshell group + stylus.
|
||||||
|
this.headGroup.position.set(hx, HEAD_Y, hz);
|
||||||
|
|
||||||
|
// Tube: unit cylinder scaled to length L, midpoint pivot↔head.
|
||||||
|
const mx = (this.px + hx) / 2, mz = (this.pz + hz) / 2;
|
||||||
|
this.tube.position.set(mx, HEAD_Y, mz);
|
||||||
|
this.tube.scale.set(1, this.L, 1);
|
||||||
|
const dir = _dir.set(hx - this.px, 0, hz - this.pz).normalize();
|
||||||
|
this.tube.quaternion.setFromUnitVectors(UP, dir);
|
||||||
|
|
||||||
|
// Counterweight behind the pivot.
|
||||||
|
this.counterweight.position.set(this.px - dir.x * 6, HEAD_Y, this.pz - dir.z * 6);
|
||||||
|
|
||||||
|
// Headshell aabb (the plow).
|
||||||
|
setAabb(this.headShape, hx, HEAD_Y, hz, 2, 2.6, 2);
|
||||||
|
|
||||||
|
// Arm collider chain: tile the pivot→head line in N small AABBs so the box
|
||||||
|
// hugs the diagonal tube instead of enclosing the whole bounding rectangle.
|
||||||
|
const n = Tonearm.ARM_SEGMENTS;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t0 = i / n, t1 = (i + 1) / n;
|
||||||
|
const x0 = this.px + (hx - this.px) * t0, z0 = this.pz + (hz - this.pz) * t0;
|
||||||
|
const x1 = this.px + (hx - this.px) * t1, z1 = this.pz + (hz - this.pz) * t1;
|
||||||
|
const s = this.armSegs[i];
|
||||||
|
s.min[0] = Math.min(x0, x1) - 1; s.max[0] = Math.max(x0, x1) + 1;
|
||||||
|
s.min[1] = HEAD_Y - 1.5; s.max[1] = HEAD_Y + 1.5;
|
||||||
|
s.min[2] = Math.min(z0, z1) - 1; s.max[2] = Math.max(z0, z1) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _dir = new THREE.Vector3(); // scratch, reused each tick (fix #5)
|
||||||
|
|
||||||
|
const UP = new THREE.Vector3(0, 1, 0);
|
||||||
|
|
||||||
|
function setAabb(s: { min: Vec3; max: Vec3 }, cx: number, cy: number, cz: number, hx: number, hy: number, hz: number): void {
|
||||||
|
s.min[0] = cx - hx; s.min[1] = cy - hy; s.min[2] = cz - hz;
|
||||||
|
s.max[0] = cx + hx; s.max[1] = cy + hy; s.max[2] = cz + hz;
|
||||||
|
}
|
||||||
131
src/machines/util.ts
Normal file
131
src/machines/util.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
// LANE D — shared visual/material helpers for machines.
|
||||||
|
// Machines are built from Three.js primitives whose colors/materials are
|
||||||
|
// derived from the block registry (blocks.ts) so the moving gear reads as the
|
||||||
|
// same material family as the surrounding voxels. This file owns no game state.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { blockDef, type BlockId } from '../core/blocks';
|
||||||
|
|
||||||
|
/** A THREE.Color from a block's sRGB tint, optionally value-scaled (darken). */
|
||||||
|
export function tintColor(id: BlockId, scale = 1): THREE.Color {
|
||||||
|
const [r, g, b] = blockDef(id).tint;
|
||||||
|
const c = new THREE.Color();
|
||||||
|
// tint is 0..255 sRGB; declare the color space so three converts correctly.
|
||||||
|
c.setRGB((r / 255) * scale, (g / 255) * scale, (b / 255) * scale, THREE.SRGBColorSpace);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatOpts {
|
||||||
|
/** Override emissive intensity (defaults to the block's emissive value). */
|
||||||
|
emissive?: number;
|
||||||
|
opacity?: number;
|
||||||
|
metalness?: number;
|
||||||
|
roughness?: number;
|
||||||
|
/** Darken/brighten the base tint (1 = as-registered). */
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A MeshStandardMaterial matching a block id (metalness/roughness/emissive/tint). */
|
||||||
|
export function blockMaterial(id: BlockId, o: MatOpts = {}): THREE.MeshStandardMaterial {
|
||||||
|
const def = blockDef(id);
|
||||||
|
const metalKinds = def.sound === 'metal';
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color: tintColor(id, o.scale ?? 1),
|
||||||
|
metalness: o.metalness ?? (metalKinds ? 0.8 : 0.12),
|
||||||
|
roughness: o.roughness ?? (def.pattern === 'brushed' ? 0.34 : 0.66),
|
||||||
|
});
|
||||||
|
const em = o.emissive ?? def.emissive;
|
||||||
|
if (em > 0) {
|
||||||
|
mat.emissive = tintColor(id);
|
||||||
|
mat.emissiveIntensity = em;
|
||||||
|
}
|
||||||
|
if (def.transparent || o.opacity !== undefined) {
|
||||||
|
mat.transparent = true;
|
||||||
|
mat.opacity = o.opacity ?? 0.6;
|
||||||
|
mat.depthWrite = false;
|
||||||
|
}
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Procedural record top-face texture: fine concentric groove rings on the vinyl
|
||||||
|
* with a printed cream center label ("TC-001 · TURNCRAFT"). Painted on a canvas
|
||||||
|
* — no external assets. Returned as an sRGB CanvasTexture for a top-face map.
|
||||||
|
*/
|
||||||
|
export function recordTopTexture(vinyl: [number, number, number], label: [number, number, number], catalog: string): THREE.CanvasTexture {
|
||||||
|
const S = 512;
|
||||||
|
const cv = document.createElement('canvas');
|
||||||
|
cv.width = cv.height = S;
|
||||||
|
const g = cv.getContext('2d')!;
|
||||||
|
const cx = S / 2, cy = S / 2;
|
||||||
|
|
||||||
|
const [vr, vg, vb] = vinyl;
|
||||||
|
g.fillStyle = `rgb(${vr},${vg},${vb})`;
|
||||||
|
g.fillRect(0, 0, S, S);
|
||||||
|
|
||||||
|
// Groove rings: many faint light + occasional darker lands (spiral read).
|
||||||
|
for (let r = S * 0.165; r < S * 0.495; r += 1.6) {
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(cx, cy, r, 0, Math.PI * 2);
|
||||||
|
g.lineWidth = 1;
|
||||||
|
g.strokeStyle = `rgba(255,255,255,${0.03 + 0.03 * Math.random()})`;
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
// A few brighter "track gap" bands, like a 3-track 12".
|
||||||
|
for (const rr of [0.24, 0.32, 0.41]) {
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(cx, cy, S * rr, 0, Math.PI * 2);
|
||||||
|
g.lineWidth = 3;
|
||||||
|
g.strokeStyle = 'rgba(210,210,210,0.18)';
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cream label.
|
||||||
|
const [lr, lg, lb] = label;
|
||||||
|
g.fillStyle = `rgb(${lr},${lg},${lb})`;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(cx, cy, S * 0.16, 0, Math.PI * 2);
|
||||||
|
g.fill();
|
||||||
|
// Label ring line.
|
||||||
|
g.strokeStyle = 'rgba(40,44,52,0.4)';
|
||||||
|
g.lineWidth = 2;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(cx, cy, S * 0.145, 0, Math.PI * 2);
|
||||||
|
g.stroke();
|
||||||
|
|
||||||
|
// Printed text.
|
||||||
|
g.fillStyle = '#22262e';
|
||||||
|
g.textAlign = 'center';
|
||||||
|
g.textBaseline = 'middle';
|
||||||
|
g.font = `bold ${Math.floor(S * 0.032)}px sans-serif`;
|
||||||
|
g.fillText('TURNCRAFT', cx, cy - S * 0.03);
|
||||||
|
g.font = `${Math.floor(S * 0.022)}px monospace`;
|
||||||
|
g.fillText(catalog, cx, cy + S * 0.02);
|
||||||
|
g.font = `${Math.floor(S * 0.016)}px monospace`;
|
||||||
|
g.fillText('33⅓ RPM · STEREO', cx, cy + S * 0.06);
|
||||||
|
|
||||||
|
// Spindle hole.
|
||||||
|
g.fillStyle = '#0b0b0d';
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(cx, cy, S * 0.012, 0, Math.PI * 2);
|
||||||
|
g.fill();
|
||||||
|
|
||||||
|
const tex = new THREE.CanvasTexture(cv);
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
tex.anisotropy = 4;
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
m.position.set(x, y, z);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A cylinder aligned to +Y (world-space centered). */
|
||||||
|
export function cylinderY(radius: number, height: number, mat: THREE.Material | THREE.Material[], x: number, y: number, z: number, segments = 48): THREE.Mesh {
|
||||||
|
const m = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, height, segments), mat);
|
||||||
|
m.position.set(x, y, z);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
22
src/main.ts
Normal file
22
src/main.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// TURNCRAFT — integration entry point.
|
||||||
|
// This stub is completed during INTEGRATION (docs/INTEGRATION.md), after the
|
||||||
|
// lanes land. Lanes DO NOT edit this file; each lane has its own demo entry
|
||||||
|
// (demo-*.html + src/demo/*.ts) for standalone verification.
|
||||||
|
//
|
||||||
|
// Intended wiring order (see docs/INTEGRATION.md):
|
||||||
|
// 1. renderer + scene + atlas (Lane A: src/engine)
|
||||||
|
// 2. const world = new VoxelWorld(...) (Lane A)
|
||||||
|
// 3. buildBooth(world) (Lane C: src/worldgen)
|
||||||
|
// 4. chunk meshes -> scene (Lane A)
|
||||||
|
// 5. const machines = createMachines() (Lane D: src/machines)
|
||||||
|
// 6. player = new PlayerController(world, machines) (Lane B: src/player)
|
||||||
|
// 7. interaction = new Interaction(...) (Lane D: src/interact)
|
||||||
|
// 8. audio + fx + hud (Lane E: src/audio, src/fx, src/ui)
|
||||||
|
// 9. fixed-step game loop: machines.update -> player.update -> fx.update -> render
|
||||||
|
|
||||||
|
const el = document.getElementById('app')!;
|
||||||
|
el.innerHTML =
|
||||||
|
'<p style="color:#888;font:14px monospace;padding:2em">' +
|
||||||
|
'TURNCRAFT integration entry — see docs/INTEGRATION.md. ' +
|
||||||
|
'Lane demos live at /demo-engine.html, /demo-player.html, /demo-worldgen.html, ' +
|
||||||
|
'/demo-machines.html, /demo-audio.html</p>';
|
||||||
86
src/player/HANDOFF.md
Normal file
86
src/player/HANDOFF.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# Lane B — Player & Physics — HANDOFF
|
||||||
|
|
||||||
|
Status: **complete**. Typecheck clean, demo runs with no console errors, every
|
||||||
|
brief acceptance criterion verified live in a browser.
|
||||||
|
|
||||||
|
## Public API surface (what the integrator imports from `src/player`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { PlayerController } from './player';
|
||||||
|
|
||||||
|
const player = new PlayerController(world /* IVoxelWorld */, {
|
||||||
|
getColliders: () => machines.flatMap(m => m.getColliders()), // KinematicCollider[]
|
||||||
|
camera, // THREE.PerspectiveCamera (controller owns position/rotation/fov)
|
||||||
|
domElement, // HTMLElement for pointer-lock + input
|
||||||
|
spawn: [x, y, z],
|
||||||
|
});
|
||||||
|
// each fixed tick, AFTER machines.update(dt):
|
||||||
|
player.update(FIXED_DT); // internally substeps; safe to call repeatedly
|
||||||
|
player.teleport([x, y, z]); // reset position + velocity
|
||||||
|
player.setFlying(true); // debug fly toggle (also 'F' in-game)
|
||||||
|
// IPlayerView (read by Lane D/E): position, eye, lookDir, onGround, groundedOn
|
||||||
|
// extras: player.input (scriptable InputState), player.viewBob, player.isFlying,
|
||||||
|
// player.carryVelocity, player.dispose()
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's done
|
||||||
|
|
||||||
|
- Pointer-lock mouse look (pitch-clamped), WASD, Space jump, Shift sprint,
|
||||||
|
F fly (Space/Ctrl = up/down in fly). Input state is a plain object separated
|
||||||
|
from physics so it can be scripted headlessly (the demo does exactly this).
|
||||||
|
- Swept per-axis AABB-vs-voxel collision (X, Y, Z Minecraft order) with internal
|
||||||
|
substepping to keep per-axis displacement < ~0.2 voxel — no tunneling at
|
||||||
|
sprint + record-rim speeds. 400 sprint-jumps into a wall never clip through.
|
||||||
|
- Auto-step over 1-voxel ledges; 2-voxel walls are correctly NOT stepped.
|
||||||
|
- Kinematic platform riding: `cylinder` (spinning record) and `aabb` (fader
|
||||||
|
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.
|
||||||
|
- 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,
|
||||||
|
`lookDir` kept normalized.
|
||||||
|
|
||||||
|
## Stubbed / demo-only (NOT for integration)
|
||||||
|
|
||||||
|
Everything in `src/demo/playerDemo.ts` marked `// DEMO MOCK`: `MockWorld`
|
||||||
|
(procedural 160×160 test course — staircase, 2-voxel wall, bridge+pit, second
|
||||||
|
pit), the two mock colliders (spinning disc + oscillating slab) and their
|
||||||
|
meshes, and the `window.__demo` debug handle. The real world comes from Lane A's
|
||||||
|
`VoxelWorld` and real colliders from Lane D's machines.
|
||||||
|
|
||||||
|
## Local tuning constants (NOT in core — see top of `PlayerController.ts`)
|
||||||
|
|
||||||
|
`core/constants.ts` `PLAYER` had no accel/friction/step numbers, so these live
|
||||||
|
locally and are clearly grouped: `GROUND_K`, `AIR_K` (~30% of ground),
|
||||||
|
`FLY_K`, `FLY_SPEED`, `TERMINAL`, `STAND_UP_EPS`, `LAND_DOWN_EPS`,
|
||||||
|
`STEP_DISTANCE`, `LAND_MIN_SPEED`, `MAX_SUBSTEPS`, `MAX_STEP_DISP`, `BOB_*`,
|
||||||
|
`SPRINT_FOV_BOOST`, and `STEP_HEIGHT` (in `collision.ts`). If the integrator
|
||||||
|
wants these centralized, promote them into a new (non-core) shared config; I did
|
||||||
|
not edit `src/core/`.
|
||||||
|
|
||||||
|
## Contract friction
|
||||||
|
|
||||||
|
- None blocking. Minor note: `constants.ts::PLAYER` defines top speeds/jump but
|
||||||
|
no horizontal acceleration/friction or step height, so those are tuned locally
|
||||||
|
(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.
|
||||||
|
|
||||||
|
## How to verify
|
||||||
|
|
||||||
|
`npm run dev` → open `http://localhost:5173/demo-player.html`. The page header
|
||||||
|
comment lists exactly what to press/look at for each acceptance criterion. HUD
|
||||||
|
shows position / onGround / groundedOn / carry velocity; the console logs step
|
||||||
|
and landing events.
|
||||||
336
src/player/PlayerController.ts
Normal file
336
src/player/PlayerController.ts
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
// Lane B — the player controller. Implements IPlayerView. Fixed-step physics:
|
||||||
|
// input -> horizontal accel -> gravity/jump -> voxel collision (X,Y,Z with
|
||||||
|
// auto-step) -> kinematic-platform resolution + surface carry -> events ->
|
||||||
|
// camera. update(dt) internally substeps so the per-axis displacement stays
|
||||||
|
// small enough that nothing tunnels, even at sprint + record-rim speeds.
|
||||||
|
|
||||||
|
import type { PerspectiveCamera } from 'three';
|
||||||
|
import type {
|
||||||
|
IVoxelWorld, IPlayerView, KinematicCollider, Vec3,
|
||||||
|
} from '../core/types';
|
||||||
|
import { PLAYER, GRAVITY } from '../core/constants';
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import { createInputState, InputController, type InputState } from './input';
|
||||||
|
import {
|
||||||
|
resolveAxis, moveHorizontalWithStep, HALF_W, HEIGHT,
|
||||||
|
} from './collision';
|
||||||
|
|
||||||
|
// --- Local feel tuning (NOT in core PLAYER; see HANDOFF.md if you re-tune). ---
|
||||||
|
const GROUND_K = 20; // horizontal responsiveness on the ground (snappy)
|
||||||
|
const AIR_K = 6; // ~30% of ground control while airborne
|
||||||
|
const FLY_K = 12; // responsiveness in fly mode (both planes)
|
||||||
|
const FLY_SPEED = 8.0; // vertical fly speed (voxels/sec)
|
||||||
|
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 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;
|
||||||
|
const MAX_STEP_DISP = 0.2; // target max displacement per substep (voxels)
|
||||||
|
const BOB_AMP = 0.055;
|
||||||
|
const BOB_FREQ = 1.9;
|
||||||
|
const SPRINT_FOV_BOOST = 5; // degrees
|
||||||
|
|
||||||
|
export interface PlayerOptions {
|
||||||
|
getColliders(): KinematicCollider[];
|
||||||
|
camera: PerspectiveCamera;
|
||||||
|
domElement: HTMLElement;
|
||||||
|
spawn: Vec3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlayerController implements IPlayerView {
|
||||||
|
// IPlayerView surface.
|
||||||
|
onGround = false;
|
||||||
|
groundedOn: string | null = null;
|
||||||
|
|
||||||
|
// Public knobs.
|
||||||
|
readonly input: InputState = createInputState();
|
||||||
|
viewBob = true;
|
||||||
|
|
||||||
|
private world: IVoxelWorld;
|
||||||
|
private getColliders: () => KinematicCollider[];
|
||||||
|
private camera: PerspectiveCamera;
|
||||||
|
private inputCtl: InputController | null;
|
||||||
|
private baseFov: number;
|
||||||
|
|
||||||
|
private _pos: [number, number, number];
|
||||||
|
private _vel: [number, number, number] = [0, 0, 0];
|
||||||
|
private _eye: [number, number, number] = [0, 0, 0];
|
||||||
|
private _look: [number, number, number] = [0, 0, -1];
|
||||||
|
|
||||||
|
// Surface velocity of the platform we're riding; inherited on jump/step-off.
|
||||||
|
private carryVel: [number, number, number] = [0, 0, 0];
|
||||||
|
private _scratch: [number, number, number] = [0, 0, 0];
|
||||||
|
|
||||||
|
private flying = false;
|
||||||
|
private stepAccum = 0;
|
||||||
|
private bobPhase = 0;
|
||||||
|
|
||||||
|
constructor(world: IVoxelWorld, opts: PlayerOptions) {
|
||||||
|
this.world = world;
|
||||||
|
this.getColliders = opts.getColliders;
|
||||||
|
this.camera = opts.camera;
|
||||||
|
this.baseFov = opts.camera.fov;
|
||||||
|
this._pos = [opts.spawn[0], opts.spawn[1], opts.spawn[2]];
|
||||||
|
this.inputCtl = new InputController(opts.domElement, this.input);
|
||||||
|
this.updateCamera(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IPlayerView ---
|
||||||
|
get position(): Vec3 { return this._pos as Vec3; }
|
||||||
|
get eye(): Vec3 { return this._eye as Vec3; }
|
||||||
|
get lookDir(): Vec3 { return this._look as Vec3; }
|
||||||
|
|
||||||
|
// --- Public control ---
|
||||||
|
teleport(v: Vec3): void {
|
||||||
|
this._pos[0] = v[0]; this._pos[1] = v[1]; this._pos[2] = v[2];
|
||||||
|
this._vel[0] = this._vel[1] = this._vel[2] = 0;
|
||||||
|
this.carryVel[0] = this.carryVel[1] = this.carryVel[2] = 0;
|
||||||
|
this.onGround = false;
|
||||||
|
this.groundedOn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlying(b: boolean): void {
|
||||||
|
this.flying = b;
|
||||||
|
if (b) this._vel[1] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFlying(): boolean { return this.flying; }
|
||||||
|
|
||||||
|
/** Current surface-carry velocity (for HUD/debug). */
|
||||||
|
get carryVelocity(): Vec3 { return this.carryVel as Vec3; }
|
||||||
|
|
||||||
|
dispose(): void { this.inputCtl?.dispose(); this.inputCtl = null; }
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
if (this.input.toggleFly) {
|
||||||
|
this.input.toggleFly = false;
|
||||||
|
this.setFlying(!this.flying);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substep so no single axis moves more than ~MAX_STEP_DISP per step.
|
||||||
|
const speedEst = Math.max(
|
||||||
|
Math.abs(this._vel[0]), Math.abs(this._vel[1]), Math.abs(this._vel[2]),
|
||||||
|
) + Math.hypot(this.carryVel[0], this.carryVel[2]);
|
||||||
|
const n = Math.min(MAX_SUBSTEPS, Math.max(1, Math.ceil((speedEst * dt) / MAX_STEP_DISP)));
|
||||||
|
const h = dt / n;
|
||||||
|
for (let i = 0; i < n; i++) this.substep(h);
|
||||||
|
|
||||||
|
this.updateCamera(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private substep(h: number): void {
|
||||||
|
const inp = this.input;
|
||||||
|
const flying = this.flying;
|
||||||
|
|
||||||
|
// Movement basis from yaw. forwardH = (-sin, 0, -cos); rightH = (cos, 0, -sin).
|
||||||
|
const sy = Math.sin(inp.yaw), cy = Math.cos(inp.yaw);
|
||||||
|
let dirX = -sy * inp.moveZ + cy * inp.moveX;
|
||||||
|
let dirZ = -cy * inp.moveZ - sy * inp.moveX;
|
||||||
|
const len = Math.hypot(dirX, dirZ);
|
||||||
|
if (len > 1e-5) { dirX /= len; dirZ /= len; } else { dirX = 0; dirZ = 0; }
|
||||||
|
|
||||||
|
let speed = inp.sprint ? PLAYER.sprintSpeed : PLAYER.walkSpeed;
|
||||||
|
if (flying) speed *= FLY_HORIZ_MULT;
|
||||||
|
const desVx = dirX * speed, desVz = dirZ * speed;
|
||||||
|
|
||||||
|
const prevOnGround = this.onGround;
|
||||||
|
const prevGroundedOn = this.groundedOn;
|
||||||
|
|
||||||
|
// Horizontal accel/friction toward the desired velocity (exponential ease).
|
||||||
|
const k = flying ? FLY_K : (prevOnGround ? GROUND_K : AIR_K);
|
||||||
|
const f = 1 - Math.exp(-k * h);
|
||||||
|
this._vel[0] += (desVx - this._vel[0]) * f;
|
||||||
|
this._vel[2] += (desVz - this._vel[2]) * f;
|
||||||
|
|
||||||
|
// Vertical.
|
||||||
|
if (flying) {
|
||||||
|
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;
|
||||||
|
this._vel[1] -= GRAVITY * h;
|
||||||
|
if (this._vel[1] < -TERMINAL) this._vel[1] = -TERMINAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startX = this._pos[0], startZ = this._pos[2];
|
||||||
|
const vyBefore = this._vel[1];
|
||||||
|
const dx = this._vel[0] * h, dy = this._vel[1] * h, dz = this._vel[2] * h;
|
||||||
|
|
||||||
|
this.onGround = false;
|
||||||
|
this.groundedOn = null;
|
||||||
|
|
||||||
|
// Voxel collision: X, Z (with auto-step) then Y.
|
||||||
|
const canStep = prevOnGround && !flying && this._vel[1] <= 0.5;
|
||||||
|
moveHorizontalWithStep(this.world, this._pos, this._vel, dx, dz, canStep);
|
||||||
|
const hitY = resolveAxis(this.world, this._pos, this._vel, 1, dy);
|
||||||
|
if (hitY && dy < 0) this.onGround = true;
|
||||||
|
|
||||||
|
// Kinematic platforms (record platter, fader sled, tonearm...).
|
||||||
|
this.resolveColliders(h);
|
||||||
|
|
||||||
|
// Leaving a ridden platform (jump or walk-off) inherits its surface speed —
|
||||||
|
// this is the record-edge launch. Applied once, at the moment of leaving.
|
||||||
|
if (prevGroundedOn !== null && this.groundedOn === null) {
|
||||||
|
this._vel[0] += this.carryVel[0];
|
||||||
|
this._vel[2] += this.carryVel[2];
|
||||||
|
this.carryVel[0] = this.carryVel[1] = this.carryVel[2] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Landing event.
|
||||||
|
if (!prevOnGround && this.onGround && vyBefore < -LAND_MIN_SPEED) {
|
||||||
|
bus.emit('player:landed', { impactSpeed: -vyBefore });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footsteps — only on real voxel ground (platforms have no block). Sample
|
||||||
|
// the whole footprint (the same cell range resolveAxis grounds against), not
|
||||||
|
// just the center column, so ledge/bridge-edge walking still finds the block
|
||||||
|
// actually under the foot instead of an adjacent air column.
|
||||||
|
if (this.onGround && this.groundedOn === null) {
|
||||||
|
this.stepAccum += Math.hypot(this._pos[0] - startX, this._pos[2] - startZ);
|
||||||
|
if (this.stepAccum >= STEP_DISTANCE) {
|
||||||
|
this.stepAccum -= STEP_DISTANCE;
|
||||||
|
const by = Math.floor(this._pos[1] - 0.1);
|
||||||
|
const cx0 = Math.floor(this._pos[0] - HALF_W + 1e-4);
|
||||||
|
const cx1 = Math.floor(this._pos[0] + HALF_W - 1e-4);
|
||||||
|
const cz0 = Math.floor(this._pos[2] - HALF_W + 1e-4);
|
||||||
|
const cz1 = Math.floor(this._pos[2] + HALF_W - 1e-4);
|
||||||
|
let block = 0;
|
||||||
|
for (let x = cx0; x <= cx1 && block === 0; x++)
|
||||||
|
for (let z = cz0; z <= cz1; z++) {
|
||||||
|
const b = this.world.getBlock(x, by, z);
|
||||||
|
if (b !== 0) { block = b; break; }
|
||||||
|
}
|
||||||
|
if (block !== 0) bus.emit('player:step', { block });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveColliders(h: number): void {
|
||||||
|
const colliders = this.getColliders();
|
||||||
|
const p = this._pos;
|
||||||
|
|
||||||
|
// Resolve geometry per collider, but ground on exactly ONE — the highest
|
||||||
|
// supporting top — and apply its surface carry once, after the loop. This
|
||||||
|
// keeps overlapping rideable colliders (e.g. a fader sled crossing a platter
|
||||||
|
// rim) from double-carrying or mis-reporting groundedOn/carryVelocity, both
|
||||||
|
// of which are IPlayerView fields Lanes D/E consume.
|
||||||
|
let groundTop = -Infinity;
|
||||||
|
let groundCollider: KinematicCollider | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < colliders.length; i++) {
|
||||||
|
const c = colliders[i];
|
||||||
|
const s = c.shape;
|
||||||
|
|
||||||
|
if (s.kind === 'cylinder') {
|
||||||
|
const cx = s.center[0], cyc = s.center[1], cz = s.center[2];
|
||||||
|
const top = cyc + s.halfHeight, bottom = cyc - s.halfHeight;
|
||||||
|
const rx = p[0] - cx, rz = p[2] - cz;
|
||||||
|
const dist = Math.hypot(rx, rz);
|
||||||
|
|
||||||
|
// Stand on top → record as a ground candidate (snapped after the loop).
|
||||||
|
if (
|
||||||
|
dist < s.radius && this._vel[1] <= 0.001 &&
|
||||||
|
p[1] <= top + STAND_UP_EPS && p[1] >= top - LAND_DOWN_EPS
|
||||||
|
) {
|
||||||
|
if (top > groundTop) { groundTop = top; groundCollider = c; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Side push-out (radial) when the body overlaps the disc slab.
|
||||||
|
if (p[1] < top && p[1] + HEIGHT > bottom && dist < s.radius + HALF_W && dist > 1e-4) {
|
||||||
|
const push = s.radius + HALF_W - dist;
|
||||||
|
const nx = rx / dist, nz = rz / dist;
|
||||||
|
p[0] += nx * push; p[2] += nz * push;
|
||||||
|
const inward = this._vel[0] * nx + this._vel[2] * nz;
|
||||||
|
if (inward < 0) { this._vel[0] -= inward * nx; this._vel[2] -= inward * nz; }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// AABB collider: push out along the minimum-penetration axis.
|
||||||
|
const pMnX = p[0] - HALF_W, pMxX = p[0] + HALF_W;
|
||||||
|
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]);
|
||||||
|
if (ox <= 0 || oy <= 0 || oz <= 0) continue;
|
||||||
|
|
||||||
|
if (oy <= ox && oy <= oz) {
|
||||||
|
const cCenterY = (s.min[1] + s.max[1]) * 0.5;
|
||||||
|
if ((pMnY + pMxY) * 0.5 > cCenterY) {
|
||||||
|
p[1] += oy;
|
||||||
|
if (this._vel[1] < 0) this._vel[1] = 0;
|
||||||
|
if (s.max[1] > groundTop) { groundTop = s.max[1]; groundCollider = c; }
|
||||||
|
} else {
|
||||||
|
p[1] -= oy;
|
||||||
|
if (this._vel[1] > 0) this._vel[1] = 0;
|
||||||
|
}
|
||||||
|
} else if (ox <= oz) {
|
||||||
|
const cCenterX = (s.min[0] + s.max[0]) * 0.5;
|
||||||
|
p[0] += p[0] > cCenterX ? ox : -ox;
|
||||||
|
this._vel[0] = 0;
|
||||||
|
} else {
|
||||||
|
const cCenterZ = (s.min[2] + s.max[2]) * 0.5;
|
||||||
|
p[2] += p[2] > cCenterZ ? oz : -oz;
|
||||||
|
this._vel[2] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groundCollider) {
|
||||||
|
p[1] = groundTop;
|
||||||
|
if (this._vel[1] < 0) this._vel[1] = 0;
|
||||||
|
this.onGround = true;
|
||||||
|
this.groundedOn = groundCollider.id;
|
||||||
|
this.setCarry(groundCollider, p);
|
||||||
|
this.applyCarry(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCarry(c: KinematicCollider, p: number[]): void {
|
||||||
|
const v = c.velocityAt(p[0], p[1], p[2]);
|
||||||
|
this.carryVel[0] = v[0]; this.carryVel[1] = 0; this.carryVel[2] = v[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate by the platform's surface velocity, sweeping voxels so the carry
|
||||||
|
// can't shove the player through a wall.
|
||||||
|
private applyCarry(h: number): void {
|
||||||
|
const s = this._scratch; s[0] = 0; s[1] = 0; s[2] = 0;
|
||||||
|
resolveAxis(this.world, this._pos, s, 0, this.carryVel[0] * h);
|
||||||
|
resolveAxis(this.world, this._pos, s, 2, this.carryVel[2] * h);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCamera(dt: number): void {
|
||||||
|
const cam = this.camera;
|
||||||
|
const inp = this.input;
|
||||||
|
|
||||||
|
const hSpeed = Math.hypot(this._vel[0], this._vel[2]);
|
||||||
|
const targetFov = this.baseFov + (inp.sprint && hSpeed > 0.5 ? SPRINT_FOV_BOOST : 0);
|
||||||
|
if (dt > 0) {
|
||||||
|
cam.fov += (targetFov - cam.fov) * Math.min(1, 10 * dt);
|
||||||
|
cam.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
let bob = 0, roll = 0;
|
||||||
|
if (this.viewBob && this.onGround && this.groundedOn === null && hSpeed > 0.5) {
|
||||||
|
this.bobPhase += hSpeed * dt * BOB_FREQ;
|
||||||
|
const ph = this.bobPhase * Math.PI * 2;
|
||||||
|
bob = Math.sin(ph) * BOB_AMP;
|
||||||
|
roll = Math.cos(ph) * BOB_AMP * 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._eye[0] = this._pos[0];
|
||||||
|
this._eye[1] = this._pos[1] + PLAYER.eyeHeight + bob;
|
||||||
|
this._eye[2] = this._pos[2];
|
||||||
|
cam.position.set(this._eye[0], this._eye[1], this._eye[2]);
|
||||||
|
cam.rotation.order = 'YXZ';
|
||||||
|
cam.rotation.set(inp.pitch, inp.yaw, roll);
|
||||||
|
|
||||||
|
const cp = Math.cos(inp.pitch), sp = Math.sin(inp.pitch);
|
||||||
|
const syaw = Math.sin(inp.yaw), cyaw = Math.cos(inp.yaw);
|
||||||
|
this._look[0] = -cp * syaw;
|
||||||
|
this._look[1] = sp;
|
||||||
|
this._look[2] = -cp * cyaw;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/player/collision.ts
Normal file
111
src/player/collision.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
// Lane B — AABB-vs-voxel collision. Pure functions that mutate the passed
|
||||||
|
// position/velocity tuples in place (zero per-call allocation on the hot path).
|
||||||
|
//
|
||||||
|
// The player is an axis-aligned box centered horizontally on `pos` (feet
|
||||||
|
// center), spanning [pos.x-HALF_W, pos.x+HALF_W] × [pos.y, pos.y+HEIGHT] ×
|
||||||
|
// [pos.z-HALF_W, pos.z+HALF_W]. Callers substep so |disp| stays small; each
|
||||||
|
// resolveAxis therefore only has to clamp against the single voxel layer the
|
||||||
|
// box newly entered along that axis.
|
||||||
|
|
||||||
|
import type { IVoxelWorld } from '../core/types';
|
||||||
|
import { PLAYER } from '../core/constants';
|
||||||
|
|
||||||
|
export const HALF_W = PLAYER.width / 2;
|
||||||
|
export const HEIGHT = PLAYER.height;
|
||||||
|
export const STEP_HEIGHT = 1.0; // auto-step ledges up to 1 voxel tall
|
||||||
|
|
||||||
|
const EPS = 1e-4;
|
||||||
|
|
||||||
|
// Scratch velocity for the step-assist vertical probes (single-threaded JS).
|
||||||
|
const _dummy: [number, number, number] = [0, 0, 0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the box along one axis by `disp`, then clamp it out of any solid voxel
|
||||||
|
* it entered. Zeros vel[axis] on contact. Returns true if it hit something.
|
||||||
|
* axis: 0 = X, 1 = Y, 2 = Z.
|
||||||
|
*/
|
||||||
|
export function resolveAxis(
|
||||||
|
world: IVoxelWorld,
|
||||||
|
pos: number[],
|
||||||
|
vel: number[],
|
||||||
|
axis: 0 | 1 | 2,
|
||||||
|
disp: number,
|
||||||
|
): boolean {
|
||||||
|
pos[axis] += disp;
|
||||||
|
if (disp === 0) return false;
|
||||||
|
|
||||||
|
const minX = pos[0] - HALF_W, maxX = pos[0] + HALF_W;
|
||||||
|
const minY = pos[1], maxY = pos[1] + HEIGHT;
|
||||||
|
const minZ = pos[2] - HALF_W, maxZ = pos[2] + HALF_W;
|
||||||
|
|
||||||
|
const cx0 = Math.floor(minX + EPS), cx1 = Math.floor(maxX - EPS);
|
||||||
|
const cy0 = Math.floor(minY + EPS), cy1 = Math.floor(maxY - EPS);
|
||||||
|
const cz0 = Math.floor(minZ + EPS), cz1 = Math.floor(maxZ - EPS);
|
||||||
|
|
||||||
|
let hit = false;
|
||||||
|
|
||||||
|
if (axis === 0) {
|
||||||
|
const layer = disp > 0 ? cx1 : cx0;
|
||||||
|
for (let y = cy0; y <= cy1 && !hit; y++)
|
||||||
|
for (let z = cz0; z <= cz1; z++)
|
||||||
|
if (world.isSolid(layer, y, z)) { hit = true; break; }
|
||||||
|
if (hit) { pos[0] = disp > 0 ? layer - HALF_W : layer + 1 + HALF_W; vel[0] = 0; }
|
||||||
|
} else if (axis === 1) {
|
||||||
|
const layer = disp > 0 ? cy1 : cy0;
|
||||||
|
for (let x = cx0; x <= cx1 && !hit; x++)
|
||||||
|
for (let z = cz0; z <= cz1; z++)
|
||||||
|
if (world.isSolid(x, layer, z)) { hit = true; break; }
|
||||||
|
if (hit) { pos[1] = disp > 0 ? layer - HEIGHT : layer + 1; vel[1] = 0; }
|
||||||
|
} else {
|
||||||
|
const layer = disp > 0 ? cz1 : cz0;
|
||||||
|
for (let x = cx0; x <= cx1 && !hit; x++)
|
||||||
|
for (let y = cy0; y <= cy1; y++)
|
||||||
|
if (world.isSolid(x, y, layer)) { hit = true; break; }
|
||||||
|
if (hit) { pos[2] = disp > 0 ? layer - HALF_W : layer + 1 + HALF_W; vel[2] = 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal move (X then Z, Minecraft order) with auto-step. If the flat move
|
||||||
|
* hits a wall and `canStep` is set, retry the move raised by STEP_HEIGHT and
|
||||||
|
* settle back down; keep whichever result travelled further horizontally. A
|
||||||
|
* 2-voxel wall naturally fails the retry (still blocked one voxel up) and is
|
||||||
|
* left un-stepped. The caller's subsequent Y resolution re-grounds the player.
|
||||||
|
*/
|
||||||
|
export function moveHorizontalWithStep(
|
||||||
|
world: IVoxelWorld,
|
||||||
|
pos: number[],
|
||||||
|
vel: number[],
|
||||||
|
dx: number,
|
||||||
|
dz: number,
|
||||||
|
canStep: boolean,
|
||||||
|
): void {
|
||||||
|
const sx = pos[0], sz = pos[2], svx = vel[0], svz = vel[2];
|
||||||
|
|
||||||
|
const hitX = resolveAxis(world, pos, vel, 0, dx);
|
||||||
|
const hitZ = resolveAxis(world, pos, vel, 2, dz);
|
||||||
|
if (!canStep || (!hitX && !hitZ)) return;
|
||||||
|
|
||||||
|
// Remember the flat (non-stepped) outcome.
|
||||||
|
const nx = pos[0], nz = pos[2], nvx = vel[0], nvz = vel[2];
|
||||||
|
const flatDist = (nx - sx) * (nx - sx) + (nz - sz) * (nz - sz);
|
||||||
|
|
||||||
|
// Retry raised by one voxel.
|
||||||
|
pos[0] = sx; pos[2] = sz; vel[0] = svx; vel[2] = svz;
|
||||||
|
const startY = pos[1];
|
||||||
|
_dummy[0] = 0; _dummy[1] = 0; _dummy[2] = 0;
|
||||||
|
resolveAxis(world, pos, _dummy, 1, STEP_HEIGHT); // clamps if there's a ceiling
|
||||||
|
const climbed = pos[1] - startY;
|
||||||
|
resolveAxis(world, pos, vel, 0, dx);
|
||||||
|
resolveAxis(world, pos, vel, 2, dz);
|
||||||
|
resolveAxis(world, pos, _dummy, 1, -climbed); // settle onto the ledge
|
||||||
|
if (pos[1] < startY) pos[1] = startY;
|
||||||
|
|
||||||
|
const stepDist = (pos[0] - sx) * (pos[0] - sx) + (pos[2] - sz) * (pos[2] - sz);
|
||||||
|
if (stepDist <= flatDist + 1e-6) {
|
||||||
|
// Stepping gained nothing (e.g. a 2-voxel wall) — revert to the flat move.
|
||||||
|
pos[0] = nx; pos[1] = startY; pos[2] = nz; vel[0] = nvx; vel[2] = nvz;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/player/index.ts
Normal file
5
src/player/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// Lane B — public surface. The integrator imports from here.
|
||||||
|
export { PlayerController } from './PlayerController';
|
||||||
|
export type { PlayerOptions } from './PlayerController';
|
||||||
|
export { createInputState, InputController } from './input';
|
||||||
|
export type { InputState } from './input';
|
||||||
111
src/player/input.ts
Normal file
111
src/player/input.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
// Lane B — input. The physics reads a plain InputState object; the demo (or the
|
||||||
|
// integrator) can either drive it with a real InputController bound to the DOM,
|
||||||
|
// or write its fields directly for scripted tests. Keeping the two separate is
|
||||||
|
// what makes the controller testable without a browser.
|
||||||
|
|
||||||
|
export interface InputState {
|
||||||
|
moveX: number; // strafe: -1 (left) .. +1 (right)
|
||||||
|
moveZ: number; // forward: -1 (back) .. +1 (forward)
|
||||||
|
jump: boolean; // Space (used as jump on the ground, up while flying)
|
||||||
|
sprint: boolean; // Shift
|
||||||
|
flyUp: boolean; // Space, while flying
|
||||||
|
flyDown: boolean; // Ctrl, while flying
|
||||||
|
yaw: number; // radians, absolute (mouse look, around +Y)
|
||||||
|
pitch: number; // radians, absolute, clamped to just under ±90°
|
||||||
|
toggleFly: boolean; // one-shot edge; the controller consumes and clears it
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInputState(): InputState {
|
||||||
|
return {
|
||||||
|
moveX: 0, moveZ: 0, jump: false, sprint: false,
|
||||||
|
flyUp: false, flyDown: false, yaw: 0, pitch: 0, toggleFly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PITCH_LIMIT = Math.PI / 2 - 0.01;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds keyboard + pointer-lock mouse look to an InputState. Space and Ctrl map
|
||||||
|
* to both jump/flyUp and flyDown so the controller can decide per-mode which to
|
||||||
|
* use. Call dispose() to detach all listeners.
|
||||||
|
*/
|
||||||
|
export class InputController {
|
||||||
|
private keys = new Set<string>();
|
||||||
|
private locked = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private dom: HTMLElement,
|
||||||
|
private state: InputState,
|
||||||
|
private sensitivity = 0.0022,
|
||||||
|
) {
|
||||||
|
window.addEventListener('keydown', this.onKeyDown);
|
||||||
|
window.addEventListener('keyup', this.onKeyUp);
|
||||||
|
window.addEventListener('blur', this.onBlur);
|
||||||
|
this.dom.addEventListener('click', this.onClick);
|
||||||
|
document.addEventListener('pointerlockchange', this.onLockChange);
|
||||||
|
document.addEventListener('mousemove', this.onMouseMove);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
window.removeEventListener('keydown', this.onKeyDown);
|
||||||
|
window.removeEventListener('keyup', this.onKeyUp);
|
||||||
|
window.removeEventListener('blur', this.onBlur);
|
||||||
|
this.dom.removeEventListener('click', this.onClick);
|
||||||
|
document.removeEventListener('pointerlockchange', this.onLockChange);
|
||||||
|
document.removeEventListener('mousemove', this.onMouseMove);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isLocked(): boolean { return this.locked; }
|
||||||
|
|
||||||
|
private onClick = (): void => {
|
||||||
|
if (!this.locked) this.dom.requestPointerLock();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onLockChange = (): void => {
|
||||||
|
this.locked = document.pointerLockElement === this.dom;
|
||||||
|
if (!this.locked) this.releaseAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onBlur = (): void => { this.releaseAll(); };
|
||||||
|
|
||||||
|
private releaseAll(): void {
|
||||||
|
this.keys.clear();
|
||||||
|
this.syncMovement();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseMove = (e: MouseEvent): void => {
|
||||||
|
if (!this.locked) return;
|
||||||
|
this.state.yaw -= e.movementX * this.sensitivity;
|
||||||
|
this.state.pitch -= e.movementY * this.sensitivity;
|
||||||
|
if (this.state.pitch > PITCH_LIMIT) this.state.pitch = PITCH_LIMIT;
|
||||||
|
if (this.state.pitch < -PITCH_LIMIT) this.state.pitch = -PITCH_LIMIT;
|
||||||
|
};
|
||||||
|
|
||||||
|
private onKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
// Only drive the player while the pointer is locked — mirrors onMouseMove, so
|
||||||
|
// WASD/F don't move the character when the game doesn't have focus.
|
||||||
|
if (!this.locked || e.repeat) return;
|
||||||
|
if (e.code === 'KeyF') { this.state.toggleFly = true; return; }
|
||||||
|
this.keys.add(e.code);
|
||||||
|
this.syncMovement();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onKeyUp = (e: KeyboardEvent): void => {
|
||||||
|
if (!this.locked) return; // keys are already cleared by releaseAll() on unlock
|
||||||
|
this.keys.delete(e.code);
|
||||||
|
this.syncMovement();
|
||||||
|
};
|
||||||
|
|
||||||
|
private syncMovement(): void {
|
||||||
|
const k = this.keys;
|
||||||
|
const fwd = (k.has('KeyW') ? 1 : 0) - (k.has('KeyS') ? 1 : 0);
|
||||||
|
const str = (k.has('KeyD') ? 1 : 0) - (k.has('KeyA') ? 1 : 0);
|
||||||
|
this.state.moveZ = fwd;
|
||||||
|
this.state.moveX = str;
|
||||||
|
this.state.sprint = k.has('ShiftLeft') || k.has('ShiftRight');
|
||||||
|
const space = k.has('Space');
|
||||||
|
this.state.jump = space;
|
||||||
|
this.state.flyUp = space;
|
||||||
|
this.state.flyDown = k.has('ControlLeft') || k.has('ControlRight');
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/ui/HANDOFF.md
Normal file
39
src/ui/HANDOFF.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Lane E — UI HANDOFF (`src/ui/**`)
|
||||||
|
|
||||||
|
Owned by Lane E. See `src/audio/HANDOFF.md` for the full lane summary.
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const hud = new Hud({
|
||||||
|
root, // optional; defaults to #app
|
||||||
|
onStart: () => { audio.init(); requestPointerLock(); }, // start-splash click (ONCE)
|
||||||
|
onResume: () => { requestPointerLock(); }, // pause overlay click — re-lock ONLY
|
||||||
|
getHotbar: () => laneD.hotbarModel, // { slots, active }
|
||||||
|
});
|
||||||
|
hud.refreshHotbar(); // after hotbar changes (also auto-refreshes on break/place)
|
||||||
|
hud.setPaused(true|false); // call on pointer-lock loss / regain
|
||||||
|
hud.showSubtitle(text); // optional manual subtitle
|
||||||
|
```
|
||||||
|
|
||||||
|
`HotbarModel` / `HotbarSlot` are exported from `Hud.ts` (no core type exists —
|
||||||
|
adapt Lane D's hotbar to this shape).
|
||||||
|
|
||||||
|
## What's in here
|
||||||
|
|
||||||
|
- `Hud.ts` — one DOM overlay (injected `<style>`, `pointer-events:none` so it
|
||||||
|
never eats gameplay input; only the start/pause/win screens capture clicks):
|
||||||
|
crosshair, 9-slot hotbar (tint swatches + counts + active ring), 5-node quest
|
||||||
|
tracker (top-right, lit per repair), event subtitle line, the **start splash
|
||||||
|
that gates the AudioContext + pointer lock** ("TURNCRAFT — click to drop the
|
||||||
|
needle"), a pause overlay, and the win banner.
|
||||||
|
- Self-wires to the bus: `signal:repair` (light node + progress + subtitle),
|
||||||
|
`game:win` (banner), `block:break`/`block:place` (refresh hotbar). All
|
||||||
|
subscriptions are stored and released in `dispose()`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Dynamic text is set via `textContent` (no `innerHTML`) — no injection risk
|
||||||
|
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`.
|
||||||
296
src/ui/Hud.ts
Normal file
296
src/ui/Hud.ts
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
// TURNCRAFT — Lane E. The HUD: a DOM overlay (not canvas) over the game canvas.
|
||||||
|
// Crosshair, 9-slot hotbar (Lane D's model via an injected getter), a 5-node
|
||||||
|
// quest tracker, an event subtitle line, the start overlay that gates the
|
||||||
|
// AudioContext + pointer lock, a pause overlay, and the win banner.
|
||||||
|
//
|
||||||
|
// The overlay is pointer-events:none except the start/pause/win screens, so it
|
||||||
|
// never eats gameplay input.
|
||||||
|
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import { SIGNAL_NODES, type SignalNode } from '../core/constants';
|
||||||
|
import { blockDef, type BlockId } from '../core/blocks';
|
||||||
|
|
||||||
|
export interface HotbarSlot { id: BlockId; count: number; }
|
||||||
|
export interface HotbarModel { slots: HotbarSlot[]; active: number; }
|
||||||
|
|
||||||
|
export interface HudOpts {
|
||||||
|
root?: HTMLElement;
|
||||||
|
/** Called ONCE when the player clicks the start splash (init audio + pointer lock). */
|
||||||
|
onStart?: () => void;
|
||||||
|
/** Called when the player clicks the pause overlay to resume (re-acquire pointer
|
||||||
|
* lock only — must NOT repeat first-start side effects). Falls back to a pure unpause. */
|
||||||
|
onResume?: () => void;
|
||||||
|
/** Injected read of Lane D's hotbar model. */
|
||||||
|
getHotbar?: () => HotbarModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_LABEL: Record<SignalNode, string> = {
|
||||||
|
stylus: 'STYLUS', rca: 'RCA', crossfader: 'X-FADER', fuse: 'FUSE', power: 'POWER',
|
||||||
|
};
|
||||||
|
const NODE_COLOR: Record<SignalNode, string> = {
|
||||||
|
stylus: '#d2d6dc', rca: '#ffb028', crossfader: '#4682ff', fuse: '#ff3028', power: '#3cff5a',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.tc-hud, .tc-hud * { box-sizing: border-box; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||||
|
.tc-hud { position:absolute; inset:0; pointer-events:none; color:#dfe2e6; user-select:none; z-index:10; }
|
||||||
|
.tc-cross { position:absolute; left:50%; top:50%; width:18px; height:18px; margin:-9px 0 0 -9px;
|
||||||
|
opacity:.6; }
|
||||||
|
.tc-cross::before, .tc-cross::after { content:""; position:absolute; background:#e8eaed; }
|
||||||
|
.tc-cross::before { left:8px; top:0; width:2px; height:18px; }
|
||||||
|
.tc-cross::after { top:8px; left:0; height:2px; width:18px; }
|
||||||
|
.tc-quest { position:absolute; top:14px; right:14px; display:flex; gap:8px; }
|
||||||
|
.tc-node { display:flex; flex-direction:column; align-items:center; gap:4px; opacity:.4;
|
||||||
|
transition:opacity .3s; }
|
||||||
|
.tc-node.lit { opacity:1; }
|
||||||
|
.tc-dot { width:16px; height:16px; border-radius:50%; background:#2a2a2e;
|
||||||
|
border:1px solid #444; box-shadow:none; transition:box-shadow .3s, background .3s; }
|
||||||
|
.tc-node.lit .tc-dot { box-shadow:0 0 10px 2px currentColor; }
|
||||||
|
.tc-nlabel { font-size:9px; letter-spacing:.06em; color:#9aa; }
|
||||||
|
.tc-progress { position:absolute; top:44px; right:14px; font-size:11px; color:#7a8; letter-spacing:.1em; }
|
||||||
|
.tc-sub { position:absolute; left:50%; bottom:120px; transform:translateX(-50%);
|
||||||
|
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-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; }
|
||||||
|
.tc-slot { width:44px; height:44px; border:2px solid #3a3a3e; border-radius:4px; position:relative;
|
||||||
|
background:#151517; display:flex; align-items:flex-end; justify-content:flex-end; }
|
||||||
|
.tc-slot.active { border-color:#ffb028; box-shadow:0 0 8px rgba(255,176,40,.6); }
|
||||||
|
.tc-swatch { position:absolute; inset:5px; border-radius:2px; }
|
||||||
|
.tc-count { position:relative; font-size:11px; padding:1px 3px; color:#fff;
|
||||||
|
text-shadow:0 1px 2px #000; }
|
||||||
|
.tc-key { position:absolute; top:2px; left:4px; font-size:9px; color:#889; }
|
||||||
|
.tc-overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center;
|
||||||
|
justify-content:center; background:rgba(6,6,8,.82); pointer-events:auto; cursor:pointer;
|
||||||
|
text-align:center; }
|
||||||
|
.tc-overlay.hidden { display:none; }
|
||||||
|
.tc-title { font-size:56px; letter-spacing:.34em; margin-left:.34em; color:#eef;
|
||||||
|
text-shadow:0 0 24px rgba(70,130,255,.6); }
|
||||||
|
.tc-tagline { margin-top:18px; font-size:15px; letter-spacing:.2em; color:#9ab; }
|
||||||
|
.tc-blink { animation:tcblink 1.2s steps(2,end) infinite; }
|
||||||
|
@keyframes tcblink { 50% { opacity:.25; } }
|
||||||
|
.tc-pause { pointer-events:auto; }
|
||||||
|
.tc-win { background:rgba(4,6,14,.5); }
|
||||||
|
.tc-win .tc-title { color:#3cff5a; text-shadow:0 0 28px rgba(60,255,90,.7); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class Hud {
|
||||||
|
private root: HTMLElement;
|
||||||
|
private el: HTMLDivElement;
|
||||||
|
private onStart?: () => void;
|
||||||
|
private onResume?: () => void;
|
||||||
|
private getHotbar?: () => HotbarModel;
|
||||||
|
|
||||||
|
private hotbarEl!: HTMLDivElement;
|
||||||
|
private slots: HTMLDivElement[] = [];
|
||||||
|
private nodeEls: Record<string, HTMLDivElement> = {};
|
||||||
|
private progressEl!: HTMLDivElement;
|
||||||
|
private subEl!: HTMLDivElement;
|
||||||
|
private startEl!: HTMLDivElement;
|
||||||
|
private pauseEl!: HTMLDivElement;
|
||||||
|
private winEl!: HTMLDivElement;
|
||||||
|
private subTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private winTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private disposers: (() => void)[] = [];
|
||||||
|
|
||||||
|
constructor(opts: HudOpts = {}) {
|
||||||
|
this.root = opts.root ?? document.getElementById('app') ?? document.body;
|
||||||
|
this.onStart = opts.onStart;
|
||||||
|
this.onResume = opts.onResume;
|
||||||
|
this.getHotbar = opts.getHotbar;
|
||||||
|
this.injectStyle();
|
||||||
|
this.el = document.createElement('div');
|
||||||
|
this.el.className = 'tc-hud';
|
||||||
|
this.buildCrosshair();
|
||||||
|
this.buildQuest();
|
||||||
|
this.buildSubtitle();
|
||||||
|
this.buildHotbar();
|
||||||
|
this.buildStart();
|
||||||
|
this.buildPause();
|
||||||
|
this.buildWin();
|
||||||
|
this.root.appendChild(this.el);
|
||||||
|
this.wireBus();
|
||||||
|
this.refreshHotbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public API ──
|
||||||
|
refreshHotbar(): void {
|
||||||
|
const model = this.getHotbar?.();
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const slot = this.slots[i];
|
||||||
|
const s = model?.slots[i];
|
||||||
|
const swatch = slot.querySelector('.tc-swatch') as HTMLDivElement;
|
||||||
|
const count = slot.querySelector('.tc-count') as HTMLDivElement;
|
||||||
|
if (s && s.id !== 0 && s.count > 0) {
|
||||||
|
const t = blockDef(s.id).tint;
|
||||||
|
swatch.style.background = `rgb(${t[0]},${t[1]},${t[2]})`;
|
||||||
|
swatch.style.display = 'block';
|
||||||
|
count.textContent = s.count > 1 ? String(s.count) : '';
|
||||||
|
} else {
|
||||||
|
swatch.style.display = 'none';
|
||||||
|
count.textContent = '';
|
||||||
|
}
|
||||||
|
slot.classList.toggle('active', (model?.active ?? 0) === i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showSubtitle(text: string): void {
|
||||||
|
this.subEl.textContent = text;
|
||||||
|
this.subEl.classList.add('show');
|
||||||
|
if (this.subTimer) clearTimeout(this.subTimer);
|
||||||
|
this.subTimer = setTimeout(() => this.subEl.classList.remove('show'), 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaused(paused: boolean): void {
|
||||||
|
this.pauseEl.classList.toggle('hidden', !paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide the start splash programmatically (e.g. after audio init succeeds). */
|
||||||
|
hideStart(): void { this.startEl.classList.add('hidden'); }
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.subTimer) clearTimeout(this.subTimer);
|
||||||
|
if (this.winTimer) clearTimeout(this.winTimer);
|
||||||
|
for (const d of this.disposers) d();
|
||||||
|
this.el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── build ──
|
||||||
|
private injectStyle(): void {
|
||||||
|
if (document.getElementById('tc-hud-style')) return;
|
||||||
|
const st = document.createElement('style');
|
||||||
|
st.id = 'tc-hud-style';
|
||||||
|
st.textContent = CSS;
|
||||||
|
document.head.appendChild(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCrosshair(): void {
|
||||||
|
const c = document.createElement('div');
|
||||||
|
c.className = 'tc-cross';
|
||||||
|
this.el.appendChild(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildQuest(): void {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'tc-quest';
|
||||||
|
for (const node of SIGNAL_NODES) {
|
||||||
|
const n = document.createElement('div');
|
||||||
|
n.className = 'tc-node';
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.className = 'tc-dot';
|
||||||
|
dot.style.color = NODE_COLOR[node];
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'tc-nlabel';
|
||||||
|
label.textContent = NODE_LABEL[node];
|
||||||
|
n.appendChild(dot); n.appendChild(label);
|
||||||
|
wrap.appendChild(n);
|
||||||
|
this.nodeEls[node] = n;
|
||||||
|
}
|
||||||
|
this.el.appendChild(wrap);
|
||||||
|
this.progressEl = document.createElement('div');
|
||||||
|
this.progressEl.className = 'tc-progress';
|
||||||
|
this.progressEl.textContent = `SIGNAL 0 / ${SIGNAL_NODES.length}`;
|
||||||
|
this.el.appendChild(this.progressEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSubtitle(): void {
|
||||||
|
this.subEl = document.createElement('div');
|
||||||
|
this.subEl.className = 'tc-sub';
|
||||||
|
this.el.appendChild(this.subEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHotbar(): void {
|
||||||
|
this.hotbarEl = document.createElement('div');
|
||||||
|
this.hotbarEl.className = 'tc-hotbar';
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const slot = document.createElement('div');
|
||||||
|
slot.className = 'tc-slot';
|
||||||
|
const key = document.createElement('div');
|
||||||
|
key.className = 'tc-key';
|
||||||
|
key.textContent = String(i + 1);
|
||||||
|
const swatch = document.createElement('div');
|
||||||
|
swatch.className = 'tc-swatch';
|
||||||
|
swatch.style.display = 'none';
|
||||||
|
const count = document.createElement('div');
|
||||||
|
count.className = 'tc-count';
|
||||||
|
slot.appendChild(swatch); slot.appendChild(key); slot.appendChild(count);
|
||||||
|
this.hotbarEl.appendChild(slot);
|
||||||
|
this.slots.push(slot);
|
||||||
|
}
|
||||||
|
this.el.appendChild(this.hotbarEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStart(): void {
|
||||||
|
const o = document.createElement('div');
|
||||||
|
o.className = 'tc-overlay';
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'tc-title';
|
||||||
|
title.textContent = 'TURNCRAFT';
|
||||||
|
const tag = document.createElement('div');
|
||||||
|
tag.className = 'tc-tagline tc-blink';
|
||||||
|
tag.textContent = 'click to drop the needle';
|
||||||
|
o.appendChild(title); o.appendChild(tag);
|
||||||
|
o.addEventListener('click', () => {
|
||||||
|
o.classList.add('hidden');
|
||||||
|
this.onStart?.();
|
||||||
|
});
|
||||||
|
this.startEl = o;
|
||||||
|
this.el.appendChild(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPause(): void {
|
||||||
|
const o = document.createElement('div');
|
||||||
|
o.className = 'tc-overlay tc-pause hidden';
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'tc-title';
|
||||||
|
title.style.fontSize = '34px';
|
||||||
|
title.textContent = 'PAUSED';
|
||||||
|
const tag = document.createElement('div');
|
||||||
|
tag.className = 'tc-tagline';
|
||||||
|
tag.textContent = 'click to resume';
|
||||||
|
o.appendChild(title); o.appendChild(tag);
|
||||||
|
// resume must NOT repeat first-start side effects (audio (re)init, deck spin-up)
|
||||||
|
o.addEventListener('click', () => { this.setPaused(false); this.onResume?.(); });
|
||||||
|
this.pauseEl = o;
|
||||||
|
this.el.appendChild(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWin(): void {
|
||||||
|
const o = document.createElement('div');
|
||||||
|
o.className = 'tc-overlay tc-win hidden';
|
||||||
|
o.style.pointerEvents = 'none';
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'tc-title';
|
||||||
|
title.style.fontSize = '40px';
|
||||||
|
title.textContent = 'THE MIX IS LIVE';
|
||||||
|
const tag = document.createElement('div');
|
||||||
|
tag.className = 'tc-tagline';
|
||||||
|
tag.textContent = 'ride the record';
|
||||||
|
o.appendChild(title); o.appendChild(tag);
|
||||||
|
this.winEl = o;
|
||||||
|
this.el.appendChild(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── bus ──
|
||||||
|
private wireBus(): void {
|
||||||
|
this.disposers.push(bus.on('signal:repair', (p) => {
|
||||||
|
const el = this.nodeEls[p.node];
|
||||||
|
if (el) el.classList.add('lit');
|
||||||
|
this.progressEl.textContent = `SIGNAL ${p.repaired} / ${p.total}`;
|
||||||
|
this.showSubtitle(`SIGNAL RESTORED: ${NODE_LABEL[p.node]} — ${p.repaired}/${p.total}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.disposers.push(bus.on('game:win', () => {
|
||||||
|
this.winEl.classList.remove('hidden');
|
||||||
|
this.showSubtitle('POWER RESTORED — THE BOOTH IS ALIVE');
|
||||||
|
// let the win banner breathe, then fade so sandbox play continues
|
||||||
|
if (this.winTimer) clearTimeout(this.winTimer);
|
||||||
|
this.winTimer = setTimeout(() => this.winEl.classList.add('hidden'), 6000);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.disposers.push(bus.on('block:break', () => this.refreshHotbar()));
|
||||||
|
this.disposers.push(bus.on('block:place', () => this.refreshHotbar()));
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/worldgen/HANDOFF.md
Normal file
102
src/worldgen/HANDOFF.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# Lane C — World Builder · HANDOFF
|
||||||
|
|
||||||
|
**Status: complete.** `npm run typecheck` clean, demo runs with no console errors,
|
||||||
|
build is deterministic and fully validated (see below).
|
||||||
|
|
||||||
|
## Public API surface (import from `src/worldgen`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
buildBooth(world: IVoxelWorld): void // writes the ENTIRE static diorama
|
||||||
|
buildShell, buildDeckA, buildDeckB, buildMixer, buildCableCanyon,
|
||||||
|
buildPatchBay, buildUnderTable // individually testable sub-builders
|
||||||
|
SPAWN: Vec3 // deck A plinth top, faces the mixer
|
||||||
|
SPAWN_LOOK: Vec3 // suggested look-at (mixer face centre)
|
||||||
|
WORLD_SEED = 1210 // fixed seed → identical booth every run
|
||||||
|
QUEST_POS: QuestPositions // {stylus,rca,crossfader,fuse,power} for Lane D
|
||||||
|
QUEST_REACH_POINTS, hasAdjacentAir(w, p) // reachability helpers (used by the demo)
|
||||||
|
DECK_A, DECK_B, MIXER, UNDER, PATCH, RCA_SOCKET, RCA_PLUG // LAYOUT-derived anchors
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's done
|
||||||
|
|
||||||
|
Every zone in DESIGN §4 is built and visually confirmed in `demo-worldgen.html`:
|
||||||
|
shell + tabletop (front lip, vent slots, crate hatch + stair), both steel-grey
|
||||||
|
plinths (feet, alu border, **empty platter well** + chrome spindle stub, start/stop
|
||||||
|
+ 33/45 recesses, pitch canyon w/ green detent, tonearm base pedestal, strobe lamp),
|
||||||
|
Deck B dust drifts + half-open glass dust cover, the mixer massif (body, speaker-mesh
|
||||||
|
climb vents, 4 channel strips = knob-stem grids + **empty fader alleys**, crossfader
|
||||||
|
tram with the dust-jam plug, two VU towers, power-switch recess, fuse chimney), the
|
||||||
|
cable canyon (5 splined RCA/power cables to the patch bay + the quest cable ending
|
||||||
|
short with a loose gold plug + power strip), the patch-bay wall (alu panel, 2×6 gold
|
||||||
|
socket grid, the **empty quest socket** with red blink, two doorway rooms w/ copper
|
||||||
|
busbars + blue glow), and the PCB Depths (green board floors, copper traces, capacitor
|
||||||
|
pillars, solder stalagmites, sparse LEDs, the fuse box with a blown fuse, the parts
|
||||||
|
bin holding the fresh-fuse + stylus pickups, and the record crate of blue/black vinyl
|
||||||
|
slabs on edge with a leaning-slab ramp).
|
||||||
|
|
||||||
|
## What's intentionally NOT built (yours, Lane D)
|
||||||
|
|
||||||
|
The moving parts sit in the sockets I left for them: the **rotating platter/record**
|
||||||
|
(the empty circular well, radius `PLATTER.radius+2`, 6 deep, spindle stub at bottom),
|
||||||
|
the **tonearm arm + headshell** (base pedestal + empty socket anchor), the **fader
|
||||||
|
caps/sleds** (empty alley slots + crossfader tram), and the **buttons/switches**
|
||||||
|
(start/stop, 33/45, power — all left as recesses). Read `QUEST_POS` for exact voxels.
|
||||||
|
|
||||||
|
## Quest anchor convention (important for Lane D)
|
||||||
|
|
||||||
|
Each `QUEST_POS` voxel is a **reachable interaction anchor** (has an adjacent air
|
||||||
|
voxel — validated). Semantics per node:
|
||||||
|
- `stylus.socket` — empty voxel on the Deck A tonearm pedestal; drop the stylus here.
|
||||||
|
- `stylus.pickup` — the chrome stylus block in the parts bin (blue-spotlit).
|
||||||
|
- `rca.socket` — empty gold ring on the patch-bay wall face (air; push the plug in).
|
||||||
|
- `rca.plug` — the loose gold plug lying on the canyon floor (top is open air).
|
||||||
|
- `crossfader.jam` — **top-centre** of the dust plug in the tram (mine downward).
|
||||||
|
- `fuse.box` — the blown fuse (rubber block, chrome caps) in the wall fuse box.
|
||||||
|
- `fuse.pickup` — the fresh fuse (glass + chrome caps) in the parts bin.
|
||||||
|
- `power.switch` — recess on the mixer rear top (dark until the other four are done).
|
||||||
|
|
||||||
|
## Validation (headless + in-demo + cross-lane, all green)
|
||||||
|
|
||||||
|
build 14 ms (< 500) · deterministic (two runs byte-identical) · **0** OOB writes ·
|
||||||
|
both platter wells empty · spawn on solid steel with headroom · **8/8** quest points
|
||||||
|
reachable · fuse/stylus anchors land on the intended block · record crate populated ·
|
||||||
|
29/30 non-air block types · ~4.5 M non-air voxels (24.6% fill).
|
||||||
|
|
||||||
|
**Cross-lane smoke test** (buildBooth against Lane A's real `VoxelWorld`, not a mock):
|
||||||
|
`fillBox` fast path ENABLED · 30.7 ms build · all acceptance checks still pass.
|
||||||
|
|
||||||
|
## Post-review fixes (adversarial multi-agent pass, all verified)
|
||||||
|
|
||||||
|
- **[HIGH]** record crate placed **zero** vinyl slabs (footprint collapsed to ~2 voxels
|
||||||
|
because it was bounded by `deckA.minX`); re-anchored to the open under-ledge volume in
|
||||||
|
front of Deck A — now 8–9 records on edge. Added a regression guard.
|
||||||
|
- **[MED]** `QUEST_POS.fuse.box` pointed at the alu frame, one voxel off the fuse; the
|
||||||
|
blown fuse now sits **exactly** at that anchor (frame moved behind). Guarded.
|
||||||
|
- **[MED]** VU-tower LED ladders were sealed inside a closed casing; the front (DJ-facing)
|
||||||
|
face is now open so the meters read.
|
||||||
|
- **[LOW]** added the "resistor beams" the brief lists for the PCB rooms.
|
||||||
|
- **[LOW]** the demo's "Record Crate" teleport aimed at `x=12` (inside the left plywood
|
||||||
|
wall) and at the crate's old location; re-aimed at the crate's real centre.
|
||||||
|
- (A `fillBox` doc-comment nit was a false positive; tightened it regardless.)
|
||||||
|
|
||||||
|
## Contract friction / notes for the integrator
|
||||||
|
|
||||||
|
1. **Mixer height.** The brief says "tabletop → `topY`+40", but `constants.ts`
|
||||||
|
(`mixer.topY = 67`, "27 voxels tall") and DESIGN §3 both put the mixer face plate
|
||||||
|
TOP surface at `topY = 67`. I built to **face-plate-top = 67** (body 40–66) with a
|
||||||
|
raised rear ridge + 12-tall VU towers for the "tallest mesa" silhouette. If the
|
||||||
|
design truly wanted a 107-tall mixer, that's a one-line change in `buildMixer`.
|
||||||
|
2. **`fillBox` fast path — RESOLVED.** `tools.box()` uses
|
||||||
|
`world.fillBox(x0,y0,z0,x1,y1,z1,id)` (INCLUSIVE bounds) if present, else a
|
||||||
|
`setBlock` loop. Confirmed against Lane A's shipped `src/engine/VoxelWorld.ts:117`:
|
||||||
|
the signature and inclusive-clamp semantics **match exactly**, so the fast path
|
||||||
|
engages at integration (verified — see cross-lane smoke test above). No action needed.
|
||||||
|
3. **Elevation model.** A `Y_*` constant is the TOP-SURFACE PLANE: the topmost solid
|
||||||
|
block of that stratum is at index `Y_*-1`; blocks placed on the surface sit at
|
||||||
|
`Y_*`. Documented in `anchors.ts`. Platter well is carved 6 down from `Y_PLINTH_TOP`
|
||||||
|
so Lane D's platter (top at `Y_RECORD_TOP=85`) rises cleanly out of it.
|
||||||
|
4. **No duplicated coords.** Every spindle/mixer/patch-bay position derives from
|
||||||
|
`LAYOUT` via `anchors.ts` (single source). Lane D should read positions from
|
||||||
|
`QUEST_POS` / the exported anchors, never re-hardcode.
|
||||||
|
5. **Structural blocks are `breakable:false`** already in `blocks.ts` (plywood, cables)
|
||||||
|
— the booth keeps its shape; nothing here relies on that beyond honoring it.
|
||||||
120
src/worldgen/anchors.ts
Normal file
120
src/worldgen/anchors.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
// TURNCRAFT — Lane C anchors. Every LAYOUT-derived position lives here ONCE.
|
||||||
|
// The builder and questPositions.ts both import from this file so no spindle /
|
||||||
|
// mixer / patch-bay coordinate is ever written twice (grep-proof).
|
||||||
|
//
|
||||||
|
// Elevation model: a `Y_*` constant names the TOP-SURFACE PLANE of a stratum,
|
||||||
|
// i.e. the topmost solid block of that stratum is at index (Y_* - 1) and blocks
|
||||||
|
// placed ON the surface sit at index Y_*. So:
|
||||||
|
// tabletop top face = Y_TABLETOP (40) -> top tabletop block at 39
|
||||||
|
// plinth top face = Y_PLINTH_TOP (80)-> top plinth block at 79
|
||||||
|
// mixer face plate = LAYOUT.mixer.topY (67) -> top mixer block at 66
|
||||||
|
|
||||||
|
import { LAYOUT, Y_TABLETOP, Y_PLINTH_TOP } from '../core/constants';
|
||||||
|
import type { Vec3 } from '../core/types';
|
||||||
|
|
||||||
|
export interface DeckFootprint {
|
||||||
|
minX: number; maxX: number; minZ: number; maxZ: number;
|
||||||
|
spindleX: number; spindleZ: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move `d` voxels inward from a footprint edge toward the given interior ref. */
|
||||||
|
function inward(edge: number, interior: number, d: number): number {
|
||||||
|
return edge < interior ? edge + d : edge - d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckAnchors {
|
||||||
|
/** front-left start/stop press-plate recess (Lane D button sits here). */
|
||||||
|
startStop: Vec3;
|
||||||
|
/** the two little 33 / 45 speed-button recesses, just inboard of start/stop. */
|
||||||
|
speed33: Vec3;
|
||||||
|
speed45: Vec3;
|
||||||
|
/** midpoint of the pitch-fader canyon slot (green centre-detent dot here). */
|
||||||
|
pitch: Vec3;
|
||||||
|
/** centre of the 10x10 tonearm base pedestal (chrome post stub on top). */
|
||||||
|
tonearmBase: Vec3;
|
||||||
|
/** empty headshell socket on the pedestal — Lane D drops the stylus in here. */
|
||||||
|
headshell: Vec3;
|
||||||
|
/** front-left Technics pop-up strobe lamp pillar. */
|
||||||
|
strobeLamp: Vec3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deck furniture anchors, derived from the deck footprint. `mirror` flips the
|
||||||
|
* left/right hand so Deck B reads as a mirror image of Deck A.
|
||||||
|
*/
|
||||||
|
export function deckAnchors(f: DeckFootprint, mirror: boolean): DeckAnchors {
|
||||||
|
const leftX = mirror ? f.maxX : f.minX;
|
||||||
|
const rightX = mirror ? f.minX : f.maxX;
|
||||||
|
const frontZ = f.minZ, backZ = f.maxZ;
|
||||||
|
const lx = (d: number) => inward(leftX, f.spindleX, d);
|
||||||
|
const rx = (d: number) => inward(rightX, f.spindleX, d);
|
||||||
|
return {
|
||||||
|
startStop: [lx(11), Y_PLINTH_TOP, frontZ + 9],
|
||||||
|
speed33: [lx(11), Y_PLINTH_TOP, frontZ + 16],
|
||||||
|
speed45: [lx(16), Y_PLINTH_TOP, frontZ + 16],
|
||||||
|
pitch: [rx(8), Y_PLINTH_TOP, f.spindleZ],
|
||||||
|
tonearmBase: [rx(12), Y_PLINTH_TOP, backZ - 12],
|
||||||
|
headshell: [rx(12), Y_PLINTH_TOP + 2, backZ - 12],
|
||||||
|
strobeLamp: [lx(5), Y_PLINTH_TOP, frontZ + 4],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DECK_A = deckAnchors(LAYOUT.deckA, false);
|
||||||
|
export const DECK_B = deckAnchors(LAYOUT.deckB, true);
|
||||||
|
|
||||||
|
// ---- Mixer face-plate anchors ----------------------------------------------
|
||||||
|
|
||||||
|
const M = LAYOUT.mixer;
|
||||||
|
const mixCX = ((M.minX + M.maxX) / 2) | 0; // massif centre X
|
||||||
|
|
||||||
|
export const MIXER = {
|
||||||
|
faceTopY: M.topY,
|
||||||
|
centerX: mixCX,
|
||||||
|
/** X centres of the four channel strips (knob grid + fader alley each). */
|
||||||
|
channelX: [0, 1, 2, 3].map((i) => M.minX + 14 + i * 18) as number[],
|
||||||
|
/** crossfader tram: runs left-right along the front of the face plate. */
|
||||||
|
crossfaderZ: M.minZ + 6,
|
||||||
|
crossfaderHalfLen: 20, // 40 long total
|
||||||
|
crossfaderDepth: 6,
|
||||||
|
/** top-centre of the dust-jam plug the player mines out (quest 'crossfader'). */
|
||||||
|
crossfaderJam: [mixCX, M.topY - 1, M.minZ + 6] as Vec3,
|
||||||
|
/** two VU meter towers at the back edge. */
|
||||||
|
vuTowerX: [M.minX + 18, M.maxX - 18] as number[],
|
||||||
|
vuTowerZ: M.maxZ - 7,
|
||||||
|
vuHeight: 12,
|
||||||
|
/** master power switch recess on the rear top edge (quest 'power'). */
|
||||||
|
powerSwitch: [M.maxX - 9, M.topY, M.maxZ - 3] as Vec3,
|
||||||
|
/** fuse-door slot on the rear that drops a chimney into the fuse room. */
|
||||||
|
fuseChimney: [M.minX + 24, M.maxZ - 8] as [number, number], // (x,z)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---- Under-table (PCB Depths) anchors, y in [2,35] --------------------------
|
||||||
|
|
||||||
|
export const UNDER = {
|
||||||
|
/** parts bin tray floor centre (holds the fresh fuse + the stylus block). */
|
||||||
|
partsBin: [M.minX + 15, 4, M.minZ + 10] as Vec3,
|
||||||
|
stylusPickup: [M.minX + 12, 5, M.minZ + 8] as Vec3,
|
||||||
|
fusePickup: [M.minX + 18, 5, M.minZ + 8] as Vec3,
|
||||||
|
/** blown fuse in the wall-mounted fuse box under the mixer (quest 'fuse'). */
|
||||||
|
fuseBox: [M.minX + 24, 8, M.maxZ - 8] as Vec3,
|
||||||
|
/** front-left tabletop hatch down into the record crate. */
|
||||||
|
crateHatch: [LAYOUT.deckA.minX - 2, Y_TABLETOP, LAYOUT.frontLipZ - 12] as Vec3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---- Patch-bay socket grid (on the back wall) ------------------------------
|
||||||
|
|
||||||
|
const P = LAYOUT.patchBay;
|
||||||
|
export const PATCH = {
|
||||||
|
cols: 6,
|
||||||
|
rows: 2,
|
||||||
|
colX: (i: number) => P.minX + 8 + i * (((P.maxX - P.minX - 16) / 5) | 0),
|
||||||
|
rowY: (j: number) => P.minY + 6 + j * 14,
|
||||||
|
frontZ: LAYOUT.backWallZ - 1, // the wall's canyon-facing face
|
||||||
|
emptyCol: 3, // the conspicuously empty quest socket
|
||||||
|
emptyRow: 0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** World coord of the empty quest RCA socket ring on the wall face. */
|
||||||
|
export const RCA_SOCKET: Vec3 = [PATCH.colX(PATCH.emptyCol), PATCH.rowY(PATCH.emptyRow), PATCH.frontZ];
|
||||||
|
/** The loose gold plug resting on the canyon floor, short of that socket. */
|
||||||
|
export const RCA_PLUG: Vec3 = [PATCH.colX(PATCH.emptyCol), Y_TABLETOP, LAYOUT.backWallZ - 8];
|
||||||
458
src/worldgen/buildBooth.ts
Normal file
458
src/worldgen/buildBooth.ts
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
// TURNCRAFT — Lane C: the World Builder.
|
||||||
|
// buildBooth(world) writes every STATIC voxel of the DJ booth diorama:
|
||||||
|
// plywood shell, tabletop, two turntable plinths, the mixer massif, the cable
|
||||||
|
// canyon, the patch-bay wall, and the PCB Depths under the table.
|
||||||
|
//
|
||||||
|
// What we do NOT build: anything that moves (platter disc, tonearm, fader caps
|
||||||
|
// and sleds, buttons) — those are Lane D machines. We build the SOCKETS they
|
||||||
|
// sit in (platter wells, empty fader slots, button recesses, the tonearm base,
|
||||||
|
// the empty patch-bay socket) at the coordinates in ./anchors + ./questPositions.
|
||||||
|
//
|
||||||
|
// Determinism: one fixed-seed PRNG for the whole build → identical every run.
|
||||||
|
|
||||||
|
import type { IVoxelWorld, Vec3 } from '../core/types';
|
||||||
|
import { AIR, BLOCK_BY_NAME } from '../core/blocks';
|
||||||
|
import {
|
||||||
|
WORLD_X, WORLD_Y, WORLD_Z,
|
||||||
|
Y_TABLETOP, Y_PLINTH_TOP, Y_BOOTH_WALL_TOP,
|
||||||
|
LAYOUT, PLATTER,
|
||||||
|
} from '../core/constants';
|
||||||
|
import { box, hollowBox, cylinderY, sphere, spline, mulberry32, randInt, setVox } from './tools';
|
||||||
|
import { DECK_A, DECK_B, MIXER, UNDER, PATCH, RCA_PLUG, type DeckAnchors, type DeckFootprint } from './anchors';
|
||||||
|
import { QUEST_POS } from './questPositions';
|
||||||
|
|
||||||
|
// ---- block palette (resolve names -> ids once) -----------------------------
|
||||||
|
|
||||||
|
function bid(name: string): number {
|
||||||
|
const d = BLOCK_BY_NAME.get(name);
|
||||||
|
if (!d) throw new Error(`worldgen: unknown block '${name}'`);
|
||||||
|
return d.id;
|
||||||
|
}
|
||||||
|
const T = {
|
||||||
|
air: AIR,
|
||||||
|
ply: bid('plywood'), plyEdge: bid('ply_edge'),
|
||||||
|
alu: bid('brushed_alu'), steel: bid('steel_grey'), black: bid('matte_black'),
|
||||||
|
rubber: bid('rubber'), felt: bid('slipmat_felt'),
|
||||||
|
vinylK: bid('vinyl_black'), vinylB: bid('vinyl_blue'), label: bid('label_cream'),
|
||||||
|
chrome: bid('chrome'), knobK: bid('knob_black'), knobS: bid('knob_silver'), fader: bid('fader_cap'),
|
||||||
|
pcb: bid('pcb_green'), copper: bid('copper'), solder: bid('solder'),
|
||||||
|
ledR: bid('led_red'), ledG: bid('led_green'), ledA: bid('led_amber'), ledB: bid('led_blue'),
|
||||||
|
glass: bid('glass'), cableK: bid('cable_black'), cableR: bid('cable_red'), cableW: bid('cable_white'),
|
||||||
|
dust: bid('dust'), mesh: bid('speaker_mesh'), strobe: bid('strobe_dot'), gold: bid('rca_gold'), screw: bid('screw'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derived elevations (see anchors.ts for the top-surface-plane model).
|
||||||
|
const RIM = LAYOUT.rimThickness; // 16
|
||||||
|
const WALL_TOP = Y_BOOTH_WALL_TOP - 1; // 139 (top solid wall block)
|
||||||
|
const BACK_Z0 = LAYOUT.backWallZ; // 200
|
||||||
|
const BACK_Z1 = BACK_Z0 + RIM - 1; // 215
|
||||||
|
const TABLE_Y0 = Y_TABLETOP - 4; // 36 (tabletop slab is 4 thick)
|
||||||
|
const TABLE_Y1 = Y_TABLETOP - 1; // 39 (its top solid block; top face = 40)
|
||||||
|
const PLINTH_Y1 = Y_PLINTH_TOP - 1; // 79 (top solid block; top face = 80)
|
||||||
|
const MIX_Y1 = MIXER.faceTopY - 1; // 66 (top mixer block; face plate = 67)
|
||||||
|
const UNDER_FLOOR = 2; // PCB green floor sits here
|
||||||
|
|
||||||
|
// ---- vertical edge-disc helper (records stand on edge in the crate) --------
|
||||||
|
function discX(w: IVoxelWorld, x: number, cy: number, cz: number, r: number, id: number, thick: number): void {
|
||||||
|
const ri = Math.ceil(r), r2 = r * r;
|
||||||
|
for (let dy = -ri; dy <= ri; dy++)
|
||||||
|
for (let dz = -ri; dz <= ri; dz++)
|
||||||
|
if (dy * dy + dz * dz <= r2)
|
||||||
|
for (let t = 0; t < thick; t++) setVox(w, x + t, cy + dy, cz + dz, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// C1 — Shell & tabletop
|
||||||
|
// =============================================================================
|
||||||
|
export function buildShell(w: IVoxelWorld): void {
|
||||||
|
// Floor slab across the whole world (2 thick).
|
||||||
|
box(w, 0, 0, 0, WORLD_X - 1, 1, WORLD_Z - 1, T.ply);
|
||||||
|
|
||||||
|
// Four plywood walls up to the booth wall top.
|
||||||
|
box(w, 0, 0, 0, RIM - 1, WALL_TOP, WORLD_Z - 1, T.ply); // left (-X)
|
||||||
|
box(w, WORLD_X - RIM, 0, 0, WORLD_X - 1, WALL_TOP, WORLD_Z - 1, T.ply); // right (+X)
|
||||||
|
box(w, 0, 0, 0, WORLD_X - 1, WALL_TOP, RIM - 1, T.ply); // front (-Z)
|
||||||
|
box(w, 0, 0, BACK_Z0, WORLD_X - 1, WALL_TOP, BACK_Z1, T.ply); // back wall (thick)
|
||||||
|
|
||||||
|
// ply_edge laminate cap on every wall top (that plywood-edge look).
|
||||||
|
box(w, 0, WALL_TOP, 0, RIM - 1, WALL_TOP, WORLD_Z - 1, T.plyEdge);
|
||||||
|
box(w, WORLD_X - RIM, WALL_TOP, 0, WORLD_X - 1, WALL_TOP, WORLD_Z - 1, T.plyEdge);
|
||||||
|
box(w, 0, WALL_TOP, 0, WORLD_X - 1, WALL_TOP, RIM - 1, T.plyEdge);
|
||||||
|
box(w, 0, WALL_TOP, BACK_Z0, WORLD_X - 1, WALL_TOP, BACK_Z1, T.plyEdge);
|
||||||
|
|
||||||
|
// Tabletop slab inside the rim, front lip back to the back wall front face.
|
||||||
|
box(w, RIM, TABLE_Y0, RIM, WORLD_X - 1 - RIM, TABLE_Y1, BACK_Z0 - 1, T.ply);
|
||||||
|
// ply_edge stripe along the exposed front cut edge of the tabletop.
|
||||||
|
box(w, RIM, TABLE_Y1, RIM, WORLD_X - 1 - RIM, TABLE_Y1, RIM + 1, T.plyEdge);
|
||||||
|
|
||||||
|
// Front safety lip / headphone ledge curb.
|
||||||
|
box(w, RIM, Y_TABLETOP, LAYOUT.frontLipZ - 1, WORLD_X - 1 - RIM, Y_TABLETOP + 2, LAYOUT.frontLipZ, T.ply);
|
||||||
|
box(w, RIM, Y_TABLETOP + 3, LAYOUT.frontLipZ - 1, WORLD_X - 1 - RIM, Y_TABLETOP + 3, LAYOUT.frontLipZ, T.plyEdge);
|
||||||
|
|
||||||
|
// Two chrome hinge plates + screws on the back wall (photo detail).
|
||||||
|
for (const hx of [RIM + 40, WORLD_X - RIM - 52]) {
|
||||||
|
box(w, hx, WALL_TOP - 30, BACK_Z0 - 1, hx + 11, WALL_TOP - 14, BACK_Z0 - 1, T.alu);
|
||||||
|
for (const sy of [WALL_TOP - 28, WALL_TOP - 16])
|
||||||
|
for (const sx of [hx + 2, hx + 9]) setVox(w, sx, sy, BACK_Z0 - 1, T.screw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// C2 — Decks A & B (steel-grey mesas)
|
||||||
|
// =============================================================================
|
||||||
|
function buildDeck(w: IVoxelWorld, f: DeckFootprint, a: DeckAnchors, rng: () => number, isB: boolean): void {
|
||||||
|
// Plinth solid block.
|
||||||
|
box(w, f.minX, Y_TABLETOP, f.minZ, f.maxX, PLINTH_Y1, f.maxZ, T.steel);
|
||||||
|
|
||||||
|
// Chamfer the four vertical corners (2x2 columns -> rounded silhouette).
|
||||||
|
for (const cx of [f.minX, f.maxX - 1]) for (const cz of [f.minZ, f.maxZ - 1])
|
||||||
|
box(w, cx, Y_TABLETOP, cz, cx + (cx === f.minX ? 1 : 0), PLINTH_Y1, cz + (cz === f.minZ ? 1 : 0), T.air);
|
||||||
|
|
||||||
|
// Rubber feet at the base corners.
|
||||||
|
for (const cx of [f.minX, f.maxX - 2]) for (const cz of [f.minZ, f.maxZ - 2])
|
||||||
|
box(w, cx, Y_TABLETOP, cz, cx + 1, Y_TABLETOP + 1, cz + 1, T.rubber);
|
||||||
|
|
||||||
|
// brushed_alu top-face border (2-wide frame around the plinth top).
|
||||||
|
box(w, f.minX, PLINTH_Y1, f.minZ, f.maxX, PLINTH_Y1, f.minZ + 1, T.alu);
|
||||||
|
box(w, f.minX, PLINTH_Y1, f.maxZ - 1, f.maxX, PLINTH_Y1, f.maxZ, T.alu);
|
||||||
|
box(w, f.minX, PLINTH_Y1, f.minZ, f.minX + 1, PLINTH_Y1, f.maxZ, T.alu);
|
||||||
|
box(w, f.maxX - 1, PLINTH_Y1, f.minZ, f.maxX, PLINTH_Y1, f.maxZ, T.alu);
|
||||||
|
|
||||||
|
// Platter well: recessed empty pit for Lane D's rotating platter.
|
||||||
|
const wellR = PLATTER.radius + 2;
|
||||||
|
cylinderY(w, f.spindleX, f.spindleZ, Y_PLINTH_TOP - 6, PLINTH_Y1, wellR, T.air);
|
||||||
|
// Chrome spindle stub at the well centre (1-voxel base + short post).
|
||||||
|
box(w, f.spindleX, Y_PLINTH_TOP - 6, f.spindleZ, f.spindleX, Y_PLINTH_TOP - 4, f.spindleZ, T.chrome);
|
||||||
|
|
||||||
|
// --- deck-top furniture (voxel, non-moving) ---
|
||||||
|
// start/stop press-plate recess (4x4x1) — Lane D button.
|
||||||
|
box(w, a.startStop[0] - 2, PLINTH_Y1, a.startStop[2] - 2, a.startStop[0] + 1, PLINTH_Y1, a.startStop[2] + 1, T.air);
|
||||||
|
// 33 / 45 round button recesses (2x2 each).
|
||||||
|
for (const s of [a.speed33, a.speed45]) box(w, s[0] - 1, PLINTH_Y1, s[2] - 1, s[0], PLINTH_Y1, s[2], T.air);
|
||||||
|
|
||||||
|
// pitch-fader canyon: 18 long (Z) x 3 wide (X) x 4 deep, green centre detent.
|
||||||
|
box(w, a.pitch[0] - 1, Y_PLINTH_TOP - 4, a.pitch[2] - 9, a.pitch[0] + 1, PLINTH_Y1, a.pitch[2] + 8, T.air);
|
||||||
|
setVox(w, a.pitch[0], Y_PLINTH_TOP - 4, a.pitch[2], T.ledG);
|
||||||
|
|
||||||
|
// tonearm base: 10x10 brushed_alu pedestal raised 2 above the plinth top,
|
||||||
|
// with a 3-voxel chrome post stub; the headshell socket is left EMPTY.
|
||||||
|
const [tbx, , tbz] = a.tonearmBase;
|
||||||
|
box(w, tbx - 4, Y_PLINTH_TOP, tbz - 4, tbx + 5, Y_PLINTH_TOP + 1, tbz + 5, T.alu);
|
||||||
|
box(w, tbx + 3, Y_PLINTH_TOP + 2, tbz + 3, tbx + 3, Y_PLINTH_TOP + 4, tbz + 3, T.chrome);
|
||||||
|
// headshell socket ring (open on top so Lane D drops the stylus in).
|
||||||
|
const [hsx, hsy, hsz] = a.headshell;
|
||||||
|
for (const [ox, oz] of [[-1, 0], [1, 0], [0, -1], [0, 1]] as const) setVox(w, hsx + ox, hsy, hsz + oz, T.alu);
|
||||||
|
setVox(w, hsx, hsy, hsz, T.air);
|
||||||
|
|
||||||
|
// Technics pop-up strobe lamp pillar (front-left corner).
|
||||||
|
const [slx, , slz] = a.strobeLamp;
|
||||||
|
box(w, slx, Y_PLINTH_TOP, slz, slx, Y_PLINTH_TOP + 2, slz, T.strobe);
|
||||||
|
setVox(w, slx, Y_PLINTH_TOP + 3, slz, T.ledR);
|
||||||
|
|
||||||
|
// --- Deck B only: dust drifts + half-open glass dust cover ---
|
||||||
|
if (isB) {
|
||||||
|
for (let i = 0; i < 90; i++) {
|
||||||
|
const dx = randInt(rng, f.minX + 2, f.maxX - 2);
|
||||||
|
const dz = randInt(rng, f.minZ + 2, f.maxZ - 2);
|
||||||
|
// only bank dust on the solid plinth top (skip the empty platter well)
|
||||||
|
const inWell = (dx - f.spindleX) ** 2 + (dz - f.spindleZ) ** 2 < wellR * wellR;
|
||||||
|
if (inWell) continue;
|
||||||
|
const h = randInt(rng, 0, 2);
|
||||||
|
box(w, dx, Y_PLINTH_TOP, dz, dx, Y_PLINTH_TOP + h, dz, T.dust);
|
||||||
|
}
|
||||||
|
// glass dust cover canopy over the rear half, hinged at the back edge.
|
||||||
|
const coverY = Y_PLINTH_TOP + 18;
|
||||||
|
box(w, f.minX + 4, coverY, f.spindleZ, f.maxX - 4, coverY + 1, f.maxZ - 2, T.glass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeckA(w: IVoxelWorld, rng: () => number): void { buildDeck(w, LAYOUT.deckA, DECK_A, rng, false); }
|
||||||
|
export function buildDeckB(w: IVoxelWorld, rng: () => number): void { buildDeck(w, LAYOUT.deckB, DECK_B, rng, true); }
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// C3 — Mixer Massif
|
||||||
|
// =============================================================================
|
||||||
|
export function buildMixer(w: IVoxelWorld, rng: () => number): void {
|
||||||
|
const M = LAYOUT.mixer;
|
||||||
|
// Body.
|
||||||
|
box(w, M.minX, Y_TABLETOP, M.minZ, M.maxX, MIX_Y1, M.maxZ, T.black);
|
||||||
|
// Raised rear ridge (gives the massif its "tallest mesa" silhouette).
|
||||||
|
box(w, M.minX, MIXER.faceTopY, M.maxZ - 5, M.maxX, MIXER.faceTopY + 4, M.maxZ, T.black);
|
||||||
|
// brushed_alu face-plate border.
|
||||||
|
box(w, M.minX, MIX_Y1, M.minZ, M.maxX, MIX_Y1, M.minZ + 1, T.alu);
|
||||||
|
box(w, M.minX, MIX_Y1, M.minZ, M.minX + 1, MIX_Y1, M.maxZ, T.alu);
|
||||||
|
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) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four channel strips: 3x3 knob-stem grid (back) + fader alley (front).
|
||||||
|
for (const cx of MIXER.channelX) {
|
||||||
|
for (let gx = -1; gx <= 1; gx++) for (let gz = -1; gz <= 1; gz++)
|
||||||
|
// top row = silver gain trims, lower rows = black EQ knobs
|
||||||
|
setVox(w, cx + gx * 2, MIXER.faceTopY, M.maxZ - 20 + gz * 2, gz === -1 ? T.knobS : T.knobK);
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crossfader tram along the front, with the quest dust-jam plug at centre.
|
||||||
|
const trZ = MIXER.crossfaderZ, half = MIXER.crossfaderHalfLen, dep = MIXER.crossfaderDepth;
|
||||||
|
const trBot = MIXER.faceTopY - dep; // slot floor (6 deep)
|
||||||
|
box(w, MIXER.centerX - half, trBot + 1, trZ - 1, MIXER.centerX + half, MIX_Y1, trZ + 2, T.air);
|
||||||
|
const [jx, jy, jz] = QUEST_POS.crossfader.jam; // jy = MIX_Y1: the plug's TOP, open to air
|
||||||
|
box(w, jx - 6, trBot + 1, jz - 1, jx + 5, jy, jz + 2, T.dust); // the 12-wide dust jam
|
||||||
|
|
||||||
|
// Two VU meter towers at the back edge (bottom-to-top LED gradient).
|
||||||
|
for (const vx of MIXER.vuTowerX) {
|
||||||
|
hollowBox(w, vx - 2, MIXER.faceTopY, MIXER.vuTowerZ - 2, vx + 2, MIXER.faceTopY + MIXER.vuHeight, MIXER.vuTowerZ + 2, T.black);
|
||||||
|
// open the front (-Z, DJ-facing) face so the LED ladder isn't occluded.
|
||||||
|
box(w, vx - 1, MIXER.faceTopY + 1, MIXER.vuTowerZ - 2, vx + 1, MIXER.faceTopY + MIXER.vuHeight - 1, MIXER.vuTowerZ - 2, T.air);
|
||||||
|
for (let i = 0; i < MIXER.vuHeight; i++) {
|
||||||
|
const id = i < 8 ? T.ledG : i < 11 ? T.ledA : T.ledR;
|
||||||
|
setVox(w, vx, MIXER.faceTopY + 1 + i, MIXER.vuTowerZ, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Master power switch recess on the rear top (Lane D machine sits here).
|
||||||
|
const [px, py, pz] = QUEST_POS.power.switch;
|
||||||
|
box(w, px - 1, py, pz - 1, px + 1, py, pz + 1, T.air);
|
||||||
|
setVox(w, px, py + 1, pz, T.ledR); // dark-until-finale indicator
|
||||||
|
|
||||||
|
// Fuse-door slot on the rear that drops a chimney into the fuse room.
|
||||||
|
const [fcx, fcz] = MIXER.fuseChimney;
|
||||||
|
box(w, fcx - 1, UNDER_FLOOR + 1, fcz - 1, fcx + 1, MIX_Y1, fcz + 1, T.air);
|
||||||
|
void rng;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// C4 — Cable Canyon (z 140..200)
|
||||||
|
// =============================================================================
|
||||||
|
export function buildCableCanyon(w: IVoxelWorld, rng: () => number): void {
|
||||||
|
const M = LAYOUT.mixer;
|
||||||
|
const rearA = LAYOUT.deckA.maxZ + 2, rearB = LAYOUT.deckB.maxZ + 2, rearM = M.maxZ + 2;
|
||||||
|
const floor = Y_TABLETOP; // canyon floor = tabletop top face
|
||||||
|
|
||||||
|
// Power strip on the canyon floor (matte_black box + amber pilot light).
|
||||||
|
const psx = MIXER.centerX, psz = BACK_Z0 - 34;
|
||||||
|
box(w, psx - 8, floor, psz, psx + 8, floor + 3, psz + 4, T.black);
|
||||||
|
setVox(w, psx - 6, floor + 4, psz + 2, T.ledA);
|
||||||
|
|
||||||
|
// Helper: a drooping cable from a rear panel point to a patch-bay socket.
|
||||||
|
const cable = (from: Vec3, toCol: number, toRow: number, sheath: number) => {
|
||||||
|
const to: Vec3 = [PATCH.colX(toCol), PATCH.rowY(toRow), BACK_Z0 - 2];
|
||||||
|
const mid: Vec3 = [(from[0] + to[0]) / 2, floor + randInt(rng, 1, 4), (from[2] + to[2]) / 2];
|
||||||
|
spline(w, [from, mid, to], T.cableK, 1);
|
||||||
|
// colored sheath + gold RCA plug head (3x3x4) near the socket end.
|
||||||
|
spline(w, [[to[0], to[1], to[2] - 10], to], sheath, 1);
|
||||||
|
box(w, to[0] - 1, to[1] - 1, to[2] - 3, to[0] + 1, to[1] + 1, to[2], T.gold);
|
||||||
|
};
|
||||||
|
cable([LAYOUT.deckA.spindleX + 24, floor + 8, rearA], 0, 0, T.cableR);
|
||||||
|
cable([LAYOUT.deckA.spindleX - 8, floor + 8, rearA], 1, 1, T.cableW);
|
||||||
|
cable([M.minX + 20, floor + 6, rearM], 2, 1, T.cableR);
|
||||||
|
cable([M.maxX - 20, floor + 6, rearM], 4, 0, T.cableW);
|
||||||
|
cable([LAYOUT.deckB.spindleX + 8, floor + 8, rearB], 5, 1, T.cableW);
|
||||||
|
|
||||||
|
// THE quest cable: runs from the power strip but ENDS short of the patch bay,
|
||||||
|
// its gold plug lying on the canyon floor by the empty socket. The plug is
|
||||||
|
// low (air above the anchor) so the player can reach and push it home.
|
||||||
|
const [gpx, gpy, gpz] = RCA_PLUG;
|
||||||
|
spline(w, [[psx + 6, floor + 3, psz + 2], [gpx, floor + randInt(rng, 2, 3), gpz - 20], [gpx, gpy + 1, gpz - 3]], T.cableR, 1);
|
||||||
|
box(w, gpx - 1, gpy, gpz - 1, gpx + 1, gpy, gpz + 1, T.gold); // flat plug base (anchor voxel, air above)
|
||||||
|
box(w, gpx - 1, gpy, gpz + 2, gpx + 1, gpy + 1, gpz + 2, T.gold); // plug barrel, pointing at the socket
|
||||||
|
|
||||||
|
// Dust drifts + a couple of leaning vinyl off-cut ramps against the walls.
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
const dx = randInt(rng, RIM + 2, WORLD_X - RIM - 2);
|
||||||
|
const dz = randInt(rng, LAYOUT.deckA.maxZ + 4, BACK_Z0 - 2);
|
||||||
|
if (dz < rearM && dx > M.minX && dx < M.maxX) continue; // keep off the gear
|
||||||
|
box(w, dx, floor, dz, dx, floor + randInt(rng, 0, 1), dz, T.dust);
|
||||||
|
}
|
||||||
|
for (const [rx, rz] of [[RIM + 4, 150], [WORLD_X - RIM - 8, 168]] as const)
|
||||||
|
discX(w, rx, floor + 10, rz, 12, i(rng) ? T.vinylB : T.vinylK, 2);
|
||||||
|
}
|
||||||
|
function i(rng: () => number): boolean { return rng() > 0.5; }
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// C5 — Patch Bay (back wall)
|
||||||
|
// =============================================================================
|
||||||
|
export function buildPatchBay(w: IVoxelWorld, rng: () => number): void {
|
||||||
|
const P = LAYOUT.patchBay;
|
||||||
|
const face = BACK_Z0 - 1; // canyon-facing wall face
|
||||||
|
// brushed_alu panel proud of the wall by 2.
|
||||||
|
box(w, P.minX - 2, P.minY - 2, BACK_Z0 - 2, P.maxX + 2, P.maxY + 2, face, T.alu);
|
||||||
|
|
||||||
|
const emptyCol = PATCH.emptyCol, emptyRow = PATCH.emptyRow;
|
||||||
|
const doorways = [[0, 0], [PATCH.cols - 1, PATCH.rows - 1]]; // two are real doorways
|
||||||
|
const plugColors = [T.cableR, T.cableW, T.cableK];
|
||||||
|
|
||||||
|
for (let c = 0; c < PATCH.cols; c++) {
|
||||||
|
for (let r = 0; r < PATCH.rows; r++) {
|
||||||
|
const cx = PATCH.colX(c), cy = PATCH.rowY(r);
|
||||||
|
// gold socket ring (3x3, hollow centre), hole bored 2 deep into the wall.
|
||||||
|
for (let ox = -1; ox <= 1; ox++) for (let oy = -1; oy <= 1; oy++)
|
||||||
|
if (ox || oy) setVox(w, cx + ox, cy + oy, face, T.gold);
|
||||||
|
box(w, cx, cy, BACK_Z0, cx, cy, BACK_Z0 + 1, T.air);
|
||||||
|
|
||||||
|
const isDoor = doorways.some(([dc, dr]) => dc === c && dr === r);
|
||||||
|
const isEmpty = c === emptyCol && r === emptyRow;
|
||||||
|
if (isDoor) {
|
||||||
|
// 3x3 tunnel 5 deep into a small copper-busbar room with a blue glow.
|
||||||
|
box(w, cx - 1, cy - 1, BACK_Z0, cx + 1, cy + 1, BACK_Z0 + 5, T.air);
|
||||||
|
box(w, cx - 3, cy - 3, BACK_Z0 + 5, cx + 3, cy + 3, BACK_Z0 + 9, T.air);
|
||||||
|
for (let bz = BACK_Z0 + 6; bz <= BACK_Z0 + 8; bz++) { setVox(w, cx - 2, cy - 2, bz, T.copper); setVox(w, cx + 2, cy + 2, bz, T.copper); }
|
||||||
|
setVox(w, cx, cy + 2, BACK_Z0 + 7, T.ledB);
|
||||||
|
} else if (isEmpty) {
|
||||||
|
// the conspicuously empty quest socket + a red blink block above it.
|
||||||
|
setVox(w, cx, cy + 2, face, T.ledR);
|
||||||
|
} else {
|
||||||
|
// plugged: fill the bore with a cable-colored plug.
|
||||||
|
setVox(w, cx, cy, BACK_Z0, plugColors[randInt(rng, 0, plugColors.length - 1)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// C6 — PCB Depths & the record crate (under-table, y 2..35)
|
||||||
|
// =============================================================================
|
||||||
|
export function buildUnderTable(w: IVoxelWorld, rng: () => number): void {
|
||||||
|
const M = LAYOUT.mixer;
|
||||||
|
|
||||||
|
// Carve the ways down: a vent shaft behind each deck, the fuse chimney is
|
||||||
|
// already open (mixer), and a front-left hatch into the crate with a stair.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
const [hx, , hz] = UNDER.crateHatch;
|
||||||
|
box(w, hx, UNDER_FLOOR + 1, hz - 2, hx + 5, TABLE_Y1, hz + 3, T.air); // 6x6 hatch
|
||||||
|
for (let s = 0; s < 6; s++) // plywood step-stair down into the crate
|
||||||
|
box(w, hx + s, UNDER_FLOOR + 1 + s * 5, hz - 2, hx + s, UNDER_FLOOR + 3 + s * 5, hz + 3, T.ply);
|
||||||
|
|
||||||
|
// PCB rooms under each deck + the mixer: green floor, copper traces, caps.
|
||||||
|
const rooms = [
|
||||||
|
{ minX: LAYOUT.deckA.minX, maxX: LAYOUT.deckA.maxX, minZ: LAYOUT.deckA.minZ, maxZ: LAYOUT.deckA.maxZ },
|
||||||
|
{ minX: LAYOUT.deckB.minX, maxX: LAYOUT.deckB.maxX, minZ: LAYOUT.deckB.minZ, maxZ: LAYOUT.deckB.maxZ },
|
||||||
|
{ minX: M.minX, maxX: M.maxX, minZ: M.minZ, maxZ: M.maxZ },
|
||||||
|
];
|
||||||
|
for (const rm of rooms) {
|
||||||
|
box(w, rm.minX, UNDER_FLOOR, rm.minZ, rm.maxX, UNDER_FLOOR, rm.maxZ, T.pcb); // green floor
|
||||||
|
// copper trace paths (a few 1-wide runs across the board).
|
||||||
|
for (let n = 0; n < 5; n++) {
|
||||||
|
const tz = randInt(rng, rm.minZ + 2, rm.maxZ - 2);
|
||||||
|
box(w, rm.minX + 2, UNDER_FLOOR + 1, tz, rm.maxX - 2, UNDER_FLOOR + 1, tz, T.copper);
|
||||||
|
}
|
||||||
|
// capacitor pillars (matte_black body, alu cross-scored top).
|
||||||
|
for (let n = 0; n < 6; n++) {
|
||||||
|
const px = randInt(rng, rm.minX + 4, rm.maxX - 4), pz = randInt(rng, rm.minZ + 4, rm.maxZ - 4);
|
||||||
|
const h = randInt(rng, 6, 14);
|
||||||
|
cylinderY(w, px, pz, UNDER_FLOOR + 1, UNDER_FLOOR + h, 2, T.black);
|
||||||
|
cylinderY(w, px, pz, UNDER_FLOOR + h + 1, UNDER_FLOOR + h + 1, 2, T.alu);
|
||||||
|
setVox(w, px, UNDER_FLOOR + h + 1, pz, T.copper);
|
||||||
|
if (rng() > 0.6) setVox(w, px + 2, UNDER_FLOOR + 2, pz, rng() > 0.5 ? T.ledG : T.ledA); // sparse LED
|
||||||
|
}
|
||||||
|
// solder-blob stalagmites (shrinking discs).
|
||||||
|
for (let n = 0; n < 8; n++) {
|
||||||
|
const sx = randInt(rng, rm.minX + 3, rm.maxX - 3), sz = randInt(rng, rm.minZ + 3, rm.maxZ - 3);
|
||||||
|
for (let k = 0; k < 3; k++) cylinderY(w, sx, sz, UNDER_FLOOR + 1 + k, UNDER_FLOOR + 1 + k, 2 - k, T.solder);
|
||||||
|
}
|
||||||
|
// resistor beams bridging gaps (raised copper spans with a dark colour band).
|
||||||
|
for (let n = 0; n < 3; n++) {
|
||||||
|
const bz = randInt(rng, rm.minZ + 4, rm.maxZ - 4), by = UNDER_FLOOR + randInt(rng, 4, 9);
|
||||||
|
const bx0 = randInt(rng, rm.minX + 3, rm.minX + 10);
|
||||||
|
box(w, bx0, by, bz, Math.min(bx0 + randInt(rng, 8, 16), rm.maxX - 3), by, bz, T.copper);
|
||||||
|
setVox(w, bx0 + 3, by, bz, T.rubber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fuse room under the mixer: fuse box + parts bin (the two quest pickups) ---
|
||||||
|
// The blown fuse sits EXACTLY at QUEST_POS.fuse.box (front air for reach), the
|
||||||
|
// alu frame is one voxel behind it so Lane D's interactive fuse lands on target.
|
||||||
|
const [fbx, fby, fbz] = QUEST_POS.fuse.box;
|
||||||
|
box(w, fbx - 2, fby - 2, fbz + 1, fbx + 2, fby + 2, fbz + 1, T.alu); // wall-mounted frame (behind)
|
||||||
|
setVox(w, fbx - 1, fby, fbz, T.chrome); // blown fuse assembly:
|
||||||
|
setVox(w, fbx, fby, fbz, T.rubber); // rubber "blown" fuse == QUEST_POS.fuse.box
|
||||||
|
setVox(w, fbx + 1, fby, fbz, T.chrome); // flanked by chrome caps
|
||||||
|
|
||||||
|
// Parts bin tray (open plywood) with the fresh fuse + the stylus block.
|
||||||
|
const [bx, by, bz] = UNDER.partsBin;
|
||||||
|
hollowBox(w, bx - 3, by - 1, bz - 2, bx + 5, by + 1, bz + 2, T.ply);
|
||||||
|
box(w, bx - 2, by, bz - 1, bx + 4, by, bz + 1, T.air); // hollow the tray
|
||||||
|
const [spx, spy, spz] = QUEST_POS.stylus.pickup;
|
||||||
|
setVox(w, spx, spy - 1, spz, T.felt); // felt pad
|
||||||
|
setVox(w, spx, spy, spz, T.chrome); // the stylus block
|
||||||
|
setVox(w, spx, spy + 2, spz, T.ledB); // spotlight
|
||||||
|
const [fpx, fpy, fpz] = QUEST_POS.fuse.pickup;
|
||||||
|
setVox(w, fpx, fpy, fpz, T.chrome);
|
||||||
|
setVox(w, fpx + 1, fpy, fpz, T.glass); // fresh fuse (glass + chrome cap)
|
||||||
|
setVox(w, fpx + 2, fpy, fpz, T.chrome);
|
||||||
|
|
||||||
|
// --- Record crate (front-left under-ledge, below the hatch): vinyl slabs on edge ---
|
||||||
|
// Sits in the open under-table volume in FRONT of Deck A (z < deckA.minZ), so it
|
||||||
|
// has real width for a row of records rather than the sliver deckA.minX allowed.
|
||||||
|
const cX0 = RIM + 2, cX1 = LAYOUT.deckA.spindleX - 6;
|
||||||
|
const cZ0 = RIM + 2, cZ1 = LAYOUT.frontLipZ + 4;
|
||||||
|
box(w, cX0, UNDER_FLOOR, cZ0, cX1, UNDER_FLOOR, cZ1, T.ply); // crate floor
|
||||||
|
const cz = (cZ0 + cZ1) >> 1, cy = UNDER_FLOOR + 13;
|
||||||
|
for (let s = 0; s < 10; s++) {
|
||||||
|
const sx = cX0 + 8 + s * 5;
|
||||||
|
if (sx > cX1 - 6) break;
|
||||||
|
discX(w, sx, cy, cz, 12, s % 2 ? T.vinylB : T.vinylK, 2);
|
||||||
|
discX(w, sx, cy, cz, 3, T.label, 2); // cream label centre
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// C7 — set-dressing pass (restrained: this is a tidy booth)
|
||||||
|
// =============================================================================
|
||||||
|
function buildDressing(w: IVoxelWorld, rng: () => number): void {
|
||||||
|
// A few stray cream stickers on the shell + one screw half-out.
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const x = randInt(rng, RIM + 4, WORLD_X - RIM - 4);
|
||||||
|
setVox(w, x, randInt(rng, Y_TABLETOP + 4, Y_BOOTH_WALL_TOP - 20), RIM, T.label);
|
||||||
|
}
|
||||||
|
setVox(w, RIM + 30, Y_TABLETOP + 10, RIM + 1, T.screw); // half-out screw (proud of wall)
|
||||||
|
// dust settled in the front corners.
|
||||||
|
for (const [x, z] of [[RIM + 2, RIM + 2], [WORLD_X - RIM - 3, RIM + 2]] as const)
|
||||||
|
box(w, x, Y_TABLETOP, z, x + 3, Y_TABLETOP, z + 3, T.dust);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Entry point + spawn
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Player spawn: Deck A plinth top, right strip (faces the mixer), clear of gear. */
|
||||||
|
export const SPAWN: Vec3 = [LAYOUT.deckA.maxX - 6, Y_PLINTH_TOP, LAYOUT.deckA.minZ + 18];
|
||||||
|
/** Suggested look-at target for Lane B: the centre of the mixer face plate. */
|
||||||
|
export const SPAWN_LOOK: Vec3 = [MIXER.centerX, MIXER.faceTopY + 2, (LAYOUT.mixer.minZ + LAYOUT.mixer.maxZ) >> 1];
|
||||||
|
|
||||||
|
/** Fixed seed → identical booth every run (Technics SL-1210). */
|
||||||
|
export const WORLD_SEED = 1210;
|
||||||
|
|
||||||
|
export function buildBooth(w: IVoxelWorld): void {
|
||||||
|
if (w.sizeX !== WORLD_X || w.sizeY !== WORLD_Y || w.sizeZ !== WORLD_Z) {
|
||||||
|
// Not fatal (we clamp every write), but the diorama is authored for this size.
|
||||||
|
console.warn(`buildBooth: world is ${w.sizeX}x${w.sizeY}x${w.sizeZ}, expected ${WORLD_X}x${WORLD_Y}x${WORLD_Z}`);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.info(`buildBooth: fillBox fast-path ${typeof (w as { fillBox?: unknown }).fillBox === 'function' ? 'ENABLED' : 'unavailable (setBlock loop)'}`);
|
||||||
|
|
||||||
|
const rng = mulberry32(WORLD_SEED);
|
||||||
|
buildShell(w);
|
||||||
|
buildDeckA(w, rng);
|
||||||
|
buildDeckB(w, rng);
|
||||||
|
buildMixer(w, rng);
|
||||||
|
buildCableCanyon(w, rng);
|
||||||
|
buildPatchBay(w, rng);
|
||||||
|
buildUnderTable(w, rng);
|
||||||
|
buildDressing(w, rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the quest contract + anchors so integrators have one import site.
|
||||||
|
export { QUEST_POS } from './questPositions';
|
||||||
|
export { DECK_A, DECK_B, MIXER } from './anchors';
|
||||||
8
src/worldgen/index.ts
Normal file
8
src/worldgen/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// TURNCRAFT — Lane C public surface. Integrators import from here.
|
||||||
|
export {
|
||||||
|
buildBooth,
|
||||||
|
buildShell, buildDeckA, buildDeckB, buildMixer, buildCableCanyon, buildPatchBay, buildUnderTable,
|
||||||
|
SPAWN, SPAWN_LOOK, WORLD_SEED,
|
||||||
|
} from './buildBooth';
|
||||||
|
export { QUEST_POS, QUEST_REACH_POINTS, hasAdjacentAir, type QuestPositions } from './questPositions';
|
||||||
|
export { DECK_A, DECK_B, MIXER, UNDER, PATCH, RCA_SOCKET, RCA_PLUG } from './anchors';
|
||||||
50
src/worldgen/questPositions.ts
Normal file
50
src/worldgen/questPositions.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
// TURNCRAFT — Lane C -> Lane D quest handoff.
|
||||||
|
// The five signal-chain repairs from DESIGN §6. Lane D reads these WORLD
|
||||||
|
// coordinates to place its interactive machines / pickups; Lane C leaves the
|
||||||
|
// matching sockets, pickups and jams in the geometry at exactly these voxels.
|
||||||
|
//
|
||||||
|
// Every coordinate derives from src/worldgen/anchors.ts (which derives from
|
||||||
|
// core/constants LAYOUT) — nothing here is a magic literal.
|
||||||
|
|
||||||
|
import type { Vec3, IVoxelWorld } from '../core/types';
|
||||||
|
import { AIR } from '../core/blocks';
|
||||||
|
import { DECK_A, MIXER, UNDER, RCA_SOCKET, RCA_PLUG } from './anchors';
|
||||||
|
|
||||||
|
export interface QuestPositions {
|
||||||
|
/** missing stylus for Deck A headshell. */
|
||||||
|
stylus: { socket: Vec3; pickup: Vec3 };
|
||||||
|
/** unplugged RCA lead in the cable canyon / patch bay. */
|
||||||
|
rca: { socket: Vec3; plug: Vec3 };
|
||||||
|
/** dust boulder jamming the crossfader slot — mine it out. */
|
||||||
|
crossfader: { jam: Vec3 };
|
||||||
|
/** blown fuse under the mixer; a fresh one waits in the parts bin. */
|
||||||
|
fuse: { box: Vec3; pickup: Vec3 };
|
||||||
|
/** master power switch on the mixer rear (the finale trigger). */
|
||||||
|
power: { switch: Vec3 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QUEST_POS: QuestPositions = {
|
||||||
|
stylus: { socket: DECK_A.headshell, pickup: UNDER.stylusPickup },
|
||||||
|
rca: { socket: RCA_SOCKET, plug: RCA_PLUG },
|
||||||
|
crossfader: { jam: MIXER.crossfaderJam },
|
||||||
|
fuse: { box: UNDER.fuseBox, pickup: UNDER.fusePickup },
|
||||||
|
power: { switch: MIXER.powerSwitch },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Flat list of the reachability-critical voxels, for demo validation. */
|
||||||
|
export const QUEST_REACH_POINTS: Vec3[] = [
|
||||||
|
QUEST_POS.stylus.socket, QUEST_POS.stylus.pickup,
|
||||||
|
QUEST_POS.rca.socket, QUEST_POS.rca.plug,
|
||||||
|
QUEST_POS.crossfader.jam,
|
||||||
|
QUEST_POS.fuse.box, QUEST_POS.fuse.pickup,
|
||||||
|
QUEST_POS.power.switch,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** True if any 6-neighbour of (x,y,z) is AIR — i.e. the voxel is reachable. */
|
||||||
|
export function hasAdjacentAir(w: IVoxelWorld, [x, y, z]: Vec3): boolean {
|
||||||
|
return (
|
||||||
|
w.getBlock(x + 1, y, z) === AIR || w.getBlock(x - 1, y, z) === AIR ||
|
||||||
|
w.getBlock(x, y + 1, z) === AIR || w.getBlock(x, y - 1, z) === AIR ||
|
||||||
|
w.getBlock(x, y, z + 1) === AIR || w.getBlock(x, y, z - 1) === AIR
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/worldgen/tools.ts
Normal file
171
src/worldgen/tools.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
// TURNCRAFT — Lane C (World Builder) voxel toolkit.
|
||||||
|
// Small, allocation-light primitives every booth builder is composed from.
|
||||||
|
// Everything routes through `setVox` so a write can never land out of bounds
|
||||||
|
// (the demo mock + validation depend on that guarantee).
|
||||||
|
//
|
||||||
|
// Coordinates are INCLUSIVE on both ends unless noted. All fills are
|
||||||
|
// deterministic: no Math.random, no Date. Randomness comes from `mulberry32`.
|
||||||
|
|
||||||
|
import type { IVoxelWorld, Vec3 } from '../core/types';
|
||||||
|
|
||||||
|
// Lane A's VoxelWorld MAY expose a batched fillBox (much faster than N setBlock
|
||||||
|
// calls). We duck-type it so our simple demo mock (setBlock only) still works.
|
||||||
|
// Contract we ASSUME (buildBooth only presence-detects it, it does not verify the
|
||||||
|
// semantics): fillBox(x0,y0,z0,x1,y1,z1,id), INCLUSIVE bounds, same overwrite
|
||||||
|
// behaviour as setBlock. If Lane A's differs, reconcile at integration — the
|
||||||
|
// setBlock loop fallback below is always correct. Lane C ships no fillBox itself.
|
||||||
|
interface FillCapable {
|
||||||
|
fillBox?(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, id: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this world provides a batched fillBox (fast path). */
|
||||||
|
export function hasFillBox(w: IVoxelWorld): boolean {
|
||||||
|
return typeof (w as FillCapable).fillBox === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single guarded, floored writer. The ONLY place a block reaches the world. */
|
||||||
|
export function setVox(w: IVoxelWorld, x: number, y: number, z: number, id: number): void {
|
||||||
|
x = x | 0; y = y | 0; z = z | 0;
|
||||||
|
if (x < 0 || y < 0 || z < 0 || x >= w.sizeX || y >= w.sizeY || z >= w.sizeZ) return;
|
||||||
|
w.setBlock(x, y, z, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampLo(v: number, hi: number): number { return v < 0 ? 0 : v > hi ? hi : v; }
|
||||||
|
|
||||||
|
/** Axis-aligned solid box, inclusive. Uses fillBox if present, else loops. */
|
||||||
|
export function box(
|
||||||
|
w: IVoxelWorld,
|
||||||
|
x0: number, y0: number, z0: number,
|
||||||
|
x1: number, y1: number, z1: number,
|
||||||
|
id: number,
|
||||||
|
): void {
|
||||||
|
let ax = Math.min(x0, x1) | 0, bx = Math.max(x0, x1) | 0;
|
||||||
|
let ay = Math.min(y0, y1) | 0, by = Math.max(y0, y1) | 0;
|
||||||
|
let az = Math.min(z0, z1) | 0, bz = Math.max(z0, z1) | 0;
|
||||||
|
// Clamp to world; if fully outside, nothing to do.
|
||||||
|
ax = clampLo(ax, w.sizeX - 1); bx = clampLo(bx, w.sizeX - 1);
|
||||||
|
ay = clampLo(ay, w.sizeY - 1); by = clampLo(by, w.sizeY - 1);
|
||||||
|
az = clampLo(az, w.sizeZ - 1); bz = clampLo(bz, w.sizeZ - 1);
|
||||||
|
if (ax > bx || ay > by || az > bz) return;
|
||||||
|
const fill = (w as FillCapable).fillBox;
|
||||||
|
if (fill) { fill.call(w, ax, ay, az, bx, by, bz, id); return; }
|
||||||
|
for (let x = ax; x <= bx; x++)
|
||||||
|
for (let y = ay; y <= by; y++)
|
||||||
|
for (let z = az; z <= bz; z++)
|
||||||
|
w.setBlock(x, y, z, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hollow box: the six outer faces of the region, `t` voxels thick, inclusive. */
|
||||||
|
export function hollowBox(
|
||||||
|
w: IVoxelWorld,
|
||||||
|
x0: number, y0: number, z0: number,
|
||||||
|
x1: number, y1: number, z1: number,
|
||||||
|
id: number, t = 1,
|
||||||
|
): void {
|
||||||
|
const ax = Math.min(x0, x1), bx = Math.max(x0, x1);
|
||||||
|
const ay = Math.min(y0, y1), by = Math.max(y0, y1);
|
||||||
|
const az = Math.min(z0, z1), bz = Math.max(z0, z1);
|
||||||
|
box(w, ax, ay, az, bx, ay + t - 1, bz, id); // bottom
|
||||||
|
box(w, ax, by - t + 1, az, bx, by, bz, id); // top
|
||||||
|
box(w, ax, ay, az, ax + t - 1, by, bz, id); // -x
|
||||||
|
box(w, bx - t + 1, ay, az, bx, by, bz, id); // +x
|
||||||
|
box(w, ax, ay, az, bx, by, az + t - 1, id); // -z
|
||||||
|
box(w, ax, ay, bz - t + 1, bx, by, bz, id); // +z
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical (axis +Y) disc / annulus / cylinder centered on voxel (cx,cz).
|
||||||
|
* Fills where rInner <= dist(voxelCenter, axis) <= rOuter for each y in [y0,y1].
|
||||||
|
* rInner = 0 → solid disc; rInner > 0 → ring wall. Radii measured from the
|
||||||
|
* spindle voxel center, so output is symmetric about (cx,cz).
|
||||||
|
*/
|
||||||
|
export function cylinderY(
|
||||||
|
w: IVoxelWorld,
|
||||||
|
cx: number, cz: number, y0: number, y1: number,
|
||||||
|
rOuter: number, id: number, rInner = 0,
|
||||||
|
): void {
|
||||||
|
const r = Math.ceil(rOuter);
|
||||||
|
const ro2 = rOuter * rOuter, ri2 = rInner * rInner;
|
||||||
|
const ay = Math.min(y0, y1), by = Math.max(y0, y1);
|
||||||
|
for (let dx = -r; dx <= r; dx++) {
|
||||||
|
for (let dz = -r; dz <= r; dz++) {
|
||||||
|
const d2 = dx * dx + dz * dz;
|
||||||
|
if (d2 > ro2 || d2 < ri2) continue;
|
||||||
|
for (let y = ay; y <= by; y++) setVox(w, cx + dx, y, cz + dz, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filled sphere of radius r centered on (cx,cy,cz). */
|
||||||
|
export function sphere(w: IVoxelWorld, cx: number, cy: number, cz: number, r: number, id: number): void {
|
||||||
|
const ri = Math.ceil(r), r2 = r * r;
|
||||||
|
for (let dx = -ri; dx <= ri; dx++)
|
||||||
|
for (let dy = -ri; dy <= ri; dy++)
|
||||||
|
for (let dz = -ri; dz <= ri; dz++)
|
||||||
|
if (dx * dx + dy * dy + dz * dz <= r2) setVox(w, cx + dx, cy + dy, cz + dz, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thick voxel line from a to b. `radius` 0 → 1-voxel line; >0 stamps a small
|
||||||
|
* cube of half-width `radius` at each step (square cross-section — cheap tube).
|
||||||
|
*/
|
||||||
|
export function line3(w: IVoxelWorld, a: Vec3, b: Vec3, id: number, radius = 0): void {
|
||||||
|
const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
|
||||||
|
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz))));
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
const px = a[0] + dx * t, py = a[1] + dy * t, pz = a[2] + dz * t;
|
||||||
|
if (radius <= 0) { setVox(w, px, py, pz, id); continue; }
|
||||||
|
const cx = px | 0, cy = py | 0, cz = pz | 0;
|
||||||
|
for (let ox = -radius; ox <= radius; ox++)
|
||||||
|
for (let oy = -radius; oy <= radius; oy++)
|
||||||
|
for (let oz = -radius; oz <= radius; oz++)
|
||||||
|
setVox(w, cx + ox, cy + oy, cz + oz, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catmull-Rom point through p1→p2 (p0,p3 are the neighbouring controls). */
|
||||||
|
function catmull(p0: number, p1: number, p2: number, p3: number, t: number): number {
|
||||||
|
const t2 = t * t, t3 = t2 * t;
|
||||||
|
return 0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smooth cable: Catmull-Rom through `pts`, stamped as spheres of `radius`.
|
||||||
|
* Endpoints are duplicated so the curve reaches the first/last control points.
|
||||||
|
* `sub` samples per segment. Great for drooping RCA / power leads.
|
||||||
|
*/
|
||||||
|
export function spline(w: IVoxelWorld, pts: Vec3[], id: number, radius = 1, sub = 12): void {
|
||||||
|
if (pts.length === 0) return;
|
||||||
|
if (pts.length === 1) { sphere(w, pts[0][0], pts[0][1], pts[0][2], radius, id); return; }
|
||||||
|
const ext: Vec3[] = [pts[0], ...pts, pts[pts.length - 1]];
|
||||||
|
for (let i = 1; i < ext.length - 2; i++) {
|
||||||
|
const p0 = ext[i - 1], p1 = ext[i], p2 = ext[i + 1], p3 = ext[i + 2];
|
||||||
|
for (let s = 0; s <= sub; s++) {
|
||||||
|
const t = s / sub;
|
||||||
|
sphere(
|
||||||
|
w,
|
||||||
|
Math.round(catmull(p0[0], p1[0], p2[0], p3[0], t)),
|
||||||
|
Math.round(catmull(p0[1], p1[1], p2[1], p3[1], t)),
|
||||||
|
Math.round(catmull(p0[2], p1[2], p2[2], p3[2], t)),
|
||||||
|
radius, id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mulberry32 — tiny deterministic PRNG. Same seed → same sequence, forever. */
|
||||||
|
export function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integer in [lo, hi] inclusive from a mulberry32 stream. */
|
||||||
|
export function randInt(rng: () => number, lo: number, hi: number): number {
|
||||||
|
return lo + Math.floor(rng() * (hi - lo + 1));
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user