- Rename all German identifiers, types, mode ids, file names, CSS classes and data-attributes to English throughout tippen/src; only user-facing text (lesson titles, word lists, labels, spoken praise) stays German. - Add a word/nonsense-word list to the "Übung: die Grundstellung" home-row lesson in the curriculum. - Remove the unused HandHint component and speech.ts, and carry forward the in-progress App.tsx/component/generator/progress edits from other sessions. - Refresh the regenerated music-library cache index. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
139 lines
4.9 KiB
TypeScript
139 lines
4.9 KiB
TypeScript
/** 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<number | null>(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 (
|
|
<div
|
|
className="view-enter"
|
|
style={{
|
|
flex: 1,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
gap: 18,
|
|
padding: "0 32px 8px",
|
|
minHeight: 0,
|
|
}}
|
|
>
|
|
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
|
|
{/* The surface line the bubbles rise toward. */}
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: 8,
|
|
left: 0,
|
|
right: 0,
|
|
height: 2,
|
|
background: "oklch(97% 0.01 175 / 0.25)",
|
|
}}
|
|
/>
|
|
{[...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 (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
position: "absolute",
|
|
left: `${LANES[i % LANES.length]}%`,
|
|
// Distance from the cursor is distance from the surface. The transition
|
|
// is what makes popping one visibly lift the rest.
|
|
top: `${6 + distance * 19}%`,
|
|
transform: "translate(-50%, 0)",
|
|
width: size,
|
|
height: size,
|
|
borderRadius: "50%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: size * 0.42,
|
|
fontWeight: 900,
|
|
color: isCurrent ? "oklch(25% 0.05 175)" : "var(--paper)",
|
|
background: isCurrent
|
|
? "var(--paper)"
|
|
: `linear-gradient(160deg, oklch(75% 0.12 ${finger?.hue ?? 175} / .45), oklch(50% 0.09 ${finger?.hue ?? 175} / .2))`,
|
|
border: `2px solid oklch(90% 0.05 ${finger?.hue ?? 175} / ${isCurrent ? 0.9 : 0.35})`,
|
|
boxShadow: isCurrent ? "0 10px 30px var(--shadow)" : "0 4px 14px var(--shadow)",
|
|
opacity: 1 - distance * 0.13,
|
|
transition: "top 380ms cubic-bezier(.2,.7,.3,1), width 240ms ease, height 240ms ease, font-size 240ms ease",
|
|
animation:
|
|
popped === i
|
|
? "correctPop 200ms ease-out"
|
|
: isCurrent && wrong
|
|
? "wrongShake 260ms ease"
|
|
: undefined,
|
|
}}
|
|
>
|
|
{letter === " " ? "␣" : letter}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<Keyboard
|
|
activeKeys={activeKeys}
|
|
nextKey={next}
|
|
progress={progress}
|
|
mode={progress.settings.keyboardHint}
|
|
size={38}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|