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>
355 lines
15 KiB
TypeScript
355 lines
15 KiB
TypeScript
// 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<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 };
|