diff --git a/README.md b/README.md
index 15ee5d0..bf3b3dd 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,9 @@ To Be Played is split into three repositories:
a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
game still in the backlog, and one to set the status to 100 % when
every achievement is unlocked.
+- **Confetti**: opening a finished game plays a small confetti burst, a finished game with every achievement a big
+ one; setting the status to finished plays it at once. Logo and accent colors, a canvas of our own (no library),
+ nothing with "reduce motion".
- **Finished?**: Core marks a game as finished by itself when the ending achievement is unlocked and the status was
never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than
most players need, finished?", with "Mark as finished" and "Not yet".
diff --git a/src/components/CompletedHint.jsx b/src/components/CompletedHint.jsx
index 391f58d..8653672 100644
--- a/src/components/CompletedHint.jsx
+++ b/src/components/CompletedHint.jsx
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {saveGameStatus} from '../utils/backlog.js';
+import {allAchievementsUnlocked} from '../utils/confetti.js';
/**
* Shown for a game with every achievement unlocked that is not marked as 100 % yet.
@@ -18,9 +19,7 @@ export default function CompletedHint({game, onChanged}) {
const expireSession = useSessionExpired();
const [busy, setBusy] = useState(false);
- const allUnlocked = game.hasAchievements && game.statsVisible && game.achievementsTotal > 0
- && game.achievementsUnlocked >= game.achievementsTotal;
- if (!allUnlocked || game.status === 'COMPLETED') return null;
+ if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
const setCompleted = async () => {
setBusy(true);
diff --git a/src/components/Confetti.jsx b/src/components/Confetti.jsx
new file mode 100644
index 0000000..7b0cb3f
--- /dev/null
+++ b/src/components/Confetti.jsx
@@ -0,0 +1,97 @@
+import {useEffect, useRef, useState} from 'react';
+
+import {LOGO_COLORS} from './Logo.jsx';
+
+const DURATION_MS = 2800;
+const PIECES = {small: 70, big: 240};
+const GRAVITY = 0.12;
+// The speeds are per frame at 60 Hz; a faster screen moves the same distance in the same time.
+const FRAME_MS = 1000 / 60;
+
+const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
+
+// The logo colors of the chosen accent and the accent itself.
+function colors() {
+ const style = getComputedStyle(document.documentElement);
+ const logo = LOGO_COLORS.map(([name, hex]) => style.getPropertyValue(name).trim() || hex);
+ const accent = style.getPropertyValue('--primary').trim();
+ return accent ? [...logo, accent] : logo;
+}
+
+// Small: one burst from the top center. Big: two cannons from the lower corners.
+function pieces(size, width, height) {
+ const palette = colors();
+ const big = size === 'big';
+ return Array.from({length: PIECES[size]}, (_, i) => {
+ const left = i % 2 === 0;
+ const angle = big ? (left ? -Math.PI / 3 : -2 * Math.PI / 3) + (Math.random() - 0.5) * 0.6
+ : Math.PI / 2 + (Math.random() - 0.5) * 2.4;
+ const speed = big ? 9 + Math.random() * 9 : 2 + Math.random() * 5;
+ return {
+ x: big ? (left ? 0 : width) : width / 2,
+ y: big ? height : height * 0.1,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ spin: Math.random() * Math.PI,
+ spinSpeed: (Math.random() - 0.5) * 0.3,
+ w: 6 + Math.random() * 6,
+ h: 3 + Math.random() * 4,
+ color: palette[i % palette.length],
+ };
+ });
+}
+
+/**
+ * Confetti over the whole page for about 3 s after mount, then onDone. Remount (a new key) to play again.
+ *
+ * @param size 'small' or 'big'
+ */
+export default function Confetti({size, onDone}) {
+ const canvasRef = useRef(null);
+ const [reduce] = useState(reducedMotion);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const context = canvas?.getContext('2d');
+ if (!context) return undefined;
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+ const all = pieces(size, canvas.width, canvas.height);
+ const start = performance.now();
+ let last = start;
+ let frame;
+ const draw = (now) => {
+ const elapsed = now - start;
+ const steps = (now - last) / FRAME_MS;
+ last = now;
+ context.setTransform(1, 0, 0, 1, 0, 0);
+ context.clearRect(0, 0, canvas.width, canvas.height);
+ if (elapsed >= DURATION_MS) {
+ onDone?.();
+ return;
+ }
+ context.globalAlpha = 1 - elapsed / DURATION_MS;
+ for (const piece of all) {
+ piece.vy += GRAVITY * steps;
+ piece.vx *= 0.99 ** steps;
+ piece.x += piece.vx * steps;
+ piece.y += piece.vy * steps;
+ piece.spin += piece.spinSpeed * steps;
+ if (piece.y > canvas.height + 20) continue;
+ const cos = Math.cos(piece.spin);
+ const sin = Math.sin(piece.spin);
+ context.setTransform(cos, sin, -sin, cos, piece.x, piece.y);
+ context.fillStyle = piece.color;
+ context.fillRect(-piece.w / 2, -piece.h / 2, piece.w, piece.h * Math.abs(cos));
+ }
+ frame = requestAnimationFrame(draw);
+ };
+ frame = requestAnimationFrame(draw);
+ return () => cancelAnimationFrame(frame);
+ // Not on onDone: a new function from a re-render must not restart the animation.
+ }, [size]);
+
+ if (reduce) return null;
+ return ;
+}
diff --git a/src/components/Confetti.test.jsx b/src/components/Confetti.test.jsx
new file mode 100644
index 0000000..3c5787a
--- /dev/null
+++ b/src/components/Confetti.test.jsx
@@ -0,0 +1,37 @@
+// @vitest-environment jsdom
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {cleanup, render} from '@testing-library/react';
+import Confetti from './Confetti.jsx';
+
+// jsdom has no canvas drawing.
+beforeEach(() => {
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+const reducedMotion = (reduce) => vi.stubGlobal('matchMedia', (query) => ({
+ matches: reduce && query.includes('prefers-reduced-motion'),
+}));
+
+describe('Confetti', () => {
+ it('draws on a canvas hidden from screen readers', () => {
+ reducedMotion(false);
+ const {container} = render();
+
+ const canvas = container.querySelector('canvas');
+ expect(canvas.getAttribute('aria-hidden')).toBe('true');
+ expect(canvas.dataset.size).toBe('big');
+ });
+
+ it('draws nothing when the user asked for reduced motion', () => {
+ reducedMotion(true);
+ const {container} = render();
+
+ expect(container.querySelector('canvas')).toBeNull();
+ });
+});
diff --git a/src/components/Logo.jsx b/src/components/Logo.jsx
index 6a2f9a7..75beea2 100644
--- a/src/components/Logo.jsx
+++ b/src/components/Logo.jsx
@@ -2,11 +2,14 @@ import {useId} from 'react';
import {BODY, CHECKLIST, DPAD, OUTLINE, PROGRESS_BAR} from './logoPaths.js';
-// Each color is [themed CSS, fixed hex]. The hex is the logo as designed; it also stands in where a browser
-// cannot read color-mix, which would otherwise make that stop black.
-const violet = ['var(--logo-violet, #7467f9)', '#7467f9'];
-const blue = ['var(--logo-blue, #0192ef)', '#0192ef'];
-const teal = ['var(--logo-teal, #17c7a4)', '#17c7a4'];
+/**
+ * The three logo colors as [CSS variable, hex as designed]; style.css sets the variables per accent.
+ */
+export const LOGO_COLORS = [['--logo-violet', '#7467f9'], ['--logo-blue', '#0192ef'], ['--logo-teal', '#17c7a4']];
+
+// Each color is [themed CSS, fixed hex]. The hex also stands in where a browser cannot read color-mix, which
+// would otherwise make that stop black.
+const [violet, blue, teal] = LOGO_COLORS.map(([name, hex]) => [`var(${name}, ${hex})`, hex]);
const mix = ([a], [b], percentOfB, hex) => [`color-mix(in oklch, ${a}, ${b} ${percentOfB}%)`, hex];
function Gradient({id, from, to, stops}) {
diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx
index 54a9c9f..d4d44f5 100644
--- a/src/pages/GameDetail.jsx
+++ b/src/pages/GameDetail.jsx
@@ -1,4 +1,4 @@
-import {useEffect, useMemo, useState} from 'react';
+import {useEffect, useMemo, useRef, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react';
@@ -10,6 +10,7 @@ import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import BacklogCard from '../components/BacklogCard.jsx';
+import Confetti from '../components/Confetti.jsx';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import ManualGameCard from '../components/ManualGameCard.jsx';
@@ -28,6 +29,7 @@ import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {canCorrect} from '../utils/corrections.js';
+import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
import {setGameHidden} from '../utils/hiddenGames.js';
import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.js';
@@ -71,6 +73,16 @@ export default function GameDetail() {
const [merging, setMerging] = useState(false);
const [reloads, setReloads] = useState(0);
const auditor = canCorrect(useCurrentUser(true));
+ const [celebration, setCelebration] = useState(null);
+ const shownLevel = useRef(null);
+
+ // Every status change on this page (backlog card, hints) goes through setData.
+ useEffect(() => {
+ if (!data) return;
+ const size = celebrationFor(shownLevel.current, data);
+ if (size) setCelebration({size, id: Date.now()});
+ shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
+ }, [data]);
useEffect(() => {
let cancelled = false;
@@ -357,6 +369,7 @@ export default function GameDetail() {
)}
>
)}
+ {celebration && setCelebration(null)}/>}
{/* Outside the content: the reload after a save must not close it. */}
{data && auditor && (
{
});
});
});
+
+describe('GameDetail confetti', () => {
+ // jsdom has no canvas drawing.
+ beforeEach(() => {
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ const opened = async (entry) => {
+ globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, ...entry})}));
+ const {container} = renderDetail();
+ await screen.findByText('Half-Life 2');
+ return container.querySelector('canvas[data-size]');
+ };
+
+ it('plays a small one for a finished game', async () => {
+ expect((await opened({status: 'FINISHED'})).dataset.size).toBe('small');
+ });
+
+ it('plays a big one when every achievement is unlocked too', async () => {
+ const canvas = await opened({status: 'COMPLETED', hasAchievements: true, statsVisible: true,
+ achievementsTotal: 3, achievementsUnlocked: 3});
+ expect(canvas.dataset.size).toBe('big');
+ });
+
+ it('plays nothing for a game not finished', async () => {
+ expect(await opened({status: 'PLAYING'})).toBeNull();
+ });
+});
diff --git a/src/utils/confetti.js b/src/utils/confetti.js
new file mode 100644
index 0000000..fc81215
--- /dev/null
+++ b/src/utils/confetti.js
@@ -0,0 +1,29 @@
+const FINISHED = ['FINISHED', 'COMPLETED'];
+
+/**
+ * Every achievement unlocked. Only while the stats are visible: Core can still send the old unlock count after they turn private.
+ */
+export const allAchievementsUnlocked = (game) => game.hasAchievements && game.statsVisible
+ && game.achievementsTotal > 0 && game.achievementsUnlocked >= game.achievementsTotal;
+
+/**
+ * 0: not finished, 1: finished, 2: finished and every achievement unlocked.
+ */
+export const celebrationLevel = (game) => {
+ if (!FINISHED.includes(game.status)) return 0;
+ return allAchievementsUnlocked(game) ? 2 : 1;
+};
+
+/**
+ * The confetti to play when the page shows this game: on opening a finished game, and when the level rises on the
+ * same page (e.g. the user sets FINISHED). A reload of the same game plays nothing.
+ *
+ * @param before {appid, level} of the game shown before, null on opening the page
+ * @returns 'small', 'big' or null
+ */
+export const celebrationFor = (before, game) => {
+ const level = celebrationLevel(game);
+ const from = before?.appid === game.appid ? before.level : 0;
+ if (level <= from) return null;
+ return level === 2 ? 'big' : 'small';
+};
diff --git a/src/utils/confetti.test.js b/src/utils/confetti.test.js
new file mode 100644
index 0000000..4ba8739
--- /dev/null
+++ b/src/utils/confetti.test.js
@@ -0,0 +1,38 @@
+import {describe, expect, it} from 'vitest';
+import {celebrationFor, celebrationLevel} from './confetti.js';
+
+const game = (status, unlocked = 0, total = 0, appid = 620) =>
+ ({appid, status, hasAchievements: total > 0, statsVisible: true, achievementsUnlocked: unlocked, achievementsTotal: total});
+
+describe('celebrationLevel', () => {
+ it('is 0 for a game not finished, 1 for finished, 2 for finished with every achievement', () => {
+ expect(celebrationLevel(game('PLAYING', 4, 4))).toBe(0);
+ expect(celebrationLevel(game('FINISHED'))).toBe(1);
+ expect(celebrationLevel(game('COMPLETED', 3, 4))).toBe(1);
+ expect(celebrationLevel(game('FINISHED', 4, 4))).toBe(2);
+ expect(celebrationLevel(game('COMPLETED', 4, 4))).toBe(2);
+ });
+
+ it('counts no achievements while the stats are hidden', () => {
+ expect(celebrationLevel({...game('FINISHED', 4, 4), statsVisible: false})).toBe(1);
+ });
+});
+
+describe('celebrationFor', () => {
+ it('plays on opening a finished game', () => {
+ expect(celebrationFor(null, game('FINISHED'))).toBe('small');
+ expect(celebrationFor(null, game('COMPLETED', 4, 4))).toBe('big');
+ expect(celebrationFor(null, game('BACKLOG'))).toBeNull();
+ });
+
+ it('plays when the status rises on the same page, not when the game loads again', () => {
+ expect(celebrationFor({appid: 620, level: 0}, game('FINISHED'))).toBe('small');
+ expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 4, 4))).toBe('big');
+ expect(celebrationFor({appid: 620, level: 1}, game('FINISHED'))).toBeNull();
+ expect(celebrationFor({appid: 620, level: 2}, game('PLAYING'))).toBeNull();
+ });
+
+ it('plays for another game opened from the page', () => {
+ expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 0, 0, 220))).toBe('small');
+ });
+});