feat(pet): гардероб — выбор аксессуаров + новые украшения

Аксессуары больше не навешиваются авто по уровню — теперь разблокируются
и НАДЕВАЮТСЯ по выбору (один на слот). Новые: шапочка выпускника, наушники,
бабочка (бесплатные — доступны даже при 0 XP). Сохранены цилиндр/корона/очки/
звезда с прежними порогами; дефолт повторяет старый вид (без сюрпризов).

Бэкенд: миграция pet_equipped, каталог ACCESSORY_CATALOG + /api/pet/equip
(валидация разблокировки и слотов). Рендер аксессуаров строго по equipped —
в обеих копиях (pet.html и pet-sprite.js для дашборда). UI гардероба на /pet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-05 13:37:45 +03:00
parent 8c961cd082
commit 7bf1da94e4
5 changed files with 173 additions and 31 deletions
+70 -10
View File
@@ -64,13 +64,42 @@ function _mood(streak, daysSinceLogin) {
return 'sad';
}
function _accessories(user) {
const acc = [];
if (user.streak_best >= 7) acc.push('hat');
if (user.level >= 5) acc.push('glasses');
if (user.xp >= 5000) acc.push('crown');
if (user.level >= 10) acc.push('star');
return acc;
/* ── Гардероб: каталог аксессуаров (разблокировка по порогам/бесплатно) ──
slot — на каждый слот можно надеть только один предмет. */
const ACCESSORY_CATALOG = [
{ id: 'grad', name: 'Шапочка выпускника', slot: 'head', unlock: { type: 'free' } },
{ id: 'hat', name: 'Цилиндр', slot: 'head', unlock: { type: 'streak', value: 7 } },
{ id: 'crown', name: 'Корона', slot: 'head', unlock: { type: 'xp', value: 5000 } },
{ id: 'glasses', name: 'Очки', slot: 'face', unlock: { type: 'level', value: 5 } },
{ id: 'bowtie', name: 'Бабочка', slot: 'neck', unlock: { type: 'free' } },
{ id: 'headphones', name: 'Наушники', slot: 'ears', unlock: { type: 'free' } },
{ id: 'star', name: 'Звезда', slot: 'accent', unlock: { type: 'level', value: 10 } },
];
const ACC_BY_ID = new Map(ACCESSORY_CATALOG.map(a => [a.id, a]));
function _accUnlocked(u, a) {
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;
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} дн.`;
return '';
}
// Дефолт при первом заходе (pet_equipped == null) — повторяет прежнее авто-поведение.
function _defaultEquipped(u) {
const eq = [];
if ((u.xp || 0) >= 5000) eq.push('crown');
if ((u.level || 0) >= 5) eq.push('glasses');
if ((u.level || 0) >= 10) eq.push('star');
if ((u.streak_best || 0) >= 7) eq.push('hat');
return eq;
}
function _quests(userId, streakCurrent) {
@@ -101,7 +130,7 @@ function getPet(req, res) {
const user = db.prepare(
`SELECT xp, level, streak_current, streak_best, streak_date, coins,
pet_name, last_login, pet_color, pet_last_petted, pet_petting_streak,
pet_bg, pet_bg_owned, pet_last_fed
pet_bg, pet_bg_owned, pet_last_fed, pet_equipped
FROM users WHERE id = ?`
).get(req.user.id);
@@ -126,7 +155,20 @@ function getPet(req, res) {
const petLvl = _petLevel(user.xp);
const mood = _mood(user.streak_current, daysSince);
const accessories = _accessories(user);
// Гардероб: разблокированные + надетые (equipped). При первом заходе — дефолт.
const unlocked = ACCESSORY_CATALOG.filter(a => _accUnlocked(user, a)).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 = []; } }
equipped = equipped.filter(id => unlocked.includes(id)); // только разблокированные
const accessories = equipped;
const wardrobe = ACCESSORY_CATALOG.map(a => ({
id: a.id, name: a.name, slot: a.slot,
locked: !unlocked.includes(a.id),
hint: unlocked.includes(a.id) ? '' : _unlockHint(a),
equipped: equipped.includes(a.id),
}));
const thresholds = [0, 500, 2000, 5000, 10000, 20000, 40000, 80000, Infinity];
const xpForNext = thresholds[petLvl] ?? Infinity;
@@ -166,6 +208,7 @@ function getPet(req, res) {
mood,
daysSinceLogin: daysSince,
accessories,
wardrobe,
xp: user.xp,
level: user.level,
streakCurrent: user.streak_current,
@@ -235,6 +278,23 @@ function updateColor(req, res) {
res.json({ ok: true, color });
}
/* ── PATCH /api/pet/equip — надеть/снять аксессуары (гардероб) ─────────── */
function equipAccessories(req, res) {
const list = req.body && req.body.equipped;
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 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;
seenSlot.add(a.slot); clean.push(id);
}
db.prepare('UPDATE users SET pet_equipped=? WHERE id=?').run(JSON.stringify(clean), req.user.id);
res.json({ ok: true, equipped: clean });
}
/* ── GET /api/pet/shop ────────────────────────────────────────────────── */
function getShop(req, res) {
const user = db.prepare('SELECT coins, pet_bg, pet_bg_owned FROM users WHERE id=?').get(req.user.id);
@@ -316,4 +376,4 @@ function feedPet(req, res) {
res.json({ ok: true, xpAwarded: 15, xp: updated.xp, coins: updated.coins });
}
module.exports = { getPet, renamePet, petAction, updateColor, starCatch, getShop, buyBg, setBg, feedPet };
module.exports = { getPet, renamePet, petAction, updateColor, starCatch, getShop, buyBg, setBg, feedPet, equipAccessories };
@@ -0,0 +1,3 @@
-- Гардероб питомца: какие аксессуары надеты (JSON-массив id).
-- NULL = ещё не настраивал → используется дефолт (старое авто-поведение по порогам).
ALTER TABLE users ADD COLUMN pet_equipped TEXT;
+1
View File
@@ -6,6 +6,7 @@ router.get('/', authMiddleware, c.getPet);
router.patch('/name', authMiddleware, c.renamePet);
router.post('/pet', authMiddleware, c.petAction);
router.patch('/color', authMiddleware, c.updateColor);
router.patch('/equip', authMiddleware, c.equipAccessories);
router.post('/star', authMiddleware, c.starCatch);
router.get('/shop', authMiddleware, c.getShop);
router.post('/shop/buy', authMiddleware, c.buyBg);