feat(shop): animated backgrounds — system-wide cosmetic + picker

A new cosmetic family: a fixed-position overlay painted behind every
page of the app, switchable from the profile shop. 4 free presets + 6
paid (250-1200 coins) so the new economy has another sink. Every
animation respects prefers-reduced-motion and falls back to its static
gradient.

Catalogue (migration 035):
  free:   none, gradient-soft, dots, dark
  paid:   gradient-flow, grid, bubbles, stars (mid)
          aurora, nebula                       (premium)

Backend:
  • migration 035 adds users.active_background + rebuilds shop_items
    CHECK to include 'background' (standard SQLite 'new + copy + swap')
    and seeds 10 items
  • shopController.getMyActive returns { background: { slug } } and
    activateItem handles type='background' (stores bare slug in
    active_background) + skips the user_purchases check for price=0
    so free presets work for everyone without per-user rows
  • routes/shop validate schema lets 'background' through

Frontend:
  • api.js applyCosmetics injects <div id='ls-bg-fx'> at body start
    and toggles class to bg-<slug>. Cleared backgrounds remove the
    element so dark→light transitions don't leave artifacts.
  • ls.css gains a self-contained 'ANIMATED BACKGROUNDS' block:
    keyframes per animated slug (ls-bg-flow, ls-bg-grid-scan,
    ls-bg-bubble-rise, ls-bg-stars-twinkle, ls-bg-aurora-spin,
    ls-bg-nebula-pan) wrapped in a prefers-reduced-motion kill-switch.
    Same .bg-<slug> classes are reused for the .bg-preview swatches.
  • profile.html shop:
    - new 'Фоны' filter button between Рамки and Титулы
    - _renderItemPreview type='background' draws a real 56-aspect swatch
      (same CSS as the page bg — what you see is what you apply)
    - _isItemActive matches by slug for background type
    - free items (price===0) treated as auto-owned in render so users
      can apply them without a fake 'purchase' step

Verified: getMyActive returns { background: { slug: 'nebula' } } after
flipping users.active_background; activate path updates the row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-29 21:13:53 +03:00
parent 41ca41d69c
commit 98ec1ed478
6 changed files with 372 additions and 13 deletions
+24 -10
View File
@@ -86,10 +86,10 @@ function getCoins(req, res) {
/* GET /api/shop/my-active — return user's active cosmetics */
function getMyActive(req, res) {
const u = db.prepare('SELECT avatar_frame, active_title, active_effect FROM users WHERE id = ?').get(req.user.id);
const u = db.prepare('SELECT avatar_frame, active_title, active_effect, active_background FROM users WHERE id = ?').get(req.user.id);
if (!u) return res.json({});
// Resolve full data for each active item
const result = { frame: null, title: null, effect: null };
const result = { frame: null, title: null, effect: null, background: null };
// Frame: resolve either a gamification frame ('fire', 'crown', ...) or
// a shop-purchased one ('shop_<id>') to { id, css } so applyCosmetics
@@ -104,6 +104,11 @@ function getMyActive(req, res) {
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_effect);
if (item) try { result.effect = JSON.parse(item.data); } catch {}
}
// Background: stored as the bare slug (e.g. 'aurora') in users.active_background.
// 'none' / null → no extra rendering on the client.
if (u.active_background && u.active_background !== 'none') {
result.background = { slug: u.active_background };
}
res.json(result);
}
@@ -115,24 +120,33 @@ function activateItem(req, res) {
// Deactivate: pass itemId = null and type
if (!itemId) {
const { type } = req.body;
if (type === 'title') db.prepare('UPDATE users SET active_title = NULL WHERE id = ?').run(userId);
if (type === 'effect') db.prepare('UPDATE users SET active_effect = NULL WHERE id = ?').run(userId);
if (type === 'frame') db.prepare("UPDATE users SET avatar_frame = 'default' WHERE id = ?").run(userId);
if (type === 'title') db.prepare('UPDATE users SET active_title = NULL WHERE id = ?').run(userId);
if (type === 'effect') db.prepare('UPDATE users SET active_effect = NULL WHERE id = ?').run(userId);
if (type === 'frame') db.prepare("UPDATE users SET avatar_frame = 'default' WHERE id = ?").run(userId);
if (type === 'background') db.prepare('UPDATE users SET active_background = NULL WHERE id = ?').run(userId);
return res.json({ ok: true });
}
const item = db.prepare('SELECT * FROM shop_items WHERE id = ?').get(itemId);
if (!item) return res.status(404).json({ error: 'Предмет не найден' });
const owned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
if (!owned) return res.status(403).json({ error: 'Предмет не куплен' });
// Free items (price=0) skip the ownership check — backgrounds &
// future freebies are available to everyone without a purchase row.
if (item.price > 0) {
const owned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
if (!owned) return res.status(403).json({ error: 'Предмет не куплен' });
}
let data;
try { data = JSON.parse(item.data); } catch { data = {}; }
if (item.type === 'frame') db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?').run('shop_' + itemId, userId);
if (item.type === 'title') db.prepare('UPDATE users SET active_title = ? WHERE id = ?').run(itemId, userId);
if (item.type === 'effect') db.prepare('UPDATE users SET active_effect = ? WHERE id = ?').run(itemId, userId);
if (item.type === 'frame') db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?').run('shop_' + itemId, userId);
if (item.type === 'title') db.prepare('UPDATE users SET active_title = ? WHERE id = ?').run(itemId, userId);
if (item.type === 'effect') db.prepare('UPDATE users SET active_effect = ? WHERE id = ?').run(itemId, userId);
if (item.type === 'background') {
const slug = (data && data.slug) || 'none';
db.prepare('UPDATE users SET active_background = ? WHERE id = ?').run(slug, userId);
}
res.json({ ok: true, type: item.type, data });
}
@@ -0,0 +1,102 @@
-- ═══════════════════════════════════════════════════════════════
-- 035: Animated backgrounds — a new cosmetic family
--
-- Adds an animated background layer that paints behind every page
-- of the app. Switchable from the profile page (Phase 6 UI). 4
-- backgrounds are free (price=0, available to everyone — the picker
-- treats price-0 items as owned without a user_purchases row), 6 are
-- paid in the 200-1200 coin range to give the new economy something
-- to reach for.
--
-- Storage:
-- • new column users.active_background — slug of the chosen bg
-- (NULL = system default; client renders nothing extra)
-- • new shop_items rows with type='background' + data.slug
-- • the existing CHECK (type IN ('frame','theme','title','effect'))
-- on shop_items has to be rebuilt to include 'background' — done
-- here via the standard SQLite "create new + copy + swap" dance.
--
-- Rendering (frontend):
-- The applyCosmetics call injects <div id="ls-bg-fx"> at body start
-- and sets its class to bg-<slug>. CSS in ls.css (added in the same
-- commit) drives the per-slug animation. Every animation respects
-- prefers-reduced-motion — falls back to the static gradient.
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE users ADD COLUMN active_background TEXT;
-- ── Rebuild shop_items with 'background' added to the type check ──
CREATE TABLE shop_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'frame'
CHECK (type IN ('frame','theme','title','effect','background')),
category TEXT NOT NULL DEFAULT 'cosmetic',
price INTEGER NOT NULL DEFAULT 100,
data TEXT NOT NULL DEFAULT '{}',
icon TEXT NOT NULL DEFAULT 'star',
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO shop_items_new
(id, name, description, type, category, price, data, icon, is_active, created_at)
SELECT
id, name, description, type, category, price, data, icon, is_active, created_at
FROM shop_items;
DROP TABLE shop_items;
ALTER TABLE shop_items_new RENAME TO shop_items;
-- ── Free backgrounds (price=0; auto-owned) ─────────────────────
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
SELECT 'Без фона', 'Системный фон без эффектов', 'background', 'cosmetic', 0,
'{"slug":"none"}', 'circle-off', 1
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Без фона' AND type='background');
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
SELECT 'Мягкий градиент', 'Спокойный переход двух пастельных тонов', 'background', 'cosmetic', 0,
'{"slug":"gradient-soft"}', 'palette', 1
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Мягкий градиент' AND type='background');
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
SELECT 'Точки', 'Тонкая сетка точек на светлом фоне', 'background', 'cosmetic', 0,
'{"slug":"dots"}', 'more-horizontal', 1
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Точки' AND type='background');
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
SELECT 'Тёмный фон', 'Глубокий синий фон без анимаций', 'background', 'cosmetic', 0,
'{"slug":"dark"}', 'moon', 1
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Тёмный фон' AND type='background');
-- ── Paid backgrounds ─────────────────────────────────────────────
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
SELECT 'Плавающий градиент', 'Медленно перетекающие фиолетово-бирюзовые волны', 'background', 'cosmetic', 250,
'{"slug":"gradient-flow"}', 'waves', 1
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Плавающий градиент' AND type='background');
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
SELECT 'Сетка', 'Голубая инженерная сетка с подсветкой', 'background', 'cosmetic', 350,
'{"slug":"grid"}', 'grid-3x3', 1
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Сетка' AND type='background');
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
SELECT 'Пузырьки', 'Лёгкие восходящие пузырьки', 'background', 'cosmetic', 450,
'{"slug":"bubbles"}', 'droplets', 1
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Пузырьки' AND type='background');
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
SELECT 'Звёзды', 'Мерцающее звёздное небо', 'background', 'cosmetic', 600,
'{"slug":"stars"}', 'sparkles', 1
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Звёзды' AND type='background');
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
SELECT 'Северное сияние', 'Плавающий conic-gradient в холодных тонах', 'background', 'cosmetic', 900,
'{"slug":"aurora"}', 'wand-sparkles', 1
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Северное сияние' AND type='background');
INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active)
SELECT 'Туманность', 'Космическая туманность с глубиной', 'background', 'cosmetic', 1200,
'{"slug":"nebula"}', 'galaxy', 1
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Туманность' AND type='background');
+2 -2
View File
@@ -18,10 +18,10 @@ function shopGate(req, res, next) {
}
const purchaseLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много покупок, подождите минуту' });
const activateSchema = { body: { type: { type: 'string', oneOf: ['frame', 'title', 'effect'] } } };
const activateSchema = { body: { type: { type: 'string', oneOf: ['frame', 'title', 'effect', 'background'] } } };
const adminItemSchema = { body: {
name: { type: 'string', required: true, minLen: 1, maxLen: 200 },
type: { type: 'string', required: true, oneOf: ['frame', 'title', 'effect'] },
type: { type: 'string', required: true, oneOf: ['frame', 'title', 'effect', 'background'] },
price: { type: 'number', required: true, min: 0 },
}};
const awardCoinsSchema = { body: {
+199
View File
@@ -1194,3 +1194,202 @@ body.no-gamification [data-gamified] { display: none !important; }
.rounded-md { border-radius: var(--r-md); }
.rounded-lg { border-radius: var(--r-lg); }
.rounded-full { border-radius: var(--r-pill); }
/* ══════════════════════════════════════════
ANIMATED BACKGROUNDS (Phase 6)
Painted by a single fixed div appended to <body> by api.js
applyCosmetics: <div id="ls-bg-fx" class="bg-<slug>"></div>.
The container is always position:fixed inset:0 z-index:-1, never
intercepts pointer events, and sits BEHIND every page chrome.
Each slug has a static fallback first (no animation), then opts
into motion outside the prefers-reduced-motion overlay below.
Picker preview swatches reuse `.bg-preview.<slug>` with the same
visuals at smaller scale.
══════════════════════════════════════════ */
#ls-bg-fx {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
overflow: hidden;
will-change: opacity, transform, background-position;
transition: opacity .4s ease;
}
.bg-preview {
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
border-radius: 10px;
overflow: hidden;
background: #f5f7fb;
}
/* ── 1. none (no overlay) ─────────────────────────────────────── */
.bg-none, .bg-preview.bg-none { background: transparent; }
/* ── 2. gradient-soft (free, static) ──────────────────────────── */
.bg-gradient-soft,
.bg-preview.bg-gradient-soft {
background: linear-gradient(135deg, #e0e7ff 0%, #fce7f3 100%);
}
/* ── 3. dots (free, static) ───────────────────────────────────── */
.bg-dots,
.bg-preview.bg-dots {
background-color: #fafbff;
background-image: radial-gradient(rgba(99,102,241,0.18) 1px, transparent 1px);
background-size: 22px 22px;
}
/* ── 4. dark (free, static) ───────────────────────────────────── */
.bg-dark,
.bg-preview.bg-dark {
background: radial-gradient(ellipse at top, #1e1b4b 0%, #0f172a 100%);
}
/* ── 5. gradient-flow (paid, animated) ────────────────────────── */
@keyframes ls-bg-flow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.bg-gradient-flow,
.bg-preview.bg-gradient-flow {
background: linear-gradient(120deg, #9B5DE5, #06D6E0, #FFD166, #9B5DE5);
background-size: 300% 300%;
animation: ls-bg-flow 24s ease-in-out infinite;
}
/* ── 6. grid (paid, animated) ─────────────────────────────────── */
@keyframes ls-bg-grid-scan {
0% { background-position: 0 0, 0 0; }
100% { background-position: 0 60px, 60px 0; }
}
.bg-grid,
.bg-preview.bg-grid {
background-color: #0f172a;
background-image:
linear-gradient(rgba(6,214,224,0.18) 1px, transparent 1px),
linear-gradient(90deg, rgba(6,214,224,0.18) 1px, transparent 1px);
background-size: 60px 60px;
animation: ls-bg-grid-scan 18s linear infinite;
}
/* ── 7. bubbles (paid, animated) ──────────────────────────────── */
@keyframes ls-bg-bubble-rise {
0% { transform: translateY(0) scale(0.9); opacity: 0.55; }
50% { transform: translateY(-50vh) scale(1.05); opacity: 0.85; }
100% { transform: translateY(-100vh) scale(0.9); opacity: 0; }
}
.bg-bubbles,
.bg-preview.bg-bubbles {
background: linear-gradient(180deg, #cbeaff 0%, #e9f5ff 100%);
position: relative;
}
.bg-bubbles::before,
.bg-bubbles::after,
.bg-preview.bg-bubbles::before,
.bg-preview.bg-bubbles::after {
content: '';
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 12% 80%, rgba(255,255,255,0.7) 6px, transparent 7px),
radial-gradient(circle at 38% 90%, rgba(255,255,255,0.5) 9px, transparent 10px),
radial-gradient(circle at 65% 95%, rgba(255,255,255,0.6) 5px, transparent 6px),
radial-gradient(circle at 85% 75%, rgba(255,255,255,0.55) 11px, transparent 12px);
animation: ls-bg-bubble-rise 14s linear infinite;
}
.bg-bubbles::after,
.bg-preview.bg-bubbles::after {
background-image:
radial-gradient(circle at 22% 70%, rgba(255,255,255,0.5) 8px, transparent 9px),
radial-gradient(circle at 50% 88%, rgba(255,255,255,0.6) 4px, transparent 5px),
radial-gradient(circle at 78% 60%, rgba(255,255,255,0.6) 7px, transparent 8px);
animation-duration: 19s;
animation-delay: -6s;
}
/* ── 8. stars (paid, animated) ────────────────────────────────── */
@keyframes ls-bg-stars-twinkle {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.bg-stars,
.bg-preview.bg-stars {
background-color: #0a0e27;
background-image:
radial-gradient(2px 2px at 20% 30%, #ffffff, transparent),
radial-gradient(1px 1px at 40% 70%, #ffffff, transparent),
radial-gradient(2px 2px at 60% 20%, #ffffff, transparent),
radial-gradient(1px 1px at 80% 50%, #ffffff, transparent),
radial-gradient(2px 2px at 90% 85%, #ffffff, transparent),
radial-gradient(1px 1px at 10% 90%, #ffffff, transparent),
radial-gradient(2px 2px at 50% 10%, #ffffff, transparent),
radial-gradient(1px 1px at 30% 50%, #ffffff, transparent);
background-size: 200px 200px;
animation: ls-bg-stars-twinkle 6s ease-in-out infinite;
}
/* ── 9. aurora (premium, animated) ────────────────────────────── */
@keyframes ls-bg-aurora-spin {
0% { transform: rotate(0deg) scale(1.2); }
50% { transform: rotate(180deg) scale(1.4); }
100% { transform: rotate(360deg) scale(1.2); }
}
.bg-aurora,
.bg-preview.bg-aurora {
background: #050b1a;
position: relative;
overflow: hidden;
}
.bg-aurora::before,
.bg-preview.bg-aurora::before {
content: '';
position: absolute;
inset: -40%;
background: conic-gradient(
from 0deg at 50% 50%,
transparent 0%,
rgba(6,214,224,0.45) 12%,
rgba(155,93,229,0.55) 24%,
transparent 36%,
rgba(6,214,160,0.35) 60%,
transparent 78%,
rgba(255,107,53,0.30) 92%,
transparent 100%
);
filter: blur(46px);
animation: ls-bg-aurora-spin 40s linear infinite;
}
/* ── 10. nebula (premium, animated) ───────────────────────────── */
@keyframes ls-bg-nebula-pan {
0% { background-position: 0% 0%, 100% 100%; }
50% { background-position: 50% 50%, 50% 50%; }
100% { background-position: 0% 0%, 100% 100%; }
}
.bg-nebula,
.bg-preview.bg-nebula {
background:
radial-gradient(ellipse at 30% 30%, rgba(155,93,229,0.6), transparent 55%),
radial-gradient(ellipse at 70% 70%, rgba(6,214,224,0.5), transparent 55%),
radial-gradient(circle at 50% 50%, rgba(255,107,53,0.25), transparent 60%),
#050214;
background-size: 150% 150%, 150% 150%, 100% 100%, 100% 100%;
animation: ls-bg-nebula-pan 30s ease-in-out infinite;
}
/* ── Reduced-motion: kill all bg animations, keep static palette ─ */
@media (prefers-reduced-motion: reduce) {
#ls-bg-fx,
#ls-bg-fx::before,
#ls-bg-fx::after,
.bg-preview,
.bg-preview::before,
.bg-preview::after {
animation: none !important;
}
}
+28 -1
View File
@@ -537,6 +537,17 @@
padding: 8px 14px; margin-bottom: 6px;
border: 1.5px dashed currentColor; border-radius: 99px;
}
/* Background preview swatch in shop items — uses the same .bg-<slug>
classes as the full-page background so what you see is what you
get. Compact size to match other previews. */
.shop-bg-preview {
width: 100%;
aspect-ratio: 16 / 10;
max-height: 90px;
border-radius: 10px;
margin-bottom: 8px;
border: 1.5px solid var(--border);
}
.shop-item-name {
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: var(--text); line-height: 1.3;
@@ -1008,6 +1019,7 @@
<div class="shop-filters">
<button class="shop-filter active" data-type="all" onclick="shopFilter('all',this)">Все</button>
<button class="shop-filter" data-type="frame" onclick="shopFilter('frame',this)">Рамки</button>
<button class="shop-filter" data-type="background" onclick="shopFilter('background',this)">Фоны</button>
<button class="shop-filter" data-type="title" onclick="shopFilter('title',this)">Титулы</button>
<button class="shop-filter" data-type="effect" onclick="shopFilter('effect',this)">Эффекты</button>
</div>
@@ -1530,6 +1542,12 @@
}
function _isItemActive(item) {
if (item.type === 'background' && _activeCosmetics.background) {
try {
const data = JSON.parse(item.data || '{}');
return data.slug === _activeCosmetics.background.slug;
} catch { return false; }
}
if (item.type === 'title' && _activeCosmetics.title) return true;
if (item.type === 'effect' && _activeCosmetics.effect) return true;
if (item.type === 'frame' && _activeCosmetics.frame) return true;
@@ -1549,7 +1567,9 @@
return;
}
grid.innerHTML = items.map(item => {
const owned = !!item.owned;
// Free items (price === 0) are auto-owned for everyone: backgrounds
// and any future freebies don't need a user_purchases row.
const owned = !!item.owned || item.price === 0;
const canBuy = _shopCoins >= item.price && !owned;
const active = owned && _isItemActive(item);
let btn = '';
@@ -1591,6 +1611,13 @@
const text = titleData.text || item.name;
return `<div class="shop-title-preview" style="color:${esc(color)}">${esc(text)}</div>`;
}
if (item.type === 'background') {
let bg = {};
try { bg = JSON.parse(item.data || '{}'); } catch {}
const slug = (bg.slug || 'none').replace(/[^a-z0-9_-]/gi, '');
// 56px swatch with the same CSS rules as the full-screen bg.
return `<div class="shop-bg-preview bg-preview bg-${slug}"></div>`;
}
// effects / other — fall back to the lucide icon
return `<div class="shop-item-icon">${LS.icon(item.icon || 'star', 28)}</div>`;
}
+17
View File
@@ -1078,6 +1078,23 @@ async function applyCosmetics() {
const c = await getMyActiveCosmetics();
if (!c) return;
// ── Background: paint a fixed div behind the whole UI ──
// The element is created on demand and reused on subsequent calls
// so swapping backgrounds doesn't flash.
if (c.background && c.background.slug && c.background.slug !== 'none') {
let bgEl = document.getElementById('ls-bg-fx');
if (!bgEl) {
bgEl = document.createElement('div');
bgEl.id = 'ls-bg-fx';
document.body.insertBefore(bgEl, document.body.firstChild);
}
bgEl.className = 'bg-' + String(c.background.slug).replace(/[^a-z0-9_-]/gi, '');
} else {
// Active bg was cleared — remove the element if present.
const bgEl = document.getElementById('ls-bg-fx');
if (bgEl) bgEl.remove();
}
// ── Frame: apply CSS to sidebar avatar on every page ──
if (c.frame && c.frame.css) {
const navAv = document.getElementById('nav-avatar');