The reward pipeline worked end to end already, but it had nothing to show for itself: the unlock was one more line at the bottom of the result sheet, a 52px thumbnail with the generic 520ms pop, below the stars, the stats, the progress bar, the animal ladder and three other badges. The sound was `playFanfare`, the same chirp used for a new lesson and a new aquarium pet. It fired correctly and was impossible to notice. This is the only reward that reaches outside the game, so it now gets the whole screen. A chest drops in shut and rattles, the lid swings open on a burst of light and confetti, the cover art rises out of it, and the tune is a real melody - two seconds landing on a held major chord - rather than another blip. It is dismissed by hand, so she can look at what she won for as long as she likes. On the map, the 15px 🎁 becomes a drawn chest, shut while the reward is unwon and open with the cover inside once it has been. It is rendered as a sibling of the lesson node rather than a child, because a locked node is dimmed to 45% and the chest that most needs to be bright is the one three worlds away. One real bug behind the missing badge state: `progressFromApi` dropped the `earned` flag the backend already sends, so the map could not tell a claimed reward from an unclaimed one. Added, with a test that names it - a hand-written field mapping loses fields without failing a type check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
/** 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 (
|
|
<div
|
|
className="view-enter"
|
|
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 40px", minHeight: 0 }}
|
|
>
|
|
<div style={{ maxWidth: 480, margin: "0 auto", display: "flex", flexDirection: "column", gap: 14 }}>
|
|
{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 (
|
|
<section key={world.number}>
|
|
<div
|
|
className="tp-glass-panel"
|
|
style={{
|
|
position: "sticky",
|
|
top: 0,
|
|
zIndex: 2,
|
|
padding: "10px 18px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 22 }}>{world.emoji}</span>
|
|
<span style={{ fontSize: 17, fontWeight: 900, color: "var(--paper)" }}>
|
|
Welt {world.number} — {world.title}
|
|
</span>
|
|
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6, marginLeft: "auto" }}>
|
|
{done}/{worldLessons.length}
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ position: "relative", height, margin: "0 auto" }}>
|
|
<svg
|
|
style={{ position: "absolute", left: "50%", top: 0, transform: "translateX(-50%)", overflow: "visible" }}
|
|
width={PATH_HALF_WIDTH * 2}
|
|
height={height}
|
|
viewBox={`${-PATH_HALF_WIDTH} 0 ${PATH_HALF_WIDTH * 2} ${height}`}
|
|
>
|
|
<path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
|
|
</svg>
|
|
|
|
{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.
|
|
<Fragment key={lesson.id}>
|
|
<button
|
|
className="card tp-glass-panel"
|
|
data-selected={index === selected}
|
|
data-locked={locked}
|
|
disabled={locked}
|
|
onClick={() => onPick(lesson)}
|
|
title={lesson.title}
|
|
style={{
|
|
position: "absolute",
|
|
left: `calc(50% + ${point.x}px)`,
|
|
top: point.y,
|
|
transform: "translate(-50%, -50%)",
|
|
width: NODE_SIZE,
|
|
height: NODE_SIZE,
|
|
borderRadius: "50%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 2,
|
|
padding: 0,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
style={{
|
|
position: "absolute",
|
|
top: -4,
|
|
right: -4,
|
|
fontSize: 15,
|
|
filter: locked ? "grayscale(1)" : "none",
|
|
opacity: locked ? 0.4 : 0.9,
|
|
}}
|
|
title={modeInfo.name}
|
|
>
|
|
{modeInfo.emoji}
|
|
</span>
|
|
|
|
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
|
|
|
|
{/* The keys themselves: for a pre-reader this is the real label, the
|
|
title is decoration. A drill has no new keys, so it says so with
|
|
a symbol instead of showing an empty line. */}
|
|
<span
|
|
style={{
|
|
fontSize: 11,
|
|
fontWeight: 800,
|
|
color: "var(--paper)",
|
|
opacity: 0.8,
|
|
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
|
|
lineHeight: 1.1,
|
|
}}
|
|
>
|
|
{lesson.isDrill
|
|
? "🔁"
|
|
: lesson.newKeys.length > 0
|
|
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
|
|
: lesson.kind === "letters"
|
|
? "üben"
|
|
: CONSOLIDATION_LABEL[lesson.kind]}
|
|
</span>
|
|
|
|
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
|
|
{[1, 2, 3].map((star) => (
|
|
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
|
|
⭐
|
|
</span>
|
|
))}
|
|
</span>
|
|
</button>
|
|
|
|
{lesson.reward.resolved && (
|
|
<RewardChest
|
|
point={point}
|
|
earned={entry?.earned ?? false}
|
|
albumId={lesson.reward.hasCover ? lesson.reward.albumId : null}
|
|
/>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
// On the node's lower-right rim, overlapping it a little so the two read as one
|
|
// object rather than as a badge parked nearby.
|
|
left: `calc(50% + ${point.x + NODE_SIZE / 2 + 2}px)`,
|
|
top: point.y + NODE_SIZE / 2 - 6,
|
|
transform: "translate(-50%, -50%)",
|
|
pointerEvents: "none",
|
|
zIndex: 1,
|
|
filter: "drop-shadow(0 3px 6px oklch(15% 0.05 175 / 0.55))",
|
|
}}
|
|
title={earned ? "Freigespielt! Im Musik-Player zu hören" : "Hier gibt es neue Musik zu gewinnen!"}
|
|
>
|
|
{/* 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 ? (
|
|
<div style={{ position: "relative", width: CHEST_SIZE, height: CHEST_SIZE }}>
|
|
<TreasureChest size={CHEST_SIZE} open layer="back" style={{ position: "absolute", inset: 0 }} />
|
|
<img
|
|
src={coverUrl(albumId)}
|
|
alt=""
|
|
// A cover that fails to load must not leave a broken-image glyph in the chest:
|
|
// the open chest on its own still says "won", which is the part that matters.
|
|
onError={(event) => {
|
|
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)",
|
|
}}
|
|
/>
|
|
<TreasureChest size={CHEST_SIZE} open layer="front" style={{ position: "absolute", inset: 0 }} />
|
|
</div>
|
|
) : (
|
|
<TreasureChest size={CHEST_SIZE} open={earned} muted={!earned} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|