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