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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,88 +108,90 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLes
|
|||||||
const modeInfo = MODE_INFO[lesson.primaryMode];
|
const modeInfo = MODE_INFO[lesson.primaryMode];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
// The node and its chest are two siblings, both positioned against
|
||||||
key={lesson.id}
|
// this world's own box - see RewardChest for why the chest must not
|
||||||
className="card tp-glass-panel"
|
// be a child of the node.
|
||||||
data-selected={index === selected}
|
<Fragment key={lesson.id}>
|
||||||
data-locked={locked}
|
<button
|
||||||
disabled={locked}
|
className="card tp-glass-panel"
|
||||||
onClick={() => onPick(lesson)}
|
data-selected={index === selected}
|
||||||
title={lesson.title}
|
data-locked={locked}
|
||||||
style={{
|
disabled={locked}
|
||||||
position: "absolute",
|
onClick={() => onPick(lesson)}
|
||||||
left: `calc(50% + ${point.x}px)`,
|
title={lesson.title}
|
||||||
top: point.y,
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
width: NODE_SIZE,
|
|
||||||
height: NODE_SIZE,
|
|
||||||
borderRadius: "50%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 2,
|
|
||||||
padding: 0,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: -4,
|
left: `calc(50% + ${point.x}px)`,
|
||||||
right: -4,
|
top: point.y,
|
||||||
fontSize: 15,
|
transform: "translate(-50%, -50%)",
|
||||||
filter: locked ? "grayscale(1)" : "none",
|
width: NODE_SIZE,
|
||||||
opacity: locked ? 0.4 : 0.9,
|
height: NODE_SIZE,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 2,
|
||||||
|
padding: 0,
|
||||||
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
title={modeInfo.name}
|
|
||||||
>
|
>
|
||||||
{modeInfo.emoji}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{lesson.reward.resolved && (
|
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
style={{ position: "absolute", top: -4, left: -4, fontSize: 15, opacity: 0.9 }}
|
style={{
|
||||||
title="Schaltet neue Musik frei!"
|
position: "absolute",
|
||||||
|
top: -4,
|
||||||
|
right: -4,
|
||||||
|
fontSize: 15,
|
||||||
|
filter: locked ? "grayscale(1)" : "none",
|
||||||
|
opacity: locked ? 0.4 : 0.9,
|
||||||
|
}}
|
||||||
|
title={modeInfo.name}
|
||||||
>
|
>
|
||||||
🎁
|
{modeInfo.emoji}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
|
||||||
|
|
||||||
|
{/* The keys themselves: for a pre-reader this is the real label, the
|
||||||
|
title is decoration. A drill has no new keys, so it says so with
|
||||||
|
a symbol instead of showing an empty line. */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--paper)",
|
||||||
|
opacity: 0.8,
|
||||||
|
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lesson.isDrill
|
||||||
|
? "🔁"
|
||||||
|
: lesson.newKeys.length > 0
|
||||||
|
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
|
||||||
|
: lesson.kind === "letters"
|
||||||
|
? "üben"
|
||||||
|
: CONSOLIDATION_LABEL[lesson.kind]}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
|
||||||
|
{[1, 2, 3].map((star) => (
|
||||||
|
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
|
||||||
|
⭐
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{lesson.reward.resolved && (
|
||||||
|
<RewardChest
|
||||||
|
point={point}
|
||||||
|
earned={entry?.earned ?? false}
|
||||||
|
albumId={lesson.reward.hasCover ? lesson.reward.albumId : null}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
</Fragment>
|
||||||
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
|
|
||||||
|
|
||||||
{/* The keys themselves: for a pre-reader this is the real label, the
|
|
||||||
title is decoration. A drill has no new keys, so it says so with
|
|
||||||
a symbol instead of showing an empty line. */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "var(--paper)",
|
|
||||||
opacity: 0.8,
|
|
||||||
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
|
|
||||||
lineHeight: 1.1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{lesson.isDrill
|
|
||||||
? "🔁"
|
|
||||||
: lesson.newKeys.length > 0
|
|
||||||
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
|
|
||||||
: lesson.kind === "letters"
|
|
||||||
? "üben"
|
|
||||||
: CONSOLIDATION_LABEL[lesson.kind]}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
|
|
||||||
{[1, 2, 3].map((star) => (
|
|
||||||
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
|
|
||||||
⭐
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
409
web/src/components/tippen/RewardUnlockOverlay.tsx
Normal file
409
web/src/components/tippen/RewardUnlockOverlay.tsx
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
/** The unlock celebration: a treasure chest that opens and hands back real music.
|
||||||
|
*
|
||||||
|
* This is the one moment the typing game exists for. Everything else it gives out -
|
||||||
|
* stars, animals, aquarium creatures - lives inside the game; this is the only reward
|
||||||
|
* that reaches outside it, into the music player. So it gets the whole screen, its own
|
||||||
|
* tune (`playRewardJingle`), and a sequence that has to be waited out, rather than being
|
||||||
|
* one more badge on the result sheet. The result sheet stays mounted underneath and is
|
||||||
|
* revealed when this closes, so nothing is lost by putting this in front of it.
|
||||||
|
*
|
||||||
|
* The staging, and why each beat is there:
|
||||||
|
*
|
||||||
|
* 0ms "drop" the chest falls in, shut, and rattles. The rattle is the beat that
|
||||||
|
* earns the opening - something inside wants out.
|
||||||
|
* 1150ms "open" the lid swings, light bursts, confetti starts, the tune starts.
|
||||||
|
* 1500ms "reveal" the cover art rises out of the chest; the title and the button
|
||||||
|
* arrive under it.
|
||||||
|
*
|
||||||
|
* Dismissal is deliberately not automatic. A six-year-old should get to look at the
|
||||||
|
* cover of the song she just won for as long as she likes, and pressing the button is
|
||||||
|
* itself part of the reward. It is, however, dismissible from the first frame: the
|
||||||
|
* sequence can always be cut short with Enter, Escape or a click. */
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { coverUrl } from "../../api/client";
|
||||||
|
import {
|
||||||
|
playChestOpen,
|
||||||
|
playChestThud,
|
||||||
|
playRewardJingle,
|
||||||
|
} from "../../lib/tippen/pop";
|
||||||
|
import type { UnlockedReward } from "../../lib/tippen/progress";
|
||||||
|
import { TreasureChest } from "./TreasureChest";
|
||||||
|
|
||||||
|
type Phase = "drop" | "open" | "reveal";
|
||||||
|
|
||||||
|
const OPEN_AT = 1150;
|
||||||
|
const REVEAL_AT = 1500;
|
||||||
|
|
||||||
|
/** The chest is drawn wider than the cover on purpose: the lid swings up across the
|
||||||
|
* middle, so a cover as wide as the chest hides it completely and the open chest reads as
|
||||||
|
* a shut one. Keeping the cover to roughly three-quarters of the chest's width leaves the
|
||||||
|
* lid standing clear beside it. */
|
||||||
|
const CHEST_SIZE = 196;
|
||||||
|
const COVER_SIZE = 148;
|
||||||
|
|
||||||
|
/** Headline and fallback glyph per reward kind. In German, and phrased as a gift rather
|
||||||
|
* than as a transaction: "freigeschaltet" is what the map's badge says, "für dich" is
|
||||||
|
* what this screen says. */
|
||||||
|
const COPY: Record<
|
||||||
|
UnlockedReward["kind"],
|
||||||
|
{ heading: string; lead: string; glyph: string }
|
||||||
|
> = {
|
||||||
|
album: {
|
||||||
|
heading: "Ein neues Lied für dich!",
|
||||||
|
lead: "Du hast Musik freigespielt",
|
||||||
|
glyph: "🎵",
|
||||||
|
},
|
||||||
|
book: {
|
||||||
|
heading: "Ein neues Hörbuch für dich!",
|
||||||
|
lead: "Du hast eine Geschichte freigespielt",
|
||||||
|
glyph: "📖",
|
||||||
|
},
|
||||||
|
podcast_episode: {
|
||||||
|
heading: "Eine neue Folge für dich!",
|
||||||
|
lead: "Du hast eine Folge freigespielt",
|
||||||
|
glyph: "🎙️",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFETTI_COLOURS = [
|
||||||
|
"oklch(85% 0.17 88)",
|
||||||
|
"oklch(78% 0.16 340)",
|
||||||
|
"oklch(82% 0.15 150)",
|
||||||
|
"oklch(80% 0.14 220)",
|
||||||
|
"oklch(88% 0.13 60)",
|
||||||
|
"oklch(75% 0.17 300)",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONFETTI_COUNT = 34;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reward: UnlockedReward;
|
||||||
|
/** Honours her own sound setting - the same flag that silences every other sound in
|
||||||
|
* the app. A silent celebration still celebrates. */
|
||||||
|
sound: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
|
||||||
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
|
const [phase, setPhase] = useState<Phase>(reducedMotion ? "reveal" : "drop");
|
||||||
|
const button = useRef<HTMLButtonElement>(null);
|
||||||
|
const copy = COPY[reward.kind];
|
||||||
|
|
||||||
|
// Fixed per mount, so a re-render (the button focusing, say) never reshuffles the
|
||||||
|
// confetti mid-fall.
|
||||||
|
const confetti = useMemo(
|
||||||
|
() => (reducedMotion ? [] : makeConfetti()),
|
||||||
|
[reducedMotion],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reducedMotion) {
|
||||||
|
if (sound) playRewardJingle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sound) playChestThud();
|
||||||
|
const timers = [
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setPhase("open");
|
||||||
|
if (sound) {
|
||||||
|
playChestOpen();
|
||||||
|
playRewardJingle();
|
||||||
|
}
|
||||||
|
}, OPEN_AT),
|
||||||
|
window.setTimeout(() => setPhase("reveal"), REVEAL_AT),
|
||||||
|
];
|
||||||
|
return () => timers.forEach(window.clearTimeout);
|
||||||
|
}, [reducedMotion, sound]);
|
||||||
|
|
||||||
|
// The button only exists from "reveal" on, so this focuses it as it appears. Taking
|
||||||
|
// focus matters: the result sheet underneath grabbed it for its own "Nochmal" button
|
||||||
|
// when it mounted, and Enter must not restart the run from behind this screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === "reveal") button.current?.focus();
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
const open = phase !== "drop";
|
||||||
|
const revealed = phase === "reveal";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="tp-reward-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`${copy.heading} ${reward.title}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{confetti.map((piece, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="tp-confetti-piece"
|
||||||
|
style={{
|
||||||
|
left: piece.left,
|
||||||
|
width: piece.width,
|
||||||
|
height: piece.height,
|
||||||
|
background: piece.colour,
|
||||||
|
borderRadius: piece.round ? "50%" : 2,
|
||||||
|
animationDelay: `${OPEN_AT + piece.delay}ms`,
|
||||||
|
animationDuration: `${piece.duration}ms`,
|
||||||
|
["--tp-drift" as string]: piece.drift,
|
||||||
|
["--tp-spin" as string]: piece.spin,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--paper)",
|
||||||
|
opacity: revealed ? 0.75 : 0,
|
||||||
|
transition: "opacity 300ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copy.lead}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The chest and the cover share one stacking box, and the cover is sandwiched
|
||||||
|
between the chest's two halves - see TreasureChest's `layer`. That is what makes
|
||||||
|
the cover come *out of* the chest rather than float in front of a picture of
|
||||||
|
one: it rises from behind the chest's front wall, under the thrown-back lid. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: CHEST_SIZE,
|
||||||
|
height: CHEST_SIZE,
|
||||||
|
// Room above for the cover, which overhangs this box by most of its height.
|
||||||
|
marginTop: COVER_SIZE * 0.78,
|
||||||
|
animation: open
|
||||||
|
? undefined
|
||||||
|
: "chestDrop 520ms cubic-bezier(0.34, 1.4, 0.64, 1), chestRattle 420ms ease-in-out 540ms 2",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TreasureChest size={CHEST_SIZE} open={open} layer="back" style={{ position: "absolute", inset: 0 }} />
|
||||||
|
|
||||||
|
{open && !reducedMotion && <Rays />}
|
||||||
|
|
||||||
|
{revealed && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
// Deep enough that the cover's foot is hidden behind the front wall.
|
||||||
|
bottom: CHEST_SIZE * 0.5,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RewardArt reward={reward} glyph={copy.glyph} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TreasureChest size={CHEST_SIZE} open={open} layer="front" style={{ position: "absolute", inset: 0 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 27,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--paper)",
|
||||||
|
marginTop: 16,
|
||||||
|
opacity: revealed ? 1 : 0,
|
||||||
|
animation: revealed
|
||||||
|
? "rewardTextEnter 360ms ease-out 80ms both"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copy.heading}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "oklch(90% 0.13 92)",
|
||||||
|
maxWidth: 460,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
marginTop: 4,
|
||||||
|
opacity: revealed ? 1 : 0,
|
||||||
|
animation: revealed
|
||||||
|
? "rewardTextEnter 360ms ease-out 200ms both"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reward.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--paper)",
|
||||||
|
opacity: revealed ? 0.7 : 0,
|
||||||
|
marginTop: 8,
|
||||||
|
transition: "opacity 300ms 400ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ab jetzt im Musik-Player 🎧
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{revealed && (
|
||||||
|
<button
|
||||||
|
ref={button}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "14px 30px",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 900,
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "var(--paper)",
|
||||||
|
boxShadow: "0 8px 24px var(--shadow)",
|
||||||
|
marginTop: 22,
|
||||||
|
animation: "rewardTextEnter 360ms ease-out 320ms both",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Toll! ⏎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cover art, or a glyph when the album has none. */
|
||||||
|
function RewardArt({
|
||||||
|
reward,
|
||||||
|
glyph,
|
||||||
|
}: {
|
||||||
|
reward: UnlockedReward;
|
||||||
|
glyph: string;
|
||||||
|
}) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const shared: React.CSSProperties = {
|
||||||
|
width: COVER_SIZE,
|
||||||
|
height: COVER_SIZE,
|
||||||
|
borderRadius: 18,
|
||||||
|
border: "3px solid oklch(90% 0.13 92 / 0.9)",
|
||||||
|
animation:
|
||||||
|
"coverRise 760ms cubic-bezier(0.22, 1.2, 0.36, 1) both, coverHalo 2.6s ease-in-out 760ms infinite",
|
||||||
|
};
|
||||||
|
|
||||||
|
// A cover the backend said exists but that fails to load would otherwise leave a
|
||||||
|
// broken-image box as the centrepiece of the celebration.
|
||||||
|
if (!reward.hasCover || failed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...shared,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 92,
|
||||||
|
background:
|
||||||
|
"linear-gradient(160deg, oklch(70% 0.09 175), oklch(45% 0.08 175))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={coverUrl(reward.albumId)}
|
||||||
|
alt=""
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
style={{
|
||||||
|
...shared,
|
||||||
|
objectFit: "cover",
|
||||||
|
background: "oklch(45% 0.08 175)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The burst of light at the moment the lid lets go. Two counter-rotating stars of rays,
|
||||||
|
* so the burst reads as light rather than as a spinning shape. */
|
||||||
|
function Rays() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "18%",
|
||||||
|
width: 260,
|
||||||
|
height: 260,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[0, 1].map((layer) => (
|
||||||
|
<div
|
||||||
|
key={layer}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
animation: `rayBurst ${layer === 0 ? 900 : 1150}ms ease-out ${layer * 90}ms both`,
|
||||||
|
background: `repeating-conic-gradient(from ${layer * 11}deg, oklch(95% 0.14 92 / 0.55) 0deg 5deg, transparent 5deg 22deg)`,
|
||||||
|
maskImage:
|
||||||
|
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
|
||||||
|
WebkitMaskImage:
|
||||||
|
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfettiPiece {
|
||||||
|
left: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
colour: string;
|
||||||
|
round: boolean;
|
||||||
|
delay: number;
|
||||||
|
duration: number;
|
||||||
|
drift: string;
|
||||||
|
spin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeConfetti(): ConfettiPiece[] {
|
||||||
|
return Array.from({ length: CONFETTI_COUNT }, (_, i) => {
|
||||||
|
const round = i % 3 === 0;
|
||||||
|
const size = 7 + Math.random() * 8;
|
||||||
|
return {
|
||||||
|
left: `${Math.random() * 100}%`,
|
||||||
|
width: round ? size : size * 0.55,
|
||||||
|
height: size,
|
||||||
|
colour: CONFETTI_COLOURS[i % CONFETTI_COLOURS.length]!,
|
||||||
|
round,
|
||||||
|
// Spread over a second and a half, so it falls as a shower rather than as a line.
|
||||||
|
delay: Math.random() * 1500,
|
||||||
|
duration: 2400 + Math.random() * 2200,
|
||||||
|
drift: `${(Math.random() - 0.5) * 220}px`,
|
||||||
|
spin: `${(Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 720)}deg`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePrefersReducedMotion(): boolean {
|
||||||
|
const [reduced, setReduced] = useState(
|
||||||
|
() =>
|
||||||
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||||
|
if (!query) return;
|
||||||
|
const onChange = () => setReduced(query.matches);
|
||||||
|
query.addEventListener("change", onChange);
|
||||||
|
return () => query.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
149
web/src/components/tippen/TreasureChest.tsx
Normal file
149
web/src/components/tippen/TreasureChest.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/** A treasure chest whose lid actually opens.
|
||||||
|
*
|
||||||
|
* Drawn rather than set as an emoji for one reason: the lid has to be a separate group
|
||||||
|
* so it can swing on a hinge. A 🎁 can only ever pop and scale, which is exactly the
|
||||||
|
* animation the old reward badge had and exactly why nobody noticed it. It is also
|
||||||
|
* sharp at every size, which matters because the same component draws both the 46px
|
||||||
|
* badge on the lesson map and the 168px chest in the unlock overlay.
|
||||||
|
*
|
||||||
|
* `open` is a plain prop, not an animation trigger: the lid transitions to its open
|
||||||
|
* angle through CSS (`.tp-chest-lid`, tippen.css), so the map can render a permanently
|
||||||
|
* open chest for a claimed reward with no animation bookkeeping at all, and the overlay
|
||||||
|
* gets its swing for free just by flipping the prop mid-sequence.
|
||||||
|
*
|
||||||
|
* `layer` splits the drawing in two so that something can be put *inside* the chest. The
|
||||||
|
* lid swings up across the space above the chest, which is exactly where a rising cover
|
||||||
|
* wants to be; drawn as one piece, the lid crosses in front of the cover and the whole
|
||||||
|
* thing reads as a z-order bug. Drawn as "back" (lid, mouth, glow), then the cover, then
|
||||||
|
* "front" (the body), the cover emerges from behind the chest's own front wall - which is
|
||||||
|
* what coming out of a chest actually looks like. The map, which puts nothing inside, and
|
||||||
|
* whose shut chests need the lid painted over the body's top edge, takes the default. */
|
||||||
|
|
||||||
|
/** Instance-unique gradient ids. Two chests on one page (the map has many) must not share
|
||||||
|
* `<defs>` ids, or the second one silently paints with the first one's stops. */
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size: number;
|
||||||
|
open: boolean;
|
||||||
|
/** Which half to draw - see the header. "all" is the whole chest, in one element. */
|
||||||
|
layer?: "all" | "back" | "front";
|
||||||
|
/** Softened - a reward whose lesson is still ahead of her. Muted rather than greyed
|
||||||
|
* out: a shut chest still has to look like treasure, or the badge promises nothing. */
|
||||||
|
muted?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreasureChest({ size, open, layer = "all", muted = false, className, style }: Props) {
|
||||||
|
const id = `tp-chest-${(nextId += 1)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 64 62"
|
||||||
|
className={className}
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
filter: muted ? "grayscale(0.35) brightness(0.9)" : undefined,
|
||||||
|
opacity: muted ? 0.9 : 1,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`${id}-wood`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="oklch(58% 0.11 55)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(38% 0.09 45)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={`${id}-lid`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="oklch(66% 0.11 58)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(46% 0.1 48)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={`${id}-gold`} x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="oklch(92% 0.14 95)" />
|
||||||
|
<stop offset="45%" stopColor="oklch(82% 0.17 88)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(66% 0.15 80)" />
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id={`${id}-glow`}>
|
||||||
|
<stop offset="0%" stopColor="oklch(97% 0.14 95 / 0.95)" />
|
||||||
|
<stop offset="55%" stopColor="oklch(90% 0.16 92 / 0.35)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(90% 0.16 92 / 0)" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* What makes it read as *open* rather than as a chest with its lid drawn beside
|
||||||
|
it: a dark mouth, with light coming out of it. Both are painted before the body,
|
||||||
|
so the body's front wall covers their lower half and the opening looks like a
|
||||||
|
hole in the chest rather than a shape on it. */}
|
||||||
|
{layer !== "front" && open && (
|
||||||
|
<>
|
||||||
|
<ellipse cx="32" cy="27" rx="24" ry="7" fill="oklch(22% 0.03 45)" />
|
||||||
|
<circle cx="32" cy="25" r="24" fill={`url(#${id}-glow)`} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layer === "back" && <Lid id={id} open={open} />}
|
||||||
|
|
||||||
|
{layer !== "back" && (
|
||||||
|
<>
|
||||||
|
<rect
|
||||||
|
x="6"
|
||||||
|
y="26"
|
||||||
|
width="52"
|
||||||
|
height="30"
|
||||||
|
rx="5"
|
||||||
|
fill={`url(#${id}-wood)`}
|
||||||
|
stroke="oklch(28% 0.06 45)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
{/* Two gold straps down the body, and the band along its foot. */}
|
||||||
|
<rect x="13" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
|
||||||
|
<rect x="46.5" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
|
||||||
|
<rect x="6" y="47" width="52" height="4" fill={`url(#${id}-gold)`} opacity="0.7" />
|
||||||
|
|
||||||
|
{/* Lock plate. It stays on the body - a real chest's hasp swings with the lid,
|
||||||
|
but a lock that leaves with the lid loses the "this was shut" read. */}
|
||||||
|
<rect
|
||||||
|
x="26"
|
||||||
|
y="30"
|
||||||
|
width="12"
|
||||||
|
height="14"
|
||||||
|
rx="2.5"
|
||||||
|
fill={`url(#${id}-gold)`}
|
||||||
|
stroke="oklch(45% 0.1 75)"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
/>
|
||||||
|
<circle cx="32" cy="36" r="2.2" fill="oklch(32% 0.05 60)" />
|
||||||
|
<rect x="31" y="36" width="2" height="5" rx="1" fill="oklch(32% 0.05 60)" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* In one piece, the lid goes last: shut, it has to cover the body's top edge. */}
|
||||||
|
{layer === "all" && <Lid id={id} open={open} />}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A half-round lid, hinged at its left end - the cartoon flip-open, and the only hinge
|
||||||
|
* that works in a flat, front-on view. */
|
||||||
|
function Lid({ id, open }: { id: string; open: boolean }) {
|
||||||
|
return (
|
||||||
|
<g className="tp-chest-lid" data-open={open || undefined} style={{ transformOrigin: "8px 27px" }}>
|
||||||
|
<path d="M8 27 A 24 21 0 0 1 56 27 Z" fill={`url(#${id}-lid)`} stroke="oklch(28% 0.06 45)" strokeWidth="2" />
|
||||||
|
<rect
|
||||||
|
x="7"
|
||||||
|
y="22.5"
|
||||||
|
width="50"
|
||||||
|
height="5"
|
||||||
|
rx="2"
|
||||||
|
fill={`url(#${id}-gold)`}
|
||||||
|
stroke="oklch(45% 0.1 75)"
|
||||||
|
strokeWidth="0.8"
|
||||||
|
/>
|
||||||
|
<path d="M13 27 A 24 21 0 0 1 15.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
|
||||||
|
<path d="M51 27 A 24 21 0 0 0 48.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user