Integrate all five lanes: wire main.ts, fix record height contract, verify live

- src/main.ts: full wiring per docs/INTEGRATION.md (renderer/world/booth/
  chunks/machines/player/interaction/audio/fx/hud + fixed-step loop),
  QuestPositions adapter (Lane C nested -> Lane D flat), attachPlayer step,
  pointer-lock-gated input, debug handle for smoke tests.
- core/constants: Y_RECORD_TOP 85 -> 81 (record was an unclimbable 5-voxel
  cliff; now 1 proud of the plinth, auto-steppable) + PLATTER.wellDepth.
- machines/platter: body + collider now fill Lane C's well down to y=74.
- fx/layout: VU sprites aligned to Lane C's physical LED towers.
- interact: per-material break times (dust 0.12s) so the crossfader jam
  isn't a 96-second mine.
- docs/INTEGRATION_NOTES.md: every glue decision + live verification results
  (120 fps, ride/mine/quest/win all green, zero console errors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-13 21:11:10 +10:00
parent 5a39e3a947
commit f88be60e60
7 changed files with 251 additions and 35 deletions

View File

@ -50,7 +50,7 @@ Vertical strata (Y):
| 040 | **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 |
| 4080 | Turntable plinths (steel-grey mesas), mixer body |
| 8085 | Plinth tops, platter + slipmat + record surface (85 = vinyl top) |
| 8081 | 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) |
| 85140 | Tonearm bridge, dust-cover glass arcs, booth walls, patch-bay wall |

82
docs/INTEGRATION_NOTES.md Normal file
View File

@ -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, faceTopYdepth+1 … faceTopY1, 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, 19/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 / maxX18, 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.

View File

@ -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

View File

@ -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:

View File

@ -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<Record<string, number>> = {
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);

View File

@ -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

View File

@ -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 =
'<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>';
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 = '';
// 12. 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);
// 34. 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 };