feat(game): play confetti for finished games

This commit is contained in:
lobo committed 2026-09-28 13:36:36 +02:00
1 parent 09e1f767d8
commit a5458b3f86
9 files changed
+260 -9

No files matched your search

+3
View File
@@ -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 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 game still in the backlog, and one to set the status to 100 % when
every achievement is unlocked. 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 - **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 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". most players need, finished?", with "Mark as finished" and "Not yet".
+2 -3
View File
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {saveGameStatus} from '../utils/backlog.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. * 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 expireSession = useSessionExpired();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const allUnlocked = game.hasAchievements && game.statsVisible && game.achievementsTotal > 0 if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
&& game.achievementsUnlocked >= game.achievementsTotal;
if (!allUnlocked || game.status === 'COMPLETED') return null;
const setCompleted = async () => { const setCompleted = async () => {
setBusy(true); setBusy(true);
+97
View File
@@ -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 <canvas ref={canvasRef} aria-hidden="true" data-size={size}
className="pointer-events-none fixed inset-0 z-50 size-full"/>;
}
+37
View File
@@ -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(<Confetti size="big"/>);
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(<Confetti size="small"/>);
expect(container.querySelector('canvas')).toBeNull();
});
});
+8 -5
View File
@@ -2,11 +2,14 @@ import {useId} from 'react';
import {BODY, CHECKLIST, DPAD, OUTLINE, PROGRESS_BAR} from './logoPaths.js'; 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. * The three logo colors as [CSS variable, hex as designed]; style.css sets the variables per accent.
const violet = ['var(--logo-violet, #7467f9)', '#7467f9']; */
const blue = ['var(--logo-blue, #0192ef)', '#0192ef']; export const LOGO_COLORS = [['--logo-violet', '#7467f9'], ['--logo-blue', '#0192ef'], ['--logo-teal', '#17c7a4']];
const teal = ['var(--logo-teal, #17c7a4)', '#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]; const mix = ([a], [b], percentOfB, hex) => [`color-mix(in oklch, ${a}, ${b} ${percentOfB}%)`, hex];
function Gradient({id, from, to, stops}) { function Gradient({id, from, to, stops}) {
+14 -1
View File
@@ -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 {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; 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'; 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 {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import BacklogCard from '../components/BacklogCard.jsx'; import BacklogCard from '../components/BacklogCard.jsx';
import Confetti from '../components/Confetti.jsx';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx';
import ManualGameCard from '../components/ManualGameCard.jsx'; import ManualGameCard from '../components/ManualGameCard.jsx';
@@ -28,6 +29,7 @@ import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js'; import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {canCorrect} from '../utils/corrections.js'; import {canCorrect} from '../utils/corrections.js';
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
import {setGameHidden} from '../utils/hiddenGames.js'; import {setGameHidden} from '../utils/hiddenGames.js';
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.js'; import {isSteamKey} from '../utils/manualGames.js';
@@ -71,6 +73,16 @@ export default function GameDetail() {
const [merging, setMerging] = useState(false); const [merging, setMerging] = useState(false);
const [reloads, setReloads] = useState(0); const [reloads, setReloads] = useState(0);
const auditor = canCorrect(useCurrentUser(true)); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -357,6 +369,7 @@ export default function GameDetail() {
)} )}
</> </>
)} )}
{celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>}
{/* Outside the content: the reload after a save must not close it. */} {/* Outside the content: the reload after a save must not close it. */}
{data && auditor && ( {data && auditor && (
<GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting} <GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting}
+32
View File
@@ -282,3 +282,35 @@ describe('GameDetail of a game added by hand', () => {
}); });
}); });
}); });
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();
});
});
+29
View File
@@ -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';
};
+38
View File
@@ -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');
});
});