// 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; }