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:
parent
5a39e3a947
commit
f88be60e60
@ -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 |
|
| 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 | **Tabletop** — plywood plateau the gear sits on |
|
||||||
| 40–80 | Turntable plinths (steel-grey mesas), mixer body |
|
| 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) |
|
| 67 | Mixer face plate (knob forest, fader alleys) |
|
||||||
| 85–140 | Tonearm bridge, dust-cover glass arcs, booth walls, patch-bay wall |
|
| 85–140 | Tonearm bridge, dust-cover glass arcs, booth walls, patch-bay wall |
|
||||||
|
|
||||||
|
|||||||
82
docs/INTEGRATION_NOTES.md
Normal file
82
docs/INTEGRATION_NOTES.md
Normal 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, 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.
|
||||||
@ -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_FLOOR = 0; // bottom of the under-table crate/cave region
|
||||||
export const Y_TABLETOP = 40; // plywood tabletop the gear sits on
|
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_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;
|
export const Y_BOOTH_WALL_TOP = 140;
|
||||||
|
|
||||||
// Gear placement — single source of truth for BOTH worldgen (Lane C) and
|
// Gear placement — single source of truth for BOTH worldgen (Lane C) and
|
||||||
@ -60,6 +63,7 @@ export const MAX_SUBSTEPS = 4;
|
|||||||
export const PLATTER = {
|
export const PLATTER = {
|
||||||
radius: 41, // voxels — the full platter incl. strobe-dot edge
|
radius: 41, // voxels — the full platter incl. strobe-dot edge
|
||||||
recordRadius: 38, // the vinyl disc sitting on it
|
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
|
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
|
rpm45: 2.7, // "45" — hazardous: edge ~11.6 v/s, faster than sprint
|
||||||
spinUpSeconds: 1.2, // torque-y Technics start/stop feel
|
spinUpSeconds: 1.2, // torque-y Technics start/stop feel
|
||||||
|
|||||||
@ -14,9 +14,12 @@ const m = LAYOUT.mixer;
|
|||||||
const midX = (m.minX + m.maxX) / 2;
|
const midX = (m.minX + m.maxX) / 2;
|
||||||
|
|
||||||
// Two LED VU towers at the back of the mixer face, driven by different bands.
|
// 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[] = [
|
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.minX + 18, z: m.maxZ - 9.4, baseY: m.topY + 1, segments: 12, segH: 1.0, band: 'low' },
|
||||||
{ x: m.maxX - 14, z: m.maxZ - 6, baseY: m.topY + 3, segments: 14, segH: 2.0, band: 'mid' },
|
{ 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:
|
// The signal chain, as a polyline the trace sprite runs on each repair:
|
||||||
|
|||||||
@ -14,7 +14,14 @@ import { Fader, QUEST_ITEMS, type MachineSet } from '../machines';
|
|||||||
import type { Hotbar } from './hotbar';
|
import type { Hotbar } from './hotbar';
|
||||||
import { raycastColliders, raycastVoxel, type VoxelHit } from './raycast';
|
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 {
|
export class Interaction {
|
||||||
private readonly world: IVoxelWorld;
|
private readonly world: IVoxelWorld;
|
||||||
@ -134,7 +141,8 @@ export class Interaction {
|
|||||||
this.breakElapsed = 0;
|
this.breakElapsed = 0;
|
||||||
}
|
}
|
||||||
this.breakElapsed += dt;
|
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;
|
const id: BlockId = hit.id;
|
||||||
this.hotbar.add(id, 1);
|
this.hotbar.add(id, 1);
|
||||||
this.world.setBlock(hit.x, hit.y, hit.z, AIR);
|
this.world.setBlock(hit.x, hit.y, hit.z, AIR);
|
||||||
|
|||||||
@ -43,9 +43,12 @@ export class Platter extends MachineBase {
|
|||||||
this.cx = spindleX;
|
this.cx = spindleX;
|
||||||
this.cz = spindleZ;
|
this.cz = spindleZ;
|
||||||
|
|
||||||
// ---- Collider: one +Y cylinder spanning plinth top → vinyl surface. ----
|
// ---- Collider: one +Y cylinder spanning the platter well floor → vinyl
|
||||||
const halfH = (Y_RECORD_TOP - Y_PLINTH_TOP) / 2; // 2.5
|
// surface, so the spinning body pushes the player out of the well's gap
|
||||||
const cy = Y_PLINTH_TOP + halfH; // 82.5
|
// 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 = {
|
this.collider = {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
shape: { kind: 'cylinder', center: [this.cx, cy, this.cz], radius: PLATTER.radius, halfHeight: halfH },
|
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 r = PLATTER.radius;
|
||||||
const rr = PLATTER.recordRadius;
|
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 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);
|
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 dotMat = blockMaterial(28, { emissive: 0.9 }); // strobe_dot
|
||||||
const dotCount = 36;
|
const dotCount = 36;
|
||||||
for (let i = 0; i < dotCount; i++) {
|
for (let i = 0; i < dotCount; i++) {
|
||||||
const a = (i / dotCount) * Math.PI * 2;
|
const a = (i / dotCount) * Math.PI * 2;
|
||||||
const d = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.5, 0.7), dotMat);
|
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;
|
d.rotation.y = -a;
|
||||||
this.spin.add(d);
|
this.spin.add(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slipmat.
|
// Slipmat.
|
||||||
const slipMat = blockMaterial(7); // slipmat_felt
|
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).
|
// Record body: blue translucent (deck A) / black (deck B).
|
||||||
const vinylId = deck === 'A' ? 9 : 8; // vinyl_blue / vinyl_black
|
const vinylId = deck === 'A' ? 9 : 8; // vinyl_blue / vinyl_black
|
||||||
|
|||||||
154
src/main.ts
154
src/main.ts
@ -1,22 +1,134 @@
|
|||||||
// TURNCRAFT — integration entry point.
|
// TURNCRAFT — integration entry point (docs/INTEGRATION.md).
|
||||||
// This stub is completed during INTEGRATION (docs/INTEGRATION.md), after the
|
// Wires the five lanes: engine (A), player (B), worldgen (C),
|
||||||
// lanes land. Lanes DO NOT edit this file; each lane has its own demo entry
|
// machines/interaction (D), audio/fx/ui (E).
|
||||||
// (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')!;
|
import type { Object3D, PerspectiveCamera } from 'three';
|
||||||
el.innerHTML =
|
import { FIXED_DT, MAX_SUBSTEPS } from './core/constants';
|
||||||
'<p style="color:#888;font:14px monospace;padding:2em">' +
|
import { createRenderer, VoxelWorld, ChunkManager } from './engine';
|
||||||
'TURNCRAFT integration entry — see docs/INTEGRATION.md. ' +
|
import { WORLD_X, WORLD_Y, WORLD_Z } from './core/constants';
|
||||||
'Lane demos live at /demo-engine.html, /demo-player.html, /demo-worldgen.html, ' +
|
import { buildBooth, SPAWN, QUEST_POS, MIXER } from './worldgen';
|
||||||
'/demo-machines.html, /demo-audio.html</p>';
|
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 };
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user