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>
115 lines
3.5 KiB
TypeScript
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);
|
|
}
|