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);
}
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}