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:
@@ -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!
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
// Шпаргалка
|
||||
|
||||
Reference in New Issue
Block a user