diff --git a/README.md b/README.md index d2c75bf..f186298 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,10 @@ To Be Played is split into three repositories: years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods; trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform. - **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and - streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent and a - banner frame, shown on the dashboard and the public profile. The balance is in the header. + streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a + banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG, in `effects.css`), + shown on the dashboard and the public profile. The balance is in the header. Every effect stands still with the + system setting "reduce motion" or the "Reduce animations" switch in the settings. - **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and finished games per year of all users. - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest @@ -71,7 +73,7 @@ To Be Played is split into three repositories: 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". + nothing with "reduce motion" (system or the settings switch). - **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". @@ -85,7 +87,7 @@ To Be Played is split into three repositories: The owner arranges it with the same edit mode on the profile page itself (the tiles, incl. the stat groups of "Your stats"), and sets its look in the settings: accent, corners, banner and background from own game art ("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs. -- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. +- **Appearance**: accent color, corner style, wide layout, reduce animations, page background and banner from your own game art. Saved in Auth, so it follows you to every device. - **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark mode stays per device. The registration sends the browser language (`de` for a German browser, else `en`): Auth diff --git a/public/i18n/de.json b/public/i18n/de.json index 37c5e40..0bdaf3b 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -860,7 +860,10 @@ "AVATAR_BORDER": "Avatarrahmen", "BADGE": "Abzeichen", "ACCENT": "Akzent", - "BANNER_FRAME": "Bannerrahmen" + "BANNER_FRAME": "Bannerrahmen", + "COMPLETED_EFFECT": "Effekt für 100 %", + "PAGE_BACKGROUND": "Seitenhintergrund", + "BANNER_BACKGROUND": "Bannerhintergrund" }, "ITEMS": { "BORDER_GOLD": "Goldener Avatarrahmen", @@ -870,7 +873,27 @@ "ACCENT_GOLD": "Akzent Gold", "ACCENT_TEAL": "Akzent Türkis", "FRAME_GOLD": "Goldener Bannerrahmen", - "FRAME_NEON": "Neon-Bannerrahmen" + "FRAME_NEON": "Neon-Bannerrahmen", + "FX_GOLD_SHINE": "Goldglanz", + "FX_NEON_PULSE": "Neon-Puls", + "FX_HOLO": "Holo-Folie", + "FX_EMBER": "Glut", + "BG_TRIANGLES": "Animierter Hintergrund: Dreiecke", + "BN_TRIANGLES": "Animiertes Banner: Dreiecke", + "BG_STARFIELD": "Animierter Hintergrund: Sternenhimmel", + "BN_STARFIELD": "Animiertes Banner: Sternenhimmel", + "BG_PIXELS": "Animierter Hintergrund: Pixel", + "BN_PIXELS": "Animiertes Banner: Pixel", + "BG_AURORA": "Animierter Hintergrund: Polarlicht", + "BN_AURORA": "Animiertes Banner: Polarlicht", + "BG_MESH": "Animierter Hintergrund: Farbverlauf", + "BN_MESH": "Animiertes Banner: Farbverlauf", + "BG_GRID": "Animierter Hintergrund: Synthwave-Gitter", + "BN_GRID": "Animiertes Banner: Synthwave-Gitter", + "BG_WAVES": "Animierter Hintergrund: Wellen", + "BN_WAVES": "Animiertes Banner: Wellen", + "BG_BOKEH": "Animierter Hintergrund: Bokeh-Lichter", + "BN_BOKEH": "Animiertes Banner: Bokeh-Lichter" }, "KINDS": { "STARTED": "Gestartet", diff --git a/public/i18n/en.json b/public/i18n/en.json index cfc7af6..95ba825 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -860,7 +860,10 @@ "AVATAR_BORDER": "Avatar border", "BADGE": "Badge", "ACCENT": "Accent", - "BANNER_FRAME": "Banner frame" + "BANNER_FRAME": "Banner frame", + "COMPLETED_EFFECT": "Completed effect", + "PAGE_BACKGROUND": "Page background", + "BANNER_BACKGROUND": "Banner background" }, "ITEMS": { "BORDER_GOLD": "Golden avatar border", @@ -870,7 +873,27 @@ "ACCENT_GOLD": "Gold accent", "ACCENT_TEAL": "Teal accent", "FRAME_GOLD": "Golden banner frame", - "FRAME_NEON": "Neon banner frame" + "FRAME_NEON": "Neon banner frame", + "FX_GOLD_SHINE": "Gold shine", + "FX_NEON_PULSE": "Neon pulse", + "FX_HOLO": "Holographic foil", + "FX_EMBER": "Embers", + "BG_TRIANGLES": "Animated background: triangles", + "BN_TRIANGLES": "Animated banner: triangles", + "BG_STARFIELD": "Animated background: starfield", + "BN_STARFIELD": "Animated banner: starfield", + "BG_PIXELS": "Animated background: pixels", + "BN_PIXELS": "Animated banner: pixels", + "BG_AURORA": "Animated background: aurora", + "BN_AURORA": "Animated banner: aurora", + "BG_MESH": "Animated background: mesh gradient", + "BN_MESH": "Animated banner: mesh gradient", + "BG_GRID": "Animated background: synthwave grid", + "BN_GRID": "Animated banner: synthwave grid", + "BG_WAVES": "Animated background: waves", + "BN_WAVES": "Animated banner: waves", + "BG_BOKEH": "Animated background: bokeh", + "BN_BOKEH": "Animated banner: bokeh" }, "KINDS": { "STARTED": "Started", diff --git a/src/components/EffectBackground.jsx b/src/components/EffectBackground.jsx new file mode 100644 index 0000000..e623706 --- /dev/null +++ b/src/components/EffectBackground.jsx @@ -0,0 +1,84 @@ +import {cn} from '@/lib/utils'; + +export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh']; + +const range = (count) => Array.from({length: count}, (_, i) => i); + +// Three groups of eight triangles on a 1600x900 canvas; a fixed pattern, so the picture is the same on every visit. +const TRIANGLES = range(3).map((group) => range(8).map((i) => { + const x = (i * 211 + group * 97) % 1600; + const y = (i * 137 + group * 263) % 900; + const size = 80 + ((i * 53 + group * 29) % 90); + return { + points: `${x},${y - size} ${x - size * 0.87},${y + size / 2} ${x + size * 0.87},${y + size / 2}`, + rotate: `rotate(${i * 40} ${x} ${y})`, + opacity: 0.06 + ((i + group) % 4) * 0.04, + }; +})); + +// One period is 1600 units; two periods make the band 2 widths wide, so a move of one width loops without a jump. +const WAVE = 'M0 100 C200 30 600 30 800 100 C1000 170 1400 170 1600 100 C1800 30 2200 30 2400 100 ' + + 'C2600 170 3000 170 3200 100 L3200 200 L0 200 Z'; + +/** + * The markup of an animated page or banner background (CSS in effects.css). Fills its parent: pass `absolute inset-0` + * or `fixed inset-0`. Nothing here is read by a screen reader. + * + * @param effect one of EFFECTS; anything else renders nothing + */ +export default function EffectBackground({effect, className}) { + if (!EFFECTS.includes(effect)) return null; + + return ( + + ); +} + +function shapes(effect) { + switch (effect) { + case 'triangles': + return ( + + {TRIANGLES.map((triangles, group) => ( + + {triangles.map((triangle) => ( + + ))} + + ))} + + ); + case 'starfield': + return range(9).map((i) => ); + case 'pixels': + return range(12).map((i) => ( + + )); + case 'aurora': + return range(2).map((i) =>
); + case 'mesh': + return range(4).map((i) =>
); + case 'grid': + return range(2).map((i) =>
); + case 'waves': + return range(3).map((i) => ( + + + + )); + case 'bokeh': + return range(10).map((i) => ( + + )); + } +} diff --git a/src/components/EffectBackground.test.jsx b/src/components/EffectBackground.test.jsx new file mode 100644 index 0000000..104d157 --- /dev/null +++ b/src/components/EffectBackground.test.jsx @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it} from 'vitest'; +import {cleanup, render} from '@testing-library/react'; +import EffectBackground, {EFFECTS} from './EffectBackground.jsx'; + +afterEach(cleanup); + +describe('EffectBackground', () => { + it.each(EFFECTS)('renders %s hidden from screen readers with its engine class and shapes', (effect) => { + const {container} = render(); + + const root = container.querySelector('[data-effect]'); + expect(root.dataset.effect).toBe(effect); + expect(root.getAttribute('aria-hidden')).toBe('true'); + expect(root.classList.contains(`fx-bg-${effect}`)).toBe(true); + expect(root.classList.contains('fx')).toBe(true); + expect(root.classList.contains('absolute')).toBe(true); + expect(root.children.length).toBeGreaterThan(0); + }); + + it('renders nothing for an unknown effect', () => { + const {container} = render(); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/effects.css b/src/effects.css new file mode 100644 index 0000000..33ae3ba --- /dev/null +++ b/src/effects.css @@ -0,0 +1,355 @@ +/* + * Pile Points effects (utils/rewards.js LOOKS, components/EffectBackground.jsx). Only transform and opacity move. + * Every effect looks good at frame 0, because that is the static picture: no animation, no motion. + * Colors come from --primary, --foreground and --logo-*, so an effect follows the accent and works in both modes. + * Backgrounds are a size container, so cq units size and move the shapes in a page and in a banner alike. + */ + +@keyframes fx-sweep { 0% { transform: translateX(-100%); } 35%, 100% { transform: translateX(100%); } } +@keyframes fx-slide { 0% { transform: translateX(-33.33%); } 60%, 100% { transform: translateX(33.33%); } } +@keyframes fx-breathe { from { opacity: 0.4; } to { opacity: 1; } } +@keyframes fx-glow { from { opacity: 0.5; transform: scale(0.95); } to { opacity: 1; transform: scale(1.05); } } +@keyframes fx-spark-rise { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-45%); opacity: 0; } } +@keyframes fx-ember { from { transform: translateY(0); opacity: 0.9; } to { transform: translateY(-3rem); opacity: 0; } } +@keyframes fx-twinkle { 0%, 100% { transform: scale(0.2); opacity: 0; } 50% { transform: scale(1); opacity: 1; } } + +/* ---- COMPLETED_EFFECT, cover of a tile (GameCover has relative and overflow-hidden) ---- */ + +.fx-cover-gold-shine::after, +.fx-cover-neon-pulse::after, +.fx-cover-holo::after, +.fx-cover-ember::before, +.fx-cover-ember::after { + content: ""; + position: absolute; + pointer-events: none; +} + +/* The golden border is an inside outline in the look (a ring would be clipped by the card around a flush cover). */ +.fx-cover-gold-shine::after { + inset: 0; + background: linear-gradient(115deg, transparent 40%, rgb(255 255 255 / 0.35) 50%, transparent 60%); + transform: translateX(-100%); + animation: fx-sweep 4s ease-in-out infinite; +} + +/* The ring is an inside outline in the look; the glow is inside, because the cover clips outside shadows. */ +.fx-cover-neon-pulse::after { + inset: 0; + border-radius: inherit; + box-shadow: inset 0 0 14px 2px var(--primary); + opacity: 0.7; + animation: fx-breathe 2.4s ease-in-out infinite alternate; +} + +.fx-cover-holo { isolation: isolate; } +.fx-cover-holo::after { + top: 0; + bottom: 0; + left: -100%; + width: 300%; + mix-blend-mode: overlay; + background: linear-gradient(120deg, + transparent 30%, + color-mix(in oklab, var(--logo-teal) 45%, transparent) 40%, + color-mix(in oklab, var(--primary) 45%, transparent) 50%, + color-mix(in oklab, var(--logo-violet) 45%, transparent) 60%, + transparent 70%); + animation: fx-slide 6s ease-in-out infinite; +} + +.fx-cover-ember::before { + inset: 0; + background: linear-gradient(to top, rgb(249 115 22 / 0.35), transparent 55%); +} +/* Three sparks as gradient dots in one layer: they rise together. */ +.fx-cover-ember::after { + inset: 0; + background: + radial-gradient(circle 2px at 22% 90%, #fdba74 60%, transparent), + radial-gradient(circle 2px at 52% 78%, #fdba74 60%, transparent), + radial-gradient(circle 2px at 80% 92%, #fdba74 60%, transparent); + animation: fx-spark-rise 3s ease-out infinite; +} + +/* ---- COMPLETED_EFFECT, details page (decoration behind the text of GameHeroCard; spans are the items) ---- */ + +.fx-detail-gold-shine > span, +.fx-detail-ember > span { + position: absolute; + left: var(--x); + top: var(--y); + animation-delay: var(--d); +} +.fx-detail-gold-shine > span { + width: 14px; + height: 14px; + background: #fbbf24; + clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%); + transform: scale(0.6); + animation: fx-twinkle 2.6s ease-in-out infinite; +} +.fx-detail-gold-shine > :nth-child(1) { --x: 46%; --y: 12%; --d: 0s; } +.fx-detail-gold-shine > :nth-child(2) { --x: 62%; --y: 70%; --d: -0.4s; } +.fx-detail-gold-shine > :nth-child(3) { --x: 78%; --y: 18%; --d: -0.9s; } +.fx-detail-gold-shine > :nth-child(4) { --x: 92%; --y: 55%; --d: -1.3s; } +.fx-detail-gold-shine > :nth-child(5) { --x: 54%; --y: 42%; --d: -1.7s; } +.fx-detail-gold-shine > :nth-child(6) { --x: 85%; --y: 86%; --d: -2.1s; } +.fx-detail-gold-shine > :nth-child(7) { --x: 70%; --y: 44%; --d: -2.4s; } +.fx-detail-gold-shine > :nth-child(8) { --x: 40%; --y: 82%; --d: -0.2s; } + +.fx-detail-neon-pulse > span { + position: absolute; + inset: 0; + background: radial-gradient(ellipse at 70% 40%, color-mix(in oklab, var(--primary) 25%, transparent), transparent 70%); + opacity: 0.8; + animation: fx-glow 2.4s ease-in-out infinite alternate; +} + +/* A faint rainbow sheen over the top edge. */ +.fx-detail-holo > span { + position: absolute; + inset: 0 0 auto; + height: 35%; + background: linear-gradient(90deg, + transparent, + color-mix(in oklab, var(--logo-teal) 18%, transparent), + color-mix(in oklab, var(--primary) 18%, transparent), + color-mix(in oklab, var(--logo-violet) 18%, transparent), + transparent); + animation: fx-breathe 6s ease-in-out infinite alternate; +} + +.fx-detail-ember > span { + width: 4px; + height: 4px; + border-radius: 50%; + background: #fb923c; + box-shadow: 0 0 6px #f97316; + animation: fx-ember 3s ease-out infinite; +} +.fx-detail-ember > :nth-child(1) { --x: 46%; --y: 70%; --d: 0s; } +.fx-detail-ember > :nth-child(2) { --x: 58%; --y: 82%; --d: -0.5s; } +.fx-detail-ember > :nth-child(3) { --x: 68%; --y: 66%; --d: -1s; } +.fx-detail-ember > :nth-child(4) { --x: 78%; --y: 84%; --d: -1.5s; } +.fx-detail-ember > :nth-child(5) { --x: 88%; --y: 72%; --d: -2s; } +.fx-detail-ember > :nth-child(6) { --x: 52%; --y: 90%; --d: -2.5s; } + +/* ---- PAGE_BACKGROUND and BANNER_BACKGROUND (EffectBackground) ---- */ + +.fx-bg { container-type: size; } +.fx-bg > * { position: absolute; } + +/* triangles: three groups of triangles drifting apart */ +@keyframes fx-tri-a { to { transform: translate(60px, -40px) rotate(5deg); } } +@keyframes fx-tri-b { to { transform: translate(-70px, 30px) rotate(-6deg); } } +@keyframes fx-tri-c { to { transform: translate(40px, 50px) rotate(4deg); } } +.fx-bg-triangles { color: var(--primary); } +.fx-bg-triangles g { transform-box: fill-box; transform-origin: center; } +.fx-bg-triangles g:nth-child(1) { animation: fx-tri-a 45s ease-in-out infinite alternate; } +.fx-bg-triangles g:nth-child(2) { animation: fx-tri-b 58s ease-in-out infinite alternate; } +.fx-bg-triangles g:nth-child(3) { animation: fx-tri-c 70s ease-in-out infinite alternate; } + +/* starfield: three tiled layers (a tile of --t px moves up by one tile, so the loop is seamless) and 6 twinkles */ +@keyframes fx-drift { to { transform: translateY(calc(var(--t) * -1)); } } +.fx-bg-starfield { --star: color-mix(in oklab, var(--foreground) 70%, transparent); } +.fx-bg-starfield > :nth-child(-n+3) { + inset: 0; + bottom: calc(var(--t) * -1); + background-size: var(--t) var(--t); + will-change: transform; + animation: fx-drift linear infinite; +} +.fx-bg-starfield > :nth-child(1) { + --t: 90px; + animation-duration: 60s; + background-image: + radial-gradient(circle at 20% 30%, var(--star) 0 1px, transparent 1.5px), + radial-gradient(circle at 70% 80%, var(--star) 0 1px, transparent 1.5px); +} +.fx-bg-starfield > :nth-child(2) { + --t: 140px; + animation-duration: 90s; + background-image: + radial-gradient(circle at 15% 65%, var(--star) 0 1px, transparent 1.5px), + radial-gradient(circle at 55% 20%, var(--star) 0 1px, transparent 1.5px), + radial-gradient(circle at 85% 75%, var(--star) 0 1px, transparent 1.5px); +} +.fx-bg-starfield > :nth-child(3) { + --t: 220px; + animation-duration: 120s; + background-image: + radial-gradient(circle at 30% 40%, var(--star) 0 1.5px, transparent 2px), + radial-gradient(circle at 75% 85%, var(--star) 0 1.5px, transparent 2px); +} +.fx-bg-starfield > :nth-child(n+4) { + left: var(--x); + top: var(--y); + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--star); + box-shadow: 0 0 6px var(--star); + opacity: 0.65; + animation: fx-breathe 3s ease-in-out infinite alternate; + animation-delay: var(--d); +} +.fx-bg-starfield > :nth-child(4) { --x: 12%; --y: 20%; --d: 0s; } +.fx-bg-starfield > :nth-child(5) { --x: 33%; --y: 62%; --d: -0.6s; } +.fx-bg-starfield > :nth-child(6) { --x: 51%; --y: 14%; --d: -1.2s; } +.fx-bg-starfield > :nth-child(7) { --x: 68%; --y: 48%; --d: -1.8s; } +.fx-bg-starfield > :nth-child(8) { --x: 84%; --y: 26%; --d: -2.4s; } +.fx-bg-starfield > :nth-child(9) { --x: 92%; --y: 78%; --d: -0.3s; } + +/* pixels: squares rise and turn; fade in at the base spot, out at the top, so the loop shows no jump */ +@keyframes fx-float { + 0% { transform: translateY(0) rotate(0); opacity: 0; } + 10%, 90% { opacity: 0.8; } + 100% { transform: translateY(calc(var(--y) * -1cqh - 10px)) rotate(180deg); opacity: 0; } +} +.fx-bg-pixels > * { + left: calc(var(--x) * 1%); + top: calc(var(--y) * 1%); + width: 4px; + height: 4px; + opacity: 0.8; + background: var(--primary); + animation: fx-float var(--dur) linear infinite; + animation-delay: var(--delay); +} +.fx-bg-pixels > :nth-child(4n+2) { background: var(--logo-violet); } +.fx-bg-pixels > :nth-child(4n+3) { background: var(--logo-blue); } +.fx-bg-pixels > :nth-child(4n) { background: var(--logo-teal); } + +/* aurora: two soft ribbons waving across the top (elliptic gradients have no hard edge) */ +@keyframes fx-wave-a { + from { transform: rotate(-8deg) translateX(-10%) scaleY(1); } + to { transform: rotate(-8deg) translateX(10%) scaleY(1.3); } +} +.fx-bg-aurora > * { + left: -30%; + width: 160%; + height: 40%; + transform: rotate(-8deg); + will-change: transform; + animation: fx-wave-a 20s ease-in-out infinite alternate; +} +.fx-bg-aurora > :nth-child(1) { + top: -5%; + background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 28%, transparent), transparent); +} +.fx-bg-aurora > :nth-child(2) { + top: 12%; + background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 24%, transparent), transparent); + animation-duration: 26s; + animation-direction: alternate-reverse; +} + +/* mesh: four big blobs in the corners; the gradient edge is the blur, a filter would cost every frame */ +@keyframes fx-blob { + from { transform: translate(-8%, -6%) scale(0.9); } + to { transform: translate(8%, 6%) scale(1.1); } +} +.fx-bg-mesh > * { + width: 60cqmax; + height: 60cqmax; + will-change: transform; + animation: fx-blob 25s ease-in-out infinite alternate; +} +.fx-bg-mesh > :nth-child(1) { + left: -15%; top: -20%; + background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 30%, transparent), transparent); +} +.fx-bg-mesh > :nth-child(2) { + right: -15%; top: -10%; + background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 26%, transparent), transparent); + animation-duration: 28s; +} +.fx-bg-mesh > :nth-child(3) { + left: -10%; bottom: -25%; + background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-violet) 26%, transparent), transparent); + animation-duration: 32s; +} +.fx-bg-mesh > :nth-child(4) { + right: -20%; bottom: -20%; + background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 22%, transparent), transparent); + animation-duration: 35s; +} + +/* grid: a perspective floor moving one cell (40px) toward the viewer; the height keeps all points in front of the camera */ +@keyframes fx-floor { + from { transform: perspective(600px) rotateX(62deg) translateY(0); } + to { transform: perspective(600px) rotateX(62deg) translateY(40px); } +} +.fx-bg-grid > :nth-child(1) { + inset: 0 0 auto; + height: 45%; + background: linear-gradient(to bottom, transparent, color-mix(in oklab, var(--primary) 25%, transparent)); +} +.fx-bg-grid > :nth-child(2) { + left: -100%; + top: 45%; + width: 300%; + height: 500px; + transform-origin: top center; + transform: perspective(600px) rotateX(62deg); + background-image: + repeating-linear-gradient(0deg, color-mix(in oklab, var(--primary) 35%, transparent) 0 1px, transparent 1px 40px), + repeating-linear-gradient(90deg, color-mix(in oklab, var(--primary) 35%, transparent) 0 1px, transparent 1px 40px); + will-change: transform; + animation: fx-floor 2s linear infinite; +} + +/* waves: three bands 2 container widths wide (one period = one width), moving one width sideways */ +@keyframes fx-wave-x { to { transform: translateX(-50%); } } +.fx-bg-waves > svg { + left: 0; + bottom: 0; + width: 200%; + color: var(--primary); + animation: fx-wave-x linear infinite; +} +.fx-bg-waves > :nth-child(1) { height: 25%; opacity: 0.08; animation-duration: 14s; } +.fx-bg-waves > :nth-child(2) { height: 32%; opacity: 0.12; animation-duration: 20s; animation-direction: reverse; } +.fx-bg-waves > :nth-child(3) { height: 40%; opacity: 0.18; animation-duration: 28s; } + +/* bokeh: soft circles drifting up */ +@keyframes fx-bokeh { + 0% { transform: translateY(8cqh); opacity: 0; } + 50% { opacity: 1; } + 100% { transform: translateY(-20cqh); opacity: 0; } +} +.fx-bg-bokeh > * { + left: calc(var(--x) * 1%); + top: calc(var(--y) * 1%); + width: calc(var(--s) * 1cqmin); + height: calc(var(--s) * 1cqmin); + opacity: 0.6; + background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 25%, transparent), transparent); + animation: fx-bokeh var(--dur) ease-in-out infinite; + animation-delay: var(--delay); +} +.fx-bg-bokeh > :nth-child(even) { + background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 22%, transparent), transparent); +} + +/* ---- Off screen covers hold their frame (components/GameCover.jsx) ---- */ + +.fx-paused, +.fx-paused *, +.fx-paused::before, +.fx-paused::after { + animation-play-state: paused; +} + +/* ---- Static mode: the system setting and the "Reduce animations" setting (utils/appearance.js) ---- */ + +@media (prefers-reduced-motion: reduce) { + .fx, .fx *, .fx::before, .fx::after { animation: none !important; } +} +html[data-reduce-motion] .fx, +html[data-reduce-motion] .fx *, +html[data-reduce-motion] .fx::before, +html[data-reduce-motion] .fx::after { + animation: none !important; +} diff --git a/src/hooks/useInView.js b/src/hooks/useInView.js new file mode 100644 index 0000000..aa3e754 --- /dev/null +++ b/src/hooks/useInView.js @@ -0,0 +1,38 @@ +import {useEffect, useState} from 'react'; + +// One observer for every element, so 80 covers cost one observer and not 80. It lives while something is watched. +const setters = new Map(); +let observer = null; + +const sharedObserver = () => { + observer ??= new IntersectionObserver((entries) => { + for (const entry of entries) setters.get(entry.target)?.(entry.isIntersecting); + }, {rootMargin: '100px'}); + return observer; +}; + +/** + * Whether the element of `ref` is on screen (or 100 px near it). True while `enabled` is false, and where the + * browser has no IntersectionObserver; it also starts true, so the first paint is not held back. + */ +export default function useInView(ref, enabled) { + const [inView, setInView] = useState(true); + + useEffect(() => { + const element = ref.current; + if (!enabled || !element || typeof IntersectionObserver === 'undefined') return undefined; + const watcher = sharedObserver(); + setters.set(element, setInView); + watcher.observe(element); + return () => { + setters.delete(element); + watcher.unobserve(element); + if (setters.size === 0) { + watcher.disconnect(); + observer = null; + } + }; + }, [ref, enabled]); + + return !enabled || inView; +} diff --git a/src/pages/PointsPage.jsx b/src/pages/PointsPage.jsx index 5885f73..5fc252f 100644 --- a/src/pages/PointsPage.jsx +++ b/src/pages/PointsPage.jsx @@ -1,3 +1,4 @@ +import {useRef} from 'react'; import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {Coins} from 'lucide-react'; @@ -8,8 +9,10 @@ import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; import {cn} from '@/lib/utils'; +import EffectBackground from '../components/EffectBackground.jsx'; import PageContainer from '../components/PageContainer.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx'; +import useInView from '../hooks/useInView.js'; import {formatDate} from '../utils/format.js'; import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js'; @@ -143,9 +146,13 @@ export default function PointsPage() { ); } -// A sample of the look: a ring on a circle, a pill with the badge icon, a color dot or a framed box. +// A sample of the look: a ring on a circle, a pill with the badge icon, a color dot, a framed box, a tile with its +// effect or a box with the animated background. function Preview({code}) { const look = LOOKS[code]; + const ref = useRef(null); + // The shop shows 20 animated previews at once; the ones off screen hold their frame. + const inView = useInView(ref, !!(look?.cover || look?.effect)); if (!look) return null; if (look.Icon) { return ; @@ -153,5 +160,15 @@ function Preview({code}) { if (look.accent) { return ; } + if (look.cover) { + return ; + } + if (look.effect) { + return ( + + + + ); + } return ; } diff --git a/src/pages/PointsPage.test.jsx b/src/pages/PointsPage.test.jsx index 6058a50..b489696 100644 --- a/src/pages/PointsPage.test.jsx +++ b/src/pages/PointsPage.test.jsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react'; +import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react'; import {toast} from 'sonner'; import PointsPage from './PointsPage.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js'; @@ -16,6 +16,9 @@ const wallet = (extra = {}) => ({ {code: 'BORDER_GOLD', slot: 'AVATAR_BORDER', price: 150}, {code: 'BORDER_NEON', slot: 'AVATAR_BORDER', price: 200}, {code: 'BADGE_PILE_SLAYER', slot: 'BADGE', price: 100}, + {code: 'FX_GOLD_SHINE', slot: 'COMPLETED_EFFECT', price: 300}, + {code: 'BG_GRID', slot: 'PAGE_BACKGROUND', price: 450}, + {code: 'BN_GRID', slot: 'BANNER_BACKGROUND', price: 300}, ], owned: ['BADGE_PILE_SLAYER'], equipped: {}, @@ -47,6 +50,42 @@ afterEach(() => { }); describe('PointsPage', () => { + it('shows the effect slots with a preview of each look', async () => { + await renderPage(); + + const card = (slot) => screen.getByText(`POINTS.SLOTS.${slot}`).closest('[data-slot="card"]'); + const cover = within(card('COMPLETED_EFFECT')).getByText('POINTS.ITEMS.FX_GOLD_SHINE').closest('li').firstChild; + expect(cover.classList.contains('fx-cover-gold-shine')).toBe(true); + expect(card('PAGE_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull(); + expect(card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull(); + }); + + it('holds the frame of previews that are off screen', async () => { + const callbacks = []; + vi.stubGlobal('IntersectionObserver', class { + constructor(callback) { callbacks.push(callback); } + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + }); + try { + await renderPage(); + const card = (slot) => screen.getByText(`POINTS.SLOTS.${slot}`).closest('[data-slot="card"]'); + const cover = within(card('COMPLETED_EFFECT')).getByText('POINTS.ITEMS.FX_GOLD_SHINE').closest('li').firstChild; + const banner = card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]').parentElement; + expect(cover.classList.contains('fx-paused')).toBe(false); + + act(() => callbacks[0]([{target: cover, isIntersecting: false}, {target: banner, isIntersecting: false}])); + expect(cover.classList.contains('fx-paused')).toBe(true); + expect(banner.classList.contains('fx-paused')).toBe(true); + + act(() => callbacks[0]([{target: cover, isIntersecting: true}])); + expect(cover.classList.contains('fx-paused')).toBe(false); + } finally { + vi.unstubAllGlobals(); + } + }); + it('groups the catalog by slot and disables Buy below the price', async () => { await renderPage(); diff --git a/src/style.css b/src/style.css index 436eda3..f533a3a 100644 --- a/src/style.css +++ b/src/style.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; +@import "./effects.css"; @import "@fontsource-variable/inter"; @custom-variant dark (&:is(.dark *)); diff --git a/src/utils/rewards.js b/src/utils/rewards.js index 93ef8ae..2d8815a 100644 --- a/src/utils/rewards.js +++ b/src/utils/rewards.js @@ -1,10 +1,11 @@ import {Swords, Trophy} from 'lucide-react'; import {apiFetch, request} from './api.js'; +import {storage} from './storage.js'; const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`; const JSON_HEADERS = {'Content-Type': 'application/json'}; -export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME']; +export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME', 'COMPLETED_EFFECT', 'PAGE_BACKGROUND', 'BANNER_BACKGROUND']; // The shop catalog of Core (ShopItem) as looks: only these classes are ever applied, never a string from the server. // An unknown code is skipped. @@ -17,6 +18,28 @@ export const LOOKS = { ACCENT_TEAL: {accent: 'teal'}, FRAME_GOLD: {className: 'ring-2 ring-amber-400 ring-offset-2 ring-offset-background'}, FRAME_NEON: {className: 'ring-2 ring-fuchsia-500 ring-offset-2 ring-offset-background shadow-[0_0_14px] shadow-fuchsia-500/60'}, + // Completed effects (effects.css): `cover` is the class set of a tile, `detail` the decoration of the details page. + FX_GOLD_SHINE: {cover: 'fx fx-cover-gold-shine outline outline-2 -outline-offset-2 outline-amber-400', detail: 'fx-detail-gold-shine'}, + FX_NEON_PULSE: {cover: 'fx fx-cover-neon-pulse outline outline-2 -outline-offset-2 outline-primary', detail: 'fx-detail-neon-pulse'}, + FX_HOLO: {cover: 'fx fx-cover-holo', detail: 'fx-detail-holo'}, + FX_EMBER: {cover: 'fx fx-cover-ember', detail: 'fx-detail-ember'}, + // Page and banner backgrounds: BG_ and BN_ of a name share one engine (components/EffectBackground.jsx). + BG_TRIANGLES: {effect: 'triangles'}, + BG_STARFIELD: {effect: 'starfield'}, + BG_PIXELS: {effect: 'pixels'}, + BG_AURORA: {effect: 'aurora'}, + BG_MESH: {effect: 'mesh'}, + BG_GRID: {effect: 'grid'}, + BG_WAVES: {effect: 'waves'}, + BG_BOKEH: {effect: 'bokeh'}, + BN_TRIANGLES: {effect: 'triangles'}, + BN_STARFIELD: {effect: 'starfield'}, + BN_PIXELS: {effect: 'pixels'}, + BN_AURORA: {effect: 'aurora'}, + BN_MESH: {effect: 'mesh'}, + BN_GRID: {effect: 'grid'}, + BN_WAVES: {effect: 'waves'}, + BN_BOKEH: {effect: 'bokeh'}, }; /** The premium accent worn, or null (then the appearance accent applies). */ @@ -25,12 +48,41 @@ export const equippedAccent = (equipped) => LOOKS[equipped?.ACCENT]?.accent ?? n /** The classes of the item worn in a slot, or undefined. */ export const lookClass = (equipped, slot) => LOOKS[equipped?.[slot]]?.className; +// Only a COMPLETED game shows the completed effect (FINISHED is not 100 %). +const completedLook = (equipped, status) => (status === 'COMPLETED' ? LOOKS[equipped?.COMPLETED_EFFECT] : undefined); + +/** The cover classes of the completed effect worn, or undefined. */ +export const coverEffect = (equipped, status) => completedLook(equipped, status)?.cover; + +/** The details page decoration class of the completed effect worn, or undefined. */ +export const detailEffect = (equipped, status) => completedLook(equipped, status)?.detail; + +/** The effect name of the page background worn, or null (then the game art applies). */ +export const backgroundEffect = (equipped) => LOOKS[equipped?.PAGE_BACKGROUND]?.effect ?? null; + +/** The effect name of the banner background worn, or null. */ +export const bannerEffect = (equipped) => LOOKS[equipped?.BANNER_BACKGROUND]?.effect ?? null; + /** The badge worn: `{code, Icon, className}` or null. */ export const equippedBadge = (equipped) => { const look = LOOKS[equipped?.BADGE]; return look?.Icon ? {code: equipped.BADGE, ...look} : null; }; +const EQUIPPED_STORAGE_KEY = 'tbp_equipped'; + +/** The last known worn looks, so the first paint does not flash the item 10 art before the wallet answers. */ +export const readCachedEquipped = () => { + try { + const value = JSON.parse(storage.get(EQUIPPED_STORAGE_KEY)); + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } catch { + return {}; + } +}; + +export const cacheEquipped = (equipped) => storage.set(EQUIPPED_STORAGE_KEY, JSON.stringify(equipped)); + /** GET /api/rewards/wallet on Core: `{ok, status, data}`. Data is the balance, catalog, owned codes, worn looks and history. */ export const loadWallet = () => request(`${REWARDS_URL}/wallet`); diff --git a/src/utils/rewards.test.js b/src/utils/rewards.test.js new file mode 100644 index 0000000..30140a6 --- /dev/null +++ b/src/utils/rewards.test.js @@ -0,0 +1,26 @@ +import {describe, expect, it} from 'vitest'; +import {backgroundEffect, bannerEffect, coverEffect, detailEffect, LOOKS} from './rewards.js'; + +describe('effects worn', () => { + const equipped = {COMPLETED_EFFECT: 'FX_GOLD_SHINE', PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES'}; + + it('shows the completed effect only on a COMPLETED game', () => { + expect(coverEffect(equipped, 'COMPLETED')).toContain('fx-cover-gold-shine'); + expect(detailEffect(equipped, 'COMPLETED')).toBe('fx-detail-gold-shine'); + expect(coverEffect(equipped, 'FINISHED')).toBeUndefined(); + expect(detailEffect(equipped, 'FINISHED')).toBeUndefined(); + }); + + it('ignores an unknown or missing code', () => { + expect(coverEffect({COMPLETED_EFFECT: 'FX_FUTURE'}, 'COMPLETED')).toBeUndefined(); + expect(coverEffect(undefined, 'COMPLETED')).toBeUndefined(); + expect(backgroundEffect({PAGE_BACKGROUND: 'BG_FUTURE'})).toBeNull(); + expect(bannerEffect({})).toBeNull(); + }); + + it('maps a background and a banner of one name to the same engine', () => { + expect(backgroundEffect(equipped)).toBe('starfield'); + expect(bannerEffect(equipped)).toBe('waves'); + expect(LOOKS.BG_GRID.effect).toBe(LOOKS.BN_GRID.effect); + }); +});