The reward pipeline worked end to end already, but it had nothing to show for itself: the unlock was one more line at the bottom of the result sheet, a 52px thumbnail with the generic 520ms pop, below the stars, the stats, the progress bar, the animal ladder and three other badges. The sound was `playFanfare`, the same chirp used for a new lesson and a new aquarium pet. It fired correctly and was impossible to notice. This is the only reward that reaches outside the game, so it now gets the whole screen. A chest drops in shut and rattles, the lid swings open on a burst of light and confetti, the cover art rises out of it, and the tune is a real melody - two seconds landing on a held major chord - rather than another blip. It is dismissed by hand, so she can look at what she won for as long as she likes. On the map, the 15px 🎁 becomes a drawn chest, shut while the reward is unwon and open with the cover inside once it has been. It is rendered as a sibling of the lesson node rather than a child, because a locked node is dimmed to 45% and the chest that most needs to be bright is the one three worlds away. One real bug behind the missing badge state: `progressFromApi` dropped the `earned` flag the backend already sends, so the map could not tell a claimed reward from an unclaimed one. Added, with a test that names it - a hand-written field mapping loses fields without failing a type check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
425 lines
16 KiB
TypeScript
425 lines
16 KiB
TypeScript
/** The typing game, as a tab of the music player rather than its own app.
|
|
*
|
|
* Ported from the standalone tippen app's App.tsx - same shape (one state object, one
|
|
* keydown listener for navigation, screens) - with two changes: curriculum and
|
|
* progress are now fetched from the backend instead of a build-time YAML import and
|
|
* localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and
|
|
* `onExit` is the new base case for "back". The lesson map is the floor screen - the
|
|
* swimming aquarium creatures already show behind it, so there is no separate landing
|
|
* screen in front of it - and one Escape from the map leaves the tab entirely, back to
|
|
* the music player.
|
|
*
|
|
* The rule that still matters most: **while a run is going, every key belongs to the
|
|
* run**. This component's own keydown listener only ever intercepts Escape and F1 - the
|
|
* run's own listener lives in hooks/useTippenRun.ts. The music player's own global
|
|
* keydown listener (lib/keyboard.ts) is kept out of this entirely: it bails out
|
|
* immediately whenever the typing tab is active, so the two never fight over a key. */
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
|
|
import { useTippenProgress } from "../hooks/useTippenProgress";
|
|
import { AppHeader } from "./tippen/AppHeader";
|
|
import { HelpOverlay } from "./tippen/HelpOverlay";
|
|
import { LessonMap } from "./tippen/LessonMap";
|
|
import { ResultSheet } from "./tippen/ResultSheet";
|
|
import { RewardUnlockOverlay } from "./tippen/RewardUnlockOverlay";
|
|
import { Stage } from "./tippen/Stage";
|
|
import { BubblesRun } from "./tippen/modes/BubblesRun";
|
|
import { DiveRun } from "./tippen/modes/DiveRun";
|
|
import { FeedRun } from "./tippen/modes/FeedRun";
|
|
import { RaceRun } from "./tippen/modes/RaceRun";
|
|
import type { CreatureId } from "../lib/tippen/aquarium";
|
|
import { creatureById } from "../lib/tippen/aquarium";
|
|
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
|
|
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
|
|
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
|
|
import { animalById } from "../lib/tippen/grading";
|
|
import type { RunResult } from "../lib/tippen/grading";
|
|
import { playFanfare, playPop } from "../lib/tippen/pop";
|
|
import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress";
|
|
import type { UnlockedReward } from "../lib/tippen/progress";
|
|
import { bubbleCountFor } from "../lib/tippen/theme";
|
|
|
|
type Screen = "map" | "run";
|
|
|
|
/** What a mode is handed to draw - see the original App.tsx for why there are only two
|
|
* shapes for four modes. */
|
|
type RunTarget =
|
|
| { kind: "letters"; letters: readonly string[] }
|
|
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
|
|
|
|
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles"];
|
|
|
|
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
|
|
|
|
interface Outcome {
|
|
result: RunResult;
|
|
unlockedTitle: string | null;
|
|
newCreature: CreatureId | null;
|
|
isNewBest: boolean;
|
|
unlockedReward: UnlockedReward | null;
|
|
}
|
|
|
|
interface Props {
|
|
onExit: () => void;
|
|
}
|
|
|
|
export function TippenApp({ onExit }: Props) {
|
|
const { curriculum, loading: curriculumLoading } = useTippenCurriculum();
|
|
const configured = curriculum !== null;
|
|
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
|
|
|
|
const [screen, setScreen] = useState<Screen>("map");
|
|
const [lessonId, setLessonId] = useState<string | null>(null);
|
|
const [mode, setMode] = useState<ModeId>("dive");
|
|
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
|
/** The unlock celebration, in front of the result sheet - see RewardUnlockOverlay.
|
|
* Separate from `outcome.unlockedReward` because it is dismissed on its own, leaving
|
|
* the result sheet (and its recap of the same reward) behind it. */
|
|
const [celebration, setCelebration] = useState<UnlockedReward | null>(null);
|
|
const [showHelp, setShowHelp] = useState(false);
|
|
const [selected, setSelected] = useState(0);
|
|
/** Bumped to generate a fresh line - a new seed for the same lesson. */
|
|
const [round, setRound] = useState(0);
|
|
|
|
const lesson = lessonId !== null && curriculum ? lessonById(curriculum, lessonId) : null;
|
|
|
|
/** The first lesson that is unlocked but not yet passed - where "Weiter üben" goes. */
|
|
const nextUp = useMemo(() => {
|
|
if (!curriculum || !progress) return null;
|
|
const unlocked = curriculum.lessons.filter((l) => progress.lessons[l.id]?.unlocked);
|
|
return unlocked.find((l) => (progress.lessons[l.id]?.bestStars ?? 0) < 2) ?? unlocked.at(-1) ?? null;
|
|
}, [curriculum, progress]);
|
|
|
|
/** The line for this run. Reproducible from the lesson, the mode and the round
|
|
* counter, so a re-render never reshuffles the text mid-run. */
|
|
const run = useMemo((): RunTarget | null => {
|
|
if (!lesson || !progress) return null;
|
|
const seed = lesson.number * 1000 + round * 7 + (mode === "bubbles" ? 3 : 0);
|
|
const rng = mulberry32(seed);
|
|
const focusKey = focusKeyFor(progress, lesson.activeKeys);
|
|
// Always a real space, even before the space-bar lesson formally teaches the thumb:
|
|
// a gap she can see but not type is confusing, not gentle. See generator.ts.
|
|
const spaceActive = true;
|
|
|
|
if (LETTER_ONLY_MODES.includes(mode)) {
|
|
const share = lesson.emphasis ? SHARE_FOR_EMPHASIS[lesson.emphasis] : undefined;
|
|
return {
|
|
kind: "letters",
|
|
letters: letterStream(
|
|
lesson.activeKeys,
|
|
rng,
|
|
bubbleCountFor(lesson.world),
|
|
focusKey,
|
|
lesson.spotlightKeys,
|
|
share,
|
|
),
|
|
};
|
|
}
|
|
const chunks = lineFor(lesson, rng, { chunks: lesson.chunks, chunkSize: lesson.chunkSize, focusKey });
|
|
return { kind: "text", chunks, text: lineText(chunks, spaceActive), spaceActive };
|
|
// `progress` is deliberately not a dependency - see the original App.tsx.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [lesson, mode, round]);
|
|
|
|
const start = useCallback((lesson: Lesson) => {
|
|
setCelebration(null);
|
|
setLessonId(lesson.id);
|
|
setMode(lesson.primaryMode);
|
|
setOutcome(null);
|
|
setRound((r) => r + 1);
|
|
setScreen("run");
|
|
}, []);
|
|
|
|
const onFinished = useCallback(
|
|
(result: RunResult) => {
|
|
if (!lessonId || !progress) return;
|
|
void recordRun(lessonId, result).then((recorded) => {
|
|
setOutcome({
|
|
result,
|
|
unlockedTitle: recorded.unlockedLessonTitle,
|
|
newCreature: recorded.newCreature,
|
|
isNewBest: recorded.isNewBest,
|
|
unlockedReward: recorded.unlockedReward,
|
|
});
|
|
// The unlock celebration brings its own sounds (and is the bigger moment), so
|
|
// the generic fanfare would only step on its opening. One or the other.
|
|
if (recorded.unlockedReward) {
|
|
setCelebration(recorded.unlockedReward);
|
|
} else if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) {
|
|
playFanfare();
|
|
}
|
|
});
|
|
},
|
|
[lessonId, progress, recordRun],
|
|
);
|
|
|
|
const retry = useCallback(() => {
|
|
setCelebration(null);
|
|
setOutcome(null);
|
|
setRound((r) => r + 1);
|
|
}, []);
|
|
|
|
/** A bonus replay in a mode this lesson didn't gate progress on - never touches the
|
|
* unlock: `onFinished` still runs underneath, so a great bonus run can only improve
|
|
* the best score, not change what is unlocked. */
|
|
const playBonus = useCallback((bonusMode: ModeId) => {
|
|
setCelebration(null);
|
|
setMode(bonusMode);
|
|
setOutcome(null);
|
|
setRound((r) => r + 1);
|
|
}, []);
|
|
|
|
const continueAfterResult = useCallback(() => {
|
|
const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
|
|
setCelebration(null);
|
|
setOutcome(null);
|
|
if (next && progress?.lessons[next.id]?.unlocked) start(next);
|
|
else setScreen("map");
|
|
}, [lessonId, curriculum, progress, start]);
|
|
|
|
const goBack = useCallback(() => {
|
|
if (celebration) return setCelebration(null);
|
|
if (outcome) return setOutcome(null);
|
|
if (screen === "run") return setScreen("map");
|
|
if (screen === "map") return onExit();
|
|
}, [celebration, outcome, screen, onExit]);
|
|
|
|
// --- navigation keys -----------------------------------------------------
|
|
|
|
const latest = useRef({ screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum });
|
|
latest.current = { screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum };
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
const current = latest.current;
|
|
|
|
if (event.key === "F1") {
|
|
event.preventDefault();
|
|
setShowHelp((open) => !open);
|
|
return;
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
setShowHelp(false);
|
|
current.goBack();
|
|
return;
|
|
}
|
|
|
|
// Enter dismisses the unlock celebration. It must be handled before the result
|
|
// sheet's own Enter below, or finishing a reward run would restart it instantly
|
|
// from behind the overlay.
|
|
if (current.celebration) {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
setCelebration(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Enter repeats a finished run; the result sheet's own button has focus, so this
|
|
// is only a fallback for when focus has been lost.
|
|
if (current.outcome) {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
current.retry();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Everything below is navigation, and must not fire while typing.
|
|
if (current.screen === "run") return;
|
|
if (!current.curriculum) return;
|
|
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
const lesson = current.curriculum.lessons[current.selected];
|
|
if (lesson) current.start(lesson);
|
|
return;
|
|
}
|
|
|
|
if (current.screen !== "map") return;
|
|
// The map is a vertical path, so "next" is down rather than to the right.
|
|
const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
|
|
if (step !== 0) {
|
|
event.preventDefault();
|
|
playPop(340);
|
|
setSelected((index) => Math.min(current.curriculum!.lessons.length - 1, Math.max(0, index + step)));
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, []);
|
|
|
|
// Settings keys live outside a run, where they cannot collide with the alphabet.
|
|
useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (screen === "run" && !outcome) return;
|
|
if (celebration) return;
|
|
if (!progress) return;
|
|
const key = event.key.toLowerCase();
|
|
if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
|
|
if (key === "h") {
|
|
void saveSettings({
|
|
...progress.settings,
|
|
keyboardHint: progress.settings.keyboardHint === "off" ? "auto" : "off",
|
|
});
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, [screen, outcome, celebration, progress, saveSettings]);
|
|
|
|
// --- render --------------------------------------------------------------
|
|
|
|
if (curriculumLoading || (configured && progressLoading) || !progress) {
|
|
return (
|
|
<Stage creatures={[]} dimmed={false}>
|
|
<AppHeader />
|
|
<LoadingScreen text="Einen Moment …" />
|
|
</Stage>
|
|
);
|
|
}
|
|
|
|
if (!curriculum) {
|
|
return (
|
|
<Stage creatures={[]} dimmed={false}>
|
|
<AppHeader />
|
|
<LoadingScreen text="Das Tippen-Spiel ist noch nicht eingerichtet." />
|
|
</Stage>
|
|
);
|
|
}
|
|
|
|
const next = lessonId ? nextLesson(curriculum, lessonId) : null;
|
|
const bestAnimal = overallBestAnimal(progress);
|
|
|
|
// Every mode takes the same bundle; only the drawing differs.
|
|
const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished };
|
|
const letterProps = (letters: readonly string[]) => ({ letters, ...shared });
|
|
const textProps = (r: Extract<RunTarget, { kind: "text" }>) => ({
|
|
chunks: r.chunks,
|
|
text: r.text,
|
|
spaceActive: r.spaceActive,
|
|
...shared,
|
|
});
|
|
|
|
return (
|
|
<Stage creatures={progress.aquarium} dimmed={screen === "run"}>
|
|
<AppHeader
|
|
title={screen === "run" && lesson ? lesson.title : "Delfin Tippen"}
|
|
compact={screen === "run"}
|
|
status={
|
|
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
|
{screen === "map" && (
|
|
<>
|
|
<div className="tp-map-creatures">
|
|
{curriculum.worlds.map((world) => {
|
|
const creature = creatureById(world.reward);
|
|
const owned = progress.aquarium.includes(creature.id);
|
|
return (
|
|
<img
|
|
key={creature.id}
|
|
src={creature.image}
|
|
alt=""
|
|
title={owned ? creature.name : `Welt ${world.number}`}
|
|
style={{
|
|
width: 22,
|
|
height: 22,
|
|
objectFit: "contain",
|
|
filter: owned ? "none" : "brightness(0) invert(1)",
|
|
opacity: owned ? 1 : 0.3,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
<span title="Tage am Stück">🔥 {progress.streak.days}</span>
|
|
{bestAnimal && (
|
|
<span title="Schnellstes Tier">
|
|
{animalById(bestAnimal).emoji} {animalById(bestAnimal).name}
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{screen === "map" && (
|
|
<LessonMap
|
|
worlds={curriculum.worlds}
|
|
lessons={curriculum.lessons}
|
|
progress={progress}
|
|
selected={selected}
|
|
onPick={start}
|
|
nextLesson={nextUp}
|
|
onContinue={() => nextUp && start(nextUp)}
|
|
/>
|
|
)}
|
|
|
|
{screen === "run" && lesson && run && (
|
|
<>
|
|
{run.kind === "letters" ? (
|
|
<BubblesRun {...letterProps(run.letters)} />
|
|
) : mode === "feed" ? (
|
|
<FeedRun {...textProps(run)} />
|
|
) : mode === "race" ? (
|
|
<RaceRun {...textProps(run)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
|
|
) : (
|
|
<DiveRun {...textProps(run)} />
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{outcome && lesson && (
|
|
<ResultSheet
|
|
result={outcome.result}
|
|
unlockedTitle={outcome.unlockedTitle}
|
|
newCreature={outcome.newCreature}
|
|
unlockedReward={outcome.unlockedReward}
|
|
isNewBest={outcome.isNewBest}
|
|
bestEver={bestAnimal}
|
|
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
|
|
onPlayBonus={playBonus}
|
|
onRetry={retry}
|
|
onContinue={continueAfterResult}
|
|
continueLabel={next && progress.lessons[next.id]?.unlocked ? `${next.title} ▶` : "Zur Karte"}
|
|
/>
|
|
)}
|
|
|
|
{celebration && (
|
|
<RewardUnlockOverlay
|
|
reward={celebration}
|
|
sound={progress.settings.sound}
|
|
onClose={() => setCelebration(null)}
|
|
/>
|
|
)}
|
|
|
|
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
|
</Stage>
|
|
);
|
|
}
|
|
|
|
function LoadingScreen({ text }: { text: string }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: "var(--paper)",
|
|
fontSize: 18,
|
|
fontWeight: 800,
|
|
textAlign: "center",
|
|
padding: "0 32px",
|
|
}}
|
|
>
|
|
{text}
|
|
</div>
|
|
);
|
|
}
|