feat(points): add effect engines and effects shop

This commit is contained in:
lobo committed 2026-10-01 15:09:25 +02:00
1 parent 2ec5833f29
commit dd45c0b5f6
12 files changed
+697 -11

No files matched your search

+6 -4
View File
@@ -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; 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. 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 - **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 streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
banner frame, shown on the dashboard and the public profile. The balance is in the header. 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 - **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. finished games per year of all users.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **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. every achievement is unlocked.
- **Confetti**: opening a finished game plays a small confetti burst, a finished game with every achievement a big - **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), 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 - **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".
@@ -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 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 "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. ("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. 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 - **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 mode stays per device. The registration sends the browser language (`de` for a German browser, else `en`): Auth
+25 -2
View File
@@ -860,7 +860,10 @@
"AVATAR_BORDER": "Avatarrahmen", "AVATAR_BORDER": "Avatarrahmen",
"BADGE": "Abzeichen", "BADGE": "Abzeichen",
"ACCENT": "Akzent", "ACCENT": "Akzent",
"BANNER_FRAME": "Bannerrahmen" "BANNER_FRAME": "Bannerrahmen",
"COMPLETED_EFFECT": "Effekt für 100 %",
"PAGE_BACKGROUND": "Seitenhintergrund",
"BANNER_BACKGROUND": "Bannerhintergrund"
}, },
"ITEMS": { "ITEMS": {
"BORDER_GOLD": "Goldener Avatarrahmen", "BORDER_GOLD": "Goldener Avatarrahmen",
@@ -870,7 +873,27 @@
"ACCENT_GOLD": "Akzent Gold", "ACCENT_GOLD": "Akzent Gold",
"ACCENT_TEAL": "Akzent Türkis", "ACCENT_TEAL": "Akzent Türkis",
"FRAME_GOLD": "Goldener Bannerrahmen", "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": { "KINDS": {
"STARTED": "Gestartet", "STARTED": "Gestartet",
+25 -2
View File
@@ -860,7 +860,10 @@
"AVATAR_BORDER": "Avatar border", "AVATAR_BORDER": "Avatar border",
"BADGE": "Badge", "BADGE": "Badge",
"ACCENT": "Accent", "ACCENT": "Accent",
"BANNER_FRAME": "Banner frame" "BANNER_FRAME": "Banner frame",
"COMPLETED_EFFECT": "Completed effect",
"PAGE_BACKGROUND": "Page background",
"BANNER_BACKGROUND": "Banner background"
}, },
"ITEMS": { "ITEMS": {
"BORDER_GOLD": "Golden avatar border", "BORDER_GOLD": "Golden avatar border",
@@ -870,7 +873,27 @@
"ACCENT_GOLD": "Gold accent", "ACCENT_GOLD": "Gold accent",
"ACCENT_TEAL": "Teal accent", "ACCENT_TEAL": "Teal accent",
"FRAME_GOLD": "Golden banner frame", "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": { "KINDS": {
"STARTED": "Started", "STARTED": "Started",
+84
View File
@@ -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 (
<div aria-hidden="true" data-effect={effect}
className={cn('fx fx-bg pointer-events-none overflow-hidden', `fx-bg-${effect}`, className)}>
{shapes(effect)}
</div>
);
}
function shapes(effect) {
switch (effect) {
case 'triangles':
return (
<svg className="size-full" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
{TRIANGLES.map((triangles, group) => (
<g key={group} fill="currentColor" className={group === 1 ? 'text-foreground' : undefined}>
{triangles.map((triangle) => (
<polygon key={triangle.points} points={triangle.points} transform={triangle.rotate}
opacity={triangle.opacity}/>
))}
</g>
))}
</svg>
);
case 'starfield':
return range(9).map((i) => <span key={i}/>);
case 'pixels':
return range(12).map((i) => (
<span key={i} style={{
'--x': (i * 37) % 100, '--y': (i * 53 + 11) % 90,
'--dur': `${25 + ((i * 7) % 16)}s`, '--delay': `${-i * 3.1}s`,
}}/>
));
case 'aurora':
return range(2).map((i) => <div key={i}/>);
case 'mesh':
return range(4).map((i) => <div key={i}/>);
case 'grid':
return range(2).map((i) => <div key={i}/>);
case 'waves':
return range(3).map((i) => (
<svg key={i} viewBox="0 0 3200 200" preserveAspectRatio="none">
<path d={WAVE} fill="currentColor"/>
</svg>
));
case 'bokeh':
return range(10).map((i) => (
<span key={i} style={{
'--x': (i * 29 + 7) % 90, '--y': (i * 47 + 10) % 90, '--s': 8 + ((i * 13) % 33),
'--dur': `${30 + ((i * 5) % 21)}s`, '--delay': `${-i * 4.3}s`,
}}/>
));
}
}
+26
View File
@@ -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(<EffectBackground effect={effect} className="absolute inset-0"/>);
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(<EffectBackground effect="matrix"/>);
expect(container.firstChild).toBeNull();
});
});
+355
View File
@@ -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;
}
+38
View File
@@ -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;
}
+18 -1
View File
@@ -1,3 +1,4 @@
import {useRef} from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Coins} from 'lucide-react'; import {Coins} from 'lucide-react';
@@ -8,8 +9,10 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; 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 EffectBackground from '../components/EffectBackground.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import useInView from '../hooks/useInView.js';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.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}) { function Preview({code}) {
const look = LOOKS[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) return null;
if (look.Icon) { if (look.Icon) {
return <span className={cn('flex size-8 items-center justify-center rounded-full', look.className)}><look.Icon className="size-4"/></span>; return <span className={cn('flex size-8 items-center justify-center rounded-full', look.className)}><look.Icon className="size-4"/></span>;
@@ -153,5 +160,15 @@ function Preview({code}) {
if (look.accent) { if (look.accent) {
return <span data-accent={look.accent} className="size-8 rounded-full bg-primary"/>; return <span data-accent={look.accent} className="size-8 rounded-full bg-primary"/>;
} }
if (look.cover) {
return <span ref={ref} className={cn('relative h-8 w-14 shrink-0 overflow-hidden rounded-md bg-muted', look.cover, !inView && 'fx-paused')}/>;
}
if (look.effect) {
return (
<span ref={ref} className={cn('relative h-8 w-14 shrink-0 overflow-hidden rounded-md bg-background', !inView && 'fx-paused')}>
<EffectBackground effect={look.effect} className="absolute inset-0"/>
</span>
);
}
return <span className={cn('size-8 shrink-0 bg-muted', look.className, code.startsWith('BORDER') ? 'rounded-full' : 'rounded-md')}/>; return <span className={cn('size-8 shrink-0 bg-muted', look.className, code.startsWith('BORDER') ? 'rounded-full' : 'rounded-md')}/>;
} }
+40 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; 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 {toast} from 'sonner';
import PointsPage from './PointsPage.jsx'; import PointsPage from './PointsPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
@@ -16,6 +16,9 @@ const wallet = (extra = {}) => ({
{code: 'BORDER_GOLD', slot: 'AVATAR_BORDER', price: 150}, {code: 'BORDER_GOLD', slot: 'AVATAR_BORDER', price: 150},
{code: 'BORDER_NEON', slot: 'AVATAR_BORDER', price: 200}, {code: 'BORDER_NEON', slot: 'AVATAR_BORDER', price: 200},
{code: 'BADGE_PILE_SLAYER', slot: 'BADGE', price: 100}, {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'], owned: ['BADGE_PILE_SLAYER'],
equipped: {}, equipped: {},
@@ -47,6 +50,42 @@ afterEach(() => {
}); });
describe('PointsPage', () => { 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 () => { it('groups the catalog by slot and disables Buy below the price', async () => {
await renderPage(); await renderPage();
+1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "./effects.css";
@import "@fontsource-variable/inter"; @import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
+53 -1
View File
@@ -1,10 +1,11 @@
import {Swords, Trophy} from 'lucide-react'; import {Swords, Trophy} from 'lucide-react';
import {apiFetch, request} from './api.js'; import {apiFetch, request} from './api.js';
import {storage} from './storage.js';
const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`; const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`;
const JSON_HEADERS = {'Content-Type': 'application/json'}; 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. // 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. // An unknown code is skipped.
@@ -17,6 +18,28 @@ export const LOOKS = {
ACCENT_TEAL: {accent: 'teal'}, ACCENT_TEAL: {accent: 'teal'},
FRAME_GOLD: {className: 'ring-2 ring-amber-400 ring-offset-2 ring-offset-background'}, 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'}, 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). */ /** 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. */ /** The classes of the item worn in a slot, or undefined. */
export const lookClass = (equipped, slot) => LOOKS[equipped?.[slot]]?.className; 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. */ /** The badge worn: `{code, Icon, className}` or null. */
export const equippedBadge = (equipped) => { export const equippedBadge = (equipped) => {
const look = LOOKS[equipped?.BADGE]; const look = LOOKS[equipped?.BADGE];
return look?.Icon ? {code: equipped.BADGE, ...look} : null; 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. */ /** 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`); export const loadWallet = () => request(`${REWARDS_URL}/wallet`);
+26
View File
@@ -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);
});
});