/** 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. * * A lesson that unlocks real music or an audiobook chapter carries a treasure chest, * drawn *outside* its node so the dimming a locked node gets never touches it: the whole * value of the chest is that it is visible from far off, on lessons she cannot play yet. * Shut while the reward is still to be won, open with the cover art inside it once it has * been - same reasoning as the dimmed-not-hidden lessons, except stronger here, because * this is the one reward that reaches outside the game. */ import { Fragment } from "react"; import { coverUrl } from "../../api/client"; import type { Lesson, World } from "../../lib/tippen/curriculum"; import { animalById } from "../../lib/tippen/grading"; import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath"; import { MODE_INFO } from "../../lib/tippen/modeInfo"; import type { Progress } from "../../lib/tippen/progress"; import { TreasureChest } from "./TreasureChest"; interface Props { worlds: readonly World[]; lessons: readonly Lesson[]; progress: Progress; /** 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; /** Big enough to read as a treasure chest at a glance, scrolling past - the 15px 🎁 this * replaced was invisible in practice. */ const CHEST_SIZE = 46; /** Half the SVG's viewBox width - wide enough for the path's full swing either side. */ const PATH_HALF_WIDTH = 160; /** What a consolidation node's key-label line says when it has no keys of its own to * show - a child-friendly word rather than the raw `LessonKind`. */ const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> = { fragments: "Wörter", words: "Wörter", sentences: "Sätze", }; export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLesson, onContinue }: Props) { return (
{worlds.map((world) => { const worldLessons = lessons.filter((lesson) => lesson.world === world.number); const done = worldLessons.filter((l) => (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length; const height = worldLessons.length * NODE_SPACING; const points = worldLessons.map((_, i) => pointFor(i)); return (
{world.emoji} Welt {world.number} — {world.title} {done}/{worldLessons.length}
{worldLessons.map((lesson, i) => { const entry = progress.lessons[lesson.id]; const locked = !entry?.unlocked; const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null; const index = lessons.indexOf(lesson); const point = points[i]!; const modeInfo = MODE_INFO[lesson.primaryMode]; return ( // The node and its chest are two siblings, both positioned against // this world's own box - see RewardChest for why the chest must not // be a child of the node. {lesson.reward.resolved && ( )} ); })}
); })}
{nextLesson && ( )}
); } /** A lesson's treasure chest, sitting on the rim of its node. * * A sibling of the node button rather than a child, for one reason that is easy to lose * again later: `.card[data-locked="true"]` dims the whole button to 45% opacity, and the * chest that most needs to be bright is exactly the one on a lesson three worlds away. * `pointerEvents: none` hands clicks straight through to the node underneath, so the * chest never becomes a second, dead target beside the real one. * * Open, with the cover art of what it gave inside it, once the lesson is earned: it stops * being a promise and becomes the trophy, which makes scrolling the map also a way of * seeing everything she has won. */ function RewardChest({ point, earned, albumId, }: { point: { x: number; y: number }; earned: boolean; albumId: string | null; }) { return (
{/* Shut, the chest is drawn in one piece, because a shut lid has to cover the body's top edge. Won, it is split around the cover art so the cover sits *in* the chest - behind the front wall, under the thrown-back lid - rather than on top of it. See TreasureChest's `layer`. */} {earned && albumId ? (
{ event.currentTarget.style.display = "none"; }} style={{ position: "absolute", left: "50%", top: "34%", transform: "translate(-50%, -50%)", width: CHEST_SIZE * 0.46, height: CHEST_SIZE * 0.46, objectFit: "cover", borderRadius: 4, border: "1.5px solid oklch(92% 0.13 92 / 0.9)", }} />
) : ( )}
); }