feat(pet): единый UI кастомизации (аксессуары/цвет/фон) + пояснение эволюции

Вынес кастомизацию из тесного блока сцены в отдельную карточку с вкладками
«Аксессуары · Цвет · Фон». Фон теперь выбирается инлайн (сетка превью с
ценой/статусом, покупка/выбор за монеты) вместо незаметной кнопки-модалки.
Добавил легенду эволюции: облик (уши/антенны/крылья/аура…) растёт с XP по
уровням — объясняет «откуда крылья». Рендереры цвета/гардероба не тронуты
(те же id), бэкенд без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-05 13:44:09 +03:00
parent 7bf1da94e4
commit 152291aec8
+92 -12
View File
@@ -315,6 +315,20 @@
/* B3 — Rainbow collar keyframe */
@keyframes rbRot { to { transform:rotate(360deg); } }
/* ── Customize panel (аксессуары / цвет / фон) ── */
.pet-customize { margin-bottom:18px; }
.pc-tabs { display:flex; gap:6px; margin:4px 0 14px; flex-wrap:wrap; }
.pc-tab { padding:7px 16px; border-radius:99px; border:1.5px solid var(--border-h); background:transparent;
color:var(--text-2); font:700 .78rem 'Manrope',sans-serif; cursor:pointer; transition:all .15s; }
.pc-tab:hover { border-color:var(--violet); color:var(--text); }
.pc-tab.active { background:var(--violet); border-color:var(--violet); color:#fff; }
.pc-hint { font-size:.72rem; color:var(--text-3); margin-bottom:11px; line-height:1.5; }
.pc-panel .pet-color-picker { display:flex; gap:9px; flex-wrap:wrap; }
.pc-panel .pet-color-dot { width:30px; height:30px; }
.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; }
@media(max-width:768px) {
.pet-hero { grid-template-columns:1fr; }
.pet-bottom { grid-template-columns:1fr; }
@@ -385,20 +399,9 @@
<div class="pet-evo-lbl">Эволюция</div>
<div class="pet-evo-name" id="pet-evo-name"></div>
<div class="pet-lvl-dots" id="pet-lvl-dots"></div>
<div class="pet-evo-legend" title="Облик растёт сам с уровнем (за XP): Ур.2 — уши, Ур.3 — антенны, Ур.4 — крылья и аура, Ур.5 — корона-сияние и орбиты, Ур.6 — вторая аура, Ур.7 — нимб и вторые крылья, Ур.8 — золотое сияние.">облик растёт с XP — наведи, чтобы узнать, что добавляется на каждом уровне</div>
</div>
<!-- Color picker + shop -->
<div class="pet-color-row">
<div class="pet-color-lbl">Цвет</div>
<div class="pet-color-picker" id="color-picker"></div>
<button class="pet-btn" onclick="openPetShop()" style="font-size:.62rem;padding:3px 9px;margin-left:2px;display:inline-flex;align-items:center;gap:4px" title="Фоны сцены">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"/><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"/><path d="M2 7h20"/><path d="M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7"/></svg>
Фоны
</button>
</div>
<div class="pet-accessories" id="pet-accessories"></div>
<button class="pet-action-btn" id="btn-pet" onclick="petThePet()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v0"/><path d="M14 10V4a2 2 0 0 0-4 0v2"/><path d="M10 10.5V6a2 2 0 0 0-4 0v8"/><path d="M6 14v0a2 2 0 0 0-2-2H2v5l6.5 6.5a2 2 0 0 0 2.8 0l3.7-3.7a2 2 0 0 0 0-2.8L18 18"/></svg>
Погладить
@@ -476,6 +479,28 @@
</div>
</div>
<!-- ── Кастомизация ── -->
<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>
</div>
<div class="pc-panel" id="pc-acc">
<div class="pc-hint">Нажми, чтобы надеть или снять. Заблокированные открываются за достижения. По одному предмету на зону (голова / лицо / шея / уши / акцент).</div>
<div class="pet-accessories" id="pet-accessories"></div>
</div>
<div class="pc-panel" id="pc-color" style="display:none">
<div class="pc-hint">Основной цвет питомца.</div>
<div class="pet-color-picker" id="color-picker"></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>
</div>
</div>
<!-- ── Bottom ── -->
<div class="pet-bottom">
@@ -632,6 +657,7 @@ let _petCooldownTimer = null;
applyTimeOfDay();
scheduleNextStar();
setupCustomizeTabs();
loadPet();
})();
@@ -877,6 +903,60 @@ async function selectBg(id, price, owned, card) {
}
}
/* ── Кастомизация: вкладки ── */
function setupCustomizeTabs() {
const tabs = document.querySelectorAll('.pc-tab');
tabs.forEach(t => t.addEventListener('click', () => {
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';
if (which === 'bg') renderBgPicker();
}));
}
/* ── Фоны (инлайн-выбор во вкладке) ── */
async function renderBgPicker() {
const grid = document.getElementById('pc-bg-grid');
if (!grid) return;
const data = await LS.api('/api/pet/shop').catch(() => null);
if (!data) { grid.innerHTML = '<div style="color:var(--text-2);font-size:.8rem">Не удалось загрузить</div>'; return; }
const coinsEl = document.getElementById('pc-bg-coins');
if (coinsEl) coinsEl.innerHTML = `Монет: <b style="color:#F9C74F">${data.coins}</b>`;
const items = [{ id: 'default', name: 'Стандарт', price: 0, owned: true }, ...data.items];
grid.innerHTML = items.map(item => {
const isActive = data.currentBg === item.id;
const isOwned = item.owned || item.price === 0;
const status = isActive ? 'Активен' : isOwned ? 'Выбрать' : item.price + ' монет';
return `<div class="pet-bg-card${isActive ? ' active' : ''}" data-id="${item.id}" data-price="${item.price || 0}" data-owned="${isOwned ? 1 : 0}">
<div class="pet-bg-preview" style="background:${BG_PREVIEWS[item.id] || BG_PREVIEWS.default}"></div>
<div class="pet-bg-info"><div class="pet-bg-name">${escHtml(item.name)}</div>
<div class="pet-bg-status">${status}</div></div>
</div>`;
}).join('');
grid.querySelectorAll('.pet-bg-card').forEach(c =>
c.addEventListener('click', () => selectBgInline(c.dataset.id, +c.dataset.price, c.dataset.owned === '1')));
}
async function selectBgInline(id, price, owned) {
const endpoint = owned ? '/api/pet/bg' : '/api/pet/shop/buy';
const res = await LS.api(endpoint, { method: owned ? 'PATCH' : 'POST', body: JSON.stringify({ id }) }).catch(e => {
if (e?.data?.error === 'insufficient_coins') LS.toast?.('Недостаточно монет', 'error');
return null;
});
if (!res?.ok) return;
const scene = document.getElementById('pet-scene');
scene.className = scene.className.replace(/\bbg-\S+/g, '') + (id !== 'default' ? ` bg-${id}` : '');
if (_petData) _petData.petBg = id;
applyBgFX(id);
if (res.coins !== undefined) {
document.getElementById('stat-coins').textContent = res.coins;
if (_petData) _petData.coins = res.coins;
}
renderBgPicker();
}
/* ── Eye tracking + A2 Parallax ── */
function onStageMouse(e) {
const wrap = document.getElementById('pet-svg-wrap');