Celebrate a media unlock with a treasure chest that opens

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>
This commit is contained in:
2026-09-19 18:10:11 +02:00
parent e6f6b15cc7
commit f3337975c2
9 changed files with 1060 additions and 99 deletions

View File

@@ -6,15 +6,24 @@
* 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 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. */
* 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[];
@@ -29,6 +38,9 @@ interface Props {
}
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;
@@ -96,88 +108,90 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
const modeInfo = MODE_INFO[lesson.primaryMode];
return (
<button
key={lesson.id}
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
// 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",
top: -4,
right: -4,
fontSize: 15,
filter: locked ? "grayscale(1)" : "none",
opacity: locked ? 0.4 : 0.9,
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",
}}
title={modeInfo.name}
>
{modeInfo.emoji}
</span>
{lesson.reward.resolved && (
<span
aria-hidden
style={{ position: "absolute", top: -4, left: -4, fontSize: 15, opacity: 0.9 }}
title="Schaltet neue Musik frei!"
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}
/>
)}
<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>
</Fragment>
);
})}
</div>
@@ -211,3 +225,74 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
</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>
);
}