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