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:
@@ -6,8 +6,9 @@
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { focusKeyFor, mastery } from "../progress";
|
||||
import { focusKeyFor, mastery, progressFromApi } from "../progress";
|
||||
import type { Progress } from "../progress";
|
||||
import type { TippenProgress as ApiProgress } from "../../../api/types";
|
||||
|
||||
function basicProgress(over: Partial<Progress> = {}): Progress {
|
||||
return {
|
||||
@@ -64,3 +65,26 @@ describe("mastery", () => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/** 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;
|
||||
bestAnimal: AnimalId | null;
|
||||
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. */
|
||||
ghost: { key: string; at: number }[] | null;
|
||||
}
|
||||
@@ -52,6 +57,7 @@ export function progressFromApi(progress: ApiProgress): Progress {
|
||||
bestStars: entry.best_stars,
|
||||
bestAnimal: entry.best_animal as AnimalId | null,
|
||||
bestPoints: entry.best_points,
|
||||
earned: entry.earned,
|
||||
ghost: entry.ghost,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user