From c262ac82614e70a0e274b3e727af3bf9f9fbbd17 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 18:04:33 +1000 Subject: [PATCH] =?UTF-8?q?LANE-JUICE:=20pure=20foundation=20=E2=80=94=20b?= =?UTF-8?q?eat=20scheduling,=20filter=20curve,=20UI=20style=20kit,=20drunk?= =?UTF-8?q?=20typo=20renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testable-in-node math extracted ahead of the WebAudio/Phaser layers: - audio/scheduling.ts: look-ahead beat grid (dueBeats never repeats or skips a beat however ragged the timer wakes; burst-capped for suspended tabs) - audio/filterCurve.ts: the location filter's exponential cutoff ramp — the door-opening sweep, in frequency-space so it blooms late like a real door - ui/style.ts: chunky pixel widget kit sharing the doll PALETTE - ui/typo.ts: deterministic drunk-typo/typing/wobble renderer (seeded, no Math.random) - ui/juiceEvents.ts: EventMap extensions via declaration merging, so door:phoneTheatre types correctly without editing frozen data/types.ts. TODO(contract) — CONTRACT CHANGE REQUEST filed in LANEHANDOVER.md. Co-Authored-By: Claude Opus 4.8 --- src/audio/filterCurve.ts | 61 ++++++++++++++++++++++++ src/audio/scheduling.ts | 69 +++++++++++++++++++++++++++ src/ui/juiceEvents.ts | 23 +++++++++ src/ui/style.ts | 100 +++++++++++++++++++++++++++++++++++++++ src/ui/typo.ts | 97 +++++++++++++++++++++++++++++++++++++ 5 files changed, 350 insertions(+) create mode 100644 src/audio/filterCurve.ts create mode 100644 src/audio/scheduling.ts create mode 100644 src/ui/juiceEvents.ts create mode 100644 src/ui/style.ts create mode 100644 src/ui/typo.ts diff --git a/src/audio/filterCurve.ts b/src/audio/filterCurve.ts new file mode 100644 index 0000000..5ffa549 --- /dev/null +++ b/src/audio/filterCurve.ts @@ -0,0 +1,61 @@ +// Pure math for the location filter — the game's signature audio trick. +// +// Music runs through one master lowpass. At the door you hear the kick through a +// brick wall; step inside and the filter opens. The sweep must feel like a door +// swinging, not a fader move, so it ramps exponentially in frequency (which is +// how ears hear pitch) rather than linearly in Hz. + +export type AudioLocation = 'door' | 'floor'; + +/** Muffled-through-the-wall. Low enough that hats vanish and only kick survives. */ +export const DOOR_CUTOFF_HZ = 250; +/** Effectively open — above this the filter is inaudible. */ +export const FLOOR_CUTOFF_HZ = 18_000; + +/** Music dips slightly outside; inside it is full. */ +export const DOOR_GAIN = 0.62; +export const FLOOR_GAIN = 1; + +/** Door opening is a fast reveal; door closing behind you is slower and sadder. */ +export const OPEN_SWEEP_MS = 600; +export const CLOSE_SWEEP_MS = 900; + +export const cutoffFor = (location: AudioLocation): number => + location === 'floor' ? FLOOR_CUTOFF_HZ : DOOR_CUTOFF_HZ; + +export const gainFor = (location: AudioLocation): number => + location === 'floor' ? FLOOR_GAIN : DOOR_GAIN; + +/** Opening (door -> floor) is the fast one. */ +export const sweepMsFor = (to: AudioLocation): number => + to === 'floor' ? OPEN_SWEEP_MS : CLOSE_SWEEP_MS; + +/** + * Cutoff at normalised progress t (0..1) of a sweep, interpolated exponentially. + * t is clamped, so callers can hand it raw elapsed/duration. + * + * Exponential in frequency == linear in perceived pitch: at t=0.5 of a + * 250Hz->18kHz sweep you are at ~2.1kHz, not the ~9kHz a linear ramp would give. + * That late-blooming brightness is what sells "the door just opened". + */ +export function cutoffAt(t: number, fromHz: number, toHz: number): number { + const clamped = Math.max(0, Math.min(1, t)); + return fromHz * Math.pow(toHz / fromHz, clamped); +} + +/** Master gain at progress t — linear is fine, the ear tracks the filter here. */ +export function gainAt(t: number, from: number, to: number): number { + const clamped = Math.max(0, Math.min(1, t)); + return from + (to - from) * clamped; +} + +/** + * Sampled ramp points for `setValueCurveAtTime`. WebAudio's + * exponentialRampToValueAtTime would do the job, but sampling lets us assert the + * curve's shape in tests and keeps door/floor behaviour identical everywhere. + */ +export function cutoffCurve(fromHz: number, toHz: number, points = 32): Float32Array { + const out = new Float32Array(points); + for (let i = 0; i < points; i++) out[i] = cutoffAt(i / (points - 1), fromHz, toHz); + return out; +} diff --git a/src/audio/scheduling.ts b/src/audio/scheduling.ts new file mode 100644 index 0000000..b747db4 --- /dev/null +++ b/src/audio/scheduling.ts @@ -0,0 +1,69 @@ +// Pure look-ahead scheduling math for TechnoEngine. +// +// The engine runs a 25ms timer; on each wake it asks this module which beats fall +// inside the next `lookaheadMs` of audio time and schedules oscillators for them. +// Keeping the arithmetic here means the beat grid is unit-testable without an +// AudioContext (tests run in node — there is no WebAudio there). + +export interface BeatGrid { + bpm: number; + /** AudioContext time (ms) at which beat 0 sounds. */ + originMs: number; +} + +export interface ScheduledBeat { + beatIndex: number; + /** Absolute AudioContext time (ms) the beat sounds at. */ + timeMs: number; +} + +export const beatIntervalMs = (bpm: number): number => 60_000 / bpm; + +/** Absolute audio time of a given beat index. */ +export const beatTimeMs = (grid: BeatGrid, beatIndex: number): number => + grid.originMs + beatIndex * beatIntervalMs(grid.bpm); + +/** + * Every beat with index >= fromBeat whose time falls at or before + * `nowMs + lookaheadMs`. Returned in ascending order; empty when nothing is due. + * + * The engine advances its own cursor past the last returned index, so a beat is + * never scheduled twice however ragged the timer wakeups are. + */ +export function dueBeats( + grid: BeatGrid, + fromBeat: number, + nowMs: number, + lookaheadMs: number, +): ScheduledBeat[] { + const horizon = nowMs + lookaheadMs; + const out: ScheduledBeat[] = []; + // Guard against a pathological horizon (tab suspended for minutes) producing a + // scheduling storm — we cap the burst and let the cursor catch up next wake. + const MAX_BURST = 64; + for (let i = fromBeat; out.length < MAX_BURST; i++) { + const timeMs = beatTimeMs(grid, i); + if (timeMs > horizon) break; + out.push({ beatIndex: i, timeMs }); + } + return out; +} + +/** 16th-note subdivision times within a beat, for hats and the bassline. */ +export function subdivisions(grid: BeatGrid, beatIndex: number, n: number): number[] { + const step = beatIntervalMs(grid.bpm) / n; + const base = beatTimeMs(grid, beatIndex); + return Array.from({ length: n }, (_, i) => base + i * step); +} + +/** Bar/beat position of a beat index in 4/4. */ +export const barOf = (beatIndex: number): number => Math.floor(beatIndex / 4); +export const beatInBar = (beatIndex: number): number => ((beatIndex % 4) + 4) % 4; +export const isDownbeat = (beatIndex: number): boolean => beatInBar(beatIndex) === 0; + +/** + * Where a 16th-note step sits in the 2-bar (32-step) bassline pattern. + * Exposed so tests can assert the pattern wraps rather than drifting. + */ +export const patternStep = (beatIndex: number, sixteenth: number, steps = 32): number => + (((beatIndex * 4 + sixteenth) % steps) + steps) % steps; diff --git a/src/ui/juiceEvents.ts b/src/ui/juiceEvents.ts new file mode 100644 index 0000000..2020d09 --- /dev/null +++ b/src/ui/juiceEvents.ts @@ -0,0 +1,23 @@ +// LANE-JUICE event extensions. +// +// TODO(contract): `door:phoneTheatre` lives outside the audio domain, so it needs +// reviewer sign-off before it moves into data/types.ts. Until then we merge it +// into EventMap by declaration merging rather than editing the frozen contract +// file — same typing guarantees, zero edits to LANE-0 territory. See the CONTRACT +// CHANGE REQUEST in LANEHANDOVER.md. +// +// `audio:*` / `beat:*` additions are ours to extend freely (LANE_JUICE.md), but +// they are declared here too so the whole juice surface reads in one place. + +declare module '../data/types' { + interface EventMap { + /** Player fiddled with their phone while a patron waits — pure theatre. */ + 'door:phoneTheatre': { durationMs: number }; + /** AudioContext resumed after the first real user gesture. */ + 'audio:unlocked': { atMs: number }; + /** Bar line boundary (every 4th beat) — for anything that wants downbeats. */ + 'beat:bar': { barIndex: number; beatIndex: number; audioTimeMs: number }; + } +} + +export {}; diff --git a/src/ui/style.ts b/src/ui/style.ts new file mode 100644 index 0000000..39d59d3 --- /dev/null +++ b/src/ui/style.ts @@ -0,0 +1,100 @@ +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); + }; + face.on('pointerdown', () => press(true)); + face.on('pointerout', () => press(false)); + face.on('pointerup', () => { + press(false); + 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); +} diff --git a/src/ui/typo.ts b/src/ui/typo.ts new file mode 100644 index 0000000..e5cad4b --- /dev/null +++ b/src/ui/typo.ts @@ -0,0 +1,97 @@ +// Drunk-typo text renderer for DialogueBox. +// +// Pure and deterministic: same (text, drunkenness, seed) always yields the same +// mangled string, so a patron's slurred answer is reproducible from the run seed +// like everything else. No Math.random — callers pass a SeededRNG stream. + +export interface RngLike { + /** 0..1, like SeededRNG streams. */ + next(): number; +} + +/** Adjacent-key swaps — a drunk thumb misses sideways, not randomly. */ +const NEIGHBOURS: Record = { + a: 's', b: 'v', c: 'x', d: 'f', e: 'w', f: 'g', g: 'h', h: 'j', i: 'o', + j: 'k', k: 'l', l: 'k', m: 'n', n: 'm', o: 'p', p: 'o', q: 'w', r: 't', + s: 'a', t: 'r', u: 'i', v: 'b', w: 'e', x: 'z', y: 'u', z: 'x', +}; + +/** Vowels get stretched when someone is really going for it. */ +const VOWELS = 'aeiou'; + +export interface TypoOptions { + /** 0..1 — 0 leaves text untouched, 1 is barely language. */ + drunkenness: number; + rng: RngLike; +} + +/** + * Mangle a line in proportion to how gone the speaker is. + * + * Four escalating effects, each gated on its own probability so a tipsy speaker + * gets the occasional doubled letter and a maggot one gets all of it at once: + * - adjacent-key substitution + * - doubled letters + * - dropped letters + * - stretched vowels ("yeahhh") + */ +export function drunkify(text: string, { drunkenness, rng }: TypoOptions): string { + const d = Math.max(0, Math.min(1, drunkenness)); + if (d <= 0) return text; + + // Below ~0.25 people type fine. Above that it ramps quickly. + const intensity = Math.max(0, (d - 0.25) / 0.75); + if (intensity <= 0) return text; + + const pSub = intensity * 0.14; + const pDouble = intensity * 0.09; + const pDrop = intensity * 0.07; + const pStretch = intensity * 0.12; + + let out = ''; + for (const ch of text) { + const lower = ch.toLowerCase(); + const isLetter = lower >= 'a' && lower <= 'z'; + if (!isLetter) { + out += ch; + continue; + } + + if (rng.next() < pDrop) continue; + + let emitted = ch; + if (rng.next() < pSub) { + const swap = NEIGHBOURS[lower]; + if (swap !== undefined) emitted = ch === lower ? swap : swap.toUpperCase(); + } + + out += emitted; + + if (VOWELS.includes(lower) && rng.next() < pStretch) { + const extra = 1 + Math.floor(rng.next() * 3); + out += emitted.repeat(extra); + } else if (rng.next() < pDouble) { + out += emitted; + } + } + return out; +} + +/** + * How many characters of `text` are visible after `elapsedMs` of typing. + * Drunk speakers type slower and in lurches — the stagger is deterministic in + * the character index so it does not shimmer between frames. + */ +export function typedLength(text: string, elapsedMs: number, charMs: number, drunkenness = 0): number { + if (charMs <= 0) return text.length; + const slow = 1 + Math.max(0, Math.min(1, drunkenness)) * 1.2; + const raw = elapsedMs / (charMs * slow); + return Math.max(0, Math.min(text.length, Math.floor(raw))); +} + +/** Per-character vertical wobble (px) for drunk render mode. Deterministic. */ +export function wobbleAt(index: number, drunkenness: number, phase: number): number { + const amp = Math.max(0, Math.min(1, drunkenness)) * 1.6; + if (amp <= 0) return 0; + return Math.sin(phase + index * 0.9) * amp; +}