feat(algebra-8): общая система опыта для главы 1 и главы 2

Раньше: каждая глава хранила XP отдельно (algebra8_ch1_xp +
algebra8_ch2_xp), формулы уровня были разные (дискретная таблица в
ch1, формула sqrt в ch2), визуально XP-карты различались.

Теперь:
- Один ключ localStorage: 'algebra8_xp' для обеих глав.
- При первой загрузке (в любой главе) — single-shot миграция:
  если новый ключ отсутствует, суммирует старые ch1 + ch2 и
  сохраняет под единый ключ. Старые ключи не удаляются (на всякий).
- Единая таблица уровней XP_LEVELS = [0, 50, 120, 220, 350, 520,
  740, 1000, 1300, 1700, 2200] (11 уровней, MAX = Ур. 11).
- Единые функции calcLevel(xp) и _xpForLevel(lv).
- XP-карта в сайдбаре главы 2 теперь идентична главе 1:
  градиент acc→pri-soft, .xp-card-title, .xp-bar, .xp-fill, .xp-nums.
- Hero badge «★ Ур. N · NN XP» добавлен в hero обоих глав.
- addXp в ch2: при повышении уровня — popup с номером уровня + confetti.
- addXp в ch1: refreshProgressUI вызывается, чтобы обновлять hero
  badge сразу после начисления.
This commit is contained in:
Maxim Dolgolyov
2026-05-27 15:41:54 +03:00
parent 58998a59c0
commit 9199427dfd
2 changed files with 83 additions and 24 deletions
+19 -3
View File
@@ -65,6 +65,8 @@ input,select,textarea{font-family:inherit}
.hero h2{font-size:1.55rem;font-weight:800;color:var(--pri2);margin-bottom:10px;letter-spacing:-.01em}
.hero p{font-size:.95rem;color:var(--text);opacity:.88;margin-bottom:14px;max-width:640px}
.hero-row{display:flex;gap:14px;flex-wrap:wrap;align-items:center}
.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}
.btn-primary{padding:11px 22px;background:linear-gradient(135deg,var(--pri),var(--pri2));color:#fff;border-radius:11px;font-weight:700;font-size:.92rem;display:inline-flex;align-items:center;gap:8px;box-shadow:var(--sh2);transition:transform .15s,box-shadow .15s}
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 8px 28px rgba(233,30,99,.28)}
.btn-secondary{padding:10px 18px;background:var(--card);color:var(--pri2);border:1.5px solid var(--pri);border-radius:11px;font-weight:700;font-size:.88rem;transition:background .15s}
@@ -784,6 +786,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>
@@ -1031,8 +1034,15 @@ function loadProgress(){
}
const sb = localStorage.getItem('algebra8_ch1_squaresBest');
if(sb) STATE.squaresBest = +sb;
const xp = localStorage.getItem('algebra8_ch1_xp');
if(xp){ STATE.xp = +xp; STATE.level = calcLevel(STATE.xp); }
// Общий XP для всех глав. Если ещё нет — собираем из старых ch1/ch2 ключей (single-shot миграция).
let xp = localStorage.getItem('algebra8_xp');
if(xp === null){
const c1 = +(localStorage.getItem('algebra8_ch1_xp') || 0);
const c2 = +(localStorage.getItem('algebra8_ch2_xp') || 0);
xp = c1 + c2;
try { localStorage.setItem('algebra8_xp', String(xp)); } catch(e){}
}
STATE.xp = +xp || 0; STATE.level = calcLevel(STATE.xp);
const sk = localStorage.getItem('algebra8_ch1_streak');
if(sk){ const o = JSON.parse(sk); STATE.streak = o.streak||0; STATE.maxStreak = o.max||0; }
const dc = localStorage.getItem('algebra8_ch1_daily');
@@ -1046,7 +1056,7 @@ function saveProgress(){
localStorage.setItem('algebra8_ch1_progress', JSON.stringify(STATE.progress));
localStorage.setItem('algebra8_ch1_achievements', JSON.stringify(Object.fromEntries(STATE.achievements)));
if(isFinite(STATE.squaresBest)) localStorage.setItem('algebra8_ch1_squaresBest', String(STATE.squaresBest));
localStorage.setItem('algebra8_ch1_xp', String(STATE.xp));
localStorage.setItem('algebra8_xp', String(STATE.xp));
localStorage.setItem('algebra8_ch1_streak', JSON.stringify({streak:STATE.streak, max:STATE.maxStreak}));
localStorage.setItem('algebra8_ch1_daily', JSON.stringify(STATE.dailyChallenge));
localStorage.setItem('algebra8_ch1_bossResults', JSON.stringify(STATE.bossResults));
@@ -1082,6 +1092,11 @@ function refreshProgressUI(){
});
// check 95% for final chapter modal
if(t >= 95) _maybeShowFinalChapter();
// XP badge in hero — единый стиль с главой 2
const xpBadge = document.getElementById('hero-xp-badge');
if(xpBadge){
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> Ур. ' + STATE.level + ' · ' + (STATE.xp || 0) + ' XP';
}
}
let _finalShown = false;
function _maybeShowFinalChapter(){
@@ -5911,6 +5926,7 @@ function addXp(amount, source){
STATE.xp += amount;
STATE.level = calcLevel(STATE.xp);
saveProgress();
refreshProgressUI(); // обновляет XP-бейдж в hero
if(STATE.level > prevLevel){
// Level up!
+64 -21
View File
@@ -269,7 +269,13 @@ input,select,textarea{font-family:inherit}
/* 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)}
/* XP card — единый стиль с главой 1 */
.xp-card{background:linear-gradient(135deg,var(--acc-soft),var(--pri-soft));border:1.5px solid var(--acc);border-radius:12px;padding:14px;margin-bottom:14px}
.xp-card-title{font-size:.68rem;font-weight:800;color:var(--acc2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between}
.xp-level{font-size:1.1rem;font-weight:900;color:var(--acc2);font-family:'Unbounded',sans-serif}
.xp-bar{height:9px;background:rgba(3,169,244,.15);border-radius:6px;overflow:hidden;margin:7px 0}
.xp-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--pri));border-radius:6px;transition:width .5s cubic-bezier(.4,0,.2,1)}
.xp-nums{font-size:.74rem;color:var(--muted);display:flex;justify-content:space-between}
/* 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}
@@ -445,8 +451,24 @@ const STATE = {
progress: { p7:0, p8:0, p9:0, p10:0, p11:0, p12:0, final2:0 },
achievements: new Map(),
xp: 0,
level: 1,
};
/* Уровни — общая таблица с главой 1 */
const XP_LEVELS = [0, 50, 120, 220, 350, 520, 740, 1000, 1300, 1700, 2200];
function calcLevel(xp){
let lv = 1;
for(let i = 0; i < XP_LEVELS.length; i++){
if(xp >= XP_LEVELS[i]) lv = i + 1;
else break;
}
return Math.min(lv, XP_LEVELS.length);
}
function _xpForLevel(lv){
const idx = Math.max(0, Math.min(lv - 1, XP_LEVELS.length - 1));
return XP_LEVELS[idx];
}
const ACH_LABELS = {
start: 'Начало главы 2!',
p7_constr: 'Конструктор уравнений',
@@ -504,15 +526,22 @@ function loadProgress(){
for(const [id, t] of Object.entries(p)) STATE.achievements.set(id, (t && t !== id) ? t : (ACH_LABELS[id] || id));
}
}
const xp = localStorage.getItem('algebra8_ch2_xp');
if(xp) STATE.xp = +xp;
// Общий XP для всех глав. Если ещё нет — собираем из старых ch1/ch2 ключей (single-shot миграция).
let xp = localStorage.getItem('algebra8_xp');
if(xp === null){
const c1 = +(localStorage.getItem('algebra8_ch1_xp') || 0);
const c2 = +(localStorage.getItem('algebra8_ch2_xp') || 0);
xp = c1 + c2;
try { localStorage.setItem('algebra8_xp', String(xp)); } catch(e){}
}
STATE.xp = +xp || 0; STATE.level = calcLevel(STATE.xp);
}catch(e){}
}
function saveProgress(){
try{
localStorage.setItem('algebra8_ch2_progress', JSON.stringify(STATE.progress));
localStorage.setItem('algebra8_ch2_achievements', JSON.stringify(Object.fromEntries(STATE.achievements)));
localStorage.setItem('algebra8_ch2_xp', String(STATE.xp));
localStorage.setItem('algebra8_xp', String(STATE.xp));
}catch(e){}
}
function bumpProgress(key, delta){
@@ -520,7 +549,23 @@ function bumpProgress(key, delta){
saveProgress();
refreshProgressUI();
}
function addXp(n, src){ STATE.xp = (STATE.xp||0) + n; saveProgress(); refreshProgressUI(); }
function addXp(n, src){
if(!n) return;
const prev = STATE.level;
STATE.xp = Math.max(0, (STATE.xp || 0) + n);
STATE.level = calcLevel(STATE.xp);
saveProgress();
refreshProgressUI();
if(STATE.level > prev){
const pop = document.getElementById('ach-popup');
if(pop){
document.getElementById('ach-text').textContent = 'Уровень ' + STATE.level + '!';
pop.classList.add('show');
setTimeout(()=>pop.classList.remove('show'), 2600);
}
if(window.confetti) try { confetti(); } catch(e){}
}
}
function refreshProgressUI(){
const total = Math.round(Object.values(STATE.progress).reduce((a,b)=>a+b,0) / 7);
const f = document.getElementById('hero-hp-fill');
@@ -534,8 +579,7 @@ function refreshProgressUI(){
});
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';
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> Ур. ' + STATE.level + ' · ' + (STATE.xp || 0) + ' XP';
}
// sidebar XP card sync
if(STATE.current && document.getElementById('sidebar-content')){
@@ -668,25 +712,24 @@ const TIPS = [
{ 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 = '';
// 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>
// XP card — единый стиль с главой 1
const xpForLv = _xpForLevel(STATE.level);
const xpNext = _xpForLevel(STATE.level + 1);
const xpInLv = STATE.xp - xpForLv;
const xpRange = xpNext - xpForLv;
const xpPct = xpRange > 0 ? Math.round(xpInLv / xpRange * 100) : 100;
html += `<div class="xp-card">
<div class="xp-card-title">
<span>XP-прогресс</span>
<span class="xp-level">Ур. ${STATE.level}</span>
</div>
<div class="xp-bar"><div class="xp-fill" style="width:${xpPct}%"></div></div>
<div class="xp-nums"><span>${STATE.xp} XP</span><span>${STATE.level < XP_LEVELS.length ? xpNext + ' XP' : 'MAX'}</span></div>
</div>`;
// Шпаргалка