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:
parent
05c2765bd8
commit
caac3da930
@ -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
BIN
public/preview.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@ -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).
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
src/main.ts
64
src/main.ts
@ -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
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user