LANE-JUICE: pure foundation — beat scheduling, filter curve, UI style kit, drunk typo renderer
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 <noreply@anthropic.com>
This commit is contained in:
parent
9805ff8fe5
commit
c262ac8261
61
src/audio/filterCurve.ts
Normal file
61
src/audio/filterCurve.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
69
src/audio/scheduling.ts
Normal file
69
src/audio/scheduling.ts
Normal file
@ -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;
|
||||||
23
src/ui/juiceEvents.ts
Normal file
23
src/ui/juiceEvents.ts
Normal file
@ -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 {};
|
||||||
100
src/ui/style.ts
Normal file
100
src/ui/style.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
97
src/ui/typo.ts
Normal file
97
src/ui/typo.ts
Normal file
@ -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<string, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user