diff --git a/web/src/components/TippenApp.tsx b/web/src/components/TippenApp.tsx index 7c3fada..bf47bd9 100644 --- a/web/src/components/TippenApp.tsx +++ b/web/src/components/TippenApp.tsx @@ -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("aquarium"); + const [screen, setScreen] = useState("map"); const [lessonId, setLessonId] = useState(null); const [mode, setMode] = useState("dive"); const [outcome, setOutcome] = useState(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={
+ {screen === "map" && ( + <> +
+ {curriculum.worlds.map((world) => { + const creature = creatureById(world.reward); + const owned = progress.aquarium.includes(creature.id); + return ( + + ); + })} +
+ 🔥 {progress.streak.days} + {bestAnimal && ( + + {animalById(bestAnimal).emoji} {animalById(bestAnimal).name} + + )} + + )} {!progress.settings.sound && 🔇}
} /> - {screen === "aquarium" && ( - nextUp && start(nextUp)} - onOpenMap={() => setScreen("map")} /> )} - {screen === "map" && ( - - )} - {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} diff --git a/web/src/components/tippen/Aquarium.tsx b/web/src/components/tippen/Aquarium.tsx deleted file mode 100644 index 1c40f7c..0000000 --- a/web/src/components/tippen/Aquarium.tsx +++ /dev/null @@ -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 ( -
-
-
- Dein Aquarium -
- -
- {worlds.map((world) => { - const creature = creatureById(world.reward); - const owned = progress.aquarium.includes(creature.id); - return ( - {owned - ); - })} -
- - {progress.aquarium.length === 0 && ( -
- Schaffe eine ganze Welt und dein erstes Tier zieht ein! -
- )} - -
- - -
-
- -
- {nextLesson && ( - - )} - -
-
- ); -} - -function Stat({ label, value }: { label: string; value: string }) { - return ( -
-
{value}
-
{label}
-
- ); -} diff --git a/web/src/components/tippen/LessonMap.tsx b/web/src/components/tippen/LessonMap.tsx index 2efe8df..1601ef8 100644 --- a/web/src/components/tippen/LessonMap.tsx +++ b/web/src/components/tippen/LessonMap.tsx @@ -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 (
+ {lesson.reward.resolved && ( + + 🎁 + + )} + {locked ? "🔒" : (animal?.emoji ?? "·")} {/* 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 ); })}
+ + {nextLesson && ( + + )} ); } diff --git a/web/src/styles/tippen.css b/web/src/styles/tippen.css index b267485..d526fc6 100644 --- a/web/src/styles/tippen.css +++ b/web/src/styles/tippen.css @@ -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; + } +}