From 7bf1da94e4f930116e2cbc2874803dbabb561322 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 5 Jun 2026 13:37:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(pet):=20=D0=B3=D0=B0=D1=80=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=BE=D0=B1=20=E2=80=94=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D0=B0=D0=BA=D1=81=D0=B5=D1=81=D1=81=D1=83=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20+=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=83=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аксессуары больше не навешиваются авто по уровню — теперь разблокируются и НАДЕВАЮТСЯ по выбору (один на слот). Новые: шапочка выпускника, наушники, бабочка (бесплатные — доступны даже при 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) --- backend/src/controllers/petController.js | 80 ++++++++++++++--- .../src/db/migrations/065_pet_wardrobe.sql | 3 + backend/src/routes/pet.js | 1 + frontend/js/pet-sprite.js | 35 ++++++-- frontend/pet.html | 85 ++++++++++++++++--- 5 files changed, 173 insertions(+), 31 deletions(-) create mode 100644 backend/src/db/migrations/065_pet_wardrobe.sql diff --git a/backend/src/controllers/petController.js b/backend/src/controllers/petController.js index 5799e15..8cb3b63 100644 --- a/backend/src/controllers/petController.js +++ b/backend/src/controllers/petController.js @@ -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 }; diff --git a/backend/src/db/migrations/065_pet_wardrobe.sql b/backend/src/db/migrations/065_pet_wardrobe.sql new file mode 100644 index 0000000..2c47f50 --- /dev/null +++ b/backend/src/db/migrations/065_pet_wardrobe.sql @@ -0,0 +1,3 @@ +-- Гардероб питомца: какие аксессуары надеты (JSON-массив id). +-- NULL = ещё не настраивал → используется дефолт (старое авто-поведение по порогам). +ALTER TABLE users ADD COLUMN pet_equipped TEXT; diff --git a/backend/src/routes/pet.js b/backend/src/routes/pet.js index 8e677ca..4f9a1c8 100644 --- a/backend/src/routes/pet.js +++ b/backend/src/routes/pet.js @@ -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); diff --git a/frontend/js/pet-sprite.js b/frontend/js/pet-sprite.js index 344a15d..b1627af 100644 --- a/frontend/js/pet-sprite.js +++ b/frontend/js/pet-sprite.js @@ -141,13 +141,34 @@ `; - /* ── Accessories ── */ + /* ── Accessories (гардероб: строго по equipped-списку, без авто по уровню) ── */ let accSvg = ''; + if (accessories.includes('headphones')) { + accSvg += ` + + + + `; + } + if (accessories.includes('grad')) { + accSvg += ` + + + + `; + } if (accessories.includes('hat')) { accSvg += ` `; } + if (accessories.includes('crown')) { + accSvg += ` + + + + `; + } if (accessories.includes('glasses')) { accSvg += ` @@ -155,14 +176,12 @@ `; } - if (accessories.includes('crown') || level >= 5) { - accSvg += ` - - - - `; + if (accessories.includes('bowtie')) { + accSvg += ` + + `; } - if (accessories.includes('star') && level < 5) { + if (accessories.includes('star')) { accSvg += ``; } diff --git a/frontend/pet.html b/frontend/pet.html index 61d1a22..85f3353 100644 --- a/frontend/pet.html +++ b/frontend/pet.html @@ -968,11 +968,8 @@ function renderPet(d) { // Color picker renderColorPicker(d.petColor || 'purple'); - // Accessories - const accEl = document.getElementById('pet-accessories'); - const ACC = { hat:'Шляпа', glasses:'Очки', crown:'Корона', star:'Звезда' }; - accEl.innerHTML = d.accessories.length - ? d.accessories.map(a => `${ACC[a]||a}`).join('') : ''; + // Гардероб (интерактивный выбор аксессуаров) + renderWardrobe(d.wardrobe || []); // Stats document.getElementById('stat-streak').textContent = d.streakCurrent + ' дн.'; @@ -1119,6 +1116,49 @@ async function selectColor(colorKey) { ); } +/* ── Гардероб (выбор аксессуаров) ── */ +const LOCK_ICO = ''; +function renderWardrobe(items) { + const el = document.getElementById('pet-accessories'); + if (!el) return; + el.style.cssText = 'display:flex;flex-wrap:wrap;gap:7px'; + if (!items.length) { el.innerHTML = ''; return; } + const base = 'display:inline-flex;align-items:center;gap:5px;padding:6px 11px;border-radius:99px;font:600 .74rem Manrope,sans-serif;border:1.5px solid;transition:all .15s;user-select:none;'; + el.innerHTML = items.map(it => { + const style = it.locked + ? base + 'border-color:rgba(255,255,255,.1);color:var(--text-3);background:transparent;cursor:not-allowed;opacity:.6' + : it.equipped + ? base + 'border-color:var(--violet);color:#fff;background:rgba(155,93,229,.22);cursor:pointer' + : base + 'border-color:var(--border-h);color:var(--text-2);background:transparent;cursor:pointer'; + const title = it.locked ? `Откроется: ${it.hint}` : (it.equipped ? 'Снять' : 'Надеть'); + const tail = it.locked && it.hint ? ` · ${it.hint}` : ''; + return `${it.locked?LOCK_ICO:''}${escHtml(it.name)}${tail}`; + }).join(''); + el.querySelectorAll('.pet-wear.tgl').forEach(ch => + ch.addEventListener('click', () => toggleEquip(ch.dataset.id))); +} +async function toggleEquip(id) { + if (!_petData || !_petData.wardrobe) return; + const item = _petData.wardrobe.find(w => w.id === id); + if (!item || item.locked) return; + const slotOf = {}; _petData.wardrobe.forEach(w => { slotOf[w.id] = w.slot; }); + let eq = _petData.wardrobe.filter(w => w.equipped).map(w => w.id); + if (item.equipped) { + eq = eq.filter(x => x !== id); // снять + } else { + eq = eq.filter(x => slotOf[x] !== item.slot); // освободить слот + eq.push(id); // надеть + } + const res = await LS.api('/api/pet/equip', { method:'PATCH', body: JSON.stringify({ equipped: eq }) }).catch(() => null); + if (!res || !res.ok) return; + const final = res.equipped || eq; + _petData.accessories = final; + _petData.wardrobe.forEach(w => { w.equipped = final.includes(w.id); }); + document.getElementById('pet-svg-wrap').innerHTML = + renderPetSVG(_petData.petLevel, _petData.mood, final, _petData.petColor || 'purple', _petData.streakCurrent || 0); + renderWardrobe(_petData.wardrobe); +} + /* ── Petting ── */ async function petThePet() { if (_petCooldown) return; @@ -1536,13 +1576,34 @@ function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak `; - /* ── Accessories ── */ + /* ── Accessories (гардероб: строго по equipped-списку, без авто по уровню) ── */ let accSvg = ''; + if (accessories.includes('headphones')) { + accSvg += ` + + + + `; + } + if (accessories.includes('grad')) { + accSvg += ` + + + + `; + } if (accessories.includes('hat')) { accSvg += ` `; } + if (accessories.includes('crown')) { + accSvg += ` + + + + `; + } if (accessories.includes('glasses')) { accSvg += ` @@ -1550,14 +1611,12 @@ function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak `; } - if (accessories.includes('crown') || level >= 5) { - accSvg += ` - - - - `; + if (accessories.includes('bowtie')) { + accSvg += ` + + `; } - if (accessories.includes('star') && level < 5) { + if (accessories.includes('star')) { accSvg += ``; }