feat(dashboard): hero-карточки главной — чтение, лаборатория дня, питомец

Пересборка верхней зоны дашборда по скриншоту (редизайн был утерян):
- 3 hero-карточки вместо action-cards: «Начать чтение» (продолжение
  курса через /api/courses/continue), «Лаборатория дня» (детерминир.
  выбор по дню + SVG-превью из lab-previews.js), «Питомец» (синхрон
  с модулем /pet через /api/pet + единый PetSprite.render).
- Подключены восстановленные ассеты pet-sprite.js и lab-previews.js.
- Убран weak-topics из hero; питомец показывает уровень/XP/стрик/
  цель дня/настроение, синхронно со страницей /pet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-31 11:11:20 +03:00
parent ca5dc3a4f3
commit 667054fa58
3 changed files with 504 additions and 42 deletions
+93 -42
View File
@@ -1440,23 +1440,64 @@
<div class="action-banner" id="action-banner" style="display:none">
<!-- populated by JS -->
</div>
<div class="action-cards" id="action-cards" style="display:none">
<a class="ac-card" id="ac-continue" style="display:none" href="#">
<span class="ac-emoji"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2V3zm20 0h-6a4 4 0 00-4 4v14a3 3 0 013-3h7V3z"/></svg></span>
<div class="ac-body">
<div class="ac-title" id="ac-cont-title"></div>
<div class="ac-sub" id="ac-cont-sub"></div>
<div class="hero-row" id="hero-row" style="display:none">
<!-- Card 1 — Continue / start reading -->
<a class="hero-card hc-read" id="hc-read" href="/textbooks">
<span class="hc-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z"/><path d="M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg>
<span id="hc-read-tag">Начать чтение</span>
</span>
<div class="hc-h" id="hc-read-title">Учебники</div>
<div class="hc-p" id="hc-read-sub">Открой учебник и продолжи курс с того места, где остановился.</div>
<div class="hc-progress" id="hc-read-prog-wrap" style="display:none"><i id="hc-read-prog" style="width:0%"></i></div>
<div class="hc-foot">
<span class="hc-meta" id="hc-read-meta">новый учебник</span>
<span class="hc-pct" id="hc-read-pct" style="display:none">0%</span>
<span class="hc-btn">Начать <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</div>
<span class="ac-badge" id="ac-cont-pct"></span>
</a>
<a class="ac-card" id="ac-weak" style="display:none" href="#">
<span class="ac-emoji"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></span>
<div class="ac-body">
<div class="ac-title" id="ac-weak-title"></div>
<div class="ac-sub" id="ac-weak-sub"></div>
<!-- Card 2 — Lab of the day -->
<a class="hero-card hc-lab" id="hc-lab" href="/lab">
<div class="hc-bg" id="hc-lab-bg"></div>
<span class="hc-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3"/><path d="M7.5 15h9"/></svg>
Лаборатория дня
</span>
<div class="hc-h" id="hc-lab-title">Газовые законы</div>
<div class="hc-p" id="hc-lab-sub">Давление, объём и температура газа.</div>
<div class="hc-chips" id="hc-lab-chips">
<span class="hc-chip subj" id="hc-lab-subj">Физика</span>
<span class="hc-chip" id="hc-lab-time">~10 мин</span>
<span class="hc-chip" id="hc-lab-level">средне</span>
</div>
<div class="hc-foot">
<span class="hc-meta" id="hc-lab-meta">Освой: уравнение состояния газа</span>
<span class="hc-btn">Открыть <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</div>
<span class="ac-badge" id="ac-weak-pct" style="color:#E0335E"></span>
</a>
<!-- Card 3 — Pet (synced with /pet module) -->
<a class="hero-card hc-pet" id="hc-pet" href="/pet">
<span class="hc-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="11" r="2"/><circle cx="9.5" cy="6.5" r="2"/><circle cx="14.5" cy="6.5" r="2"/><circle cx="18.5" cy="11" r="2"/><path d="M8.2 16.4C8.2 14.3 9.9 13 12 13s3.8 1.3 3.8 3.4c0 1.7-1.3 2.8-2.6 3.2-.8.2-1.6.2-2.4 0-1.3-.4-2.6-1.5-2.6-3.2z"/></svg>
Питомец
</span>
<div class="hc-pet-top">
<div class="hc-pet-name" id="hc-pet-name">Квантик</div>
<div class="hc-pet-art" id="hc-pet-art"></div>
</div>
<div class="hc-xp-row"><span>Ур. <b id="hc-pet-lvl">1</b></span><span><b id="hc-pet-xp">0</b> / <span id="hc-pet-xpmax">500</span> XP</span></div>
<div class="hc-progress"><i id="hc-pet-prog" style="width:0%"></i></div>
<div class="hc-pet-chips">
<div class="hc-pchip"><b id="hc-pet-streak">0</b><span>стрик</span></div>
<div class="hc-pchip"><b id="hc-pet-goal">0/2</b><span>цель дня</span></div>
<div class="hc-pchip"><b id="hc-pet-mood">бодр</b><span>настроение</span></div>
</div>
<span class="hc-btn">Ухаживать <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</a>
</div>
</div>
@@ -3177,41 +3218,49 @@
}
}
/* ══ WIDGET: Continue reading (action card) ══════════════════════ */
/* ══ HERO: Reading card (continue or start) ══════════════════════ */
async function loadContinueWidget() {
const card = document.getElementById('ac-continue');
const cardsWrap = document.getElementById('action-cards');
const card = document.getElementById('hc-read');
if (!card) return;
try {
const data = await LS.api('/api/courses/continue');
if (!data || !data.courseId) { card.style.display = 'none'; return; }
if (!data || !data.courseId) return; // оставляем дефолт «Начать чтение → Учебники»
const pct = data.lessonCount > 0 ? Math.round((data.doneCount || 0) / data.lessonCount * 100) : 0;
const href = `/lesson?course=${data.courseId}&lesson=${data.lessonId}`;
card.href = href;
card.style.display = '';
if (cardsWrap) cardsWrap.style.display = '';
document.getElementById('ac-cont-title').textContent = data.courseTitle || 'Продолжить';
document.getElementById('ac-cont-sub').textContent = `${data.lessonTitle || ''} · ${data.doneCount || 0}/${data.lessonCount || 0} уроков`;
document.getElementById('ac-cont-pct').textContent = pct + '%';
} catch { card.style.display = 'none'; }
card.href = `/lesson?course=${data.courseId}&lesson=${data.lessonId}`;
document.getElementById('hc-read-tag').textContent = 'Продолжить чтение';
document.getElementById('hc-read-title').textContent = data.courseTitle || 'Продолжить';
document.getElementById('hc-read-sub').textContent = data.lessonTitle || '';
document.getElementById('hc-read-meta').textContent = `${data.doneCount || 0} / ${data.lessonCount || 0} уроков`;
const progWrap = document.getElementById('hc-read-prog-wrap');
if (progWrap) { progWrap.style.display = ''; document.getElementById('hc-read-prog').style.width = pct + '%'; }
const pctEl = document.getElementById('hc-read-pct');
if (pctEl) { pctEl.style.display = ''; pctEl.textContent = pct + '%'; }
} catch { /* нет курса в процессе — карточка остаётся в дефолте */ }
}
/* ══ WIDGET: Weak topics (action card) ════════════════════════════ */
async function loadWeakWidget() {
const card = document.getElementById('ac-weak');
const cardsWrap = document.getElementById('action-cards');
/* ══ HERO: Lab of the day (deterministic daily pick) ═════════════ */
const LAB_OF_DAY = [
{ key:'isoprocess', href:'/lab?sim=molphys', title:'Газовые законы', sub:'Давление, объём и температура газа.', subj:'Физика', time:'~10 мин', level:'средне', goal:'уравнение состояния газа' },
{ key:'opticsbench', href:'/lab?sim=opticsbench', title:'Оптическая скамья', sub:'Собери систему линз и проследи ход лучей.', subj:'Физика', time:'~12 мин', level:'средне', goal:'построение изображения в линзе' },
{ key:'circuit', href:'/lab?sim=circuit', title:'Электрическая цепь', sub:'Закон Ома: ток, напряжение и сопротивление.', subj:'Физика', time:'~8 мин', level:'легко', goal:'расчёт цепи по закону Ома' },
{ key:'pendulum', href:'/lab?sim=pendulum', title:'Математический маятник', sub:'Период колебаний и зависимость от длины.', subj:'Физика', time:'~9 мин', level:'легко', goal:'формула периода маятника' },
{ key:'waves', href:'/lab?sim=waves', title:'Волны и колебания', sub:'Длина волны, частота и стоячие волны.', subj:'Физика', time:'~11 мин', level:'средне', goal:'связь v = λf' },
{ key:'stereo', href:'/lab?sim=stereo', title:'Стереометрия 3D', sub:'Сечения и объёмы пространственных фигур.', subj:'Геометрия', time:'~10 мин', level:'сложно', goal:'построение сечений' },
];
function loadLabOfDay() {
const card = document.getElementById('hc-lab');
if (!card) return;
try {
const topics = await LS.getWeakTopics();
if (!topics.length) { card.style.display = 'none'; return; }
const t = topics[0];
card.href = `/test-run?subject=${t.subject_slug}&mode=exam&count=15&topic=${t.topic_id}`;
card.style.display = '';
if (cardsWrap) cardsWrap.style.display = '';
document.getElementById('ac-weak-title').textContent = t.topic;
document.getElementById('ac-weak-sub').textContent = `${t.subject_name} · ${t.wrong} из ${t.total} неверно`;
document.getElementById('ac-weak-pct').textContent = t.error_pct + '%';
} catch { card.style.display = 'none'; }
const dayIdx = Math.floor(Date.now() / 86400000) % LAB_OF_DAY.length;
const lab = LAB_OF_DAY[dayIdx];
card.href = lab.href;
const bg = document.getElementById('hc-lab-bg');
if (bg && window.LabPreviews && LabPreviews[lab.key]) bg.innerHTML = LabPreviews[lab.key];
document.getElementById('hc-lab-title').textContent = lab.title;
document.getElementById('hc-lab-sub').textContent = lab.sub;
document.getElementById('hc-lab-subj').textContent = lab.subj;
document.getElementById('hc-lab-time').textContent = lab.time;
document.getElementById('hc-lab-level').textContent = lab.level;
document.getElementById('hc-lab-meta').textContent = 'Освой: ' + lab.goal;
}
/* ══ ACTIVITY: data structure ══════════════════════════════════════ */
@@ -3999,9 +4048,11 @@
loadSubjProgressWidget(rows);
renderStreakCalendar(rows);
} catch {}
const heroRow = document.getElementById('hero-row');
if (heroRow) heroRow.style.display = '';
loadContinueWidget();
loadWeakWidget();
loadTheoryWidget();
loadLabOfDay();
loadPetHero();
loadFlashcardWidget();
}
+93
View File
@@ -0,0 +1,93 @@
'use strict';
/*
* LabPreviews — реальные SVG-превью симуляций (зеркало P_* из labs/lab-glue.js)
* для карточки «Лаборатория дня» на дашборде. Тёмные тайлы 270×140.
* Источник истины — lab-glue.js; здесь лёгкая копия, чтобы не грузить весь
* движок лаборатории на дашборде.
*/
(function () {
function _grid(fg='rgba(255,255,255,0.06)') {
return `<g stroke="${fg}" stroke-width="1">
<line x1="45" y1="0" x2="45" y2="140"/><line x1="90" y1="0" x2="90" y2="140"/>
<line x1="135" y1="0" x2="135" y2="140"/><line x1="180" y1="0" x2="180" y2="140"/>
<line x1="225" y1="0" x2="225" y2="140"/>
<line x1="0" y1="35" x2="270" y2="35"/><line x1="0" y1="70" x2="270" y2="70"/>
<line x1="0" y1="105" x2="270" y2="105"/>
</g>`;
}
function _svg(body) {
return `<svg class="sim-preview" viewBox="0 0 270 140" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">
<rect width="270" height="140" fill="#0D0D1A"/>${body}</svg>`;
}
const P_LENS = _svg(`${_grid('rgba(255,255,255,0.04)')}
<line x1="10" y1="70" x2="260" y2="70" stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
<path d="M 135,20 Q 155,70 135,120 Q 115,70 135,20" fill="rgba(6,214,224,0.12)" stroke="#06D6E0" stroke-width="2"/>
<line x1="30" y1="45" x2="135" y2="45" stroke="#9B5DE5" stroke-width="1.8"/>
<line x1="135" y1="45" x2="230" y2="90" stroke="#9B5DE5" stroke-width="1.8"/>
<line x1="30" y1="70" x2="230" y2="70" stroke="#06D6E0" stroke-width="1.5" stroke-dasharray="3,3" opacity="0.5"/>
<line x1="30" y1="95" x2="135" y2="95" stroke="#F15BB5" stroke-width="1.8"/>
<line x1="135" y1="95" x2="230" y2="55" stroke="#F15BB5" stroke-width="1.8"/>
<circle cx="30" cy="70" r="5" fill="#9B5DE5" opacity="0.7"/>
<line x1="30" y1="40" x2="30" y2="100" stroke="rgba(255,255,255,0.4)" stroke-width="1.5"/>`);
const P_CIRCUIT = _svg(`${_grid('rgba(255,255,255,0.04)')}
<rect x="30" y="25" width="210" height="90" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="1.5" rx="4"/>
<line x1="30" y1="70" x2="70" y2="70" stroke="#06D6E0" stroke-width="2"/>
<rect x="70" y="58" width="36" height="24" fill="rgba(6,214,224,0.15)" stroke="#06D6E0" stroke-width="1.8" rx="3"/>
<text x="88" y="74" font-size="10" fill="#06D6E0" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">R₁</text>
<line x1="106" y1="70" x2="130" y2="70" stroke="#06D6E0" stroke-width="2"/>
<rect x="130" y="58" width="36" height="24" fill="rgba(6,214,224,0.15)" stroke="#06D6E0" stroke-width="1.8" rx="3"/>
<text x="148" y="74" font-size="10" fill="#06D6E0" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">R₂</text>
<line x1="166" y1="70" x2="190" y2="70" stroke="#06D6E0" stroke-width="2"/>
<rect x="190" y="56" width="18" height="28" fill="rgba(241,91,181,0.15)" stroke="#F15BB5" stroke-width="1.8" rx="3"/>
<line x1="208" y1="70" x2="240" y2="70" stroke="#06D6E0" stroke-width="2"/>
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">I = U/R</text>`);
const P_PENDULUM = _svg(`${_grid('rgba(255,255,255,0.04)')}
<line x1="135" y1="15" x2="165" y2="95" stroke="rgba(255,255,255,0.5)" stroke-width="2"/>
<circle cx="165" cy="100" r="12" fill="rgba(6,214,224,0.25)" stroke="#06D6E0" stroke-width="2"/>
<line x1="135" y1="15" x2="95" y2="95" stroke="rgba(255,255,255,0.2)" stroke-width="1.5" stroke-dasharray="4,3"/>
<circle cx="95" cy="100" r="12" fill="none" stroke="rgba(6,214,224,0.3)" stroke-width="1.5" stroke-dasharray="3,3"/>
<path d="M 110,60 A 55,55 0 0 1 160,60" fill="none" stroke="rgba(6,214,224,0.4)" stroke-width="1.2" stroke-dasharray="3,3"/>
<circle cx="135" cy="15" r="4" fill="rgba(255,255,255,0.5)"/>
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">T = 2π√(l/g)</text>`);
const P_WAVES = _svg(`${_grid()}
<line x1="0" y1="70" x2="270" y2="70" stroke="rgba(255,255,255,0.13)" stroke-width="1" stroke-dasharray="4,3"/>
<path d="M 0,70 C 17,26 33,26 67,70 C 101,114 117,114 135,70 C 153,26 169,26 202,70 C 236,114 252,114 270,70"
stroke="#9B5DE5" stroke-width="2" fill="none" opacity="0.7"/>
<path d="M 0,70 C 22,18 44,18 90,70 C 136,122 158,122 180,70 C 202,18 224,18 270,70"
stroke="#06D6E0" stroke-width="1.5" fill="none" opacity="0.5"/>
<path d="M 0,70 C 12,10 28,8 50,40 C 72,72 88,118 112,85 C 136,52 155,18 180,50 C 205,82 240,108 270,70"
stroke="#F15BB5" stroke-width="2.5" fill="none" opacity="0.9"/>
<text x="135" y="132" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">v = \u03bbf \u00b7 y = A sin(\u03c9t \u2212 kx) \u00b7 \u0441\u0442\u043e\u044f\u0447\u0438\u0435 \u0432\u043e\u043b\u043d\u044b</text>`);
const P_ISOPROCESS = _svg(`${_grid('rgba(255,255,255,0.04)')}
<line x1="30" y1="10" x2="30" y2="125" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"/>
<line x1="30" y1="125" x2="265" y2="125" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"/>
<path d="M 50,20 Q 140,60 240,110" fill="none" stroke="#EF476F" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
<path d="M 50,20 Q 130,80 230,118" fill="none" stroke="#FFD166" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
<line x1="50" y1="20" x2="50" y2="118" stroke="#06D6E0" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
<line x1="50" y1="20" x2="230" y2="20" stroke="#7BF5A4" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
<path d="M 50,20 Q 120,55 220,108" fill="none" stroke="#EF476F" stroke-width="2.5"/>
<circle cx="50" cy="20" r="5" fill="#9B5DE5"/>
<circle cx="220" cy="108" r="5" fill="#EF476F"/>
<text x="240" y="113" font-size="9" fill="#EF476F" font-family="Manrope,sans-serif">2</text>
<text x="40" y="16" font-size="9" fill="#9B5DE5" font-family="Manrope,sans-serif">1</text>
<text x="255" y="128" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">V</text>
<text x="18" y="12" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">P</text>`);
const P_3D = _svg(`${_grid('rgba(255,255,255,0.04)')}
<polygon points="135,20 210,58 210,115 135,77" fill="rgba(155,93,229,0.15)" stroke="#9B5DE5" stroke-width="1.8"/>
<polygon points="135,20 60,58 60,115 135,77" fill="rgba(155,93,229,0.08)" stroke="#9B5DE5" stroke-width="1.8"/>
<polygon points="60,58 135,20 210,58 135,96" fill="rgba(155,93,229,0.22)" stroke="#9B5DE5" stroke-width="1.8"/>
<line x1="135" y1="77" x2="135" y2="96" stroke="#9B5DE5" stroke-width="1.8"/>
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">V = a³</text>`);
window.LabPreviews = {
opticsbench: P_LENS, circuit: P_CIRCUIT, pendulum: P_PENDULUM,
waves: P_WAVES, isoprocess: P_ISOPROCESS, stereo: P_3D,
};
})();
+318
View File
@@ -0,0 +1,318 @@
'use strict';
/*
* PetSprite — единый источник модели питомца для /pet и /dashboard.
* Канонические PET_PALETTES + shadeColor + renderPetSVG. pet.html и
* dashboard.html используют window.PetSprite.render(...) — без дублей.
*/
(function () {
const PET_PALETTES = {
purple:'#9B5DE5', cyan:'#06D6E0', gold:'#F9C74F',
red:'#F94144', green:'#38D95A', blue:'#4A90D9',
};
function shadeColor(hex, pct) {
const n = parseInt(hex.slice(1), 16);
const r = Math.min(255,Math.max(0,(n>>16)+pct));
const g = Math.min(255,Math.max(0,((n>>8)&0xff)+pct));
const b = Math.min(255,Math.max(0,(n&0xff)+pct));
return `rgb(${r},${g},${b})`;
}
function renderPetSVG(level, mood, accessories = [], colorKey = 'purple', streak = 0) {
const col = PET_PALETTES[colorKey] || '#9B5DE5';
const dark = shadeColor(col, -45);
const light = shadeColor(col, 52);
const uid = `pg${level}${mood[0]}${colorKey[0]}`;
const bodyPath = 'M55,22 C70,22 86,37 87,56 C89,75 78,94 55,97 C32,94 21,75 23,56 C24,37 40,22 55,22 Z';
const eyeY = 52, eyeX1 = 40, eyeX2 = 70;
/* ── Eyebrows ── */
let eyebrows = '';
if (mood !== 'sleeping') {
if (mood === 'ecstatic') {
eyebrows = `<path d="M33,46 C37,42 43,42 47,46" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<path d="M63,46 C67,42 73,42 77,46" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"/>`;
} else if (mood === 'sad' || mood === 'hungry') {
eyebrows = `<path d="M33,47 C37,51 43,51 47,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M63,47 C67,51 73,51 77,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>`;
} else {
eyebrows = `<path d="M33,47 Q40,45 47,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M63,47 Q70,45 77,47" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"/>`;
}
}
/* ── Eyes ── */
let eyeGroups = '', cheeks = '', extras = '';
if (mood === 'sleeping') {
eyeGroups = `<path d="M${eyeX1-8},${eyeY} Q${eyeX1},${eyeY-8} ${eyeX1+8},${eyeY}" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<path d="M${eyeX2-8},${eyeY} Q${eyeX2},${eyeY-8} ${eyeX2+8},${eyeY}" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
} else {
const ry1 = mood === 'ecstatic' ? 13 : mood === 'happy' ? 12 : 10.5;
const prx = mood === 'ecstatic' ? 7 : mood === 'happy' ? 6.5 : 5.5;
const pry = mood === 'ecstatic' ? 8 : mood === 'happy' ? 7.5 : 6.5;
const pOff = (mood === 'sad' || mood === 'hungry') ? 3 : 2;
eyeGroups = `
<g class="pet-eye-blink" style="transform-box:fill-box;transform-origin:center">
<ellipse cx="${eyeX1}" cy="${eyeY}" rx="10" ry="${ry1}" fill="white"/>
<ellipse class="pet-pupil" cx="${eyeX1+1}" cy="${eyeY+pOff}" rx="${prx}" ry="${pry}" fill="#111"/>
<circle cx="${eyeX1-3}" cy="${eyeY-3}" r="2" fill="white" opacity=".9"/>
</g>
<g class="pet-eye-blink2" style="transform-box:fill-box;transform-origin:center">
<ellipse cx="${eyeX2}" cy="${eyeY}" rx="10" ry="${ry1}" fill="white"/>
<ellipse class="pet-pupil" cx="${eyeX2+1}" cy="${eyeY+pOff}" rx="${prx}" ry="${pry}" fill="#111"/>
<circle cx="${eyeX2-3}" cy="${eyeY-3}" r="2" fill="white" opacity=".9"/>
</g>`;
}
/* ── Nose ── */
const nose = mood !== 'sleeping'
? `<ellipse cx="55" cy="62" rx="3.5" ry="2.5" fill="${dark}" opacity=".45"/>` : '';
/* ── Mouth + cheeks ── */
let mouth = '';
if (mood === 'sleeping') {
mouth = `<path d="M47,68 Q55,72 63,68" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
} else if (mood === 'ecstatic') {
mouth = `<path d="M43,67 Q55,82 67,67" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
cheeks = `<ellipse cx="24" cy="62" rx="9" ry="6" fill="${col}" opacity=".4"/>
<ellipse cx="86" cy="62" rx="9" ry="6" fill="${col}" opacity=".4"/>`;
} else if (mood === 'happy') {
mouth = `<path d="M44,68 Q55,80 66,68" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
cheeks = `<ellipse cx="25" cy="62" rx="8" ry="5.5" fill="${col}" opacity=".32"/>
<ellipse cx="85" cy="62" rx="8" ry="5.5" fill="${col}" opacity=".32"/>`;
} else if (mood === 'sad' || mood === 'hungry') {
mouth = `<path d="M44,72 Q55,64 66,72" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
if (mood === 'hungry')
extras = `<ellipse cx="78" cy="59" rx="2.5" ry="3.5" fill="rgba(130,195,255,.8)"/>`;
} else {
mouth = `<path d="M44,68 Q55,74 66,68" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>`;
}
/* ── Animated tail (all levels) ── */
const tail = `<g>
<path d="M68,91 C78,88 92,86 90,101 C88,110 76,110 72,103 C68,97 66,95 68,91 Z" fill="${col}" stroke="${dark}" stroke-width="1.2"/>
<animateTransform attributeName="transform" type="rotate" values="-7 68 91; 7 68 91; -7 68 91" dur="0.75s" repeatCount="indefinite"/>
</g>`;
/* ── Ears (level 2+) ── */
let ears = '';
if (level >= 2) {
ears = `<ellipse cx="32" cy="34" rx="9" ry="12" fill="${col}" transform="rotate(-15 32 34)"/>
<ellipse cx="32" cy="35" rx="6" ry="8" fill="${light}" opacity=".4" transform="rotate(-15 32 35)"/>
<ellipse cx="78" cy="34" rx="9" ry="12" fill="${col}" transform="rotate(15 78 34)"/>
<ellipse cx="78" cy="35" rx="6" ry="8" fill="${light}" opacity=".4" transform="rotate(15 78 35)"/>`;
}
/* ── Antennae (level 3+) ── */
let antennae = '';
if (level >= 3) {
antennae = `<line x1="44" y1="27" x2="34" y2="11" stroke="${col}" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="33" cy="9" r="5" fill="${col}"/>
<circle cx="31" cy="7" r="2" fill="white" opacity=".7"/>
<line x1="66" y1="27" x2="76" y2="11" stroke="${col}" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="77" cy="9" r="5" fill="${col}"/>
<circle cx="75" cy="7" r="2" fill="white" opacity=".7"/>`;
}
/* ── Wings with flutter (level 4+) ── */
let wings = '';
if (level >= 4) {
wings = `<g>
<path d="M14,62 C2,46 2,30 16,38 C26,44 22,59 18,65 Z" fill="${light}" opacity=".65"/>
<path d="M16,62 C6,50 8,38 18,42 C26,46 24,57 20,62 Z" fill="white" opacity=".15"/>
<animateTransform attributeName="transform" type="rotate" values="0 14 62; -10 14 62; 0 14 62" dur="0.45s" repeatCount="indefinite"/>
</g>
<g>
<path d="M96,62 C108,46 108,30 94,38 C84,44 88,59 92,65 Z" fill="${light}" opacity=".65"/>
<path d="M94,62 C104,50 102,38 92,42 C84,46 86,57 90,62 Z" fill="white" opacity=".15"/>
<animateTransform attributeName="transform" type="rotate" values="0 96 62; 10 96 62; 0 96 62" dur="0.45s" repeatCount="indefinite"/>
</g>`;
}
/* ── Paws with fingers ── */
const paws = `
<ellipse cx="14" cy="76" rx="12" ry="8" fill="url(#${uid})" transform="rotate(-25 14 76)"/>
<circle cx="8" cy="82" r="2.8" fill="${dark}" opacity=".45"/>
<circle cx="13" cy="84" r="2.8" fill="${dark}" opacity=".45"/>
<circle cx="18" cy="83" r="2.8" fill="${dark}" opacity=".45"/>
<ellipse cx="96" cy="76" rx="12" ry="8" fill="url(#${uid})" transform="rotate(25 96 76)"/>
<circle cx="102" cy="82" r="2.8" fill="${dark}" opacity=".45"/>
<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 ── */
let accSvg = '';
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('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"/>
<line x1="54" y1="${eyeY}" x2="56" y2="${eyeY}" stroke="rgba(255,255,255,.65)" stroke-width="2"/>
<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('star') && level < 5) {
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"/>`;
}
/* ── Aura ring (level 4+) ── */
let aura = '';
if (level >= 4) {
aura = `<g>
<circle cx="55" cy="60" r="47" fill="none" stroke="${col}" stroke-width="2.5" stroke-dasharray="9 6">
<animate attributeName="opacity" values=".35;.55;.35" dur="2.5s" repeatCount="indefinite"/>
</circle>
<animateTransform attributeName="transform" type="rotate" from="0 55 60" to="360 55 60" dur="9s" repeatCount="indefinite"/>
</g>`;
}
/* ── B3 Rainbow collar (streak ≥ 7) ── */
let rainbowCollar = '';
if (streak >= 7) {
rainbowCollar = `<g style="animation:rbRot 3s linear infinite;transform-box:fill-box;transform-origin:55px 38px">
<defs>
<linearGradient id="${uid}rb" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#F94144"/>
<stop offset="20%" stop-color="#F9C74F"/>
<stop offset="40%" stop-color="#38D95A"/>
<stop offset="60%" stop-color="#06D6E0"/>
<stop offset="80%" stop-color="#9B5DE5"/>
<stop offset="100%" stop-color="#F94144"/>
</linearGradient>
</defs>
<ellipse cx="55" cy="38" rx="22" ry="8" fill="none" stroke="url(#${uid}rb)" stroke-width="3.5" opacity=".88"/>
</g>`;
}
/* ── Orbital particles (level 5+) ── */
let orbitals = '';
if (level >= 5) {
const oData = [
{ start: 0, dur: 2.8, r: 46, size: 4, fill: col, op: .85 },
{ start: 120, dur: 3.6, r: 43, size: 3, fill: light, op: .7 },
{ start: 240, dur: 2.2, r: 48, size: 3.5, fill: 'white', op: .6 },
];
// Extra orbitals for level 6+
if (level >= 6) {
oData.push(
{ start: 60, dur: 1.9, r: 51, size: 2.5, fill: light, op: .65 },
{ start: 180, dur: 4.1, r: 40, size: 2, fill: col, op: .55 },
{ start: 300, dur: 3.0, r: 54, size: 3, fill: 'white', op: .5 },
);
}
// Even more for level 8
if (level >= 8) {
oData.push(
{ start: 45, dur: 1.5, r: 56, size: 3.5, fill: '#FFD700', op: .9 },
{ start: 225, dur: 2.4, r: 38, size: 2.5, fill: '#FFD700', op: .75 },
);
}
orbitals = oData.map(o => `<g>
<circle cx="55" cy="${60 - o.r}" r="${o.size}" fill="${o.fill}" opacity="${o.op}"/>
<animateTransform attributeName="transform" type="rotate"
from="${o.start} 55 60" to="${o.start + 360} 55 60" dur="${o.dur}s" repeatCount="indefinite"/>
</g>`).join('\n');
}
/* ── Second aura ring (level 6+) ── */
if (level >= 6) {
aura += `<g>
<circle cx="55" cy="60" r="53" fill="none" stroke="${col}" stroke-width="1.5" stroke-dasharray="5 9" opacity="0.4">
<animate attributeName="opacity" values=".25;.45;.25" dur="3.2s" repeatCount="indefinite"/>
</circle>
<animateTransform attributeName="transform" type="rotate" from="360 55 60" to="0 55 60" dur="14s" repeatCount="indefinite"/>
</g>`;
}
/* ── Crystal halo (level 7+) ── */
let halo = '';
if (level >= 7) {
halo = `<g>
<ellipse cx="55" cy="18" rx="20" ry="5" fill="none" stroke="${col}" stroke-width="2.5" opacity=".75">
<animate attributeName="opacity" values=".55;.85;.55" dur="2s" repeatCount="indefinite"/>
</ellipse>
<ellipse cx="55" cy="18" rx="14" ry="3" fill="none" stroke="white" stroke-width="1" opacity=".4"/>
</g>`;
}
/* ── Second wing pair (level 7+) ── */
if (level >= 7) {
wings += `<g>
<path d="M18,55 C8,43 10,33 20,37 C27,40 25,50 22,55 Z" fill="${light}" opacity=".45"/>
<animateTransform attributeName="transform" type="rotate" values="0 18 55; -12 18 55; 0 18 55" dur="0.35s" repeatCount="indefinite"/>
</g>
<g>
<path d="M92,55 C102,43 100,33 90,37 C83,40 85,50 88,55 Z" fill="${light}" opacity=".45"/>
<animateTransform attributeName="transform" type="rotate" values="0 92 55; 12 92 55; 0 92 55" dur="0.35s" repeatCount="indefinite"/>
</g>`;
}
/* ── Third aura + body shimmer (level 8) ── */
let shimmer = '';
if (level >= 8) {
aura += `<g>
<circle cx="55" cy="60" r="58" fill="none" stroke="#FFD700" stroke-width="1" stroke-dasharray="3 12" opacity="0.35">
<animate attributeName="opacity" values=".2;.5;.2" dur="4s" repeatCount="indefinite"/>
</circle>
<animateTransform attributeName="transform" type="rotate" from="0 55 60" to="360 55 60" dur="20s" repeatCount="indefinite"/>
</g>`;
shimmer = `<ellipse cx="55" cy="58" rx="30" ry="35" fill="url(#${uid}sh)" opacity="0.18"/>`;
}
return `<svg viewBox="0 0 110 115" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="${uid}" cx="38%" cy="28%" r="70%">
<stop offset="0%" stop-color="${light}"/>
<stop offset="55%" stop-color="${col}"/>
<stop offset="100%" stop-color="${dark}"/>
</radialGradient>
<radialGradient id="${uid}b" cx="50%" cy="55%" r="50%">
<stop offset="0%" stop-color="white" stop-opacity="0.22"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</radialGradient>
<radialGradient id="${uid}sh" cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color="#FFD700" stop-opacity="1"/>
<stop offset="100%" stop-color="#FFD700" stop-opacity="0"/>
</radialGradient>
</defs>
<ellipse cx="55" cy="111" rx="28" ry="4" fill="rgba(0,0,0,.18)"/>
${aura}
${halo}
${tail}
${wings}
${ears}
${antennae}
${paws}
<path d="${bodyPath}" fill="url(#${uid})"/>
${shimmer}
<ellipse cx="55" cy="70" rx="18" ry="12" fill="url(#${uid}b)"/>
${cheeks}${eyebrows}${eyeGroups}${nose}${mouth}${extras}
${rainbowCollar}
${accSvg}
${orbitals}
</svg>`;
}
var MOOD_RU = {
ecstatic: 'в восторге', happy: 'в духе', neutral: 'бодр',
sad: 'скучает', hungry: 'голоден', sleeping: 'спит',
};
window.PetSprite = {
PALETTES: PET_PALETTES,
shade: shadeColor,
render: renderPetSVG,
moodLabel: function (m) { return MOOD_RU[m] || 'бодр'; },
};
})();