Audio (src/audio/), raw WebAudio, no samples or libraries: - TechnoEngine: 128 BPM four-on-the-floor kick, off-beat hats, 2-bar 16th bassline, 8-bar pad. Look-ahead scheduler (25ms timer, 100ms horizon) is the beat authority — emits beat:tick with the SCHEDULED audio time, so consumers can compare honestly. Replaces core/StubBeatClock at integration; same event. Stale beats are resynced to the next downbeat rather than caught up: a throttled/hidden tab otherwise dumps a 64-beat backlog onto one sample. Music runs through the location lowpass; SFX bypass it and stay crisp. - Sfx: 10 procedural one-shots + rain bed, one shared noise buffer, voices self-disconnect on ended so a six-hour night doesn't accumulate nodes. UI (src/ui/), reusable Phaser containers, no door/floor imports: - Stamp (the signature interaction), Phone (Dazza thread + phoneTheatre), DialogueBox (typed-out, drunk-typo render), Clicker (digit roll), MeterHud (neon vibe / crowd-temp aggro / hype badge / 3 heat slots). - JuiceDemoScene exercises every widget and SFX, with a log-mapped cutoff slider and a DOOR/FLOOR toggle that routes through the bus. Verified in-browser: the door->floor sweep ramps 250Hz->18kHz over 600ms, monotonic and exponential (halfway lands ~2.1kHz, not the ~9kHz a linear ramp would give) — that late bloom is what sells the door opening. Under a real hidden tab, 52 consecutive beats scheduled with zero in the past, 75-98ms lead, max one beat per wake. main.ts touched to route J / #juice to the demo — outside lane ownership, flagged for sign-off in LANEHANDOVER.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
269 lines
8.1 KiB
TypeScript
269 lines
8.1 KiB
TypeScript
import Phaser from 'phaser';
|
|
import { UI, font, shake } from './style';
|
|
|
|
// The stamp is the whole game in one gesture, so the timing is doing the acting:
|
|
// a wind-up above the target, 160ms of accelerating travel, a hard hit, then a
|
|
// 90ms dead hang before the lift. The hang is what makes it feel like a decision
|
|
// rather than an animation — do not shorten it.
|
|
|
|
const HEAD_W = 54;
|
|
const HEAD_H = 22;
|
|
|
|
const TRAVEL_MS = 160;
|
|
const HANG_MS = 90;
|
|
const RELEASE_MS = 180;
|
|
const RING_MS = 200;
|
|
const DUST_MS = 350;
|
|
|
|
const DUST_COUNT = 9;
|
|
const SPLATS = 7;
|
|
|
|
/** Head sits above the decal; ring and dust sit between them. */
|
|
const RING_DEPTH_OFFSET = 5;
|
|
const HEAD_DEPTH_OFFSET = 10;
|
|
|
|
export interface StampSfx {
|
|
stampSlam(): void;
|
|
stampInk(): void;
|
|
}
|
|
|
|
export interface StampHandle {
|
|
/** The ink mark left behind. The caller owns it; destroy it when the patron leaves. */
|
|
decal: Phaser.GameObjects.Container;
|
|
}
|
|
|
|
export interface StampOptions {
|
|
/** Always scene/world coordinates — converted into `parent` space when one is given. */
|
|
x: number;
|
|
y: number;
|
|
text?: string;
|
|
colour?: number;
|
|
depth?: number;
|
|
sfx?: StampSfx;
|
|
/** Parent for the decal, so the mark travels with the patron. Defaults to the scene root. */
|
|
parent?: Phaser.GameObjects.Container;
|
|
/** Per-impression variation. Same seed => same mark. Defaults to a hash of `text`. */
|
|
seed?: number;
|
|
onSlam?: () => void;
|
|
onDone?: () => void;
|
|
}
|
|
|
|
/**
|
|
* FNV-1a over the label so DENIED and ADMITTED get different splatter, stably; the
|
|
* caller's seed is folded in after so two DENIEDs are not the same printed mess.
|
|
*/
|
|
function hashText(text: string, seed?: number): number {
|
|
let h = 2166136261;
|
|
for (const ch of text) {
|
|
h ^= ch.charCodeAt(0);
|
|
h = Math.imul(h, 16777619);
|
|
}
|
|
if (seed !== undefined) {
|
|
let s = seed >>> 0;
|
|
for (let i = 0; i < 4; i++) {
|
|
h ^= s & 0xff;
|
|
h = Math.imul(h, 16777619);
|
|
s >>>= 8;
|
|
}
|
|
}
|
|
return (h >>> 0) % 4096;
|
|
}
|
|
|
|
/** Hash noise in place of Math.random — same label always prints the same mess. */
|
|
function sprinkle(seed: number, i: number): number {
|
|
const s = Math.sin(seed * 12.9898 + i * 78.233) * 43758.5453;
|
|
return s - Math.floor(s);
|
|
}
|
|
|
|
/** The rubber head: dark body, inked face, knurled grip on top. */
|
|
function buildHead(
|
|
scene: Phaser.Scene,
|
|
colour: number,
|
|
label: string,
|
|
): Phaser.GameObjects.Container {
|
|
const head = scene.add.container(0, 0);
|
|
|
|
const body = scene.add.graphics();
|
|
body.fillStyle(UI.ink, 1);
|
|
body.fillRoundedRect(-HEAD_W / 2, -HEAD_H / 2, HEAD_W, HEAD_H, 3);
|
|
body.fillStyle(UI.panelLip, 1);
|
|
body.fillRect(-10, -HEAD_H / 2 - 8, 20, 8);
|
|
body.fillStyle(UI.panel, 1);
|
|
body.fillRect(-14, -HEAD_H / 2 - 11, 28, 4);
|
|
|
|
const face = scene.add.graphics();
|
|
face.fillStyle(colour, 1);
|
|
face.fillRoundedRect(-HEAD_W / 2 + 3, -HEAD_H / 2 + 4, HEAD_W - 6, HEAD_H - 7, 2);
|
|
|
|
const text = scene.add.text(0, 1, label, font(8, UI.paper)).setOrigin(0.5).setLetterSpacing(1);
|
|
|
|
head.add([body, face, text]);
|
|
return head;
|
|
}
|
|
|
|
/** The mark that outlives the gesture: border, label, and uneven ink. */
|
|
function fillDecal(
|
|
scene: Phaser.Scene,
|
|
decal: Phaser.GameObjects.Container,
|
|
colour: number,
|
|
label: string,
|
|
seed: number,
|
|
): void {
|
|
// Never quite square to the card — a machine would print it straight.
|
|
decal.setRotation((sprinkle(seed, 0) - 0.5) * 0.16);
|
|
|
|
const border = scene.add.graphics();
|
|
border.lineStyle(2, colour, 0.85);
|
|
border.strokeRoundedRect(-HEAD_W / 2 + 2, -HEAD_H / 2 + 3, HEAD_W - 4, HEAD_H - 6, 2);
|
|
decal.add(border);
|
|
|
|
// Smudge under the crisp pass: one bad impression, printed twice.
|
|
decal.add(
|
|
scene.add.text(1, 2, label, font(9, colour)).setOrigin(0.5).setLetterSpacing(1).setAlpha(0.35),
|
|
);
|
|
decal.add(scene.add.text(0, 1, label, font(9, colour)).setOrigin(0.5).setLetterSpacing(1).setAlpha(0.92));
|
|
|
|
// Over-inked pooling near the edges plus a few thrown specks.
|
|
for (let i = 0; i < SPLATS; i++) {
|
|
const a = (i / SPLATS) * Math.PI * 2 + sprinkle(seed, 100 + i);
|
|
const r = 12 + sprinkle(seed, 200 + i) * 16;
|
|
const size = 1 + Math.round(sprinkle(seed, 300 + i) * 2);
|
|
decal.add(
|
|
scene.add
|
|
.circle(Math.cos(a) * r, Math.sin(a) * r * 0.5, size, colour, 1)
|
|
.setAlpha(0.28 + sprinkle(seed, 400 + i) * 0.35),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Shockwave: a flattened ring, because the ink lands on a surface, not in air. */
|
|
function spawnRing(scene: Phaser.Scene, opts: StampOptions, colour: number, depth: number): void {
|
|
const ring = scene.add
|
|
.circle(opts.x, opts.y, 10, colour, 0)
|
|
.setStrokeStyle(2, colour, 0.9)
|
|
.setDepth(depth)
|
|
.setScale(0.4, 0.25);
|
|
|
|
scene.tweens.add({
|
|
targets: ring,
|
|
scaleX: 2.4,
|
|
scaleY: 1.3,
|
|
alpha: 0,
|
|
duration: RING_MS,
|
|
ease: 'Cubic.easeOut',
|
|
onComplete: () => ring.destroy(),
|
|
});
|
|
}
|
|
|
|
/** Fixed angular steps — a random spray reads as noise, an even ring reads as force. */
|
|
function spawnDust(scene: Phaser.Scene, opts: StampOptions, colour: number, seed: number, depth: number): void {
|
|
for (let i = 0; i < DUST_COUNT; i++) {
|
|
const a = (i / DUST_COUNT) * Math.PI * 2;
|
|
const dist = 13 + sprinkle(seed, 500 + i) * 11;
|
|
const size = 1 + Math.round(sprinkle(seed, 600 + i));
|
|
// Alternating grit and ink so the burst reads as both dust and spatter.
|
|
const tint = i % 2 === 0 ? UI.paper : colour;
|
|
|
|
const p = scene.add
|
|
.rectangle(opts.x, opts.y, size, size, tint, 0.75)
|
|
.setDepth(depth);
|
|
|
|
scene.tweens.add({
|
|
targets: p,
|
|
x: opts.x + Math.cos(a) * dist,
|
|
y: opts.y + Math.sin(a) * dist * 0.45 + 4,
|
|
alpha: 0,
|
|
duration: DUST_MS,
|
|
ease: 'Quad.easeOut',
|
|
onComplete: () => p.destroy(),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Slam a stamp onto world (x, y). The head, ring and dust clean themselves up; the
|
|
* returned decal is the caller's to destroy — nothing else will. It is empty until
|
|
* the head lands, so the returned container is safe to reparent or destroy early.
|
|
* Colour and label are pure inputs — the ADMITTED variant is the same code path.
|
|
*/
|
|
export function stamp(scene: Phaser.Scene, opts: StampOptions): StampHandle {
|
|
const label = opts.text ?? 'DENIED';
|
|
const colour = opts.colour ?? UI.danger;
|
|
const depth = opts.depth ?? 0;
|
|
const seed = hashText(label, opts.seed);
|
|
|
|
const decal = scene.add.container(0, 0).setDepth(depth);
|
|
if (opts.parent) opts.parent.add(decal);
|
|
let decalAlive = true;
|
|
decal.once(Phaser.GameObjects.Events.DESTROY, () => {
|
|
decalAlive = false;
|
|
});
|
|
|
|
const head = buildHead(scene, colour, label);
|
|
head
|
|
.setDepth(depth + HEAD_DEPTH_OFFSET)
|
|
.setPosition(opts.x + 14, opts.y - 70)
|
|
.setScale(1.6)
|
|
.setRotation(-0.35)
|
|
.setAlpha(0.35);
|
|
|
|
const impact = (): void => {
|
|
shake(scene, 4, 120);
|
|
opts.sfx?.stampSlam();
|
|
opts.sfx?.stampInk();
|
|
opts.onSlam?.();
|
|
|
|
if (decalAlive) {
|
|
// Placed at impact, not at call time, so a moving parent stamps where the ink hit.
|
|
const local = opts.parent
|
|
? opts.parent.getWorldTransformMatrix().applyInverse(opts.x, opts.y)
|
|
: { x: opts.x, y: opts.y };
|
|
decal.setPosition(local.x, local.y);
|
|
fillDecal(scene, decal, colour, label, seed);
|
|
}
|
|
spawnRing(scene, opts, colour, depth + RING_DEPTH_OFFSET);
|
|
spawnDust(scene, opts, colour, seed, depth + RING_DEPTH_OFFSET);
|
|
|
|
// `delay` is the hang — nothing moves, and that stillness is the punctuation.
|
|
scene.tweens.add({
|
|
targets: head,
|
|
y: opts.y - 26,
|
|
alpha: 0,
|
|
duration: RELEASE_MS,
|
|
delay: HANG_MS,
|
|
ease: 'Quad.easeOut',
|
|
onComplete: () => {
|
|
head.destroy();
|
|
opts.onDone?.();
|
|
},
|
|
});
|
|
};
|
|
|
|
// Split easings per axis: the horizontal settles early while the vertical is
|
|
// still accelerating, which bends the path into an arc without a path object.
|
|
scene.tweens.add({
|
|
targets: head,
|
|
x: opts.x,
|
|
duration: TRAVEL_MS,
|
|
ease: 'Quad.easeOut',
|
|
});
|
|
scene.tweens.add({
|
|
targets: head,
|
|
alpha: 1,
|
|
duration: TRAVEL_MS * 0.6,
|
|
ease: 'Linear',
|
|
});
|
|
scene.tweens.add({
|
|
targets: head,
|
|
y: opts.y,
|
|
rotation: 0,
|
|
scaleX: 1,
|
|
scaleY: 1,
|
|
duration: TRAVEL_MS,
|
|
ease: 'Back.easeIn',
|
|
onComplete: impact,
|
|
});
|
|
|
|
return { decal };
|
|
}
|