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:
@@ -310,3 +310,141 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- the unlock celebration
|
||||
|
||||
What happens when a lesson opens real music (RewardUnlockOverlay.tsx). Its own
|
||||
section because it is the only thing in this app that deliberately interrupts: it
|
||||
sits above the result sheet and has to be waited out, since the whole point of the
|
||||
typing game - for a six-year-old - is that it hands back songs. Everything here is
|
||||
staged rather than simultaneous; the sequence is what makes it read as a chest being
|
||||
opened rather than as a dialog appearing. */
|
||||
|
||||
/* Above .tp-overlay's own layer: the result sheet is already on screen underneath, and
|
||||
is meant to be revealed when this closes rather than replaced by it. */
|
||||
.tp-reward-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(
|
||||
circle at 50% 42%,
|
||||
oklch(45% 0.07 175 / 0.82) 0%,
|
||||
oklch(16% 0.04 175 / 0.94) 70%
|
||||
);
|
||||
animation: backdropEnter 260ms ease-out;
|
||||
/* Thrown right back, out of the column the cover rises through - see the note on
|
||||
.tp-chest-lid. Alone this would look like a lid coming off; with the cover standing
|
||||
in the middle it reads as the lid flung open to make room for it. */
|
||||
--tp-chest-open: -108deg;
|
||||
}
|
||||
|
||||
/* The lid's two states. Transitioned rather than keyframed so the map can draw a
|
||||
permanently open chest with no animation at all - see TreasureChest.tsx. */
|
||||
.tp-chest-lid {
|
||||
transform-box: view-box;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 620ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* How far the lid swings. The default is for a chest standing on its own (the lesson
|
||||
map): far enough back to be unmistakably open, but not so far that it stops looking
|
||||
attached to its hinge - a lid much past -90deg, alone, reads as a flap flying off.
|
||||
The overlay overrides it above, because there the lid has a cover rising through the
|
||||
middle of the chest and has to clear out of its way. */
|
||||
.tp-chest-lid[data-open] {
|
||||
transform: rotate(var(--tp-chest-open, -62deg));
|
||||
}
|
||||
|
||||
/* The chest arriving: dropped in, with a squash on landing. */
|
||||
@keyframes chestDrop {
|
||||
0% { opacity: 0; transform: translateY(-120px) scale(0.7); }
|
||||
55% { opacity: 1; transform: translateY(0) scale(1.06, 0.9); }
|
||||
75% { transform: translateY(-6px) scale(0.98, 1.04); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* The wait before the lid goes: the chest rattling, because something inside wants out.
|
||||
This is the beat that earns the opening - a lid that opens the instant the chest lands
|
||||
is just a transition, not a reveal. */
|
||||
@keyframes chestRattle {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
15% { transform: translateX(-3px) rotate(-2.5deg); }
|
||||
30% { transform: translateX(3px) rotate(2.5deg); }
|
||||
45% { transform: translateX(-2px) rotate(-1.5deg); }
|
||||
60% { transform: translateX(2px) rotate(1.5deg); }
|
||||
80% { transform: translateX(-1px) rotate(-0.5deg); }
|
||||
}
|
||||
|
||||
/* The burst of light at the moment the lid lets go. */
|
||||
@keyframes rayBurst {
|
||||
0% { opacity: 0; transform: scale(0.2) rotate(0deg); }
|
||||
25% { opacity: 0.9; }
|
||||
100% { opacity: 0; transform: scale(2.4) rotate(140deg); }
|
||||
}
|
||||
|
||||
/* The cover art coming up out of the chest and settling where it can be looked at.
|
||||
Overshoots on the way up - a reward that rises and stops dead looks placed, not
|
||||
thrown. */
|
||||
@keyframes coverRise {
|
||||
0% { opacity: 0; transform: translateY(46px) scale(0.28) rotate(-14deg); }
|
||||
45% { opacity: 1; transform: translateY(-26px) scale(1.12) rotate(5deg); }
|
||||
70% { transform: translateY(2px) scale(0.97) rotate(-2deg); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0deg); }
|
||||
}
|
||||
|
||||
/* The cover's own halo, once it has settled - a slow pulse, so the thing she just won
|
||||
keeps drawing the eye while the text and the button arrive under it. */
|
||||
@keyframes coverHalo {
|
||||
0%, 100% { box-shadow: 0 10px 34px var(--shadow), 0 0 0 0 oklch(92% 0.15 92 / 0.55); }
|
||||
50% { box-shadow: 0 10px 34px var(--shadow), 0 0 42px 10px oklch(92% 0.15 92 / 0.28); }
|
||||
}
|
||||
|
||||
@keyframes rewardTextEnter {
|
||||
0% { opacity: 0; transform: translateY(12px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Confetti. Each piece gets its own left/delay/duration/colour/spin from inline style -
|
||||
the keyframe only owns the fall. `--tp-drift` is how far sideways it wanders, so no
|
||||
two pieces trace the same line down the screen. */
|
||||
@keyframes confettiFall {
|
||||
0% { opacity: 0; transform: translate(0, -8vh) rotate(0deg); }
|
||||
8% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translate(var(--tp-drift, 0px), 104vh) rotate(var(--tp-spin, 540deg)); }
|
||||
}
|
||||
|
||||
.tp-confetti-piece {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
will-change: transform;
|
||||
animation-name: confettiFall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: 1;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* The reveal still happens - it is information, not decoration - but nothing travels:
|
||||
the chest is simply open, the cover simply there, and the confetti does not fall.
|
||||
RewardUnlockOverlay also skips straight to its final step when this is set, so the
|
||||
staging never leaves her waiting on animations that were turned off. */
|
||||
.tp-reward-overlay,
|
||||
.tp-reward-overlay * {
|
||||
animation: none !important;
|
||||
}
|
||||
.tp-chest-lid {
|
||||
transition: none;
|
||||
}
|
||||
.tp-confetti-piece {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user