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:
@@ -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');
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user