not-tonight/src/ui/style.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

115 lines
3.5 KiB
TypeScript

import Phaser from 'phaser';
import { PALETTE } from '../data/outfits';
// Shared look for every juice widget. Chunky, high contrast, slightly grubby —
// this club has not been deep-cleaned since the Olympics. Colours lean on the
// doll PALETTE so UI and patrons live in the same world.
export const UI = {
ink: 0x0b0b12,
panel: 0x1a1a24,
panelLip: 0x2c2c3a,
grime: 0x24242e,
paper: PALETTE.paper ?? 0xdad4c4,
neonPink: 0xd03470,
neonGreen: 0x9fe8a0,
kebabAmber: 0xd8a020,
danger: PALETTE.red ?? 0xc03434,
ok: PALETTE.green ?? 0x2e7d46,
dim: 0x556,
} as const;
export const hex = (c: number): string => `#${c.toString(16).padStart(6, '0')}`;
export const font = (size: number, colour: number): Phaser.Types.GameObjects.Text.TextStyle => ({
fontFamily: 'monospace',
fontSize: `${size}px`,
color: hex(colour),
});
/**
* A grubby recessed panel: dark fill, lit top lip, dark bottom shadow. Returns a
* container positioned at (x, y) with the panel centred on its origin, so
* callers can add children in local coordinates.
*/
export function panel(
scene: Phaser.Scene,
x: number,
y: number,
w: number,
h: number,
fill: number = UI.panel,
): Phaser.GameObjects.Container {
const c = scene.add.container(x, y);
c.add(scene.add.rectangle(0, 0, w, h, fill).setStrokeStyle(1, UI.ink));
c.add(scene.add.rectangle(0, -h / 2 + 1, w - 2, 1, UI.panelLip, 0.5));
c.add(scene.add.rectangle(0, h / 2 - 1, w - 2, 1, UI.ink, 0.6));
return c;
}
export interface ChunkyButtonOptions {
w?: number;
h?: number;
fill?: number;
textColour?: number;
fontSize?: number;
onClick: () => void;
}
/**
* Chunky pixel button with a real press: it drops 1px and darkens on pointerdown
* and springs back on release. The 1px travel is most of why it feels physical.
*/
export function chunkyButton(
scene: Phaser.Scene,
x: number,
y: number,
label: string,
opts: ChunkyButtonOptions,
): Phaser.GameObjects.Container {
const w = opts.w ?? 56;
const h = opts.h ?? 16;
const fill = opts.fill ?? UI.panelLip;
const c = scene.add.container(x, y);
const face = scene.add.rectangle(0, 0, w, h, fill).setStrokeStyle(1, UI.ink);
const shadow = scene.add.rectangle(0, h / 2, w, 2, UI.ink, 0.8);
const text = scene.add
.text(0, 0, label, font(opts.fontSize ?? 8, opts.textColour ?? UI.paper))
.setOrigin(0.5);
c.add([shadow, face, text]);
face.setInteractive({ useHandCursor: true });
const press = (down: boolean): void => {
face.y = down ? 1 : 0;
text.y = down ? 1 : 0;
shadow.setAlpha(down ? 0.2 : 0.8);
face.setFillStyle(down ? Phaser.Display.Color.ValueToColor(fill).darken(20).color : fill);
};
// Phaser fires 'pointerup' on any release over the object, even one whose
// 'pointerdown' landed elsewhere. Arm on our own down and disarm the moment the
// pointer leaves, so a stray release can't phantom-click (clicker tally is load-bearing).
let armed = false;
face.on('pointerdown', () => {
armed = true;
press(true);
});
// Leaving the face is the only way to release elsewhere, so this covers the
// press-drag-away-release case too.
face.on('pointerout', () => {
armed = false;
press(false);
});
face.on('pointerup', () => {
const fire = armed;
armed = false;
press(false);
if (fire) opts.onClick();
});
return c;
}
/** Short, sharp camera shake. Used by the stamp and anything else with weight. */
export function shake(scene: Phaser.Scene, px = 4, ms = 120): void {
scene.cameras.main.shake(ms, px / 1000, true);
}