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 += ``;
}