feat(algebra-8 ch2): XP-карта, бейдж в hero, совет дня, фикс sidebar 'Финал'

- SIDEBARS.final2: убрал stub 'будет в Wave 4', добавил 5 строк по
  финалу (7 боссов, типы заданий, награда, практика, серия).
- XP card в сайдбаре: уровень (Lv N), текущий XP, прогресс-бар до
  следующего уровня, остаток XP. Формула: Lv = floor(sqrt(xp/50)).
- XP badge в hero (рядом с прогрессом): жёлто-розовая пилюля
  «★ Lv N · NN XP», обновляется при каждом addXp.
- TIPS: 7 советов (по одному на каждый §+финал). В сайдбаре отдельная
  карточка «Подсказка» с жёлтым градиентом — контекстная под текущий
  параграф.
- refreshProgressUI: после изменения XP пересобирает сайдбар, чтобы
  карточки опыта/совета оставались актуальными.
This commit is contained in:
Maxim Dolgolyov
2026-05-27 15:36:11 +03:00
parent e21b12a7ce
commit 58998a59c0
+64 -2
View File
@@ -266,6 +266,11 @@ input,select,textarea{font-family:inherit}
.eq-show{font-family:'JetBrains Mono',monospace}
.pipe-tabs .btn.active{background:var(--sec-acc,var(--pri));color:#fff;border-color:var(--sec-acc,var(--pri))}
/* XP badge in hero */
.hero-xp-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:linear-gradient(135deg,var(--warn,#f59e0b),var(--pri));color:#fff;border-radius:99px;font-size:.82rem;font-weight:800;letter-spacing:.02em;box-shadow:0 4px 12px rgba(233,30,99,.22);font-family:'Unbounded',sans-serif}
.hero-xp-badge svg{flex-shrink:0}
.xp-card .hp-bar{box-shadow:inset 0 1px 2px rgba(0,0,0,.05)}
/* GLOSSARY tooltip */
.gloss-term{border-bottom:1.5px dotted var(--sec-acc,var(--pri));cursor:help;color:var(--sec-acc-d,var(--pri2));font-weight:600;padding:0 1px}
.gloss-term:hover{background:var(--sec-acc-soft,var(--pri-soft));border-radius:3px}
@@ -360,6 +365,7 @@ input,select,textarea{font-family:inherit}
<div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div>
<span id="hero-hp-text" class="hp-text">0%</span>
</div>
<div id="hero-xp-badge" class="hero-xp-badge" title="Опыт"></div>
</div>
</section>
@@ -526,6 +532,15 @@ function refreshProgressUI(){
const fl = el.querySelector('.psel-prog-fill');
if(fl) fl.style.width = (STATE.progress[k]||0) + '%';
});
const xpBadge = document.getElementById('hero-xp-badge');
if(xpBadge){
const lv = levelFromXp(STATE.xp || 0);
xpBadge.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Lv ' + lv + ' · ' + (STATE.xp || 0) + ' XP';
}
// sidebar XP card sync
if(STATE.current && document.getElementById('sidebar-content')){
try { buildSidebar(STATE.current); } catch(e){}
}
}
function achievement(id, text){
if(STATE.achievements.has(id)) return;
@@ -636,16 +651,62 @@ const SIDEBARS = {
['Дробное','умножить на ОЗ, проверить ОДЗ'],
['ОДЗ','знаменатель $\\neq 0$'],
]},
final2:{ title:'Финал', rows:[['Итоги главы','будет в Wave 4']]},
final2:{ title:'Финал главы', rows:[
['7 боссов','один на каждый параграф + общий'],
['Тип задач','select / yes-no / input'],
['Награда','«Магистр квадратных уравнений»'],
['Практика','случайные задачи всей главы'],
['Серия 5×','+ достижение «Серия из 5 верных»'],
]},
};
const TIPS = [
{ sec:'p7', html:'Если в неполном <b>ax² + c = 0</b> знаки <i>a</i> и <i>c</i> одинаковые — корней нет. Проверяй знак $-c/a$.' },
{ sec:'p8', html:'$D = b^2 - 4ac$ — даже не считай корни, если $D < 0$.' },
{ sec:'p9', html:'Перед использованием Виета убедись, что <b>a = 1</b>. Иначе формулы дают $-b/a$ и $c/a$.' },
{ sec:'p10', html:'Если $D < 0$, на множители первой степени <b>не</b> раскладывается. Не пытайся!' },
{ sec:'p11', html:'В задачах на движение часто помогает «общее время»: $\\dfrac{s_1}{v_1} + \\dfrac{s_2}{v_2}$.' },
{ sec:'p12', html:'После $t = x^2$ всегда проверяй $t \\geq 0$ — отрицательные $t$ дают пустое множество.' },
{ sec:'final2', html:'Не бойся пробовать боссов несколько раз: ошибка не «съедает» прогресс.' },
];
function levelFromXp(xp){ return Math.floor(Math.sqrt(xp / 50)); }
function xpForLevel(lv){ return 50 * lv * lv; }
function buildSidebar(id){
const box = document.getElementById('sidebar-content');
const sb = SIDEBARS[id] || SIDEBARS.p7;
let html = `<div class="sidecard"><h4>${sb.title}</h4>`;
let html = '';
// XP card
const xp = STATE.xp || 0;
const lv = levelFromXp(xp);
const cur = xpForLevel(lv);
const next = xpForLevel(lv + 1);
const pct = next > cur ? Math.round((xp - cur) / (next - cur) * 100) : 100;
html += `<div class="sidecard xp-card">
<h4>Опыт <span style="float:right;color:var(--pri)">Lv ${lv}</span></h4>
<div class="xp-nums" style="display:flex;justify-content:space-between;font-size:.84rem;font-weight:700;color:var(--pri2);margin-bottom:5px"><span>${xp} XP</span><span style="color:var(--muted);font-weight:600">${next} XP</span></div>
<div class="hp-bar" style="height:6px;background:var(--pri-soft);border-radius:3px;overflow:hidden"><div style="height:100%;width:${pct}%;background:linear-gradient(90deg,var(--pri),var(--acc));transition:width .4s"></div></div>
<div style="margin-top:6px;font-size:.74rem;color:var(--muted);text-align:center">До Lv ${lv + 1}: ${Math.max(0, next - xp)} XP</div>
</div>`;
// Шпаргалка
html += `<div class="sidecard"><h4>${sb.title}</h4>`;
sb.rows.forEach(([k,v])=>{
html += `<div class="sidecard-row"><b>${k}</b> ${v ? '— ' + v : ''}</div>`;
});
html += '</div>';
// Совет дня
const tip = TIPS.find(t => t.sec === id) || TIPS[0];
html += `<div class="sidecard tip-card" style="background:linear-gradient(135deg,var(--warn-bg,#fef3c7),var(--pri-soft));border-color:var(--warn,#f59e0b)">
<h4 style="color:#92400e;display:flex;align-items:center;gap:6px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/><circle cx="12" cy="12" r="4"/></svg>
Подсказка
</h4>
<div class="sidecard-row" style="margin-bottom:0;font-size:.84rem;line-height:1.55">${tip.html}</div>
</div>`;
// Достижения
if(STATE.achievements.size > 0){
html += `<div class="sidecard"><h4>Достижения <span style="color:var(--warn);float:right">${STATE.achievements.size}</span></h4>`;
[...STATE.achievements.values()].slice(-4).forEach(text=>{
@@ -653,6 +714,7 @@ function buildSidebar(id){
});
html += '</div>';
}
box.innerHTML = html;
if(window.renderMathInElement) try{ renderMath(box); }catch(e){}
}