Make the lesson map the floor screen of the typing tab

The aquarium was a separate landing screen in front of the map, showing the pets
collected so far and a "Weiter üben" button. But the pets already swim behind
every screen in the tab, so the screen mostly restated what was visible anyway,
at the cost of one extra step between opening the tab and typing.

The map absorbs what was worth keeping: the "Weiter üben" button now floats over
it, and the pets, streak and best animal move into the header. One Escape from
the map leaves the tab entirely, where it used to go back a screen first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 18:08:11 +02:00
parent f3f7082fb8
commit e6f6b15cc7
4 changed files with 109 additions and 160 deletions

View File

@@ -1,12 +1,13 @@
/** 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, four screens) - with two changes: curriculum and
* 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" - Escape/the map's own navigation peel one
* layer at a time, same as before, but the aquarium screen is no longer the floor: one
* more Escape leaves the tab entirely, back to the music player.
* `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
@@ -18,7 +19,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
import { useTippenProgress } from "../hooks/useTippenProgress";
import { Aquarium } from "./tippen/Aquarium";
import { AppHeader } from "./tippen/AppHeader";
import { HelpOverlay } from "./tippen/HelpOverlay";
import { LessonMap } from "./tippen/LessonMap";
@@ -29,16 +29,18 @@ 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 = "aquarium" | "map" | "run";
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. */
@@ -67,7 +69,7 @@ export function TippenApp({ onExit }: Props) {
const configured = curriculum !== null;
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
const [screen, setScreen] = useState<Screen>("aquarium");
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);
@@ -170,8 +172,7 @@ export function TippenApp({ onExit }: Props) {
const goBack = useCallback(() => {
if (outcome) return setOutcome(null);
if (screen === "run") return setScreen("map");
if (screen === "map") return setScreen("aquarium");
if (screen === "aquarium") return onExit();
if (screen === "map") return onExit();
}, [outcome, screen, onExit]);
// --- navigation keys -----------------------------------------------------
@@ -211,12 +212,8 @@ export function TippenApp({ onExit }: Props) {
if (event.key === "Enter") {
event.preventDefault();
if (current.screen === "aquarium") {
if (current.nextUp) current.start(current.nextUp);
} else {
const lesson = current.curriculum.lessons[current.selected];
if (lesson) current.start(lesson);
}
return;
}
@@ -273,6 +270,7 @@ export function TippenApp({ onExit }: Props) {
}
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 };
@@ -291,25 +289,54 @@ export function TippenApp({ onExit }: Props) {
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 === "aquarium" && (
<Aquarium
{screen === "map" && (
<LessonMap
worlds={curriculum.worlds}
lessons={curriculum.lessons}
progress={progress}
selected={selected}
onPick={start}
nextLesson={nextUp}
onContinue={() => nextUp && start(nextUp)}
onOpenMap={() => setScreen("map")}
/>
)}
{screen === "map" && (
<LessonMap worlds={curriculum.worlds} lessons={curriculum.lessons} progress={progress} selected={selected} onPick={start} />
)}
{screen === "run" && lesson && run && (
<>
{run.kind === "letters" ? (
@@ -331,7 +358,7 @@ export function TippenApp({ onExit }: Props) {
newCreature={outcome.newCreature}
unlockedReward={outcome.unlockedReward}
isNewBest={outcome.isNewBest}
bestEver={overallBestAnimal(progress)}
bestEver={bestAnimal}
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
onPlayBonus={playBonus}
onRetry={retry}

View File

@@ -1,135 +0,0 @@
/** The home screen: what she has collected, before she is asked to do anything.
*
* Deliberately the first thing on opening the app. The reward for finishing a world is a
* pet that moves in for good - it swims behind every screen from then on (see
* AquariumCreatures.tsx) - and a reward you can see before you start is worth more than
* one you are told about afterwards.
*
* The panel shows every pet there is to earn: the ones at home in colour, the rest as
* pale outlines. The same idea as the animal ladder - the next thing has to be visible to
* be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
import { creatureById } from "../../lib/tippen/aquarium";
import type { Lesson, World } from "../../lib/tippen/curriculum";
import { animalById } from "../../lib/tippen/grading";
import { overallBestAnimal } from "../../lib/tippen/progress";
import type { Progress } from "../../lib/tippen/progress";
interface Props {
worlds: readonly World[];
progress: Progress;
/** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
nextLesson: Lesson | null;
onContinue: () => void;
onOpenMap: () => void;
}
export function Aquarium({ worlds, progress, nextLesson, onContinue, onOpenMap }: Props) {
const bestAnimal = overallBestAnimal(progress);
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 22,
padding: "0 32px 32px",
minHeight: 0,
}}
>
<div
className="tp-glass-panel"
style={{ padding: "26px 34px", width: "min(680px, 100%)", textAlign: "center" }}
>
<div style={{ fontSize: 15, fontWeight: 900, color: "var(--paper)", opacity: 0.8 }}>
Dein Aquarium
</div>
<div
aria-label="Deine Tiere"
style={{
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
gap: 18,
margin: "16px 0 6px",
alignItems: "center",
}}
>
{worlds.map((world) => {
const creature = creatureById(world.reward);
const owned = progress.aquarium.includes(creature.id);
return (
<img
key={creature.id}
src={creature.image}
alt={owned ? creature.name : `Noch nicht da - Welt ${world.number}`}
title={owned ? creature.name : `Welt ${world.number}`}
style={{
width: 62,
height: 62,
objectFit: "contain",
// Not yet earned: a pale outline of the shape, no colours given away.
filter: owned ? "none" : "brightness(0) invert(1)",
opacity: owned ? 1 : 0.2,
animation: owned ? `dolphinBob ${3 + world.number * 0.4}s ease-in-out infinite` : undefined,
}}
/>
);
})}
</div>
{progress.aquarium.length === 0 && (
<div style={{ fontSize: 15, fontWeight: 800, color: "var(--paper)", opacity: 0.65 }}>
Schaffe eine ganze Welt und dein erstes Tier zieht ein!
</div>
)}
<div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}>
<Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} />
<Stat
label="Schnellstes Tier"
value={bestAnimal ? `${animalById(bestAnimal).emoji} ${animalById(bestAnimal).name}` : "—"}
/>
</div>
</div>
<div style={{ display: "flex", gap: 12 }}>
{nextLesson && (
<button
onClick={onContinue}
style={{
border: "none",
borderRadius: 999,
padding: "15px 32px",
fontSize: 19,
fontWeight: 900,
cursor: "pointer",
background: "var(--accent)",
color: "var(--paper)",
boxShadow: "0 8px 24px var(--shadow)",
}}
>
{nextLesson.title}
</button>
)}
<button className="tp-pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}>
🗺 Alle Lektionen
</button>
</div>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>{value}</div>
<div style={{ fontSize: 11, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>{label}</div>
</div>
);
}

View File

@@ -1,9 +1,14 @@
/** The map: a single winding path, one world section at a time.
*
* The floor screen of the tippen tab - the swimming aquarium creatures already show
* behind it, so there is no separate landing screen in front of this one anymore.
*
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
* waiting is half the reason to finish the world that is open. Each node carries its own
* best animal, star count and a badge for which game it plays, so the map doubles as
* both a path forward and a trophy cabinet. */
* both a path forward and a trophy cabinet. A second badge marks a lesson that unlocks
* real music or an audiobook chapter, shown whether or not the lesson itself is locked
* yet - same reasoning as the dimmed-not-hidden lessons: seeing it coming is the point. */
import type { Lesson, World } from "../../lib/tippen/curriculum";
import { animalById } from "../../lib/tippen/grading";
@@ -18,6 +23,9 @@ interface Props {
/** Which card the keyboard selection is on. */
selected: number;
onPick: (lesson: Lesson) => void;
/** The lesson the floating "Weiter üben" button jumps to - the first unfinished one. */
nextLesson: Lesson | null;
onContinue: () => void;
}
const NODE_SIZE = 88;
@@ -32,7 +40,7 @@ const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> =
sentences: "Sätze",
};
export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props) {
export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLesson, onContinue }: Props) {
return (
<div
className="view-enter"
@@ -128,6 +136,16 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props
{modeInfo.emoji}
</span>
{lesson.reward.resolved && (
<span
aria-hidden
style={{ position: "absolute", top: -4, left: -4, fontSize: 15, opacity: 0.9 }}
title="Schaltet neue Musik frei!"
>
🎁
</span>
)}
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
{/* The keys themselves: for a pre-reader this is the real label, the
@@ -167,6 +185,29 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props
);
})}
</div>
{nextLesson && (
<button
onClick={onContinue}
style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
border: "none",
borderRadius: 999,
padding: "15px 32px",
fontSize: 19,
fontWeight: 900,
cursor: "pointer",
background: "var(--accent)",
color: "var(--paper)",
boxShadow: "0 8px 24px var(--shadow)",
}}
>
{nextLesson.title}
</button>
)}
</div>
);
}

View File

@@ -294,3 +294,19 @@
.tp-target-char[data-blank="true"] {
min-width: 0.9em;
}
/* The world-reward pets, shown small in the map's header: one per world, in colour once
it has moved in and as a white silhouette until then. The same "the next thing has to
be visible to be worth aiming at" rule as the animal ladder on the result sheet. */
.tp-map-creatures {
display: flex;
align-items: center;
gap: 4px;
}
@media (max-width: 520px) {
/* The streak and the best animal say more per pixel on a narrow screen. */
.tp-map-creatures {
display: none;
}
}