feat(pet): большое наполнение кастомизации контентом

Цвета 6→11 (розовый/оранжевый/бирюза/лайм/индиго). Узоры 5→8
(сердечки/звёздочки/клетка). Аксессуары 11→18 + новая зона «В лапах»
(бини, нимб, монокль, медаль, серёжки, палочка, шарик). Разблокировка за
учёбу: нимб (8 достижений), медаль (30 тестов). Фоны 7→11
(класс/лаборатория/зима/радуга) с градиентами и частицами. Новая вкладка
«Образы» — 4 готовых набора (Учёный/Волшебник/Чемпион/Милашка) применяют
аксессуары+узор+цвет одним кликом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-05 14:52:58 +03:00
parent 1ed9dbcacf
commit 5417083f88
3 changed files with 199 additions and 17 deletions
+39 -11
View File
@@ -40,6 +40,10 @@ const BG_SHOP = [
{ id:'aurora', name:'Сияние', price:100, desc:'Северное сияние' },
{ id:'candy', name:'Леденец', price:100, desc:'Сладкие облака' },
{ id:'sakura', name:'Сакура', price:125, desc:'Цветущая вишня' },
{ id:'class', name:'Класс', price:75, desc:'Школьный класс' },
{ id:'lab', name:'Лаборатория', price:100, desc:'Химлаборатория' },
{ id:'winter', name:'Зима', price:100, desc:'Снежная ночь' },
{ id:'rainbow', name:'Радуга', price:125, desc:'Радужное небо' },
];
function _parseOwned(raw) {
@@ -73,16 +77,24 @@ const ACCESSORY_CATALOG = [
// голова
{ id: 'grad', name: 'Шапочка выпускника', slot: 'head', unlock: { type: 'free' } },
{ id: 'party', name: 'Праздничный колпак', slot: 'head', unlock: { type: 'free' } },
{ id: 'beanie', name: 'Шапка-бини', slot: 'head', unlock: { type: 'free' } },
{ id: 'hat', name: 'Цилиндр', slot: 'head', unlock: { type: 'streak', value: 7 } },
{ id: 'halo', name: 'Нимб', slot: 'head', unlock: { type: 'achievements', value: 8 } },
{ id: 'crown', name: 'Корона', slot: 'head', unlock: { type: 'xp', value: 5000 } },
// лицо
{ id: 'glasses', name: 'Очки', slot: 'face', unlock: { type: 'level', value: 5 } },
{ id: 'sunglasses', name: 'Тёмные очки', slot: 'face', unlock: { type: 'free' } },
{ id: 'monocle', name: 'Монокль', slot: 'face', unlock: { type: 'free' } },
// шея
{ id: 'bowtie', name: 'Бабочка', slot: 'neck', unlock: { type: 'free' } },
{ id: 'scarf', name: 'Шарф', slot: 'neck', unlock: { type: 'free' } },
{ id: 'medal', name: 'Медаль', slot: 'neck', unlock: { type: 'tests', value: 30 } },
// уши
{ id: 'headphones', name: 'Наушники', slot: 'ears', unlock: { type: 'free' } },
{ id: 'earrings', name: 'Серёжки', slot: 'ears', unlock: { type: 'free' } },
// в лапах
{ id: 'wand', name: 'Волшебная палочка', slot: 'hands', unlock: { type: 'free' } },
{ id: 'balloon', name: 'Шарик', slot: 'hands', unlock: { type: 'free' } },
// акцент
{ id: 'flower', name: 'Цветок', slot: 'accent', unlock: { type: 'free' } },
{ id: 'star', name: 'Звезда', slot: 'accent', unlock: { type: 'level', value: 10 } },
@@ -96,24 +108,38 @@ const PET_PATTERNS = [
{ id: 'stripes', name: 'Полоски' },
{ id: 'gradient', name: 'Градиент' },
{ id: 'galaxy', name: 'Галактика' },
{ id: 'hearts', name: 'Сердечки' },
{ id: 'stars', name: 'Звёздочки' },
{ id: 'checker', name: 'Клетка' },
];
const PATTERN_IDS = new Set(PET_PATTERNS.map(p => p.id));
function _accUnlocked(u, a) {
function _accUnlocked(u, a, st) {
const k = a.unlock;
if (k.type === 'free') return true;
if (k.type === 'xp') return (u.xp || 0) >= k.value;
if (k.type === 'level') return (u.level || 0) >= k.value;
if (k.type === 'streak') return (u.streak_best || 0) >= k.value;
if (k.type === 'free') return true;
if (k.type === 'xp') return (u.xp || 0) >= k.value;
if (k.type === 'level') return (u.level || 0) >= k.value;
if (k.type === 'streak') return (u.streak_best || 0) >= k.value;
if (k.type === 'tests') return (st && st.tests || 0) >= k.value;
if (k.type === 'achievements') return (st && st.achievements || 0) >= k.value;
return false;
}
function _unlockHint(a) {
const k = a.unlock;
if (k.type === 'xp') return `${k.value} XP`;
if (k.type === 'level') return `уровень ${k.value}`;
if (k.type === 'streak') return `серия ${k.value} дн.`;
if (k.type === 'xp') return `${k.value} XP`;
if (k.type === 'level') return `уровень ${k.value}`;
if (k.type === 'streak') return `серия ${k.value} дн.`;
if (k.type === 'tests') return `${k.value} тестов`;
if (k.type === 'achievements') return `${k.value} достижений`;
return '';
}
// Статистика для разблокировки косметики за учёбу.
function _petStats(userId) {
let tests = 0, achievements = 0;
try { tests = db.prepare("SELECT COUNT(*) n FROM test_sessions WHERE user_id=? AND status='completed'").get(userId).n; } catch (e) {}
try { achievements = db.prepare("SELECT COUNT(*) n FROM user_achievements WHERE user_id=?").get(userId).n; } catch (e) {}
return { tests, achievements };
}
// Дефолт при первом заходе (pet_equipped == null) — повторяет прежнее авто-поведение.
function _defaultEquipped(u) {
const eq = [];
@@ -179,7 +205,8 @@ function getPet(req, res) {
const mood = _mood(user.streak_current, daysSince);
// Гардероб: разблокированные + надетые (equipped). При первом заходе — дефолт.
const unlocked = ACCESSORY_CATALOG.filter(a => _accUnlocked(user, a)).map(a => a.id);
const stats = _petStats(req.user.id);
const unlocked = ACCESSORY_CATALOG.filter(a => _accUnlocked(user, a, stats)).map(a => a.id);
let equipped;
if (user.pet_equipped == null) equipped = _defaultEquipped(user);
else { try { equipped = JSON.parse(user.pet_equipped) || []; } catch (e) { equipped = []; } }
@@ -296,7 +323,7 @@ function renamePet(req, res) {
/* ── PATCH /api/pet/color ─────────────────────────────────────────────── */
function updateColor(req, res) {
const { color } = req.body;
const VALID = ['purple', 'cyan', 'gold', 'red', 'green', 'blue'];
const VALID = ['purple', 'cyan', 'gold', 'red', 'green', 'blue', 'pink', 'orange', 'teal', 'lime', 'indigo'];
if (!VALID.includes(color)) return res.status(400).json({ error: 'invalid color' });
db.prepare('UPDATE users SET pet_color = ? WHERE id = ?').run(color, req.user.id);
res.json({ ok: true, color });
@@ -316,11 +343,12 @@ function equipAccessories(req, res) {
if (!Array.isArray(list)) return res.status(400).json({ error: 'equipped[] required' });
const user = db.prepare('SELECT xp, level, streak_best FROM users WHERE id=?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'not found' });
const stats = _petStats(req.user.id);
// оставляем только существующие, разблокированные, по одному на слот
const seenSlot = new Set(), clean = [];
for (const id of list) {
const a = ACC_BY_ID.get(id);
if (!a || !_accUnlocked(user, a) || seenSlot.has(a.slot)) continue;
if (!a || !_accUnlocked(user, a, stats) || seenSlot.has(a.slot)) continue;
seenSlot.add(a.slot); clean.push(id);
}
db.prepare('UPDATE users SET pet_equipped=? WHERE id=?').run(JSON.stringify(clean), req.user.id);