Public Access
feat(points): add effect engines and effects shop
This commit is contained in:
1 parent
2ec5833f29
commit
dd45c0b5f6
12 files changed
+697
-11
No files matched your search
@@ -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
|
||||
|
||||
+25
-2
@@ -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",
|
||||
|
||||
+25
-2
@@ -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",
|
||||
|
||||
@@ -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`,
|
||||
}}/>
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <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) {
|
||||
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')}/>;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./effects.css";
|
||||
@import "@fontsource-variable/inter";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
+53
-1
@@ -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`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user