From f3337975c243d0a4a3682e343b0713c94d906e1c Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Sat, 19 Sep 2026 18:10:11 +0200 Subject: [PATCH] Celebrate a media unlock with a treasure chest that opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/TippenApp.tsx | 47 +- web/src/components/tippen/LessonMap.tsx | 237 ++++++---- web/src/components/tippen/ResultSheet.tsx | 36 +- .../components/tippen/RewardUnlockOverlay.tsx | 409 ++++++++++++++++++ web/src/components/tippen/TreasureChest.tsx | 149 +++++++ web/src/lib/tippen/__tests__/progress.test.ts | 26 +- web/src/lib/tippen/pop.ts | 111 +++++ web/src/lib/tippen/progress.ts | 6 + web/src/styles/tippen.css | 138 ++++++ 9 files changed, 1060 insertions(+), 99 deletions(-) create mode 100644 web/src/components/tippen/RewardUnlockOverlay.tsx create mode 100644 web/src/components/tippen/TreasureChest.tsx diff --git a/web/src/components/TippenApp.tsx b/web/src/components/TippenApp.tsx index bf47bd9..78bbad2 100644 --- a/web/src/components/TippenApp.tsx +++ b/web/src/components/TippenApp.tsx @@ -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(null); const [mode, setMode] = useState("dive"); const [outcome, setOutcome] = useState(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(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 && ( + setCelebration(null)} + /> + )} + {showHelp && setShowHelp(false)} />} ); diff --git a/web/src/components/tippen/LessonMap.tsx b/web/src/components/tippen/LessonMap.tsx index 1601ef8..4ba2a72 100644 --- a/web/src/components/tippen/LessonMap.tsx +++ b/web/src/components/tippen/LessonMap.tsx @@ -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 ( - + + {lesson.reward.resolved && ( + )} - - {locked ? "🔒" : (animal?.emoji ?? "·")} - - {/* 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. */} - - {lesson.isDrill - ? "🔁" - : lesson.newKeys.length > 0 - ? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ") - : lesson.kind === "letters" - ? "üben" - : CONSOLIDATION_LABEL[lesson.kind]} - - - - {[1, 2, 3].map((star) => ( - = star ? 1 : 0.22 }}> - ⭐ - - ))} - - + ); })} @@ -211,3 +225,74 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes ); } + +/** 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 ( +
+ {/* 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 ? ( +
+ + { + 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)", + }} + /> + +
+ ) : ( + + )} +
+ ); +} diff --git a/web/src/components/tippen/ResultSheet.tsx b/web/src/components/tippen/ResultSheet.tsx index 689bd02..c8bcb9e 100644 --- a/web/src/components/tippen/ResultSheet.tsx +++ b/web/src/components/tippen/ResultSheet.tsx @@ -148,34 +148,42 @@ export function ResultSheet({ {creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen! )} + {/* 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 && (
{unlockedReward.hasCover ? ( ) : ( - 🎁 + 🎵 )} - 🎁 Neu zum Anhören: {unlockedReward.title} +
+
+ Freigespielt — ab jetzt im Musik-Player +
+
+ {unlockedReward.title} +
+
)} {!result.passed && !unlockedTitle && ( diff --git a/web/src/components/tippen/RewardUnlockOverlay.tsx b/web/src/components/tippen/RewardUnlockOverlay.tsx new file mode 100644 index 0000000..aa71c69 --- /dev/null +++ b/web/src/components/tippen/RewardUnlockOverlay.tsx @@ -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(reducedMotion ? "reveal" : "drop"); + const button = useRef(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 ( +
+ {confetti.map((piece, i) => ( + + ))} + +
+ {copy.lead} +
+ + {/* 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. */} +
+ + + {open && !reducedMotion && } + + {revealed && ( + + )} + + +
+ +
+ {copy.heading} +
+ +
+ {reward.title} +
+ +
+ Ab jetzt im Musik-Player 🎧 +
+ + {revealed && ( + + )} +
+ ); +} + +/** 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 ( +
+ {glyph} +
+ ); + } + + return ( + 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 ( +
+ {[0, 1].map((layer) => ( +
+ ))} +
+ ); +} + +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; +} diff --git a/web/src/components/tippen/TreasureChest.tsx b/web/src/components/tippen/TreasureChest.tsx new file mode 100644 index 0000000..d4b1bdf --- /dev/null +++ b/web/src/components/tippen/TreasureChest.tsx @@ -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 + * `` 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 ( + + + + + + + + + + + + + + + + + + + + + + + {/* 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 && ( + <> + + + + )} + + {layer === "back" && } + + {layer !== "back" && ( + <> + + {/* Two gold straps down the body, and the band along its foot. */} + + + + + {/* 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. */} + + + + + )} + + {/* In one piece, the lid goes last: shut, it has to cover the body's top edge. */} + {layer === "all" && } + + ); +} + +/** 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 ( + + + + + + + ); +} diff --git a/web/src/lib/tippen/__tests__/progress.test.ts b/web/src/lib/tippen/__tests__/progress.test.ts index 9b7eb5d..c4009b8 100644 --- a/web/src/lib/tippen/__tests__/progress.test.ts +++ b/web/src/lib/tippen/__tests__/progress.test.ts @@ -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 { 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); + }); +}); diff --git a/web/src/lib/tippen/pop.ts b/web/src/lib/tippen/pop.ts index b51caf0..8cf2dea 100644 --- a/web/src/lib/tippen/pop.ts +++ b/web/src/lib/tippen/pop.ts @@ -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" }, + ]); +} diff --git a/web/src/lib/tippen/progress.ts b/web/src/lib/tippen/progress.ts index 19c6f43..c9d95b4 100644 --- a/web/src/lib/tippen/progress.ts +++ b/web/src/lib/tippen/progress.ts @@ -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, }; } diff --git a/web/src/styles/tippen.css b/web/src/styles/tippen.css index d526fc6..113e033 100644 --- a/web/src/styles/tippen.css +++ b/web/src/styles/tippen.css @@ -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; + } +}