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

View File

@@ -6,15 +6,24 @@
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is * 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 * 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 * 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 * both a path forward and a trophy cabinet.
* 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. */ * 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 type { Lesson, World } from "../../lib/tippen/curriculum";
import { animalById } from "../../lib/tippen/grading"; import { animalById } from "../../lib/tippen/grading";
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath"; import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
import { MODE_INFO } from "../../lib/tippen/modeInfo"; import { MODE_INFO } from "../../lib/tippen/modeInfo";
import type { Progress } from "../../lib/tippen/progress"; import type { Progress } from "../../lib/tippen/progress";
import { TreasureChest } from "./TreasureChest";
interface Props { interface Props {
worlds: readonly World[]; worlds: readonly World[];
@@ -29,6 +38,9 @@ interface Props {
} }
const NODE_SIZE = 88; 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. */ /** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
const PATH_HALF_WIDTH = 160; const PATH_HALF_WIDTH = 160;
@@ -96,8 +108,11 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
const modeInfo = MODE_INFO[lesson.primaryMode]; const modeInfo = MODE_INFO[lesson.primaryMode];
return ( return (
// 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 <button
key={lesson.id}
className="card tp-glass-panel" className="card tp-glass-panel"
data-selected={index === selected} data-selected={index === selected}
data-locked={locked} data-locked={locked}
@@ -136,16 +151,6 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
{modeInfo.emoji} {modeInfo.emoji}
</span> </span>
{lesson.reward.resolved && (
<span
aria-hidden
style={{ position: "absolute", top: -4, left: -4, fontSize: 15, opacity: 0.9 }}
title="Schaltet neue Musik frei!"
>
🎁
</span>
)}
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span> <span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
{/* The keys themselves: for a pre-reader this is the real label, the {/* The keys themselves: for a pre-reader this is the real label, the
@@ -178,6 +183,15 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
))} ))}
</span> </span>
</button> </button>
{lesson.reward.resolved && (
<RewardChest
point={point}
earned={entry?.earned ?? false}
albumId={lesson.reward.hasCover ? lesson.reward.albumId : null}
/>
)}
</Fragment>
); );
})} })}
</div> </div>
@@ -211,3 +225,74 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
</div> </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>
);
}

View File

@@ -148,34 +148,42 @@ export function ResultSheet({
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen! {creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
</div> </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 && ( {unlockedReward && (
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", gap: 12,
gap: 10, marginTop: 12,
color: "var(--ink)", padding: "10px 14px",
fontWeight: 900, borderRadius: 16,
marginTop: 8, textAlign: "left",
background: "oklch(93% 0.06 92)",
border: "1px solid oklch(80% 0.12 90)",
}} }}
> >
{unlockedReward.hasCover ? ( {unlockedReward.hasCover ? (
<img <img
src={coverUrl(unlockedReward.albumId)} src={coverUrl(unlockedReward.albumId)}
alt="" alt=""
style={{ style={{ width: 52, height: 52, objectFit: "cover", borderRadius: 8, flexShrink: 0 }}
width: 52,
height: 52,
objectFit: "cover",
borderRadius: 8,
animation: "tierEnter 520ms ease-out",
}}
/> />
) : ( ) : (
<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> </div>
)} )}
{!result.passed && !unlockedTitle && ( {!result.passed && !unlockedTitle && (

View 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;
}

View 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>
);
}

View File

@@ -6,8 +6,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { focusKeyFor, mastery } from "../progress"; import { focusKeyFor, mastery, progressFromApi } from "../progress";
import type { Progress } from "../progress"; import type { Progress } from "../progress";
import type { TippenProgress as ApiProgress } from "../../../api/types";
function basicProgress(over: Partial<Progress> = {}): Progress { function basicProgress(over: Partial<Progress> = {}): Progress {
return { return {
@@ -64,3 +65,26 @@ describe("mastery", () => {
expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5); 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);
});
});

View File

@@ -54,3 +54,114 @@ export function playFanfare(): void {
window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110); 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" },
]);
}

View File

@@ -19,6 +19,11 @@ export interface LessonProgress {
bestStars: 0 | 1 | 2 | 3; bestStars: 0 | 1 | 2 | 3;
bestAnimal: AnimalId | null; bestAnimal: AnimalId | null;
bestPoints: number; 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. */ /** Best-run keystrokes, replayed as the opponent in race mode. */
ghost: { key: string; at: number }[] | null; ghost: { key: string; at: number }[] | null;
} }
@@ -52,6 +57,7 @@ export function progressFromApi(progress: ApiProgress): Progress {
bestStars: entry.best_stars, bestStars: entry.best_stars,
bestAnimal: entry.best_animal as AnimalId | null, bestAnimal: entry.best_animal as AnimalId | null,
bestPoints: entry.best_points, bestPoints: entry.best_points,
earned: entry.earned,
ghost: entry.ghost, ghost: entry.ghost,
}; };
} }

View File

@@ -310,3 +310,141 @@
display: none; 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;
}
}