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: {