Anglicize tippen's codebase and finish pending UI/curriculum cleanup
- 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>
This commit is contained in:
138
tippen/src/components/modes/BubblesRun.tsx
Normal file
138
tippen/src/components/modes/BubblesRun.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user