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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user