diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4442962..0576282 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -50,7 +50,7 @@ Vertical strata (Y): | 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) | +| 80–81 | Plinth tops; the record surface sits 1 proud of the plinth (81 = vinyl top, auto-steppable), platter body filling the 6-deep well below | | 67 | Mixer face plate (knob forest, fader alleys) | | 85–140 | Tonearm bridge, dust-cover glass arcs, booth walls, patch-bay wall | diff --git a/docs/INTEGRATION_NOTES.md b/docs/INTEGRATION_NOTES.md new file mode 100644 index 0000000..9624c0a --- /dev/null +++ b/docs/INTEGRATION_NOTES.md @@ -0,0 +1,82 @@ +# TURNCRAFT — Integration Notes (what the integrator changed and why) + +Integration performed per [INTEGRATION.md](./INTEGRATION.md) after all five +lanes landed. Every cross-lane glue decision is listed here. + +## Contract changes (core is integrator-owned) + +1. **`Y_RECORD_TOP`: 85 → 81** (`src/core/constants.ts`). At 85 the vinyl + surface was a 5-voxel cliff above the plinth (jump height ≈ 1.3 — the + record was unreachable), and Lane D's platter stack floated on the plinth + top, ignoring the 6-deep well Lane C carved. Now the record sits 1 proud of + the plinth (auto-steppable, like a real 1200) and the platter body fills + the well. Everything downstream (tonearm heights, audio panner positions, + FX rim sparkle, D's quest defaults) derives from the constant and moved + automatically. Added `PLATTER.wellDepth = 6` alongside. +2. Lane B's requested `velocityAt` out-param overload was NOT taken: Lane D + already returns a reused per-collider scratch array (no allocation), which + satisfies the hot-path concern with no contract change. + +## Cross-lane glue (in `src/main.ts`) + +- **QuestPositions adapter**: Lane C's nested `QUEST_POS` is mapped to Lane + D's flat `QuestPositions`; the crossfader slot span is derived from C's + `MIXER` anchors (centerX ± halfLen, faceTopY−depth+1 … faceTopY−1, z −1…+2) + so D's slide gate covers exactly the tram volume C carved. +- **`machines.attachPlayer(player)`** called after the player is built + (Lane D's added step "6.5") — enables walk-push faders and walk-press plates. +- **Hotbar**: D's `Hotbar` structurally satisfies E's `HotbarModel`; passed + directly as `getHotbar`, with `hotbar.onChange(() => hud.refreshHotbar())`. +- **Input wiring**: LMB hold = mine, RMB = place, E = use, 1–9/wheel = hotbar, + all gated on pointer lock (matching Lane B's own key gating). Start splash + click → `audio.init()` + pointer lock; resume click → re-lock only. +- **Fixed-step loop**: machines → player → interaction → fx at `FIXED_DT`, + with `MAX_SUBSTEPS` catch-up and an accumulator clamp so a backgrounded tab + doesn't spiral; `audio.updateListener` + `chunks.update()` + render per frame. +- Debug handle `window.TURNCRAFT` (world/player/machines/quest/hotbar/audio/ + fx/hud/chunks/interaction) — used by the smoke tests, harmless in play. + +## Lane-file adjustments (documented per CONTRACTS "integrator resolves") + +- `src/machines/platter.ts` — platter body now spans the well floor to 1 + below the vinyl (visuals) and its collider spans well floor → `Y_RECORD_TOP` + so the spinning body also pushes the player out of the well's 2-voxel gap + ring (which is now a real, intentional fall-in hazard). +- `src/fx/layout.ts` — VU overlay sprites aligned to Lane C's physical LED + towers (x = mixer minX+18 / maxX−18, 12 segments, in front of the open + casing face) per Lane E's friction note #5. +- `src/interact/interaction.ts` — per-material break times (soft 0.12 s, + glass 0.25 s, default 0.4 s). The crossfader jam is ~240 dust blocks; at a + flat 0.4 s it was a 96-second chore. +- `docs/DESIGN.md` — elevation table updated for the Y_RECORD_TOP change. + +## Deviations accepted as-is + +- **45 speed available pre-win** (D's reading; DESIGN's "45 unlocked at win" + treated as flavor). `Platter.unlocked45` exists if we ever gate it. +- **`fader_cross` does not drive audio** (E maps pitch + channel digits only); + crossfader is quest/physical-only in v1. +- **Both deck pitch faders drive the one global mix pitch** (E has a single + transport; fine until per-deck stems exist). +- **Tonearm pedestal (C's voxels) vs D's arm pivot** are ~11 voxels apart — + cosmetic misalignment, queued for polish, gameplay unaffected. + +## Verified at integration (live browser, real code paths) + +- Boot: booth builds + meshes in ~670 ms dev / 46 modules build in 0.5 s; + fillBox fast path ENABLED; **120 fps**; zero console errors all session. +- Ride: standing on the record at 33 → clean circle, r drift 20.00→20.02 over + 4 s, y = 81 exactly, spin-up ramp visible; at 45 near the rim → 10.7 v/s + (= ω×r), jump lands back on the moving disc. +- Mining: real DDA aim → dust breaks into hotbar; crossfader stays gated + while dust remains. +- Quest chain: stems stack 0→5 per `signal:repair`, power switch arms exactly + after the 4th repair, real button press → `game:win` once, both platters + auto-start, HUD banner + tracker 5/5, AudioContext running. +- The platter-well gap ring behaves as a trench (collider pushes, no clipping). + +## Not yet done (deploy phase) + +Run `/ship-check` before exposing anything publicly; deploy per deploy-map +conventions (static `dist/` — 156 KB gzipped). Cloudflare/host wiring is a +separate step, to be done with the token holder present. diff --git a/src/core/constants.ts b/src/core/constants.ts index 2a96532..cccaa10 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -16,7 +16,10 @@ export const WORLD_Z = 256; // front lip -> gear -> cable canyon -> back wall 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) +// Vinyl surface. Sits 1 proud of the plinth (like a real 1200) so the player +// can auto-step onto the record; the platter body fills the 6-deep well Lane C +// carved below it. (Integration fix: was 85, an unclimbable 5-voxel cliff.) +export const Y_RECORD_TOP = 81; export const Y_BOOTH_WALL_TOP = 140; // Gear placement — single source of truth for BOTH worldgen (Lane C) and @@ -60,6 +63,7 @@ export const MAX_SUBSTEPS = 4; export const PLATTER = { radius: 41, // voxels — the full platter incl. strobe-dot edge recordRadius: 38, // the vinyl disc sitting on it + wellDepth: 6, // the recessed pit in the plinth the platter body sits in 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 diff --git a/src/fx/layout.ts b/src/fx/layout.ts index e97874e..6adcc0a 100644 --- a/src/fx/layout.ts +++ b/src/fx/layout.ts @@ -14,9 +14,12 @@ 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. +// Aligned to Lane C's physical towers (worldgen buildMixer): 12-tall LED +// ladders at x = minX+18 / maxX-18, column z = maxZ-7, casing front face open +// at z-2 — the sprites sit just in front of that opening. 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' }, + { x: m.minX + 18, z: m.maxZ - 9.4, baseY: m.topY + 1, segments: 12, segH: 1.0, band: 'low' }, + { x: m.maxX - 18, z: m.maxZ - 9.4, baseY: m.topY + 1, segments: 12, segH: 1.0, band: 'mid' }, ]; // The signal chain, as a polyline the trace sprite runs on each repair: diff --git a/src/interact/interaction.ts b/src/interact/interaction.ts index f9d544e..2cbf626 100644 --- a/src/interact/interaction.ts +++ b/src/interact/interaction.ts @@ -14,7 +14,14 @@ 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 +// Seconds to mine a block, by material feel: soft stuff (dust, felt) digs +// fast — the crossfader jam is ~240 dust blocks, and 0.4 s each made that +// quest node a 90-second chore (integration tune). +const BREAK_TIME_DEFAULT = 0.4; +const BREAK_TIME_BY_SOUND: Partial> = { + soft: 0.12, + glass: 0.25, +}; export class Interaction { private readonly world: IVoxelWorld; @@ -134,7 +141,8 @@ export class Interaction { this.breakElapsed = 0; } this.breakElapsed += dt; - if (this.breakElapsed >= BREAK_TIME) { + const breakTime = BREAK_TIME_BY_SOUND[blockDef(hit.id).sound] ?? BREAK_TIME_DEFAULT; + if (this.breakElapsed >= breakTime) { const id: BlockId = hit.id; this.hotbar.add(id, 1); this.world.setBlock(hit.x, hit.y, hit.z, AIR); diff --git a/src/machines/platter.ts b/src/machines/platter.ts index c94c752..1f7298d 100644 --- a/src/machines/platter.ts +++ b/src/machines/platter.ts @@ -43,9 +43,12 @@ export class Platter extends MachineBase { 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 + // ---- Collider: one +Y cylinder spanning the platter well floor → vinyl + // surface, so the spinning body pushes the player out of the well's gap + // ring too (not just off the top). Top face = Y_RECORD_TOP exactly. + const wellFloor = Y_PLINTH_TOP - PLATTER.wellDepth; // 74 + const halfH = (Y_RECORD_TOP - wellFloor) / 2; // 3.5 + const cy = wellFloor + halfH; // 77.5 this.collider = { id: this.id, shape: { kind: 'cylinder', center: [this.cx, cy, this.cz], radius: PLATTER.radius, halfHeight: halfH }, @@ -73,25 +76,29 @@ export class Platter extends MachineBase { const r = PLATTER.radius; const rr = PLATTER.recordRadius; - // Platter: brushed dark alu disc just under the record. + // Platter: brushed dark alu body filling Lane C's well, its top edge + // breaking the plinth surface (Y_PLINTH_TOP) like a real 1200. + const wellFloor = Y_PLINTH_TOP - PLATTER.wellDepth; + const platterTop = Y_RECORD_TOP - 1.0; // = plinth top plane 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); + const bodyH = platterTop - (wellFloor + 0.2); + const platter = cylinderY(r, bodyH, platterMat, 0, wellFloor + 0.2 + bodyH / 2, 0); this.spin.add(platter); - // Strobe-dot ring: small emissive red studs around the platter rim. + // Strobe-dot ring: small emissive red studs on the platter's exposed edge. 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.position.set(Math.cos(a) * (r - 0.9), platterTop + 0.15, 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)); + this.spin.add(cylinderY(rr + 1.5, 0.5, slipMat, 0, Y_RECORD_TOP - 0.75, 0)); // Record body: blue translucent (deck A) / black (deck B). const vinylId = deck === 'A' ? 9 : 8; // vinyl_blue / vinyl_black diff --git a/src/main.ts b/src/main.ts index 5333d4a..0a7d3a1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,22 +1,134 @@ -// 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 +// TURNCRAFT — integration entry point (docs/INTEGRATION.md). +// Wires the five lanes: engine (A), player (B), worldgen (C), +// machines/interaction (D), audio/fx/ui (E). -const el = document.getElementById('app')!; -el.innerHTML = - '

' + - '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

'; +import type { Object3D, PerspectiveCamera } from 'three'; +import { FIXED_DT, MAX_SUBSTEPS } from './core/constants'; +import { createRenderer, VoxelWorld, ChunkManager } from './engine'; +import { WORLD_X, WORLD_Y, WORLD_Z } from './core/constants'; +import { buildBooth, SPAWN, QUEST_POS, MIXER } from './worldgen'; +import { createMachines, type QuestPositions } from './machines'; +import { Interaction, Hotbar } from './interact'; +import { PlayerController } from './player'; +import { AudioEngine } from './audio/AudioEngine'; +import { FxSystem } from './fx/FxSystem'; +import { Hud } from './ui/Hud'; + +const container = document.getElementById('app')!; +container.innerHTML = ''; + +// 1–2. Renderer + world. createRenderer builds THE atlas; ChunkManager must +// share it so setEmissiveBoost reaches the chunk materials (Lane A handoff). +const { renderer, scene, camera, atlas, setEmissiveBoost, render } = createRenderer(container); +const world = new VoxelWorld(WORLD_X, WORLD_Y, WORLD_Z); + +// 3–4. Build the booth, then mesh it. +const t0 = performance.now(); +buildBooth(world); +const chunks = new ChunkManager(world, scene, atlas, 4); +chunks.buildAll(); +console.info( + `[turncraft] booth built+meshed in ${(performance.now() - t0).toFixed(0)} ms, ` + + `${chunks.meshCount} chunk meshes`, +); + +// 5. Machines, with Lane C's real quest positions adapted to Lane D's shape +// (the two lanes defined QuestPositions independently; C's is nested per-node, +// D's is flat + a crossfader slot span derived from C's MIXER anchors). +const questPos: QuestPositions = { + stylusSocket: QUEST_POS.stylus.socket, + rcaPlug: QUEST_POS.rca.plug, + rcaSocket: QUEST_POS.rca.socket, + crossfaderSlot: { + min: [MIXER.centerX - MIXER.crossfaderHalfLen, MIXER.faceTopY - MIXER.crossfaderDepth + 1, MIXER.crossfaderZ - 1], + max: [MIXER.centerX + MIXER.crossfaderHalfLen, MIXER.faceTopY - 1, MIXER.crossfaderZ + 2], + }, + fuseBox: QUEST_POS.fuse.box, + masterSwitch: QUEST_POS.power.switch, +}; +const machines = createMachines(world, questPos); +for (const o of machines.getObject3Ds()) scene.add(o as Object3D); + +// 6 (+6.5). Player, then give machines the player view (walk-push faders, +// walk-press buttons — Lane D handoff friction #2). +const player = new PlayerController(world, { + getColliders: machines.getColliders, + camera: camera as PerspectiveCamera, + domElement: renderer.domElement, + spawn: SPAWN, +}); +machines.attachPlayer(player); + +// 7. Interaction + hotbar. +const hotbar = new Hotbar(); +const interaction = new Interaction(world, player, machines, hotbar); +scene.add(interaction.getObject3D()); + +// 8. Audio, FX, HUD. AudioEngine/FxSystem/Hud self-wire to the bus; audio +// starts only from the HUD's start-splash click (autoplay policy). +const audio = new AudioEngine(); +const fx = new FxSystem({ scene, setEmissiveBoost, getLevels: () => audio.getLevels() }); +const hud = new Hud({ + onStart: () => { void audio.init(); renderer.domElement.requestPointerLock(); }, + onResume: () => { renderer.domElement.requestPointerLock(); }, + getHotbar: () => hotbar, // D's Hotbar structurally satisfies E's HotbarModel +}); +hotbar.onChange(() => hud.refreshHotbar()); + +let started = false; +document.addEventListener('pointerlockchange', () => { + const locked = document.pointerLockElement === renderer.domElement; + if (locked) started = true; + else if (started) hud.setPaused(true); +}); + +// Interaction input (mouse buttons + E + hotbar keys), gated on pointer lock +// like Lane B's movement keys. +const locked = () => document.pointerLockElement === renderer.domElement; +renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault()); +renderer.domElement.addEventListener('mousedown', (e) => { + if (!locked()) return; + if (e.button === 0) interaction.startMining(); + else if (e.button === 2) interaction.place(); +}); +window.addEventListener('mouseup', (e) => { if (e.button === 0) interaction.stopMining(); }); +window.addEventListener('keydown', (e) => { + if (!locked()) return; + if (e.code === 'KeyE') interaction.use(); + else if (e.code.startsWith('Digit')) { + const n = parseInt(e.code.slice(5), 10); + if (n >= 1 && n <= 9) hotbar.setActive(n - 1); + } +}); +window.addEventListener('wheel', (e) => { + if (locked()) hotbar.setActive(hotbar.active + (e.deltaY > 0 ? 1 : -1)); +}); + +// 9. Fixed-step loop, render decoupled. +let last = performance.now(); +let acc = 0; +function frame(now: number): void { + requestAnimationFrame(frame); + acc += Math.min((now - last) / 1000, 0.25); // clamp huge tab-switch gaps + last = now; + let steps = 0; + while (acc >= FIXED_DT && steps < MAX_SUBSTEPS) { + machines.update(FIXED_DT); + player.update(FIXED_DT); + interaction.update(FIXED_DT); + fx.update(FIXED_DT); + acc -= FIXED_DT; + steps++; + } + if (steps === MAX_SUBSTEPS) acc = 0; // don't spiral after long stalls + audio.updateListener(player); + chunks.update(); + render(); +} +requestAnimationFrame(frame); + +// Dev/debug handle (also used by the integration smoke tests). +declare global { + interface Window { TURNCRAFT: unknown } +} +window.TURNCRAFT = { world, player, machines, quest: machines.quest, hotbar, audio, fx, hud, chunks, interaction };