// 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 = ''; // 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, setBloom, 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); // 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[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 = { 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 };