/** Bubbles mode - the first arcade mode, and the proof that the engine is * mode-agnostic: same `useRun`, same grading, same unlock. Only the drawing differs. * * Each letter of the line is a bubble. The bubble at the cursor is the one nearest the * surface; popping it lets the ones below rise. Position is derived from distance to the * cursor rather than from a clock, which has two consequences worth stating: the rise is * a CSS transition instead of a requestAnimationFrame loop, and - more importantly - * taking your time costs nothing. The plan calls this a soft clock. Speed still shows up * in the animal, because `grade` is measuring the keystrokes either way, but no bubble * ever escapes and nothing is ever lost. At six, "you were too slow" is the fastest way * to end a session. */ import { useState } from "react"; import { currentChar } from "../../lib/engine"; import type { RunEvent } from "../../lib/engine"; import { fingerOf } from "../../lib/fingers"; import type { RunResult } from "../../lib/grading"; import type { Progress } from "../../lib/progress"; import { useRun } from "../../hooks/useRun"; import { Keyboard } from "../Keyboard"; interface Props { letters: readonly string[]; activeKeys: readonly string[]; progress: Progress; paused: boolean; onFinished: (result: RunResult) => void; } /** How many bubbles are in the water at once. More than five and the column of letters * reads as a wall of text; fewer and there is nothing to look forward to. */ const VISIBLE_COUNT = 5; /** Fixed horizontal lanes, so bubbles do not jitter sideways as they rise. */ const LANES = [50, 28, 68, 38, 60, 46]; export function BubblesRun({ letters, activeKeys, progress, paused, onFinished }: Props) { const text = letters.join(""); const [popped, setPopped] = useState(null); const onEvent = (event: RunEvent) => { // Remember which bubble just popped so it can play its burst before disappearing. if (event.type === "correct") setPopped(event.index); }; const { state, wrong } = useRun({ target: text, sound: progress.settings.sound, paused, onFinished, onEvent, }); const next = currentChar(state); return (
{/* The surface line the bubbles rise toward. */}
{[...text].map((letter, i) => { const distance = i - state.index; if (distance < 0 || distance >= VISIBLE_COUNT) return null; const finger = fingerOf(letter); const isCurrent = distance === 0; const size = isCurrent ? 104 : 68; return (
{letter === " " ? "␣" : letter}
); })}
); }