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 });
}