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

@@ -23,6 +23,7 @@ import { AppHeader } from "./tippen/AppHeader";
import { HelpOverlay } from "./tippen/HelpOverlay";
import { LessonMap } from "./tippen/LessonMap";
import { ResultSheet } from "./tippen/ResultSheet";
import { RewardUnlockOverlay } from "./tippen/RewardUnlockOverlay";
import { Stage } from "./tippen/Stage";
import { BubblesRun } from "./tippen/modes/BubblesRun";
import { DiveRun } from "./tippen/modes/DiveRun";
@@ -73,6 +74,10 @@ export function TippenApp({ onExit }: Props) {
const [lessonId, setLessonId] = useState<string | null>(null);
const [mode, setMode] = useState<ModeId>("dive");
const [outcome, setOutcome] = useState<Outcome | null>(null);
/** The unlock celebration, in front of the result sheet - see RewardUnlockOverlay.
* Separate from `outcome.unlockedReward` because it is dismissed on its own, leaving
* the result sheet (and its recap of the same reward) behind it. */
const [celebration, setCelebration] = useState<UnlockedReward | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [selected, setSelected] = useState(0);
/** Bumped to generate a fresh line - a new seed for the same lesson. */
@@ -119,6 +124,7 @@ export function TippenApp({ onExit }: Props) {
}, [lesson, mode, round]);
const start = useCallback((lesson: Lesson) => {
setCelebration(null);
setLessonId(lesson.id);
setMode(lesson.primaryMode);
setOutcome(null);
@@ -137,10 +143,11 @@ export function TippenApp({ onExit }: Props) {
isNewBest: recorded.isNewBest,
unlockedReward: recorded.unlockedReward,
});
if (
progress.settings.sound &&
(recorded.unlockedLessonId || recorded.newCreature || recorded.unlockedReward)
) {
// The unlock celebration brings its own sounds (and is the bigger moment), so
// the generic fanfare would only step on its opening. One or the other.
if (recorded.unlockedReward) {
setCelebration(recorded.unlockedReward);
} else if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) {
playFanfare();
}
});
@@ -149,6 +156,7 @@ export function TippenApp({ onExit }: Props) {
);
const retry = useCallback(() => {
setCelebration(null);
setOutcome(null);
setRound((r) => r + 1);
}, []);
@@ -157,6 +165,7 @@ export function TippenApp({ onExit }: Props) {
* unlock: `onFinished` still runs underneath, so a great bonus run can only improve
* the best score, not change what is unlocked. */
const playBonus = useCallback((bonusMode: ModeId) => {
setCelebration(null);
setMode(bonusMode);
setOutcome(null);
setRound((r) => r + 1);
@@ -164,21 +173,23 @@ export function TippenApp({ onExit }: Props) {
const continueAfterResult = useCallback(() => {
const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
setCelebration(null);
setOutcome(null);
if (next && progress?.lessons[next.id]?.unlocked) start(next);
else setScreen("map");
}, [lessonId, curriculum, progress, start]);
const goBack = useCallback(() => {
if (celebration) return setCelebration(null);
if (outcome) return setOutcome(null);
if (screen === "run") return setScreen("map");
if (screen === "map") return onExit();
}, [outcome, screen, onExit]);
}, [celebration, outcome, screen, onExit]);
// --- navigation keys -----------------------------------------------------
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start, curriculum });
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start, curriculum };
const latest = useRef({ screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum });
latest.current = { screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum };
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -196,6 +207,17 @@ export function TippenApp({ onExit }: Props) {
return;
}
// Enter dismisses the unlock celebration. It must be handled before the result
// sheet's own Enter below, or finishing a reward run would restart it instantly
// from behind the overlay.
if (current.celebration) {
if (event.key === "Enter") {
event.preventDefault();
setCelebration(null);
}
return;
}
// Enter repeats a finished run; the result sheet's own button has focus, so this
// is only a fallback for when focus has been lost.
if (current.outcome) {
@@ -235,6 +257,7 @@ export function TippenApp({ onExit }: Props) {
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (screen === "run" && !outcome) return;
if (celebration) return;
if (!progress) return;
const key = event.key.toLowerCase();
if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
@@ -247,7 +270,7 @@ export function TippenApp({ onExit }: Props) {
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [screen, outcome, progress, saveSettings]);
}, [screen, outcome, celebration, progress, saveSettings]);
// --- render --------------------------------------------------------------
@@ -367,6 +390,14 @@ export function TippenApp({ onExit }: Props) {
/>
)}
{celebration && (
<RewardUnlockOverlay
reward={celebration}
sound={progress.settings.sound}
onClose={() => setCelebration(null)}
/>
)}
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
</Stage>
);

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>
);
}

View File

@@ -148,34 +148,42 @@ export function ResultSheet({
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
</div>
)}
{/* The recap, not the reveal. RewardUnlockOverlay has already given this its own
full-screen celebration by the time this sheet is visible, so here it only has
to stay on the page as a reminder of what she just won - which is also why it
is the one badge on this sheet that gets a tinted row of its own rather than a
line of text. */}
{unlockedReward && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
color: "var(--ink)",
fontWeight: 900,
marginTop: 8,
gap: 12,
marginTop: 12,
padding: "10px 14px",
borderRadius: 16,
textAlign: "left",
background: "oklch(93% 0.06 92)",
border: "1px solid oklch(80% 0.12 90)",
}}
>
{unlockedReward.hasCover ? (
<img
src={coverUrl(unlockedReward.albumId)}
alt=""
style={{
width: 52,
height: 52,
objectFit: "cover",
borderRadius: 8,
animation: "tierEnter 520ms ease-out",
}}
style={{ width: 52, height: 52, objectFit: "cover", borderRadius: 8, flexShrink: 0 }}
/>
) : (
<span style={{ fontSize: 40, animation: "tierEnter 520ms ease-out" }}>🎁</span>
<span style={{ fontSize: 40, flexShrink: 0 }}>🎵</span>
)}
🎁 Neu zum Anhören: {unlockedReward.title}
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 800, color: "var(--ink)", opacity: 0.7 }}>
Freigespielt — ab jetzt im Musik-Player
</div>
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--ink)" }}>
{unlockedReward.title}
</div>
</div>
</div>
)}
{!result.passed && !unlockedTitle && (

View File

@@ -0,0 +1,409 @@
/** The unlock celebration: a treasure chest that opens and hands back real music.
*
* This is the one moment the typing game exists for. Everything else it gives out -
* stars, animals, aquarium creatures - lives inside the game; this is the only reward
* that reaches outside it, into the music player. So it gets the whole screen, its own
* tune (`playRewardJingle`), and a sequence that has to be waited out, rather than being
* one more badge on the result sheet. The result sheet stays mounted underneath and is
* revealed when this closes, so nothing is lost by putting this in front of it.
*
* The staging, and why each beat is there:
*
* 0ms "drop" the chest falls in, shut, and rattles. The rattle is the beat that
* earns the opening - something inside wants out.
* 1150ms "open" the lid swings, light bursts, confetti starts, the tune starts.
* 1500ms "reveal" the cover art rises out of the chest; the title and the button
* arrive under it.
*
* Dismissal is deliberately not automatic. A six-year-old should get to look at the
* cover of the song she just won for as long as she likes, and pressing the button is
* itself part of the reward. It is, however, dismissible from the first frame: the
* sequence can always be cut short with Enter, Escape or a click. */
import { useEffect, useMemo, useRef, useState } from "react";
import { coverUrl } from "../../api/client";
import {
playChestOpen,
playChestThud,
playRewardJingle,
} from "../../lib/tippen/pop";
import type { UnlockedReward } from "../../lib/tippen/progress";
import { TreasureChest } from "./TreasureChest";
type Phase = "drop" | "open" | "reveal";
const OPEN_AT = 1150;
const REVEAL_AT = 1500;
/** The chest is drawn wider than the cover on purpose: the lid swings up across the
* middle, so a cover as wide as the chest hides it completely and the open chest reads as
* a shut one. Keeping the cover to roughly three-quarters of the chest's width leaves the
* lid standing clear beside it. */
const CHEST_SIZE = 196;
const COVER_SIZE = 148;
/** Headline and fallback glyph per reward kind. In German, and phrased as a gift rather
* than as a transaction: "freigeschaltet" is what the map's badge says, "für dich" is
* what this screen says. */
const COPY: Record<
UnlockedReward["kind"],
{ heading: string; lead: string; glyph: string }
> = {
album: {
heading: "Ein neues Lied für dich!",
lead: "Du hast Musik freigespielt",
glyph: "🎵",
},
book: {
heading: "Ein neues Hörbuch für dich!",
lead: "Du hast eine Geschichte freigespielt",
glyph: "📖",
},
podcast_episode: {
heading: "Eine neue Folge für dich!",
lead: "Du hast eine Folge freigespielt",
glyph: "🎙️",
},
};
const CONFETTI_COLOURS = [
"oklch(85% 0.17 88)",
"oklch(78% 0.16 340)",
"oklch(82% 0.15 150)",
"oklch(80% 0.14 220)",
"oklch(88% 0.13 60)",
"oklch(75% 0.17 300)",
];
const CONFETTI_COUNT = 34;
interface Props {
reward: UnlockedReward;
/** Honours her own sound setting - the same flag that silences every other sound in
* the app. A silent celebration still celebrates. */
sound: boolean;
onClose: () => void;
}
export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
const reducedMotion = usePrefersReducedMotion();
const [phase, setPhase] = useState<Phase>(reducedMotion ? "reveal" : "drop");
const button = useRef<HTMLButtonElement>(null);
const copy = COPY[reward.kind];
// Fixed per mount, so a re-render (the button focusing, say) never reshuffles the
// confetti mid-fall.
const confetti = useMemo(
() => (reducedMotion ? [] : makeConfetti()),
[reducedMotion],
);
useEffect(() => {
if (reducedMotion) {
if (sound) playRewardJingle();
return;
}
if (sound) playChestThud();
const timers = [
window.setTimeout(() => {
setPhase("open");
if (sound) {
playChestOpen();
playRewardJingle();
}
}, OPEN_AT),
window.setTimeout(() => setPhase("reveal"), REVEAL_AT),
];
return () => timers.forEach(window.clearTimeout);
}, [reducedMotion, sound]);
// The button only exists from "reveal" on, so this focuses it as it appears. Taking
// focus matters: the result sheet underneath grabbed it for its own "Nochmal" button
// when it mounted, and Enter must not restart the run from behind this screen.
useEffect(() => {
if (phase === "reveal") button.current?.focus();
}, [phase]);
const open = phase !== "drop";
const revealed = phase === "reveal";
return (
<div
className="tp-reward-overlay"
role="dialog"
aria-label={`${copy.heading} ${reward.title}`}
onClick={onClose}
>
{confetti.map((piece, i) => (
<span
key={i}
className="tp-confetti-piece"
style={{
left: piece.left,
width: piece.width,
height: piece.height,
background: piece.colour,
borderRadius: piece.round ? "50%" : 2,
animationDelay: `${OPEN_AT + piece.delay}ms`,
animationDuration: `${piece.duration}ms`,
["--tp-drift" as string]: piece.drift,
["--tp-spin" as string]: piece.spin,
}}
/>
))}
<div
style={{
fontSize: 15,
fontWeight: 800,
color: "var(--paper)",
opacity: revealed ? 0.75 : 0,
transition: "opacity 300ms",
}}
>
{copy.lead}
</div>
{/* The chest and the cover share one stacking box, and the cover is sandwiched
between the chest's two halves - see TreasureChest's `layer`. That is what makes
the cover come *out of* the chest rather than float in front of a picture of
one: it rises from behind the chest's front wall, under the thrown-back lid. */}
<div
style={{
position: "relative",
width: CHEST_SIZE,
height: CHEST_SIZE,
// Room above for the cover, which overhangs this box by most of its height.
marginTop: COVER_SIZE * 0.78,
animation: open
? undefined
: "chestDrop 520ms cubic-bezier(0.34, 1.4, 0.64, 1), chestRattle 420ms ease-in-out 540ms 2",
}}
>
<TreasureChest size={CHEST_SIZE} open={open} layer="back" style={{ position: "absolute", inset: 0 }} />
{open && !reducedMotion && <Rays />}
{revealed && (
<div
style={{
position: "absolute",
left: "50%",
// Deep enough that the cover's foot is hidden behind the front wall.
bottom: CHEST_SIZE * 0.5,
transform: "translateX(-50%)",
}}
>
<RewardArt reward={reward} glyph={copy.glyph} />
</div>
)}
<TreasureChest size={CHEST_SIZE} open={open} layer="front" style={{ position: "absolute", inset: 0 }} />
</div>
<div
style={{
fontSize: 27,
fontWeight: 900,
color: "var(--paper)",
marginTop: 16,
opacity: revealed ? 1 : 0,
animation: revealed
? "rewardTextEnter 360ms ease-out 80ms both"
: undefined,
}}
>
{copy.heading}
</div>
<div
style={{
fontSize: 19,
fontWeight: 800,
color: "oklch(90% 0.13 92)",
maxWidth: 460,
lineHeight: 1.3,
marginTop: 4,
opacity: revealed ? 1 : 0,
animation: revealed
? "rewardTextEnter 360ms ease-out 200ms both"
: undefined,
}}
>
{reward.title}
</div>
<div
style={{
fontSize: 14,
fontWeight: 700,
color: "var(--paper)",
opacity: revealed ? 0.7 : 0,
marginTop: 8,
transition: "opacity 300ms 400ms",
}}
>
Ab jetzt im Musik-Player 🎧
</div>
{revealed && (
<button
ref={button}
onClick={(event) => {
event.stopPropagation();
onClose();
}}
style={{
border: "none",
borderRadius: 999,
padding: "14px 30px",
fontSize: 18,
fontWeight: 900,
cursor: "pointer",
background: "var(--accent)",
color: "var(--paper)",
boxShadow: "0 8px 24px var(--shadow)",
marginTop: 22,
animation: "rewardTextEnter 360ms ease-out 320ms both",
}}
>
Toll! ⏎
</button>
)}
</div>
);
}
/** The cover art, or a glyph when the album has none. */
function RewardArt({
reward,
glyph,
}: {
reward: UnlockedReward;
glyph: string;
}) {
const [failed, setFailed] = useState(false);
const shared: React.CSSProperties = {
width: COVER_SIZE,
height: COVER_SIZE,
borderRadius: 18,
border: "3px solid oklch(90% 0.13 92 / 0.9)",
animation:
"coverRise 760ms cubic-bezier(0.22, 1.2, 0.36, 1) both, coverHalo 2.6s ease-in-out 760ms infinite",
};
// A cover the backend said exists but that fails to load would otherwise leave a
// broken-image box as the centrepiece of the celebration.
if (!reward.hasCover || failed) {
return (
<div
style={{
...shared,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 92,
background:
"linear-gradient(160deg, oklch(70% 0.09 175), oklch(45% 0.08 175))",
}}
>
{glyph}
</div>
);
}
return (
<img
src={coverUrl(reward.albumId)}
alt=""
onError={() => setFailed(true)}
style={{
...shared,
objectFit: "cover",
background: "oklch(45% 0.08 175)",
}}
/>
);
}
/** The burst of light at the moment the lid lets go. Two counter-rotating stars of rays,
* so the burst reads as light rather than as a spinning shape. */
function Rays() {
return (
<div
aria-hidden
style={{
position: "absolute",
left: "50%",
top: "18%",
width: 260,
height: 260,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
}}
>
{[0, 1].map((layer) => (
<div
key={layer}
style={{
position: "absolute",
inset: 0,
animation: `rayBurst ${layer === 0 ? 900 : 1150}ms ease-out ${layer * 90}ms both`,
background: `repeating-conic-gradient(from ${layer * 11}deg, oklch(95% 0.14 92 / 0.55) 0deg 5deg, transparent 5deg 22deg)`,
maskImage:
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
WebkitMaskImage:
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
}}
/>
))}
</div>
);
}
interface ConfettiPiece {
left: string;
width: number;
height: number;
colour: string;
round: boolean;
delay: number;
duration: number;
drift: string;
spin: string;
}
function makeConfetti(): ConfettiPiece[] {
return Array.from({ length: CONFETTI_COUNT }, (_, i) => {
const round = i % 3 === 0;
const size = 7 + Math.random() * 8;
return {
left: `${Math.random() * 100}%`,
width: round ? size : size * 0.55,
height: size,
colour: CONFETTI_COLOURS[i % CONFETTI_COLOURS.length]!,
round,
// Spread over a second and a half, so it falls as a shower rather than as a line.
delay: Math.random() * 1500,
duration: 2400 + Math.random() * 2200,
drift: `${(Math.random() - 0.5) * 220}px`,
spin: `${(Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 720)}deg`,
};
});
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false,
);
useEffect(() => {
const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!query) return;
const onChange = () => setReduced(query.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return reduced;
}

View File

@@ -0,0 +1,149 @@
/** A treasure chest whose lid actually opens.
*
* Drawn rather than set as an emoji for one reason: the lid has to be a separate group
* so it can swing on a hinge. A 🎁 can only ever pop and scale, which is exactly the
* animation the old reward badge had and exactly why nobody noticed it. It is also
* sharp at every size, which matters because the same component draws both the 46px
* badge on the lesson map and the 168px chest in the unlock overlay.
*
* `open` is a plain prop, not an animation trigger: the lid transitions to its open
* angle through CSS (`.tp-chest-lid`, tippen.css), so the map can render a permanently
* open chest for a claimed reward with no animation bookkeeping at all, and the overlay
* gets its swing for free just by flipping the prop mid-sequence.
*
* `layer` splits the drawing in two so that something can be put *inside* the chest. The
* lid swings up across the space above the chest, which is exactly where a rising cover
* wants to be; drawn as one piece, the lid crosses in front of the cover and the whole
* thing reads as a z-order bug. Drawn as "back" (lid, mouth, glow), then the cover, then
* "front" (the body), the cover emerges from behind the chest's own front wall - which is
* what coming out of a chest actually looks like. The map, which puts nothing inside, and
* whose shut chests need the lid painted over the body's top edge, takes the default. */
/** Instance-unique gradient ids. Two chests on one page (the map has many) must not share
* `<defs>` ids, or the second one silently paints with the first one's stops. */
let nextId = 0;
interface Props {
size: number;
open: boolean;
/** Which half to draw - see the header. "all" is the whole chest, in one element. */
layer?: "all" | "back" | "front";
/** Softened - a reward whose lesson is still ahead of her. Muted rather than greyed
* out: a shut chest still has to look like treasure, or the badge promises nothing. */
muted?: boolean;
className?: string;
style?: React.CSSProperties;
}
export function TreasureChest({ size, open, layer = "all", muted = false, className, style }: Props) {
const id = `tp-chest-${(nextId += 1)}`;
return (
<svg
width={size}
height={size}
viewBox="0 0 64 62"
className={className}
aria-hidden
style={{
overflow: "visible",
filter: muted ? "grayscale(0.35) brightness(0.9)" : undefined,
opacity: muted ? 0.9 : 1,
...style,
}}
>
<defs>
<linearGradient id={`${id}-wood`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="oklch(58% 0.11 55)" />
<stop offset="100%" stopColor="oklch(38% 0.09 45)" />
</linearGradient>
<linearGradient id={`${id}-lid`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="oklch(66% 0.11 58)" />
<stop offset="100%" stopColor="oklch(46% 0.1 48)" />
</linearGradient>
<linearGradient id={`${id}-gold`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="oklch(92% 0.14 95)" />
<stop offset="45%" stopColor="oklch(82% 0.17 88)" />
<stop offset="100%" stopColor="oklch(66% 0.15 80)" />
</linearGradient>
<radialGradient id={`${id}-glow`}>
<stop offset="0%" stopColor="oklch(97% 0.14 95 / 0.95)" />
<stop offset="55%" stopColor="oklch(90% 0.16 92 / 0.35)" />
<stop offset="100%" stopColor="oklch(90% 0.16 92 / 0)" />
</radialGradient>
</defs>
{/* What makes it read as *open* rather than as a chest with its lid drawn beside
it: a dark mouth, with light coming out of it. Both are painted before the body,
so the body's front wall covers their lower half and the opening looks like a
hole in the chest rather than a shape on it. */}
{layer !== "front" && open && (
<>
<ellipse cx="32" cy="27" rx="24" ry="7" fill="oklch(22% 0.03 45)" />
<circle cx="32" cy="25" r="24" fill={`url(#${id}-glow)`} />
</>
)}
{layer === "back" && <Lid id={id} open={open} />}
{layer !== "back" && (
<>
<rect
x="6"
y="26"
width="52"
height="30"
rx="5"
fill={`url(#${id}-wood)`}
stroke="oklch(28% 0.06 45)"
strokeWidth="2"
/>
{/* Two gold straps down the body, and the band along its foot. */}
<rect x="13" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
<rect x="46.5" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
<rect x="6" y="47" width="52" height="4" fill={`url(#${id}-gold)`} opacity="0.7" />
{/* Lock plate. It stays on the body - a real chest's hasp swings with the lid,
but a lock that leaves with the lid loses the "this was shut" read. */}
<rect
x="26"
y="30"
width="12"
height="14"
rx="2.5"
fill={`url(#${id}-gold)`}
stroke="oklch(45% 0.1 75)"
strokeWidth="1.2"
/>
<circle cx="32" cy="36" r="2.2" fill="oklch(32% 0.05 60)" />
<rect x="31" y="36" width="2" height="5" rx="1" fill="oklch(32% 0.05 60)" />
</>
)}
{/* In one piece, the lid goes last: shut, it has to cover the body's top edge. */}
{layer === "all" && <Lid id={id} open={open} />}
</svg>
);
}
/** A half-round lid, hinged at its left end - the cartoon flip-open, and the only hinge
* that works in a flat, front-on view. */
function Lid({ id, open }: { id: string; open: boolean }) {
return (
<g className="tp-chest-lid" data-open={open || undefined} style={{ transformOrigin: "8px 27px" }}>
<path d="M8 27 A 24 21 0 0 1 56 27 Z" fill={`url(#${id}-lid)`} stroke="oklch(28% 0.06 45)" strokeWidth="2" />
<rect
x="7"
y="22.5"
width="50"
height="5"
rx="2"
fill={`url(#${id}-gold)`}
stroke="oklch(45% 0.1 75)"
strokeWidth="0.8"
/>
<path d="M13 27 A 24 21 0 0 1 15.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
<path d="M51 27 A 24 21 0 0 0 48.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
</g>
);
}