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