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>
This commit is contained in:
type-two 2026-07-20 18:01:37 +10:00
parent 05c2765bd8
commit caac3da930
7 changed files with 203 additions and 4 deletions

View File

@ -4,6 +4,13 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TURNCRAFT</title>
<meta name="description" content="You are 7mm tall inside a dead DJ booth. Fix the signal chain, ride the spinning records, bring the mix back to life. Multiplayer voxel weirdness." />
<meta property="og:title" content="TURNCRAFT" />
<meta property="og:description" content="You are 7mm tall inside a dead DJ booth. Fix the signal chain, ride the spinning records, bring the mix back to life." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://partly.party/turncraft/" />
<meta property="og:image" content="https://partly.party/turncraft/preview.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #0a0a0c; }
#app { width: 100%; height: 100%; }

BIN
public/preview.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -66,6 +66,14 @@ export class AudioEngine {
private won = false;
private lastTorqueTick = 0;
// First-five-minutes pass: the dying booth has a heartbeat — a slow,
// muffled lub-dub from inside the mixer while ZERO repairs are done.
// First repair silences it (real music takes over); a reset revives it.
private repairs = 0;
private heartNext = 0;
private heartTimer: number | null = null;
private heartDest: AudioNode | null = null;
private readonly levels = { low: 0, mid: 0, high: 0 };
constructor() {
@ -168,6 +176,25 @@ export class AudioEngine {
);
this.transport.start();
// ── the dying booth's heartbeat ──
// Positional at the mixer's core, heavily lowpassed: a pulse you feel
// more than hear, telling new players the booth is dying, not dead.
{
const hp = ctx.createPanner();
hp.panningModel = 'equalpower';
hp.distanceModel = 'inverse';
hp.refDistance = 30; hp.maxDistance = 500; hp.rolloffFactor = 0.9;
this.setPos(hp, (LAYOUT.mixer.minX + LAYOUT.mixer.maxX) / 2, 55,
(LAYOUT.mixer.minZ + LAYOUT.mixer.maxZ) / 2);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 240;
const g = ctx.createGain(); g.gain.value = 0.55;
lp.connect(g); g.connect(hp); hp.connect(this.outGain);
this.heartDest = lp;
this.heartNext = ctx.currentTime + 0.5;
this.heartTimer = window.setInterval(() => this.scheduleHeartbeat(), 250);
}
// apply any state that arrived before init
this.groove.setStemCount(this.stemCount);
for (let i = 0; i < this.channel.length; i++) this.groove.setChannelGain(i, this.channel[i]);
@ -367,6 +394,51 @@ export class AudioEngine {
* dying mains hum lands on top. Once it's silent we reset the chain so the
* quest is cleanly replayable. Idempotent: a reset with no win is harmless.
*/
/** Lookahead scheduler for the dying booth's lub-dub (~55 BPM). */
private scheduleHeartbeat(): void {
if (!this.ctx || !this.heartDest) return;
const now = this.ctx.currentTime;
if (this.repairs !== 0 || this.won) {
this.heartNext = now + 0.4; // pinned so a reset resumes promptly
return;
}
while (this.heartNext < now + 0.7) {
this.heartThump(this.heartNext, 1.0); // LUB
this.heartThump(this.heartNext + 0.3, 0.5); // dub
this.heartNext += 1.1;
}
}
private heartThump(t: number, amp: number): void {
if (!this.ctx || !this.heartDest) return;
const ctx = this.ctx;
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(64, t);
osc.frequency.exponentialRampToValueAtTime(38, t + 0.14);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.5 * amp, t + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.26);
osc.connect(g); g.connect(this.heartDest);
osc.start(t); osc.stop(t + 0.3);
}
/** Rising-pitch tick while holding to mine (dry — it's your own hands). */
private miningTick(progress: number): void {
if (!this.ctx) return;
const ctx = this.ctx;
const t = ctx.currentTime + 0.001;
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.value = 260 + progress * 520;
const g = ctx.createGain();
g.gain.setValueAtTime(0.06, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.045);
osc.connect(g); g.connect(this.sfxGain);
osc.start(t); osc.stop(t + 0.05);
}
private runResetAudio(): void {
this.won = false;
this.deck.A = { playing: false, rpm: PLATTER.rpm33 };
@ -408,6 +480,7 @@ export class AudioEngine {
// ── event bus wiring ──────────────────────────────────────────────────────
private wireBus(): void {
bus.on('signal:repair', (p) => {
this.repairs = p.repaired;
this.setStemCount(p.repaired);
const sfx: Record<string, () => void> = {
stylus: () => this.playSfx('place', undefined, 'metal'),
@ -421,7 +494,10 @@ export class AudioEngine {
bus.on('game:win', () => this.runWinAudio());
bus.on('quest:reset', () => this.runResetAudio());
bus.on('quest:reset', () => { this.repairs = 0; this.runResetAudio(); });
// Mining juice: quiet dry ticks whose pitch rises with hold progress.
bus.on('block:mining', (p) => this.miningTick(p.progress));
// Emotes: the airhorn is the only one with a voice. Remote horns are
// positional at the emoter; your own plays dry (you're at the listener).

View File

@ -36,6 +36,11 @@ export type GameEvents = {
'workshop:torque': { value: number | null }; // radial HUD meter (null hides)
'workshop:msg': { text: string }; // transient HUD subtitle
// Mining-in-progress juice (first-five-minutes pass). Emitted ~8 Hz while
// a block is being held-mined; FX puffs and audio ticks react. FX-only —
// no gameplay reads this.
'block:mining': { x: number; y: number; z: number; progress: number };
// Audio analysis (Lane E emits every beat/bar of the synthesized mix).
'audio:beat': { energy: number }; // 0..1 low-band energy
'audio:bar': { bar: number };

View File

@ -57,6 +57,14 @@ export class FxSystem {
private fadeFrom = 0;
private lastBoost = 0.35;
// Broken-node spark beacons (first-five-minutes pass): each unrepaired
// quest fixture crackles with intermittent electric sparks — diegetic
// breadcrumbs visible across the booth. Wired by the integrator via
// setBeacons(); silent until then.
private beacons: { node: string; pos: [number, number, number]; next: number }[] = [];
private beaconRepaired: (node: string) => boolean = () => true;
private beaconClock = 0;
constructor(opts: FxOpts) {
this.scene = opts.scene;
this.setBoost = opts.setEmissiveBoost;
@ -78,6 +86,28 @@ export class FxSystem {
this.wireBus();
}
/** Anchor the broken-node spark beacons (integrator supplies positions + state). */
setBeacons(
anchors: { node: string; pos: [number, number, number] }[],
isRepaired: (node: string) => boolean,
): void {
this.beacons = anchors.map((a, i) => ({ ...a, next: 0.6 + i * 0.5 }));
this.beaconRepaired = isRepaired;
}
private updateBeacons(dt: number): void {
this.beaconClock += dt;
for (const b of this.beacons) {
if (this.beaconClock < b.next) continue;
b.next = this.beaconClock + 1.4 + Math.random() * 2.4;
if (this.beaconRepaired(b.node)) continue;
// A short electric crackle: blue-white, fast, falls and dies quickly.
this.puff.spawn(9, b.pos[0] + 0.5, b.pos[1] + 0.8, b.pos[2] + 0.5, {
r: 0.72, g: 0.86, b: 1.0, spread: 5, speed: 6, up: 3.2, life: 0.32, jitter: 0.5,
});
}
}
update(dt: number): void {
this.dust.update(dt);
this.puff.update(dt);
@ -87,6 +117,7 @@ export class FxSystem {
this.patch.update(dt);
this.vu.update(this.getLevels());
this.updateRimSparkle(dt);
this.updateBeacons(dt);
// LED pulse decay
this.pulse *= Math.exp(-dt / this.pulseTau);
@ -161,6 +192,13 @@ export class FxSystem {
// ── bus wiring ─────────────────────────────────────────────────────────
private wireBus(): void {
// Mining juice: a few chips fly while a block is being held-mined.
bus.on('block:mining', (p) => {
this.puff.spawn(3, p.x + 0.5, p.y + 0.6, p.z + 0.5, {
r: 0.55, g: 0.53, b: 0.5, spread: 3, speed: 4, up: 2, life: 0.3, jitter: 0.4,
});
});
bus.on('audio:beat', (p) => {
if (this.repairs < 1 && !this.won) return; // dead booth stays dim
const amp = 0.85 * p.energy;

View File

@ -55,6 +55,7 @@ export class Interaction {
private guarding = false;
private breakTarget: { x: number; y: number; z: number } | null = null;
private breakElapsed = 0;
private lastMiningEmit = 0;
private lastHit: RayHit = null;
private lastColliderId: string | null = null;
@ -196,11 +197,12 @@ export class Interaction {
private advanceMining(dt: number, hit: RayHit): void {
if (!this.mining || !hit || hit.kind !== 'block' || !blockDef(hit.id).breakable) {
this.endGuard();
this.breakTarget = null; this.breakElapsed = 0; return;
this.breakTarget = null; this.breakElapsed = 0; this.lastMiningEmit = 0; return;
}
if (!this.breakTarget || this.breakTarget.x !== hit.x || this.breakTarget.y !== hit.y || this.breakTarget.z !== hit.z) {
this.breakTarget = { x: hit.x, y: hit.y, z: hit.z };
this.breakElapsed = 0;
this.lastMiningEmit = 0;
this.endGuard();
}
this.breakElapsed += dt;
@ -209,6 +211,15 @@ export class Interaction {
// (SOCIAL_RESET: the fuse takes 5 s once the booth is won). Null = normal.
const override = this.guard ? this.guard.timeFor(hit.x, hit.y, hit.z, hit.id) : null;
const breakTime = override ?? BREAK_TIME_BY_SOUND[blockDef(hit.id).sound] ?? BREAK_TIME_DEFAULT;
// Mining juice: throttled progress events (FX puffs + rising audio ticks).
if (this.breakElapsed - this.lastMiningEmit > 0.12) {
this.lastMiningEmit = this.breakElapsed;
bus.emit('block:mining', {
x: hit.x, y: hit.y, z: hit.z,
progress: Math.min(1, this.breakElapsed / breakTime),
});
}
if (override !== null) {
this.guarding = true;
this.guard!.progress?.(Math.min(1, this.breakElapsed / breakTime));
@ -227,7 +238,7 @@ export class Interaction {
this.hotbar.add(id, 1);
this.world.setBlock(hit.x, hit.y, hit.z, AIR);
bus.emit('block:break', { x: hit.x, y: hit.y, z: hit.z, id });
this.breakTarget = null; this.breakElapsed = 0;
this.breakTarget = null; this.breakElapsed = 0; this.lastMiningEmit = 0;
}
}

View File

@ -90,8 +90,70 @@ scene.add(interaction.getObject3D());
// 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(); },
onStart: () => {
void audio.init();
renderer.domElement.requestPointerLock();
runFlythrough();
},
onResume: () => { renderer.domElement.requestPointerLock(); },
getHotbar: () => hotbar, // D's Hotbar structurally satisfies E's HotbarModel
});