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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
409
web/src/components/tippen/RewardUnlockOverlay.tsx
Normal file
409
web/src/components/tippen/RewardUnlockOverlay.tsx
Normal 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;
|
||||
}
|
||||
149
web/src/components/tippen/TreasureChest.tsx
Normal file
149
web/src/components/tippen/TreasureChest.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { focusKeyFor, mastery } from "../progress";
|
||||
import { focusKeyFor, mastery, progressFromApi } from "../progress";
|
||||
import type { Progress } from "../progress";
|
||||
import type { TippenProgress as ApiProgress } from "../../../api/types";
|
||||
|
||||
function basicProgress(over: Partial<Progress> = {}): Progress {
|
||||
return {
|
||||
@@ -64,3 +65,26 @@ describe("mastery", () => {
|
||||
expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("progressFromApi", () => {
|
||||
/** `earned` was silently dropped here once, which is not a visible bug anywhere except
|
||||
* on the lesson map, where it decides whether a reward's treasure chest is drawn open.
|
||||
* A dropped field fails no type check - the mapping is written out by hand - so it
|
||||
* needs a test that names it. */
|
||||
it("keeps the backend's derived `earned` flag", () => {
|
||||
const api = {
|
||||
lessons: {
|
||||
won: { unlocked: true, runs: 3, best_stars: 3, best_animal: "delfin", best_points: 9, earned: true, ghost: null },
|
||||
open: { unlocked: true, runs: 1, best_stars: 1, best_animal: "krabbe", best_points: 2, earned: false, ghost: null },
|
||||
},
|
||||
key_stats: {},
|
||||
aquarium: [],
|
||||
streak: { days: 2, last_played: null },
|
||||
settings: { sound: true, keyboard_hint: "auto" },
|
||||
} as unknown as ApiProgress;
|
||||
|
||||
const progress = progressFromApi(api);
|
||||
expect(progress.lessons.won?.earned).toBe(true);
|
||||
expect(progress.lessons.open?.earned).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,3 +54,114 @@ export function playFanfare(): void {
|
||||
window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110);
|
||||
});
|
||||
}
|
||||
|
||||
/** One scheduled note of a tune. `at`/`duration` are seconds relative to the tune's
|
||||
* own start, so a melody reads as a score rather than as nested `setTimeout`s - which
|
||||
* also keeps the notes sample-accurate against each other instead of drifting by
|
||||
* however late the event loop happened to be. */
|
||||
interface Note {
|
||||
/** Hz. */
|
||||
hz: number;
|
||||
at: number;
|
||||
duration: number;
|
||||
gain?: number;
|
||||
type?: OscillatorType;
|
||||
}
|
||||
|
||||
function playTune(notes: readonly Note[]): void {
|
||||
try {
|
||||
context ??= new AudioContext();
|
||||
// Resuming matters here specifically: this tune plays at the end of a run, and on
|
||||
// some browsers the context created during the run's first keystroke is suspended
|
||||
// again by the time the result screen opens.
|
||||
void context.resume?.();
|
||||
const start = context.currentTime + 0.02;
|
||||
for (const note of notes) {
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
const peak = note.gain ?? 0.12;
|
||||
const from = start + note.at;
|
||||
oscillator.type = note.type ?? "triangle";
|
||||
oscillator.frequency.setValueAtTime(note.hz, from);
|
||||
// A short attack rather than an instant one: a hard start on a triangle wave
|
||||
// clicks, and a click in a reward jingle sounds like a fault.
|
||||
gain.gain.setValueAtTime(0.0001, from);
|
||||
gain.gain.exponentialRampToValueAtTime(peak, from + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, from + note.duration);
|
||||
oscillator.connect(gain).connect(context.destination);
|
||||
oscillator.start(from);
|
||||
oscillator.stop(from + note.duration + 0.02);
|
||||
}
|
||||
} catch {
|
||||
// Same as `blip`: no audio context, no sound, never an exception.
|
||||
}
|
||||
}
|
||||
|
||||
const C5 = 523.25;
|
||||
const D5 = 587.33;
|
||||
const E5 = 659.25;
|
||||
const F5 = 698.46;
|
||||
const G5 = 783.99;
|
||||
const A5 = 880;
|
||||
const C6 = 1046.5;
|
||||
const E6 = 1318.5;
|
||||
const G6 = 1568;
|
||||
const C4 = 261.63;
|
||||
const E4 = 329.63;
|
||||
const G4 = 392;
|
||||
const G3 = 196;
|
||||
|
||||
/** The unlock tune: about two seconds of unambiguous "you got something".
|
||||
*
|
||||
* Deliberately a whole melody rather than one more blip. `playFanfare` already marks
|
||||
* every smaller win in this app - a new lesson, a new animal, a new aquarium creature -
|
||||
* so if opening real music sounded like that too, the biggest reward in the game would
|
||||
* be the one thing she could not hear coming. This one is longer, has a bass line under
|
||||
* it and lands on a held major chord, which is what makes it read as an arrival.
|
||||
*
|
||||
* C major throughout: a rising C-E-G-C run, a little D-E-F-G turn over it, then the
|
||||
* tonic triad plus its octave held together over a low C. */
|
||||
export function playRewardJingle(): void {
|
||||
playTune([
|
||||
// The run up.
|
||||
{ hz: C5, at: 0, duration: 0.16 },
|
||||
{ hz: E5, at: 0.12, duration: 0.16 },
|
||||
{ hz: G5, at: 0.24, duration: 0.16 },
|
||||
{ hz: C6, at: 0.36, duration: 0.26 },
|
||||
// The turn - the bit that makes it a tune instead of an arpeggio.
|
||||
{ hz: A5, at: 0.62, duration: 0.13 },
|
||||
{ hz: G5, at: 0.74, duration: 0.13 },
|
||||
{ hz: A5, at: 0.86, duration: 0.13 },
|
||||
{ hz: C6, at: 0.98, duration: 0.22 },
|
||||
// The arrival: a held triad, with the bass under it.
|
||||
{ hz: C5, at: 1.24, duration: 0.95, gain: 0.1 },
|
||||
{ hz: E5, at: 1.24, duration: 0.95, gain: 0.09 },
|
||||
{ hz: G5, at: 1.24, duration: 0.95, gain: 0.09 },
|
||||
{ hz: C6, at: 1.24, duration: 0.95, gain: 0.08 },
|
||||
{ hz: C4, at: 1.24, duration: 1, gain: 0.09, type: "sine" },
|
||||
{ hz: G3, at: 1.24, duration: 1, gain: 0.07, type: "sine" },
|
||||
// Sparkles over the held chord, as the cover comes out of the chest.
|
||||
{ hz: E6, at: 1.4, duration: 0.2, gain: 0.05, type: "sine" },
|
||||
{ hz: G6, at: 1.56, duration: 0.2, gain: 0.045, type: "sine" },
|
||||
{ hz: C6 * 2, at: 1.72, duration: 0.3, gain: 0.04, type: "sine" },
|
||||
]);
|
||||
}
|
||||
|
||||
/** The chest landing on the screen, just before its lid opens: two low thuds. Pitched
|
||||
* well below the jingle so it reads as a thing arriving, not as a note. */
|
||||
export function playChestThud(): void {
|
||||
playTune([
|
||||
{ hz: 150, at: 0, duration: 0.12, gain: 0.1, type: "sine" },
|
||||
{ hz: 110, at: 0.14, duration: 0.16, gain: 0.09, type: "sine" },
|
||||
]);
|
||||
}
|
||||
|
||||
/** The lid coming open: a bright upward creak-and-pop. */
|
||||
export function playChestOpen(): void {
|
||||
playTune([
|
||||
{ hz: G4, at: 0, duration: 0.1, gain: 0.07 },
|
||||
{ hz: E4 * 2, at: 0.06, duration: 0.12, gain: 0.07 },
|
||||
{ hz: D5 * 2, at: 0.13, duration: 0.14, gain: 0.06 },
|
||||
{ hz: F5 * 2, at: 0.2, duration: 0.2, gain: 0.05, type: "sine" },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ export interface LessonProgress {
|
||||
bestStars: 0 | 1 | 2 | 3;
|
||||
bestAnimal: AnimalId | null;
|
||||
bestPoints: number;
|
||||
/** Passed on its own merits, or given up on gracefully after enough tries - derived
|
||||
* server-side from `bestStars`/`runs`, see `progress.py`. This, not `bestStars`, is
|
||||
* what decides whether a lesson's `unlocks:` reward has actually been claimed, so the
|
||||
* map draws its treasure chest from this. */
|
||||
earned: boolean;
|
||||
/** Best-run keystrokes, replayed as the opponent in race mode. */
|
||||
ghost: { key: string; at: number }[] | null;
|
||||
}
|
||||
@@ -52,6 +57,7 @@ export function progressFromApi(progress: ApiProgress): Progress {
|
||||
bestStars: entry.best_stars,
|
||||
bestAnimal: entry.best_animal as AnimalId | null,
|
||||
bestPoints: entry.best_points,
|
||||
earned: entry.earned,
|
||||
ghost: entry.ghost,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -310,3 +310,141 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- the unlock celebration
|
||||
|
||||
What happens when a lesson opens real music (RewardUnlockOverlay.tsx). Its own
|
||||
section because it is the only thing in this app that deliberately interrupts: it
|
||||
sits above the result sheet and has to be waited out, since the whole point of the
|
||||
typing game - for a six-year-old - is that it hands back songs. Everything here is
|
||||
staged rather than simultaneous; the sequence is what makes it read as a chest being
|
||||
opened rather than as a dialog appearing. */
|
||||
|
||||
/* Above .tp-overlay's own layer: the result sheet is already on screen underneath, and
|
||||
is meant to be revealed when this closes rather than replaced by it. */
|
||||
.tp-reward-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(
|
||||
circle at 50% 42%,
|
||||
oklch(45% 0.07 175 / 0.82) 0%,
|
||||
oklch(16% 0.04 175 / 0.94) 70%
|
||||
);
|
||||
animation: backdropEnter 260ms ease-out;
|
||||
/* Thrown right back, out of the column the cover rises through - see the note on
|
||||
.tp-chest-lid. Alone this would look like a lid coming off; with the cover standing
|
||||
in the middle it reads as the lid flung open to make room for it. */
|
||||
--tp-chest-open: -108deg;
|
||||
}
|
||||
|
||||
/* The lid's two states. Transitioned rather than keyframed so the map can draw a
|
||||
permanently open chest with no animation at all - see TreasureChest.tsx. */
|
||||
.tp-chest-lid {
|
||||
transform-box: view-box;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 620ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* How far the lid swings. The default is for a chest standing on its own (the lesson
|
||||
map): far enough back to be unmistakably open, but not so far that it stops looking
|
||||
attached to its hinge - a lid much past -90deg, alone, reads as a flap flying off.
|
||||
The overlay overrides it above, because there the lid has a cover rising through the
|
||||
middle of the chest and has to clear out of its way. */
|
||||
.tp-chest-lid[data-open] {
|
||||
transform: rotate(var(--tp-chest-open, -62deg));
|
||||
}
|
||||
|
||||
/* The chest arriving: dropped in, with a squash on landing. */
|
||||
@keyframes chestDrop {
|
||||
0% { opacity: 0; transform: translateY(-120px) scale(0.7); }
|
||||
55% { opacity: 1; transform: translateY(0) scale(1.06, 0.9); }
|
||||
75% { transform: translateY(-6px) scale(0.98, 1.04); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* The wait before the lid goes: the chest rattling, because something inside wants out.
|
||||
This is the beat that earns the opening - a lid that opens the instant the chest lands
|
||||
is just a transition, not a reveal. */
|
||||
@keyframes chestRattle {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
15% { transform: translateX(-3px) rotate(-2.5deg); }
|
||||
30% { transform: translateX(3px) rotate(2.5deg); }
|
||||
45% { transform: translateX(-2px) rotate(-1.5deg); }
|
||||
60% { transform: translateX(2px) rotate(1.5deg); }
|
||||
80% { transform: translateX(-1px) rotate(-0.5deg); }
|
||||
}
|
||||
|
||||
/* The burst of light at the moment the lid lets go. */
|
||||
@keyframes rayBurst {
|
||||
0% { opacity: 0; transform: scale(0.2) rotate(0deg); }
|
||||
25% { opacity: 0.9; }
|
||||
100% { opacity: 0; transform: scale(2.4) rotate(140deg); }
|
||||
}
|
||||
|
||||
/* The cover art coming up out of the chest and settling where it can be looked at.
|
||||
Overshoots on the way up - a reward that rises and stops dead looks placed, not
|
||||
thrown. */
|
||||
@keyframes coverRise {
|
||||
0% { opacity: 0; transform: translateY(46px) scale(0.28) rotate(-14deg); }
|
||||
45% { opacity: 1; transform: translateY(-26px) scale(1.12) rotate(5deg); }
|
||||
70% { transform: translateY(2px) scale(0.97) rotate(-2deg); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0deg); }
|
||||
}
|
||||
|
||||
/* The cover's own halo, once it has settled - a slow pulse, so the thing she just won
|
||||
keeps drawing the eye while the text and the button arrive under it. */
|
||||
@keyframes coverHalo {
|
||||
0%, 100% { box-shadow: 0 10px 34px var(--shadow), 0 0 0 0 oklch(92% 0.15 92 / 0.55); }
|
||||
50% { box-shadow: 0 10px 34px var(--shadow), 0 0 42px 10px oklch(92% 0.15 92 / 0.28); }
|
||||
}
|
||||
|
||||
@keyframes rewardTextEnter {
|
||||
0% { opacity: 0; transform: translateY(12px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Confetti. Each piece gets its own left/delay/duration/colour/spin from inline style -
|
||||
the keyframe only owns the fall. `--tp-drift` is how far sideways it wanders, so no
|
||||
two pieces trace the same line down the screen. */
|
||||
@keyframes confettiFall {
|
||||
0% { opacity: 0; transform: translate(0, -8vh) rotate(0deg); }
|
||||
8% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translate(var(--tp-drift, 0px), 104vh) rotate(var(--tp-spin, 540deg)); }
|
||||
}
|
||||
|
||||
.tp-confetti-piece {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
will-change: transform;
|
||||
animation-name: confettiFall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: 1;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* The reveal still happens - it is information, not decoration - but nothing travels:
|
||||
the chest is simply open, the cover simply there, and the confetti does not fall.
|
||||
RewardUnlockOverlay also skips straight to its final step when this is set, so the
|
||||
staging never leaves her waiting on animations that were turned off. */
|
||||
.tp-reward-overlay,
|
||||
.tp-reward-overlay * {
|
||||
animation: none !important;
|
||||
}
|
||||
.tp-chest-lid {
|
||||
transition: none;
|
||||
}
|
||||
.tp-confetti-piece {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user