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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user