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:
+93
-42
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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] || 'бодр'; },
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user