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>
98 lines
3.2 KiB
TypeScript
98 lines
3.2 KiB
TypeScript
// 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;
|
|
}
|