Public Access
feat(game): play confetti for finished games
This commit is contained in:
1 parent
09e1f767d8
commit
a5458b3f86
9 files changed
+260
-9
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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"/>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}) {
|
||||
|
||||
Reference in new issue
Block a user