feat(pet): прокачанный блок кастомизации + много контента

Контент:
- Узор тела (новая ось): пятнышки, полоски, градиент, галактика (клип по
  силуэту, рендер в обоих рендерерах) + миграция pet_pattern + /api/pet/pattern.
- +4 аксессуара: колпак, тёмные очки, шарф, цветок (все бесплатные).
- +3 фона: Сияние, Леденец, Сакура (CSS-градиенты + частицы: звёзды/пузыри/лепестки).

UI кастомизации:
- Вкладка «Узор» со свотчами-превью.
- Гардероб по зонам (Голова/Лицо/Шея/Уши/Акцент) + счётчик надетого,
  кнопки «Случайный образ» и «Снять всё».
- Фон — инлайн-сетка с превью/ценой/балансом (как раньше).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-05 13:58:12 +03:00
parent 152291aec8
commit 6880e1a55a
5 changed files with 251 additions and 42 deletions
+34 -2
View File
@@ -37,6 +37,9 @@ const BG_SHOP = [
{ id:'forest', name:'Лес', price:50, desc:'Таинственный лес' },
{ id:'aqua', name:'Океан', price:75, desc:'Морская глубина' },
{ id:'sunset', name:'Закат', price:75, desc:'Пурпурный закат' },
{ id:'aurora', name:'Сияние', price:100, desc:'Северное сияние' },
{ id:'candy', name:'Леденец', price:100, desc:'Сладкие облака' },
{ id:'sakura', name:'Сакура', price:125, desc:'Цветущая вишня' },
];
function _parseOwned(raw) {
@@ -67,16 +70,35 @@ function _mood(streak, daysSinceLogin) {
/* ── Гардероб: каталог аксессуаров (разблокировка по порогам/бесплатно) ──
slot — на каждый слот можно надеть только один предмет. */
const ACCESSORY_CATALOG = [
// голова
{ id: 'grad', name: 'Шапочка выпускника', slot: 'head', unlock: { type: 'free' } },
{ id: 'party', 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: 'sunglasses', name: 'Тёмные очки', slot: 'face', unlock: { type: 'free' } },
// шея
{ id: 'bowtie', name: 'Бабочка', slot: 'neck', unlock: { type: 'free' } },
{ id: 'scarf', name: 'Шарф', slot: 'neck', unlock: { type: 'free' } },
// уши
{ id: 'headphones', name: 'Наушники', slot: 'ears', unlock: { type: 'free' } },
// акцент
{ id: 'flower', name: 'Цветок', slot: 'accent', 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]));
// Узоры тела (новая ось кастомизации). Все бесплатны.
const PET_PATTERNS = [
{ id: 'none', name: 'Без узора' },
{ id: 'spots', name: 'Пятнышки' },
{ id: 'stripes', name: 'Полоски' },
{ id: 'gradient', name: 'Градиент' },
{ id: 'galaxy', name: 'Галактика' },
];
const PATTERN_IDS = new Set(PET_PATTERNS.map(p => p.id));
function _accUnlocked(u, a) {
const k = a.unlock;
if (k.type === 'free') return true;
@@ -130,7 +152,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_equipped
pet_bg, pet_bg_owned, pet_last_fed, pet_equipped, pet_pattern
FROM users WHERE id = ?`
).get(req.user.id);
@@ -205,6 +227,8 @@ function getPet(req, res) {
petName: user.pet_name || 'Квантик',
petLevel: petLvl,
petColor: user.pet_color || 'purple',
petPattern: user.pet_pattern || 'none',
patterns: PET_PATTERNS,
mood,
daysSinceLogin: daysSince,
accessories,
@@ -278,6 +302,14 @@ function updateColor(req, res) {
res.json({ ok: true, color });
}
/* ── PATCH /api/pet/pattern — узор тела ───────────────────────────────── */
function updatePattern(req, res) {
const pattern = req.body && req.body.pattern;
if (!PATTERN_IDS.has(pattern)) return res.status(400).json({ error: 'invalid pattern' });
db.prepare('UPDATE users SET pet_pattern = ? WHERE id = ?').run(pattern, req.user.id);
res.json({ ok: true, pattern });
}
/* ── PATCH /api/pet/equip — надеть/снять аксессуары (гардероб) ─────────── */
function equipAccessories(req, res) {
const list = req.body && req.body.equipped;
@@ -376,4 +408,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, equipAccessories };
module.exports = { getPet, renamePet, petAction, updateColor, starCatch, getShop, buyBg, setBg, feedPet, equipAccessories, updatePattern };
@@ -0,0 +1,2 @@
-- Узор тела питомца (none/spots/stripes/gradient/galaxy). NULL = none.
ALTER TABLE users ADD COLUMN pet_pattern TEXT;
+1
View File
@@ -7,6 +7,7 @@ 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.patch('/pattern', authMiddleware, c.updatePattern);
router.post('/star', authMiddleware, c.starCatch);
router.get('/shop', authMiddleware, c.getShop);
router.post('/shop/buy', authMiddleware, c.buyBg);
+37 -1
View File
@@ -18,7 +18,7 @@
return `rgb(${r},${g},${b})`;
}
function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak = 0) {
function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak = 0, pattern = 'none') {
const col = PET_PALETTES[colorKey] || '#9B5DE5';
const dark = shadeColor(col, -45);
const light = shadeColor(col, 52);
@@ -143,6 +143,26 @@
/* ── Accessories (гардероб: строго по equipped-списку, без авто по уровню) ── */
let accSvg = '';
if (accessories.includes('party')) {
accSvg += `<path d="M55,1 L45,26 L65,26 Z" fill="${col}"/>
<path d="M55,1 L49,16 L61,16 Z" fill="${light}" opacity=".55"/>
<rect x="44" y="24" width="22" height="4" rx="2" fill="${dark}"/>
<circle cx="55" cy="1.5" r="3.5" fill="#F9C74F"/>`;
}
if (accessories.includes('sunglasses')) {
accSvg += `<rect x="28" y="46" width="22" height="13" rx="5" fill="#16181d"/>
<rect x="60" y="46" width="22" height="13" rx="5" fill="#16181d"/>
<rect x="49" y="50" width="12" height="3" rx="1.5" fill="#16181d"/>
<rect x="31" y="48" width="8" height="3" rx="1.5" fill="rgba(255,255,255,.25)"/>
<rect x="63" y="48" width="8" height="3" rx="1.5" fill="rgba(255,255,255,.25)"/>`;
}
if (accessories.includes('scarf')) {
accSvg += `<path d="M36,80 Q55,90 74,80 L74,86 Q55,96 36,86 Z" fill="${col}" stroke="${dark}" stroke-width="1"/>
<path d="M66,85 L74,99 L68,100 L62,87 Z" fill="${col}" stroke="${dark}" stroke-width="1"/>`;
}
if (accessories.includes('flower')) {
accSvg += `<g transform="translate(82,30)"><circle cx="0" cy="-5" r="3.2" fill="#FF8FB1"/><circle cx="5" cy="-1" r="3.2" fill="#FF8FB1"/><circle cx="3" cy="5" r="3.2" fill="#FF8FB1"/><circle cx="-3" cy="5" r="3.2" fill="#FF8FB1"/><circle cx="-5" cy="-1" r="3.2" fill="#FF8FB1"/><circle cx="0" cy="0" r="2.6" fill="#F9C74F"/></g>`;
}
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"/>
@@ -289,6 +309,17 @@
shimmer = `<ellipse cx="55" cy="58" rx="30" ry="35" fill="url(#${uid}sh)" opacity="0.18"/>`;
}
// Узор тела (клипуется по силуэту)
let patternSvg = '';
if (pattern && pattern !== 'none') {
let pin = '';
if (pattern === 'spots') pin = `<circle cx="38" cy="44" r="7" fill="${dark}" opacity=".18"/><circle cx="70" cy="40" r="6" fill="${dark}" opacity=".16"/><circle cx="60" cy="72" r="8" fill="${dark}" opacity=".18"/><circle cx="36" cy="74" r="6" fill="${dark}" opacity=".15"/><circle cx="76" cy="66" r="5" fill="${dark}" opacity=".15"/><circle cx="52" cy="34" r="5" fill="${light}" opacity=".3"/>`;
else if (pattern === 'stripes') pin = `<g transform="rotate(20 55 60)">${[-10,8,26,44,62,80,98].map(x => `<rect x="${x}" y="-10" width="9" height="140" fill="${dark}" opacity=".16"/>`).join('')}</g>`;
else if (pattern === 'galaxy') pin = `<ellipse cx="48" cy="52" rx="36" ry="42" fill="#1a0a3a" opacity=".42"/><circle cx="40" cy="44" r="1.4" fill="#fff"/><circle cx="66" cy="38" r="1.1" fill="#fff"/><circle cx="58" cy="64" r="1.5" fill="#fff"/><circle cx="36" cy="70" r="1" fill="#fff"/><circle cx="74" cy="60" r="1.2" fill="#fff"/><circle cx="52" cy="82" r="1" fill="#fff"/><circle cx="68" cy="74" r="1.3" fill="#cba6ff"/>`;
else if (pattern === 'gradient') pin = `<rect x="16" y="18" width="78" height="82" fill="url(#${uid}pg)"/>`;
patternSvg = `<clipPath id="${uid}clip"><path d="${bodyPath}"/></clipPath><g clip-path="url(#${uid}clip)">${pin}</g>`;
}
return `<svg viewBox="0 0 110 115" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="${uid}" cx="38%" cy="28%" r="70%">
@@ -304,6 +335,10 @@
<stop offset="0%" stop-color="#FFD700" stop-opacity="1"/>
<stop offset="100%" stop-color="#FFD700" stop-opacity="0"/>
</radialGradient>
<linearGradient id="${uid}pg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${light}" stop-opacity="0"/>
<stop offset="100%" stop-color="${dark}" stop-opacity="0.55"/>
</linearGradient>
</defs>
<ellipse cx="55" cy="111" rx="28" ry="4" fill="rgba(0,0,0,.18)"/>
${aura}
@@ -314,6 +349,7 @@
${antennae}
${paws}
<path d="${bodyPath}" fill="url(#${uid})"/>
${patternSvg}
${shimmer}
<ellipse cx="55" cy="70" rx="18" ry="12" fill="url(#${uid}b)"/>
${cheeks}${eyebrows}${eyeGroups}${nose}${mouth}${extras}
+177 -39
View File
@@ -286,6 +286,9 @@
.pet-scene.bg-forest { background:radial-gradient(ellipse at 50% 100%,rgba(56,217,90,.8) 0%,transparent 50%),radial-gradient(ellipse at 15% 60%,rgba(10,160,40,.5) 0%,transparent 40%),radial-gradient(ellipse at 85% 35%,rgba(80,200,60,.35) 0%,transparent 35%),linear-gradient(170deg,#010701,#041203,#082007) !important; }
.pet-scene.bg-aqua { background:radial-gradient(ellipse at 50% 100%,rgba(6,214,224,.8) 0%,transparent 50%),radial-gradient(ellipse at 80% 25%,rgba(6,170,220,.5) 0%,transparent 40%),radial-gradient(ellipse at 20% 50%,rgba(0,100,180,.4) 0%,transparent 35%),linear-gradient(170deg,#010810,#021422,#03203a) !important; }
.pet-scene.bg-sunset { background:radial-gradient(ellipse at 50% 95%,rgba(249,100,20,.9) 0%,transparent 50%),radial-gradient(ellipse at 30% 50%,rgba(249,160,20,.55) 0%,transparent 40%),radial-gradient(ellipse at 75% 30%,rgba(200,50,10,.45) 0%,transparent 35%),linear-gradient(170deg,#100201,#250602,#1e0404) !important; }
.pet-scene.bg-aurora { background:radial-gradient(ellipse at 30% 25%,rgba(56,217,140,.6) 0%,transparent 42%),radial-gradient(ellipse at 70% 30%,rgba(120,90,255,.55) 0%,transparent 44%),radial-gradient(ellipse at 50% 92%,rgba(6,214,180,.4) 0%,transparent 52%),linear-gradient(170deg,#02040f,#04122a,#0a0426) !important; }
.pet-scene.bg-candy { background:radial-gradient(ellipse at 30% 25%,rgba(255,170,210,.7) 0%,transparent 46%),radial-gradient(ellipse at 75% 70%,rgba(150,200,255,.6) 0%,transparent 46%),linear-gradient(170deg,#3a2540,#52324f,#3a2848) !important; }
.pet-scene.bg-sakura { background:radial-gradient(ellipse at 50% 100%,rgba(255,140,190,.7) 0%,transparent 52%),radial-gradient(ellipse at 25% 30%,rgba(255,180,210,.45) 0%,transparent 42%),linear-gradient(170deg,#1a0a14,#2e1226,#241026) !important; }
/* B2 — BgFX particle container + keyframes */
.pet-bgfx { position:absolute; inset:0; pointer-events:none; overflow:hidden; border-radius:50%; z-index:1; }
@@ -293,6 +296,7 @@
@keyframes bgFirefly { 0%,100%{opacity:.08;transform:translate(0,0)} 50%{opacity:1;transform:translate(var(--fx,3px),var(--fy,-4px))} }
@keyframes bgBubble { 0%{transform:translateY(0) scale(1);opacity:.75} 100%{transform:translateY(-140px) scale(1.5);opacity:0} }
@keyframes bgEmber { 0%{transform:translate(0,0);opacity:1} 60%{opacity:.65} 100%{transform:translate(var(--ex,5px),-130px);opacity:0} }
@keyframes bgPetal { 0%{transform:translate(0,0) rotate(0);opacity:.9} 100%{transform:translate(var(--px,12px),190px) rotate(220deg);opacity:0} }
/* B2 — Shop modal */
.pet-shop-overlay { position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:1000;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px); }
@@ -328,6 +332,30 @@
.pc-bg-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(118px,1fr)); gap:10px; }
.pet-evo-legend { font-size:.6rem; color:var(--text-3); margin-top:7px; line-height:1.45; text-align:center;
cursor:help; max-width:230px; }
/* Гардероб: панель действий + зоны */
.wr-bar { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
.wr-count { font-size:.74rem; color:var(--text-2); font-weight:700; }
.wr-count b { color:var(--violet); }
.wr-actions { display:flex; gap:7px; flex-wrap:wrap; }
.wr-btn { padding:5px 12px; border-radius:99px; border:1.5px solid var(--border-h); background:transparent;
color:var(--text-2); font:700 .72rem 'Manrope',sans-serif; cursor:pointer; transition:all .15s; }
.wr-btn:hover { border-color:var(--violet); color:var(--text); }
.wr-zone { margin-bottom:11px; }
.wr-zone-lbl { font-size:.6rem; font-weight:800; color:var(--text-3); text-transform:uppercase; letter-spacing:.06em; margin-bottom:6px; }
.wr-chips { display:flex; flex-wrap:wrap; gap:7px; }
/* Узор: свотчи */
.pc-pattern-grid { display:flex; flex-wrap:wrap; gap:8px; }
.pc-swatch { display:inline-flex; align-items:center; gap:7px; padding:6px 12px 6px 6px; border-radius:99px;
border:1.5px solid var(--border-h); background:transparent; color:var(--text-2);
font:700 .76rem 'Manrope',sans-serif; cursor:pointer; transition:all .15s; }
.pc-swatch:hover { border-color:var(--violet); color:var(--text); }
.pc-swatch.active { border-color:var(--violet); background:rgba(155,93,229,.18); color:#fff; }
.pc-swatch-dot { width:24px; height:24px; border-radius:50%; flex-shrink:0; border:1.5px solid rgba(255,255,255,.18); }
.pc-swatch-dot.pat-none { background:#9B5DE5; }
.pc-swatch-dot.pat-spots { background:radial-gradient(circle at 32% 32%,#5a2da0 2.2px,transparent 2.4px),radial-gradient(circle at 70% 66%,#5a2da0 2.2px,transparent 2.4px),#9B5DE5; }
.pc-swatch-dot.pat-stripes { background:repeating-linear-gradient(45deg,#9B5DE5 0 4px,#5a2da0 4px 7px); }
.pc-swatch-dot.pat-gradient{ background:linear-gradient(180deg,#c9a6ec,#5a2da0); }
.pc-swatch-dot.pat-galaxy { background:radial-gradient(circle at 62% 40%,#1a0a3a 55%,#9B5DE5); }
@media(max-width:768px) {
.pet-hero { grid-template-columns:1fr; }
@@ -483,9 +511,10 @@
<div class="pet-card pet-customize">
<div class="pet-card-title"><i data-lucide="palette" width="12" height="12"></i> Кастомизация</div>
<div class="pc-tabs">
<button class="pc-tab active" data-tab="acc" type="button">Аксессуары</button>
<button class="pc-tab" data-tab="color" type="button">Цвет</button>
<button class="pc-tab" data-tab="bg" type="button">Фон</button>
<button class="pc-tab active" data-tab="acc" type="button">Аксессуары</button>
<button class="pc-tab" data-tab="color" type="button">Цвет</button>
<button class="pc-tab" data-tab="pattern" type="button">Узор</button>
<button class="pc-tab" data-tab="bg" type="button">Фон</button>
</div>
<div class="pc-panel" id="pc-acc">
<div class="pc-hint">Нажми, чтобы надеть или снять. Заблокированные открываются за достижения. По одному предмету на зону (голова / лицо / шея / уши / акцент).</div>
@@ -495,6 +524,10 @@
<div class="pc-hint">Основной цвет питомца.</div>
<div class="pet-color-picker" id="color-picker"></div>
</div>
<div class="pc-panel" id="pc-pattern" style="display:none">
<div class="pc-hint">Узор на теле питомца.</div>
<div class="pc-pattern-grid" id="pc-pattern-grid"></div>
</div>
<div class="pc-panel" id="pc-bg" style="display:none">
<div class="pc-hint">Фон сцены. <span id="pc-bg-coins"></span></div>
<div class="pc-bg-grid" id="pc-bg-grid"><div style="color:var(--text-2);font-size:.8rem">Загрузка…</div></div>
@@ -762,6 +795,28 @@ function applyBgFX(bgId) {
d.style.cssText = `position:absolute;left:${rnd(8,88)}%;bottom:4%;width:${s}px;height:${s}px;border-radius:50%;background:${col};box-shadow:0 0 6px ${col};--ex:${ex}px;animation:bgEmber ${rnd(1.5,3)}s ${rnd(0,5)}s ease-out infinite`;
c.appendChild(d);
}
} else if (bgId === 'aurora') {
for (let i = 0; i < 20; i++) {
const d = document.createElement('div');
const s = rnd(0.8, 2.6);
d.style.cssText = `position:absolute;left:${rnd(3,95)}%;top:${rnd(3,72)}%;width:${s}px;height:${s}px;border-radius:50%;background:#aef7d8;box-shadow:0 0 4px #6ee7c0;animation:bgStarTwink ${rnd(1.2,3.5)}s ${rnd(0,4)}s ease-in-out infinite`;
c.appendChild(d);
}
} else if (bgId === 'candy') {
for (let i = 0; i < 11; i++) {
const d = document.createElement('div');
const s = rnd(5, 15);
const col = Math.random() > .5 ? 'rgba(255,150,200,.85)' : 'rgba(150,200,255,.85)';
d.style.cssText = `position:absolute;left:${rnd(8,88)}%;bottom:-16px;width:${s}px;height:${s}px;border-radius:50%;border:1.5px solid ${col};animation:bgBubble ${rnd(2.5,5)}s ${rnd(0,6)}s ease-out infinite`;
c.appendChild(d);
}
} else if (bgId === 'sakura') {
for (let i = 0; i < 14; i++) {
const d = document.createElement('div');
const s = rnd(4, 8);
d.style.cssText = `position:absolute;left:${rnd(2,94)}%;top:-12px;width:${s}px;height:${(s*.8).toFixed(1)}px;border-radius:60% 0 60% 0;background:rgba(255,160,200,.85);--px:${rndI(-18,18)}px;animation:bgPetal ${rnd(3,6)}s ${rnd(0,5)}s linear infinite`;
c.appendChild(d);
}
}
}
@@ -827,8 +882,11 @@ const BG_PREVIEWS = {
forest: 'radial-gradient(ellipse at 50% 100%,rgba(56,217,90,.8) 0%,transparent 55%),linear-gradient(155deg,#010701,#082007)',
aqua: 'radial-gradient(ellipse at 50% 100%,rgba(6,214,224,.8) 0%,transparent 55%),linear-gradient(155deg,#010810,#03203a)',
sunset: 'radial-gradient(ellipse at 50% 100%,rgba(249,100,20,.9) 0%,transparent 55%),linear-gradient(155deg,#100201,#1e0404)',
aurora: 'radial-gradient(ellipse at 35% 25%,rgba(56,217,140,.85) 0%,transparent 50%),radial-gradient(ellipse at 70% 35%,rgba(120,90,255,.7) 0%,transparent 50%),linear-gradient(155deg,#02040f,#0a0426)',
candy: 'radial-gradient(ellipse at 30% 25%,rgba(255,170,210,.9) 0%,transparent 55%),radial-gradient(ellipse at 75% 75%,rgba(150,200,255,.8) 0%,transparent 55%),linear-gradient(155deg,#3a2540,#3a2848)',
sakura: 'radial-gradient(ellipse at 50% 100%,rgba(255,140,190,.9) 0%,transparent 55%),linear-gradient(155deg,#1a0a14,#241026)',
};
const BG_NAMES = { default:'Стандарт', space:'Космос', forest:'Лес', aqua:'Океан', sunset:'Закат' };
const BG_NAMES = { default:'Стандарт', space:'Космос', forest:'Лес', aqua:'Океан', sunset:'Закат', aurora:'Сияние', candy:'Леденец', sakura:'Сакура' };
async function openPetShop() {
const data = await LS.api('/api/pet/shop').catch(() => null);
@@ -910,9 +968,10 @@ function setupCustomizeTabs() {
tabs.forEach(x => x.classList.remove('active'));
t.classList.add('active');
const which = t.dataset.tab;
document.getElementById('pc-acc').style.display = which === 'acc' ? '' : 'none';
document.getElementById('pc-color').style.display = which === 'color' ? '' : 'none';
document.getElementById('pc-bg').style.display = which === 'bg' ? '' : 'none';
document.getElementById('pc-acc').style.display = which === 'acc' ? '' : 'none';
document.getElementById('pc-color').style.display = which === 'color' ? '' : 'none';
document.getElementById('pc-pattern').style.display = which === 'pattern' ? '' : 'none';
document.getElementById('pc-bg').style.display = which === 'bg' ? '' : 'none';
if (which === 'bg') renderBgPicker();
}));
}
@@ -998,7 +1057,7 @@ function renderPet(d) {
// Pet SVG (B3: pass streak for rainbow collar)
const wrap = document.getElementById('pet-svg-wrap');
wrap.className = `pet-svg-wrap pet-float mood-${d.mood}`;
wrap.innerHTML = renderPetSVG(d.petLevel, d.mood, d.accessories, d.petColor || 'purple', d.streakCurrent || 0);
wrap.innerHTML = renderPetSVG(d.petLevel, d.mood, d.accessories, d.petColor || 'purple', d.streakCurrent || 0, d.petPattern || 'none');
// Scene mood class + weather + tod + B2 background
const scene = document.getElementById('pet-scene');
@@ -1048,8 +1107,9 @@ function renderPet(d) {
// Color picker
renderColorPicker(d.petColor || 'purple');
// Гардероб (интерактивный выбор аксессуаров)
// Гардероб (интерактивный выбор аксессуаров) + узор
renderWardrobe(d.wardrobe || []);
renderPatternPicker(d.patterns || [], d.petPattern || 'none');
// Stats
document.getElementById('stat-streak').textContent = d.streakCurrent + ' дн.';
@@ -1190,53 +1250,95 @@ async function selectColor(colorKey) {
if (!res?.ok) return;
_petData.petColor = colorKey;
document.getElementById('pet-svg-wrap').innerHTML =
renderPetSVG(_petData.petLevel, _petData.mood, _petData.accessories, colorKey);
renderPetSVG(_petData.petLevel, _petData.mood, _petData.accessories, colorKey, _petData.streakCurrent || 0, _petData.petPattern || 'none');
document.querySelectorAll('.pet-color-dot').forEach(d =>
d.classList.toggle('active', d.dataset.color === 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>';
const ZONE_LABELS = { head:'Голова', face:'Лицо', neck:'Шея', ears:'Уши', accent:'Акцент' };
function wearChip(it) {
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;';
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>`;
}
function renderWardrobe(items) {
const el = document.getElementById('pet-accessories');
if (!el) return;
el.style.cssText = 'display:flex;flex-wrap:wrap;gap:7px';
el.style.cssText = '';
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)));
const onCount = items.filter(i => i.equipped).length;
let html = `<div class="wr-bar"><span class="wr-count">Надето: <b>${onCount}</b></span>
<span class="wr-actions">
<button type="button" class="wr-btn" id="wr-random">Случайный образ</button>
<button type="button" class="wr-btn" id="wr-clear">Снять всё</button>
</span></div>`;
['head','face','neck','ears','accent'].forEach(z => {
const zi = items.filter(i => i.slot === z);
if (!zi.length) return;
html += `<div class="wr-zone"><div class="wr-zone-lbl">${ZONE_LABELS[z]||z}</div><div class="wr-chips">${zi.map(wearChip).join('')}</div></div>`;
});
el.innerHTML = html;
el.querySelectorAll('.pet-wear.tgl').forEach(ch => ch.addEventListener('click', () => toggleEquip(ch.dataset.id)));
document.getElementById('wr-clear')?.addEventListener('click', () => setEquipped([]));
document.getElementById('wr-random')?.addEventListener('click', randomLook);
}
async function toggleEquip(id) {
async function setEquipped(list) {
if (!_petData || !_petData.wardrobe) return;
const res = await LS.api('/api/pet/equip', { method:'PATCH', body: JSON.stringify({ equipped: list }) }).catch(() => null);
if (!res || !res.ok) return;
const final = res.equipped || list;
_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, _petData.petPattern || 'none');
renderWardrobe(_petData.wardrobe);
}
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 (item.equipped) eq = eq.filter(x => x !== id);
else { eq = eq.filter(x => slotOf[x] !== item.slot); eq.push(id); }
setEquipped(eq);
}
function randomLook() {
if (!_petData || !_petData.wardrobe) return;
const bySlot = {};
_petData.wardrobe.filter(w => !w.locked).forEach(w => { (bySlot[w.slot] = bySlot[w.slot] || []).push(w); });
const pick = [];
Object.values(bySlot).forEach(arr => { if (Math.random() < 0.7) pick.push(arr[Math.floor(Math.random()*arr.length)].id); });
setEquipped(pick);
}
/* ── Узор тела ── */
function renderPatternPicker(list, current) {
const el = document.getElementById('pc-pattern-grid');
if (!el) return;
el.innerHTML = (list || []).map(p => {
const on = p.id === current;
return `<button type="button" class="pc-swatch${on?' active':''}" data-pat="${p.id}"><span class="pc-swatch-dot pat-${p.id}"></span>${escHtml(p.name)}</button>`;
}).join('');
el.querySelectorAll('.pc-swatch').forEach(b => b.addEventListener('click', () => applyPattern(b.dataset.pat)));
}
async function applyPattern(id) {
if (!_petData) return;
const res = await LS.api('/api/pet/pattern', { method:'PATCH', body: JSON.stringify({ pattern: id }) }).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); });
_petData.petPattern = id;
document.getElementById('pet-svg-wrap').innerHTML =
renderPetSVG(_petData.petLevel, _petData.mood, final, _petData.petColor || 'purple', _petData.streakCurrent || 0);
renderWardrobe(_petData.wardrobe);
renderPetSVG(_petData.petLevel, _petData.mood, _petData.accessories, _petData.petColor || 'purple', _petData.streakCurrent || 0, id);
renderPatternPicker(_petData.patterns || [], id);
}
/* ── Petting ── */
@@ -1533,7 +1635,7 @@ function shadeColor(hex, pct) {
}
/* ── Pet SVG renderer ── */
function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak = 0) {
function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak = 0, pattern = 'none') {
const col = PET_PALETTES[colorKey] || '#9B5DE5';
const dark = shadeColor(col, -45);
const light = shadeColor(col, 52);
@@ -1658,6 +1760,26 @@ function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak
/* ── Accessories (гардероб: строго по equipped-списку, без авто по уровню) ── */
let accSvg = '';
if (accessories.includes('party')) {
accSvg += `<path d="M55,1 L45,26 L65,26 Z" fill="${col}"/>
<path d="M55,1 L49,16 L61,16 Z" fill="${light}" opacity=".55"/>
<rect x="44" y="24" width="22" height="4" rx="2" fill="${dark}"/>
<circle cx="55" cy="1.5" r="3.5" fill="#F9C74F"/>`;
}
if (accessories.includes('sunglasses')) {
accSvg += `<rect x="28" y="46" width="22" height="13" rx="5" fill="#16181d"/>
<rect x="60" y="46" width="22" height="13" rx="5" fill="#16181d"/>
<rect x="49" y="50" width="12" height="3" rx="1.5" fill="#16181d"/>
<rect x="31" y="48" width="8" height="3" rx="1.5" fill="rgba(255,255,255,.25)"/>
<rect x="63" y="48" width="8" height="3" rx="1.5" fill="rgba(255,255,255,.25)"/>`;
}
if (accessories.includes('scarf')) {
accSvg += `<path d="M36,80 Q55,90 74,80 L74,86 Q55,96 36,86 Z" fill="${col}" stroke="${dark}" stroke-width="1"/>
<path d="M66,85 L74,99 L68,100 L62,87 Z" fill="${col}" stroke="${dark}" stroke-width="1"/>`;
}
if (accessories.includes('flower')) {
accSvg += `<g transform="translate(82,30)"><circle cx="0" cy="-5" r="3.2" fill="#FF8FB1"/><circle cx="5" cy="-1" r="3.2" fill="#FF8FB1"/><circle cx="3" cy="5" r="3.2" fill="#FF8FB1"/><circle cx="-3" cy="5" r="3.2" fill="#FF8FB1"/><circle cx="-5" cy="-1" r="3.2" fill="#FF8FB1"/><circle cx="0" cy="0" r="2.6" fill="#F9C74F"/></g>`;
}
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"/>
@@ -1804,6 +1926,17 @@ function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak
shimmer = `<ellipse cx="55" cy="58" rx="30" ry="35" fill="url(#${uid}sh)" opacity="0.18"/>`;
}
// Узор тела (клипуется по силуэту)
let patternSvg = '';
if (pattern && pattern !== 'none') {
let pin = '';
if (pattern === 'spots') pin = `<circle cx="38" cy="44" r="7" fill="${dark}" opacity=".18"/><circle cx="70" cy="40" r="6" fill="${dark}" opacity=".16"/><circle cx="60" cy="72" r="8" fill="${dark}" opacity=".18"/><circle cx="36" cy="74" r="6" fill="${dark}" opacity=".15"/><circle cx="76" cy="66" r="5" fill="${dark}" opacity=".15"/><circle cx="52" cy="34" r="5" fill="${light}" opacity=".3"/>`;
else if (pattern === 'stripes') pin = `<g transform="rotate(20 55 60)">${[-10,8,26,44,62,80,98].map(x => `<rect x="${x}" y="-10" width="9" height="140" fill="${dark}" opacity=".16"/>`).join('')}</g>`;
else if (pattern === 'galaxy') pin = `<ellipse cx="48" cy="52" rx="36" ry="42" fill="#1a0a3a" opacity=".42"/><circle cx="40" cy="44" r="1.4" fill="#fff"/><circle cx="66" cy="38" r="1.1" fill="#fff"/><circle cx="58" cy="64" r="1.5" fill="#fff"/><circle cx="36" cy="70" r="1" fill="#fff"/><circle cx="74" cy="60" r="1.2" fill="#fff"/><circle cx="52" cy="82" r="1" fill="#fff"/><circle cx="68" cy="74" r="1.3" fill="#cba6ff"/>`;
else if (pattern === 'gradient') pin = `<rect x="16" y="18" width="78" height="82" fill="url(#${uid}pg)"/>`;
patternSvg = `<clipPath id="${uid}clip"><path d="${bodyPath}"/></clipPath><g clip-path="url(#${uid}clip)">${pin}</g>`;
}
return `<svg viewBox="0 0 110 115" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="${uid}" cx="38%" cy="28%" r="70%">
@@ -1819,6 +1952,10 @@ function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak
<stop offset="0%" stop-color="#FFD700" stop-opacity="1"/>
<stop offset="100%" stop-color="#FFD700" stop-opacity="0"/>
</radialGradient>
<linearGradient id="${uid}pg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${light}" stop-opacity="0"/>
<stop offset="100%" stop-color="${dark}" stop-opacity="0.55"/>
</linearGradient>
</defs>
<ellipse cx="55" cy="111" rx="28" ry="4" fill="rgba(0,0,0,.18)"/>
${aura}
@@ -1829,6 +1966,7 @@ function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak
${antennae}
${paws}
<path d="${bodyPath}" fill="url(#${uid})"/>
${patternSvg}
${shimmer}
<ellipse cx="55" cy="70" rx="18" ry="12" fill="url(#${uid}b)"/>
${cheeks}${eyebrows}${eyeGroups}${nose}${mouth}${extras}