/** 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 (
{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 ? (