Celebrate a media unlock with a treasure chest that opens

The reward pipeline worked end to end already, but it had nothing to show for
itself: the unlock was one more line at the bottom of the result sheet, a 52px
thumbnail with the generic 520ms pop, below the stars, the stats, the progress
bar, the animal ladder and three other badges. The sound was `playFanfare`, the
same chirp used for a new lesson and a new aquarium pet. It fired correctly and
was impossible to notice.

This is the only reward that reaches outside the game, so it now gets the whole
screen. A chest drops in shut and rattles, the lid swings open on a burst of
light and confetti, the cover art rises out of it, and the tune is a real melody
- two seconds landing on a held major chord - rather than another blip. It is
dismissed by hand, so she can look at what she won for as long as she likes.

On the map, the 15px 🎁 becomes a drawn chest, shut while the reward is unwon
and open with the cover inside once it has been. It is rendered as a sibling of
the lesson node rather than a child, because a locked node is dimmed to 45% and
the chest that most needs to be bright is the one three worlds away.

One real bug behind the missing badge state: `progressFromApi` dropped the
`earned` flag the backend already sends, so the map could not tell a claimed
reward from an unclaimed one. Added, with a test that names it - a hand-written
field mapping loses fields without failing a type check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 18:10:11 +02:00
parent e6f6b15cc7
commit f3337975c2
9 changed files with 1060 additions and 99 deletions

View File

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