TURNCRAFT/src/main.ts
type-two caac3da930 First-five-minutes pass: heartbeat, spark beacons, flythrough, mining juice, og tags
The systems were all there but the opening experience sagged: a new player
spawned into a dark, silent booth with no idea they're tiny or that
anything is broken. Five fixes, all verified live:

- The dying booth has a HEARTBEAT: a slow muffled lub-dub (~55bpm sine
  thumps, lowpassed, positional from inside the mixer) while zero repairs
  are done. First repair silences it with real music; a reset revives it.
- Broken quest nodes SPARK: intermittent blue-white electric crackles at
  each unrepaired fixture (FxSystem.setBeacons, anchored from QUEST_POS) -
  diegetic breadcrumbs visible across the booth.
- First-visit FLYTHROUGH: 9 skippable seconds - wide booth reveal, sweep
  over the mixer, dive to spawn - riding the existing cine override.
  Teaches 'you are tiny in a DJ booth' better than any text.
- Mining JUICE: new 'block:mining' bus event (~8Hz while held) drives
  grey chip particles + rising-pitch ticks; throttle resets per target.
- og/twitter meta tags + public/preview.jpg so sharing the link unfurls
  with the hero shot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:01:37 +10:00

355 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// TURNCRAFT — integration entry point (docs/INTEGRATION.md).
// Wires the five lanes: engine (A), player (B), worldgen (C),
// machines/interaction (D), audio/fx/ui (E).
import { PointLight, type Object3D, type 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 { DecalSet } from './fx/decals';
import { Hud } from './ui/Hud';
import { Menu, QUALITY_PIXEL_RATIO } from './ui/Menu';
import { Roster } from './ui/Roster';
import { Avatars, EMOTE } from './net/Avatars';
import { NetClient, relayUrl } from './net/NetClient';
import { bus } from './core/events';
import { QUEST_ITEMS } from './machines/quest';
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, setBloom, 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);
// Glow-Up G3: screen-print decals (labels, socket numbers, party flyers) baked
// onto thin proud planes over the static gear. Purely cosmetic, built once.
const decals = new DecalSet();
scene.add(decals.group);
// Warm light pools under the six ceiling lamp panels worldgen places
// (buildShell); positions must match its [112,224,336] × [70,160] grid.
for (const lx of [112, 224, 336]) {
for (const lz of [70, 160]) {
const lamp = new PointLight(0xffd9a6, 900, 0, 1.9);
lamp.position.set(lx, 136, lz);
scene.add(lamp);
}
}
// 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() });
// Broken-node spark beacons: each unrepaired quest fixture crackles — the
// world itself points at what's broken (first-five-minutes pass).
const cs = questPos.crossfaderSlot;
fx.setBeacons(
[
{ node: 'stylus', pos: questPos.stylusSocket as [number, number, number] },
{ node: 'rca', pos: questPos.rcaPlug as [number, number, number] },
{ node: 'crossfader', pos: [(cs.min[0] + cs.max[0]) / 2, cs.max[1] + 1, (cs.min[2] + cs.max[2]) / 2] },
{ node: 'fuse', pos: questPos.fuseBox as [number, number, number] },
{ node: 'power', pos: questPos.masterSwitch as [number, number, number] },
],
(n) => machines.quest.isRepaired(n as Parameters<typeof machines.quest.isRepaired>[0]),
);
// First-visit flythrough: nine skippable seconds that teach the premise
// ("you are tiny, this is a DJ booth") better than any text could. Rides
// the same cine override the loop already applies; any click/key skips.
const FLY_KEY = 'turncraft.flown';
function runFlythrough(): void {
if (localStorage.getItem(FLY_KEY)) return;
localStorage.setItem(FLY_KEY, '1');
const K: Array<{ t: number; pos: number[]; look: number[] }> = [
{ t: 0.0, pos: [224, 132, 26], look: [224, 60, 128] }, // wide: the whole booth
{ t: 3.2, pos: [330, 100, 60], look: [224, 66, 100] }, // sweep over the mixer
{ t: 6.0, pos: [140, 96, 60], look: [80, 81, 96] }, // deck A, the blue record
{ t: 9.0, pos: [SPAWN[0], SPAWN[1] + 1.62, SPAWN[2]], look: [80, 81, 96] }, // land at spawn
];
const w = window as unknown as { TURNCRAFT_CINE: { pos: number[]; look: number[] } | null };
const t0 = performance.now();
let done = false;
const finish = () => {
if (done) return;
done = true;
w.TURNCRAFT_CINE = null;
window.removeEventListener('mousedown', finish);
window.removeEventListener('keydown', finish);
};
window.addEventListener('mousedown', finish);
window.addEventListener('keydown', finish);
const tick = () => {
if (done) return;
const t = (performance.now() - t0) / 1000;
if (t >= K[K.length - 1].t) { finish(); return; }
let i = 0;
while (i < K.length - 2 && t >= K[i + 1].t) i++;
const a = K[i], b = K[i + 1];
const f = Math.min(1, Math.max(0, (t - a.t) / (b.t - a.t)));
const e = f * f * (3 - 2 * f); // smoothstep
w.TURNCRAFT_CINE = {
pos: a.pos.map((v, k) => v + (b.pos[k] - v) * e),
look: a.look.map((v, k) => v + (b.look[k] - v) * e),
};
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
const hud = new Hud({
onStart: () => {
void audio.init();
renderer.domElement.requestPointerLock();
runFlythrough();
},
onResume: () => { renderer.domElement.requestPointerLock(); },
getHotbar: () => hotbar, // D's Hotbar structurally satisfies E's HotbarModel
});
hotbar.onChange(() => hud.refreshHotbar());
let started = false;
// Help & Settings (H). Applies persisted settings on construction.
const menu = new Menu({
onApply: (s) => {
audio.setMasterVolume(s.volume);
player.setSensitivity(0.0022 * s.sensitivity);
player.viewBob = s.viewBob;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, QUALITY_PIXEL_RATIO[s.quality]));
setBloom(s.bloom && s.quality !== 'fast'); // Glow-Up G2: LED bloom, off on 'fast'
},
onOpen: () => { document.exitPointerLock(); hud.setPaused(false); },
onClose: () => { if (started) renderer.domElement.requestPointerLock(); },
// Avatar edits apply live: push to the relay and update our own roster row.
onAvatar: (look) => {
net?.setAvatar(look);
roster?.setSelf({ name: menu.getSettings().name, look });
},
});
document.addEventListener('pointerlockchange', () => {
const locked = document.pointerLockElement === renderer.domElement;
if (locked) started = true;
else if (started && !menu.isOpen()) hud.setPaused(true);
});
// Multiplayer: shared booth via the relay (server/relay.mjs). Fully optional —
// if the relay is unreachable this is a normal single-player session.
const avatars = new Avatars(scene);
const netChip = document.createElement('div');
netChip.style.cssText =
'position:fixed;top:10px;left:150px;z-index:40;font:11px ui-monospace,monospace;' +
'color:#8a8378;background:#0008;border:1px solid #3a352c;border-radius:6px;padding:4px 8px;display:none';
document.body.appendChild(netChip);
// Who's-here list (hold Tab). Seeded with the local DJ; peers arrive from net.
const roster = new Roster({ name: menu.getSettings().name, look: menu.getSettings().avatar });
const net = new NetClient({
world, machines, player, avatars,
name: menu.getSettings().name,
avatar: menu.getSettings().avatar,
url: relayUrl(import.meta.env.BASE_URL),
onStatus: (peerCount, connected) => {
netChip.style.display = connected ? 'block' : 'none';
netChip.textContent = peerCount === 0
? 'online — booth to yourself'
: `online — ${peerCount} other DJ${peerCount === 1 ? '' : 's'} in the booth`;
},
onPeers: (peers) => roster.setPeers(peers),
});
// 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 === 'ShiftLeft' || e.code === 'ShiftRight') interaction.shiftDown = true;
if (e.code === 'KeyE') { if (!e.repeat) interaction.useDown(e.shiftKey); } // E press (hold = crimp/torque)
else if (e.code === 'KeyQ') interaction.dropCarry(); // drop a carried wire
else if (EMOTE_KEYS[e.code] !== undefined) { if (!e.repeat) fireEmote(EMOTE_KEYS[e.code]); }
else if (e.code.startsWith('Digit')) {
const n = parseInt(e.code.slice(5), 10);
if (n >= 1 && n <= 9) hotbar.setActive(n - 1);
}
});
// ── The Reset Ritual (SOCIAL_RESET Feature 3) ─────────────────────────────
// Once the mix is live, the fuse in the PCB-Depths box becomes the way to play
// the quest again — but only on a deliberate 5 s hold, and only after a
// cooldown. The relay is the authority; this is the ceremony around it.
const FUSE_HOLD_SECS = 5;
const RESET_COOLDOWN_MS = 10 * 60_000; // must match relay.mjs
let wonAt = 0;
bus.on('game:win', () => { wonAt = Date.now(); });
bus.on('quest:reset', () => { wonAt = 0; });
const fuse = QUEST_POS.fuse.box;
interaction.setBreakGuard({
timeFor: (x, y, z) => {
// Only the fuse voxel, and only while the booth is actually won.
if (!machines.quest.isWon()) return null;
if (x !== fuse[0] || y !== fuse[1] || z !== fuse[2]) return null;
return FUSE_HOLD_SECS;
},
// Reuse the workshop's radial meter as the "are you sure" dial.
progress: (p) => {
if (p < 0) hud.hideTorque();
else hud.showTorque(p, 'KEEP MINING TO KILL THE MIX — EVERYONE STARTS OVER');
},
onComplete: () => {
if (Date.now() - wonAt < RESET_COOLDOWN_MS) {
// The relay would reject this anyway — say so and don't eat the fuse.
bus.emit('workshop:msg', { text: 'the fuse is still hot — give it a minute' });
return true; // veto the break
}
net.sendReset(); // relay validates + broadcasts; everyone resets together
return false; // let the fuse actually break
},
});
// The one block edit the reset writes: a fresh blackened fuse back in the box,
// so the quest is physically replayable. Player builds are untouched.
bus.on('quest:reset', ({ by }) => {
world.setBlock(fuse[0], fuse[1], fuse[2], QUEST_ITEMS.fuse);
hud.hideTorque();
hud.showSubtitle(by ? `${by} BLEW THE FUSE` : '⚡ THE FUSE IS BLOWN');
});
// ── Social: emotes (SOCIAL_RESET Feature 2) ───────────────────────────────
// Z X C V, pointer-locked only so they can't collide with the hotbar digits.
const EMOTE_KEYS: Record<string, number> = {
KeyZ: EMOTE.WAVE, KeyX: EMOTE.NOD, KeyC: EMOTE.POINT, KeyV: EMOTE.AIRHORN,
};
const EMOTE_LABEL = ['👋 wave', '🎧 nod', '👉 point', '📢 airhorn'];
const emoteToast = document.createElement('div');
emoteToast.style.cssText =
'position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:35;' +
'font:12px ui-monospace,monospace;color:#ffe9c0;background:#000a;border:1px solid #4a4234;' +
'border-radius:6px;padding:4px 10px;opacity:0;transition:opacity .18s;pointer-events:none';
document.body.appendChild(emoteToast);
let emoteToastTimer = 0;
function fireEmote(kind: number): void {
// The relay enforces the same 4 s ceiling; if our own cooldown ate it, stay
// silent locally too so what we hear matches what peers actually get.
if (!net.sendEmote(kind)) {
showEmoteToast('… still catching your breath');
return;
}
// First person: you can't see your own wave, so the sound (airhorn) plus this
// toast are the "something happened". Audio + FX listen on the bus.
bus.emit('player:emote', { kind, self: true, at: [...player.position] as [number, number, number] });
showEmoteToast(EMOTE_LABEL[kind] ?? '');
}
function showEmoteToast(text: string): void {
emoteToast.textContent = text;
emoteToast.style.opacity = '1';
clearTimeout(emoteToastTimer);
emoteToastTimer = window.setTimeout(() => { emoteToast.style.opacity = '0'; }, 900);
}
window.addEventListener('keyup', (e) => {
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') interaction.shiftDown = false;
if (e.code === 'KeyE') interaction.useUp(); // release ends any crimp/torque hold
});
window.addEventListener('wheel', (e) => {
if (locked()) hotbar.setActive(hotbar.active + (e.deltaY > 0 ? 1 : -1));
});
// 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.jumpScale = machines.workshop.isCarrying() !== null ? 0.5 : 1; // wire tug caps jump
player.update(FIXED_DT);
interaction.update(FIXED_DT);
fx.update(FIXED_DT);
avatars.update(FIXED_DT);
acc -= FIXED_DT;
steps++;
}
if (steps === MAX_SUBSTEPS) acc = 0; // don't spiral after long stalls
audio.updateListener(player);
// Dev/trailer cinematic override: window.TURNCRAFT_CINE = { pos, look }
// detaches the camera from the player until set back to null.
const cine = (window as unknown as { TURNCRAFT_CINE?: { pos: number[]; look: number[] } }).TURNCRAFT_CINE;
if (cine) {
camera.position.set(cine.pos[0], cine.pos[1], cine.pos[2]);
camera.lookAt(cine.look[0], cine.look[1], cine.look[2]);
}
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, camera, render, net, avatars, setBloom, setEmissiveBoost, decals };