not-tonight/src/ui/Stamp.ts
type-two 2eb3c94ccd LANE-JUICE: audio engine, SFX set, five UI widgets, JuiceDemoScene
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>
2026-07-19 18:52:43 +10:00

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 };
}