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:
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -141,13 +141,34 @@
|
||||
<circle cx="97" cy="84" r="2.8" fill="${dark}" opacity=".45"/>
|
||||
<circle cx="92" cy="83" r="2.8" fill="${dark}" opacity=".45"/>`;
|
||||
|
||||
/* ── Accessories ── */
|
||||
/* ── Accessories (гардероб: строго по equipped-списку, без авто по уровню) ── */
|
||||
let accSvg = '';
|
||||
if (accessories.includes('headphones')) {
|
||||
accSvg += `<path d="M27,42 Q55,6 83,42" fill="none" stroke="#2a3340" stroke-width="4" stroke-linecap="round"/>
|
||||
<rect x="21" y="38" width="11" height="17" rx="4.5" fill="#2a3340"/>
|
||||
<rect x="78" y="38" width="11" height="17" rx="4.5" fill="#2a3340"/>
|
||||
<rect x="24" y="41" width="5" height="11" rx="2.5" fill="${col}"/>
|
||||
<rect x="81" y="41" width="5" height="11" rx="2.5" fill="${col}"/>`;
|
||||
}
|
||||
if (accessories.includes('grad')) {
|
||||
accSvg += `<path d="M44,25 Q55,30 66,25 L66,30 Q55,35 44,30 Z" fill="#2a3340"/>
|
||||
<path d="M28,21 L55,13 L82,21 L55,29 Z" fill="#1f2733"/>
|
||||
<line x1="55" y1="21" x2="78" y2="22" stroke="#F9C74F" stroke-width="1.3"/>
|
||||
<line x1="78" y1="22" x2="78" y2="33" stroke="#F9C74F" stroke-width="1.3"/>
|
||||
<circle cx="78" cy="35" r="2.6" fill="#F9C74F"/>`;
|
||||
}
|
||||
if (accessories.includes('hat')) {
|
||||
accSvg += `<rect x="36" y="22" width="38" height="6" rx="3" fill="#2a2a2a"/>
|
||||
<rect x="42" y="6" width="26" height="17" rx="4" fill="#1a1a1a"/>
|
||||
<rect x="42" y="6" width="26" height="5" rx="2" fill="#333" opacity=".6"/>`;
|
||||
}
|
||||
if (accessories.includes('crown')) {
|
||||
accSvg += `<path d="M33,26 L41,10 L55,22 L69,10 L77,26 L78,32 L32,32 Z" fill="#F9C74F"/>
|
||||
<rect x="32" y="28" width="46" height="6" rx="2" fill="#F9C74F"/>
|
||||
<circle cx="41" cy="11" r="4" fill="#F94144"/>
|
||||
<circle cx="55" cy="23" r="4" fill="#06D6E0"/>
|
||||
<circle cx="69" cy="11" r="4" fill="#9B5DE5"/>`;
|
||||
}
|
||||
if (accessories.includes('glasses')) {
|
||||
accSvg += `<circle cx="${eyeX1}" cy="${eyeY}" r="14" fill="none" stroke="rgba(255,255,255,.65)" stroke-width="2"/>
|
||||
<circle cx="${eyeX2}" cy="${eyeY}" r="14" fill="none" stroke="rgba(255,255,255,.65)" stroke-width="2"/>
|
||||
@@ -155,14 +176,12 @@
|
||||
<line x1="19" y1="${eyeY-3}" x2="26" y2="${eyeY}" stroke="rgba(255,255,255,.45)" stroke-width="1.5"/>
|
||||
<line x1="91" y1="${eyeY-3}" x2="84" y2="${eyeY}" stroke="rgba(255,255,255,.45)" stroke-width="1.5"/>`;
|
||||
}
|
||||
if (accessories.includes('crown') || level >= 5) {
|
||||
accSvg += `<path d="M33,26 L41,10 L55,22 L69,10 L77,26 L78,32 L32,32 Z" fill="#F9C74F"/>
|
||||
<rect x="32" y="28" width="46" height="6" rx="2" fill="#F9C74F"/>
|
||||
<circle cx="41" cy="11" r="4" fill="#F94144"/>
|
||||
<circle cx="55" cy="23" r="4" fill="#06D6E0"/>
|
||||
<circle cx="69" cy="11" r="4" fill="#9B5DE5"/>`;
|
||||
if (accessories.includes('bowtie')) {
|
||||
accSvg += `<path d="M55,85 L44,80 L44,90 Z" fill="${dark}"/>
|
||||
<path d="M55,85 L66,80 L66,90 Z" fill="${dark}"/>
|
||||
<rect x="52" y="82" width="6" height="6" rx="2" fill="${light}"/>`;
|
||||
}
|
||||
if (accessories.includes('star') && level < 5) {
|
||||
if (accessories.includes('star')) {
|
||||
accSvg += `<polygon points="98,18 100,24 106,24 101,28 103,34 98,30 93,34 95,28 90,24 96,24" fill="#F9C74F"/>`;
|
||||
}
|
||||
|
||||
|
||||
+72
-13
@@ -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 => `<span class="pet-acc-badge">${ACC[a]||a}</span>`).join('') : '';
|
||||
// Гардероб (интерактивный выбор аксессуаров)
|
||||
renderWardrobe(d.wardrobe || []);
|
||||
|
||||
// Stats
|
||||
document.getElementById('stat-streak').textContent = d.streakCurrent + ' дн.';
|
||||
@@ -1119,6 +1116,49 @@ async function selectColor(colorKey) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Гардероб (выбор аксессуаров) ── */
|
||||
const LOCK_ICO = '<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px;flex-shrink:0"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
||||
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 `<span class="pet-wear${it.locked?'':' tgl'}" data-id="${it.id}" title="${title}" style="${style}">${it.locked?LOCK_ICO:''}${escHtml(it.name)}${tail}</span>`;
|
||||
}).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
|
||||
<circle cx="97" cy="84" r="2.8" fill="${dark}" opacity=".45"/>
|
||||
<circle cx="92" cy="83" r="2.8" fill="${dark}" opacity=".45"/>`;
|
||||
|
||||
/* ── Accessories ── */
|
||||
/* ── Accessories (гардероб: строго по equipped-списку, без авто по уровню) ── */
|
||||
let accSvg = '';
|
||||
if (accessories.includes('headphones')) {
|
||||
accSvg += `<path d="M27,42 Q55,6 83,42" fill="none" stroke="#2a3340" stroke-width="4" stroke-linecap="round"/>
|
||||
<rect x="21" y="38" width="11" height="17" rx="4.5" fill="#2a3340"/>
|
||||
<rect x="78" y="38" width="11" height="17" rx="4.5" fill="#2a3340"/>
|
||||
<rect x="24" y="41" width="5" height="11" rx="2.5" fill="${col}"/>
|
||||
<rect x="81" y="41" width="5" height="11" rx="2.5" fill="${col}"/>`;
|
||||
}
|
||||
if (accessories.includes('grad')) {
|
||||
accSvg += `<path d="M44,25 Q55,30 66,25 L66,30 Q55,35 44,30 Z" fill="#2a3340"/>
|
||||
<path d="M28,21 L55,13 L82,21 L55,29 Z" fill="#1f2733"/>
|
||||
<line x1="55" y1="21" x2="78" y2="22" stroke="#F9C74F" stroke-width="1.3"/>
|
||||
<line x1="78" y1="22" x2="78" y2="33" stroke="#F9C74F" stroke-width="1.3"/>
|
||||
<circle cx="78" cy="35" r="2.6" fill="#F9C74F"/>`;
|
||||
}
|
||||
if (accessories.includes('hat')) {
|
||||
accSvg += `<rect x="36" y="22" width="38" height="6" rx="3" fill="#2a2a2a"/>
|
||||
<rect x="42" y="6" width="26" height="17" rx="4" fill="#1a1a1a"/>
|
||||
<rect x="42" y="6" width="26" height="5" rx="2" fill="#333" opacity=".6"/>`;
|
||||
}
|
||||
if (accessories.includes('crown')) {
|
||||
accSvg += `<path d="M33,26 L41,10 L55,22 L69,10 L77,26 L78,32 L32,32 Z" fill="#F9C74F"/>
|
||||
<rect x="32" y="28" width="46" height="6" rx="2" fill="#F9C74F"/>
|
||||
<circle cx="41" cy="11" r="4" fill="#F94144"/>
|
||||
<circle cx="55" cy="23" r="4" fill="#06D6E0"/>
|
||||
<circle cx="69" cy="11" r="4" fill="#9B5DE5"/>`;
|
||||
}
|
||||
if (accessories.includes('glasses')) {
|
||||
accSvg += `<circle cx="${eyeX1}" cy="${eyeY}" r="14" fill="none" stroke="rgba(255,255,255,.65)" stroke-width="2"/>
|
||||
<circle cx="${eyeX2}" cy="${eyeY}" r="14" fill="none" stroke="rgba(255,255,255,.65)" stroke-width="2"/>
|
||||
@@ -1550,14 +1611,12 @@ function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak
|
||||
<line x1="19" y1="${eyeY-3}" x2="26" y2="${eyeY}" stroke="rgba(255,255,255,.45)" stroke-width="1.5"/>
|
||||
<line x1="91" y1="${eyeY-3}" x2="84" y2="${eyeY}" stroke="rgba(255,255,255,.45)" stroke-width="1.5"/>`;
|
||||
}
|
||||
if (accessories.includes('crown') || level >= 5) {
|
||||
accSvg += `<path d="M33,26 L41,10 L55,22 L69,10 L77,26 L78,32 L32,32 Z" fill="#F9C74F"/>
|
||||
<rect x="32" y="28" width="46" height="6" rx="2" fill="#F9C74F"/>
|
||||
<circle cx="41" cy="11" r="4" fill="#F94144"/>
|
||||
<circle cx="55" cy="23" r="4" fill="#06D6E0"/>
|
||||
<circle cx="69" cy="11" r="4" fill="#9B5DE5"/>`;
|
||||
if (accessories.includes('bowtie')) {
|
||||
accSvg += `<path d="M55,85 L44,80 L44,90 Z" fill="${dark}"/>
|
||||
<path d="M55,85 L66,80 L66,90 Z" fill="${dark}"/>
|
||||
<rect x="52" y="82" width="6" height="6" rx="2" fill="${light}"/>`;
|
||||
}
|
||||
if (accessories.includes('star') && level < 5) {
|
||||
if (accessories.includes('star')) {
|
||||
accSvg += `<polygon points="98,18 100,24 106,24 101,28 103,34 98,30 93,34 95,28 90,24 96,24" fill="#F9C74F"/>`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user