feat(textbooks): Wave 4 — геймификация Алгебры 8 (+1064 строк, итог 5595)

1. XP/уровни: XP_LEVELS[11], addXp(source) во всех тренажёрах и квизах, синий level-up popup, XP-карточка в сайдбаре. Persists в LocalStorage algebra8_ch1_xp
2. Streak-серии: текущая+рекорд, milestones 3/5/7/10 → оранжевый popup + ачивки streak3/5/7/10. Сброс на ошибке
3. Daily Challenge: 7 задач в DAILY_TASKS, дата-гарда, кнопка в шапке с пульсирующим индикатором, модалка с вопросом, +30 XP за прохождение
4. Achievements Gallery: кнопка 'Трофеи' в шапке, модалка с сеткой 20 ачивок (ACH_DEFS), SVG-иконки, статус earned/locked
5. Circular Progress: SVG-кольцо вместо линейной полосы на карточках §§ в para-selector
6. Финальный фейерверк: при общем прогрессе ≥95% автомодалка с confetti×5, статистикой XP/streak/achievements, освоенными темами
7. Sound effects: playTone() через Web Audio, sounds.correct/wrong/levelUp/achievement, кнопка mute в шапке с LocalStorage флагом

Все существующие функции (BUILDERS, STATE.progress, achievement, goTo, buildPN) — без изменений, новое добавлено через IIFE-обёртки.
This commit is contained in:
Maxim Dolgolyov
2026-05-27 12:28:44 +03:00
parent 898629a5b6
commit 42408ee301
+772 -6
View File
@@ -544,7 +544,92 @@ input,select,textarea{font-family:inherit}
.col-side.side-open{transform:translateX(0)}
}
@media(min-width:981px){.col-side-close{display:none}.col-side{transform:none!important}}
/* ═══════════════════════════════════════════════
WAVE 4 — GAMIFICATION
═══════════════════════════════════════════════ */
/* XP / Level card */
.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}
/* Streak card */
.streak-card{background:linear-gradient(135deg,#fff8e1,#fce7f3);border:1.5px solid var(--warn);border-radius:12px;padding:12px 14px;margin-bottom:14px}
.dark .streak-card{background:linear-gradient(135deg,#2a1f0a,#3a1229)}
.streak-row{display:flex;align-items:center;gap:10px}
.streak-icon{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:rgba(245,158,11,.15);border-radius:8px;flex-shrink:0}
.streak-nums{display:flex;gap:14px;margin-top:6px}
.streak-num{text-align:center}
.streak-val{font-size:1.3rem;font-weight:900;color:var(--warn);font-family:'JetBrains Mono',monospace}
.streak-lab{font-size:.68rem;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em}
/* Level popup (синий) */
.lvl-popup{position:fixed;top:18px;right:18px;background:linear-gradient(135deg,var(--acc),var(--acc2));color:#fff;padding:14px 20px;border-radius:12px;font-weight:700;font-size:.92rem;box-shadow:0 8px 32px rgba(3,169,244,.4);z-index:1001;display:none;align-items:center;gap:10px}
.lvl-popup.show{display:flex;animation:achBounce .5s cubic-bezier(.34,1.56,.64,1) forwards}
/* Streak popup */
.streak-popup{position:fixed;top:70px;right:18px;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;padding:12px 18px;border-radius:11px;font-weight:700;font-size:.9rem;box-shadow:0 8px 28px rgba(245,158,11,.45);z-index:1002;display:none;align-items:center;gap:8px}
.streak-popup.show{display:flex;animation:achBounce .45s cubic-bezier(.34,1.56,.64,1) forwards}
/* Daily challenge button */
.daily-btn{position:relative}
.daily-dot{position:absolute;top:3px;right:3px;width:8px;height:8px;background:var(--warn);border-radius:50%;border:2px solid #fff;display:none}
.daily-dot.show{display:block;animation:dotPulse 1.4s ease-in-out infinite}
@keyframes dotPulse{0%,100%{box-shadow:0 0 0 0 rgba(245,158,11,.6)}50%{box-shadow:0 0 0 5px rgba(245,158,11,0)}}
/* Daily modal */
.daily-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);backdrop-filter:blur(4px);z-index:2010;align-items:center;justify-content:center}
.daily-modal.open{display:flex;animation:modalIn .3s ease}
.daily-box{background:var(--card);border-radius:18px;padding:30px 28px;max-width:430px;width:92%;box-shadow:0 20px 60px rgba(0,0,0,.3);animation:boxIn .4s cubic-bezier(.34,1.56,.64,1)}
.daily-badge{display:inline-flex;align-items:center;gap:7px;padding:5px 12px;background:linear-gradient(135deg,var(--warn),#ef4444);color:#fff;border-radius:8px;font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px}
.daily-q{font-size:1.1rem;font-weight:700;color:var(--text);margin-bottom:18px;line-height:1.5}
.daily-hint{font-size:.8rem;color:var(--muted);margin-top:-10px;margin-bottom:12px;font-style:italic}
.daily-done{text-align:center;padding:20px 0}
.daily-done-icon{font-size:3rem;margin-bottom:10px}
/* Achievements gallery modal */
.ach-gallery-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);backdrop-filter:blur(5px);z-index:2005;align-items:flex-start;justify-content:center;overflow-y:auto;padding:24px 16px}
.ach-gallery-modal.open{display:flex;animation:modalIn .3s ease}
.ach-gallery-box{background:var(--card);border-radius:20px;padding:28px;width:100%;max-width:680px;box-shadow:0 24px 64px rgba(0,0,0,.35);margin:auto;animation:boxIn .4s cubic-bezier(.34,1.56,.64,1)}
.ach-gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px;margin-top:18px}
.ach-card{background:var(--bg);border:1.5px solid var(--border);padding:16px 12px;border-radius:12px;text-align:center;opacity:.38;transition:opacity .25s,border-color .25s,transform .2s}
.ach-card:hover{transform:translateY(-2px)}
.ach-card.earned{opacity:1;border-color:var(--sec-acc,var(--warn));background:linear-gradient(135deg,var(--pri-soft),var(--acc-soft))}
.ach-card-icon{width:44px;height:44px;margin:0 auto 8px;display:flex;align-items:center;justify-content:center;border-radius:12px;background:var(--pri-soft)}
.ach-card.earned .ach-card-icon{background:linear-gradient(135deg,var(--pri),var(--acc));color:#fff}
.ach-card-title{font-weight:800;font-size:.82rem;color:var(--text);line-height:1.25;margin-bottom:4px}
.ach-card-desc{font-size:.72rem;color:var(--muted);line-height:1.35}
.ach-card-date{font-size:.68rem;color:var(--ok);margin-top:5px;font-weight:600}
/* Circular progress */
.psel-prog-circle{width:34px;height:34px;position:absolute;top:10px;right:10px}
.psel-prog-bg{fill:none;stroke:rgba(233,30,99,.12);stroke-width:3.5}
.psel-prog-fg{fill:none;stroke:var(--pri);stroke-width:3.5;stroke-linecap:round;transform:rotate(-90deg);transform-origin:50% 50%;transition:stroke-dasharray .5s}
.psel-prog-circle text{font-size:8px;font-weight:800;fill:var(--pri2);font-family:'Inter',sans-serif}
/* Final chapter modal */
.final-chapter-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);backdrop-filter:blur(8px);z-index:2020;align-items:center;justify-content:center;padding:16px}
.final-chapter-modal.open{display:flex;animation:modalIn .4s ease}
.final-chapter-box{background:var(--card);border-radius:22px;padding:36px 32px;max-width:480px;width:100%;box-shadow:0 28px 80px rgba(233,30,99,.25);text-align:center;animation:boxIn .5s cubic-bezier(.34,1.56,.64,1)}
.fc-title{font-size:1.6rem;font-weight:900;color:var(--pri2);font-family:'Unbounded',sans-serif;margin:14px 0 8px}
.fc-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:16px 0}
.fc-stat{background:var(--pri-soft);border-radius:10px;padding:12px 8px;text-align:center}
.fc-stat-val{font-size:1.4rem;font-weight:900;color:var(--pri2);font-family:'JetBrains Mono',monospace}
.fc-stat-lab{font-size:.7rem;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-top:3px}
.fc-topics{text-align:left;margin:14px 0;background:var(--acc-soft);border-radius:10px;padding:12px 14px}
.fc-topics li{font-size:.85rem;margin-bottom:4px;color:var(--text)}
/* Mute button */
.mute-btn{position:relative}
/* Sound indicator */
#sound-muted-hint{display:none}
</style>
</head>
<body>
@@ -567,6 +652,19 @@ input,select,textarea{font-family:inherit}
<svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
<span class="search-hint-badge">Ctrl+K</span>
</button>
<button id="daily-btn" class="hdr-btn daily-btn" onclick="openDailyChallenge()" title="Задача дня">
<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span>Задача дня</span>
<span id="daily-dot" class="daily-dot"></span>
</button>
<button id="ach-gallery-btn" class="hdr-btn" onclick="openAchGallery()" title="Галерея достижений">
<svg class="ic" viewBox="0 0 24 24"><path d="M6 9H4l-1-3h18l-1 3h-2M6 9l1 6h10l1-6M6 9h12"/><path d="M9 21h6M12 15v6"/></svg>
<span>Трофеи</span>
</button>
<button id="mute-btn" class="hdr-btn mute-btn" onclick="toggleMute()" title="Звук">
<svg id="sound-on-ic" class="ic" viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
<svg id="sound-off-ic" class="ic" viewBox="0 0 24 24" style="display:none"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
</button>
<button id="theme-btn" class="hdr-btn" title="Сменить тему">
<svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<span id="theme-lab">Тёмная</span>
@@ -714,6 +812,68 @@ input,select,textarea{font-family:inherit}
<!-- Wave 3: Minimap -->
<div id="minimap" class="minimap" title="Мини-карта секции"></div>
<!-- Wave 4: Level popup -->
<div id="lvl-popup" class="lvl-popup">
<svg class="ic" viewBox="0 0 24 24" style="width:22px;height:22px;stroke:#fff"><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>
<span id="lvl-popup-text">Уровень 2!</span>
</div>
<!-- Wave 4: Streak popup -->
<div id="streak-popup" class="streak-popup">
<svg class="ic" viewBox="0 0 24 24" style="width:20px;height:20px;stroke:#fff"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
<span id="streak-popup-text">Streak x3!</span>
</div>
<!-- Wave 4: Daily Challenge modal -->
<div id="daily-modal" class="daily-modal" onclick="if(event.target===this)closeDailyChallenge()">
<div class="daily-box">
<div class="daily-badge">
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;stroke:#fff"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Задача дня
</div>
<div id="daily-content"></div>
<div style="text-align:right;margin-top:8px"><button class="btn small" onclick="closeDailyChallenge()">Закрыть</button></div>
</div>
</div>
<!-- Wave 4: Achievements Gallery modal -->
<div id="ach-gallery-modal" class="ach-gallery-modal" onclick="if(event.target===this)closeAchGallery()">
<div class="ach-gallery-box">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<h3 style="font-size:1.1rem;font-weight:800;color:var(--pri2)">Галерея достижений</h3>
<button class="btn small" onclick="closeAchGallery()">Закрыть</button>
</div>
<div id="ach-gallery-count" style="font-size:.8rem;color:var(--muted)"></div>
<div id="ach-gallery-grid" class="ach-gallery-grid"></div>
</div>
</div>
<!-- Wave 4: Final Chapter modal -->
<div id="final-chapter-modal" class="final-chapter-modal" onclick="if(event.target===this)closeFinalChapterModal()">
<div class="final-chapter-box">
<div style="font-size:2.8rem">
<svg viewBox="0 0 24 24" style="width:56px;height:56px;display:inline-block;stroke:#f59e0b;fill:rgba(245,158,11,.15)"><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" stroke-width="1.5"/></svg>
</div>
<div class="fc-title">Глава 1 завершена!</div>
<p style="color:var(--muted);font-size:.9rem;margin-bottom:14px">Вы освоили все темы Главы 1!</p>
<div class="fc-stats" id="fc-stats-box"></div>
<div class="fc-topics">
<ul style="padding-left:18px;list-style:none">
<li>&#10003; Арифметический квадратный корень</li>
<li>&#10003; Иррациональные и действительные числа</li>
<li>&#10003; Свойства квадратных корней</li>
<li>&#10003; Применение свойств (упрощение, сравнение)</li>
<li>&#10003; Числовые промежутки, ∪ и ∩</li>
<li>&#10003; Системы и совокупности неравенств</li>
</ul>
</div>
<div style="display:flex;gap:10px;justify-content:center;margin-top:18px">
<button class="btn primary" onclick="alert('Скоро! Глава 2 — Квадратные уравнения.')">Перейти к главе 2</button>
<button class="btn" onclick="closeFinalChapterModal()">Закрыть</button>
</div>
</div>
</div>
<script>
'use strict';
@@ -725,6 +885,11 @@ const STATE = {
progress: { p1: 0, p2: 0, p3: 0, p4: 0, p5: 0, p6: 0, final: 0 },
achievements: new Map(), // id → human-readable text
squaresBest: Infinity,
xp: 0,
level: 1,
streak: 0,
maxStreak: 0,
dailyChallenge: { date: null, completed: false, taskIdx: 0 },
};
/* Словарь имён достижений — используется и для отображения, и для retroactive-фикса старых записей */
@@ -743,6 +908,12 @@ const ACH_LABELS = {
pr1: 'Дорожка с розами',
pr2: 'Цемент',
decode: 'Расшифровал код',
daily_1: 'Задача дня выполнена!',
streak3: 'Серия x3 — не сдавайся!',
streak5: 'Серия x5 — горишь!',
streak7: 'Серия x7 — в ударе!',
streak10: 'Серия x10 — легенда!',
lv5: 'Достигнут уровень 5',
};
function loadProgress(){
@@ -764,6 +935,12 @@ 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); }
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');
if(dc){ Object.assign(STATE.dailyChallenge, JSON.parse(dc)); }
}catch(e){}
}
function saveProgress(){
@@ -771,6 +948,9 @@ 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_ch1_streak', JSON.stringify({streak:STATE.streak, max:STATE.maxStreak}));
localStorage.setItem('algebra8_ch1_daily', JSON.stringify(STATE.dailyChallenge));
}catch(e){}
}
function bumpProgress(key, delta){
@@ -786,11 +966,31 @@ function refreshProgressUI(){
if(fill) fill.style.width = t + '%';
const txt = document.getElementById('hero-hp-text');
if(txt) txt.textContent = t + '% пройдено';
document.querySelectorAll('[data-prog-card]').forEach(el=>{
const k = el.dataset.progCard;
const fl = el.querySelector('.psel-prog-fill');
if(fl) fl.style.width = (STATE.progress[k]||0) + '%';
const circ = 97.4;
document.querySelectorAll('[data-prog-card]').forEach(card=>{
const k = card.dataset.progCard;
const pct = STATE.progress[k] || 0;
const fl = card.querySelector('.psel-prog-fill');
if(fl) fl.style.width = pct + '%';
// circular
const svg = card.querySelector('[data-prog-circle]');
if(svg){
const fg = svg.querySelector('.psel-prog-fg');
const tx = svg.querySelector('text');
if(fg) fg.setAttribute('stroke-dasharray', (pct / 100 * circ).toFixed(1) + ', ' + circ);
if(tx) tx.textContent = pct + '%';
}
});
// check 95% for final chapter modal
if(t >= 95) _maybeShowFinalChapter();
}
let _finalShown = false;
function _maybeShowFinalChapter(){
if(_finalShown) return;
if(localStorage.getItem('algebra8_final_shown')) return;
_finalShown = true;
localStorage.setItem('algebra8_final_shown', '1');
setTimeout(showFinalChapterModal, 600);
}
function achievement(id, text){
if(STATE.achievements.has(id)) return;
@@ -802,6 +1002,9 @@ function achievement(id, text){
setTimeout(()=>pop.classList.remove('show'), 3300);
// Wave 1: celebratory confetti
setTimeout(()=>confetti(), 150);
// Wave 4: +20 XP for achievement
addXp(20, 'ach');
sounds.achievement();
}
/* ════════════════════════════════════════════════════════
@@ -834,10 +1037,17 @@ function buildParaSelector(){
card.dataset.progCard = p.id;
const topicsHtml = (p.topics||[]).map(t=>`<div class="psel-preview-topic">${t}</div>`).join('');
const progPct = STATE.progress[p.id] || 0;
const circleCircumference = 97.4; // 2 * PI * 15.5 ≈ 97.4
const dashArr = Math.round(progPct / 100 * circleCircumference * 10) / 10;
card.innerHTML = `
<div class="psel-num">${p.num}</div>
<div class="psel-name">${p.name}</div>
<div class="psel-prog"><div class="psel-prog-fill"></div></div>
<svg class="psel-prog-circle" viewBox="0 0 36 36" data-prog-circle="${p.id}">
<path class="psel-prog-bg" d="M18 2.5 a 15.5 15.5 0 1 1 0 31 a 15.5 15.5 0 1 1 0 -31"/>
<path class="psel-prog-fg" d="M18 2.5 a 15.5 15.5 0 1 1 0 31 a 15.5 15.5 0 1 1 0 -31" stroke-dasharray="${dashArr}, ${circleCircumference}"/>
<text x="18" y="21" text-anchor="middle">${progPct}%</text>
</svg>
<div class="psel-card-preview">
<div class="psel-preview-title">${p.name}</div>
${topicsHtml}
@@ -967,16 +1177,48 @@ const SIDEBARS = {
function buildSidebar(id){
const box = document.getElementById('sidebar-content');
const sb = SIDEBARS[id] || SIDEBARS.p1;
let html = `<div class="sidecard"><h4>${sb.title}</h4>`;
// XP card
const xpForLevel = _xpForLevel(STATE.level);
const xpNext = _xpForLevel(STATE.level + 1);
const xpInLevel = STATE.xp - xpForLevel;
const xpRange = xpNext - xpForLevel;
const xpPct = xpRange > 0 ? Math.round(xpInLevel / xpRange * 100) : 100;
let 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" id="xp-fill" style="width:${xpPct}%"></div></div>
<div class="xp-nums"><span>${STATE.xp} XP</span><span>${STATE.level < 10 ? xpNext + ' XP' : 'MAX'}</span></div>
</div>`;
// Streak card
html += `<div class="streak-card">
<div class="streak-row">
<div class="streak-icon">
<svg class="ic" viewBox="0 0 24 24" style="stroke:var(--warn)"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</div>
<span style="font-size:.8rem;font-weight:700;color:var(--text)">Серия правильных ответов</span>
</div>
<div class="streak-nums">
<div class="streak-num"><div class="streak-val">${STATE.streak}</div><div class="streak-lab">Текущая</div></div>
<div class="streak-num"><div class="streak-val">${STATE.maxStreak}</div><div class="streak-lab">Рекорд</div></div>
</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>';
// achievements
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=>{
html += `<div class="sidecard-row" style="font-size:.78rem;color:var(--ok)"> ${text}</div>`;
html += `<div class="sidecard-row" style="font-size:.78rem;color:var(--ok)">&#10003; ${text}</div>`;
});
html += '</div>';
}
@@ -4483,6 +4725,9 @@ function _initKeyboard(){
if(e.key === 'Escape'){
closeSearch();
closeShortcutsModal();
closeDailyChallenge();
closeAchGallery();
closeFinalChapterModal();
return;
}
// Ignore if inside input
@@ -4825,5 +5070,526 @@ function initWave3(){
document.addEventListener('DOMContentLoaded', ()=>setTimeout(initWave3, 100));
</script>
<script>
'use strict';
/* ════════════════════════════════════════════════════════
WAVE 4 — GAMIFICATION
════════════════════════════════════════════════════════ */
/* ── XP levels ── */
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];
}
function addXp(amount, source){
if(!amount || amount <= 0) return;
if(_isMuted()) {} // still give XP even if muted
const prevLevel = STATE.level;
STATE.xp += amount;
STATE.level = calcLevel(STATE.xp);
saveProgress();
if(STATE.level > prevLevel){
// Level up!
const pop = document.getElementById('lvl-popup');
if(pop){
document.getElementById('lvl-popup-text').textContent = 'Уровень ' + STATE.level + '!';
pop.classList.add('show');
setTimeout(()=>pop.classList.remove('show'), 3000);
}
sounds.levelUp();
if(STATE.level >= 5 && !STATE.achievements.has('lv5')){
achievement('lv5', 'Достигнут уровень 5');
}
}
// refresh sidebar if open
const box = document.getElementById('sidebar-content');
if(box && box.querySelector('.xp-card')){
const xpForLv = _xpForLevel(STATE.level);
const xpNext = _xpForLevel(STATE.level + 1);
const xpPct = xpNext > xpForLv ? Math.round((STATE.xp - xpForLv) / (xpNext - xpForLv) * 100) : 100;
const fill = box.querySelector('.xp-fill');
if(fill) fill.style.width = xpPct + '%';
const xpNums = box.querySelectorAll('.xp-nums span');
if(xpNums[0]) xpNums[0].textContent = STATE.xp + ' XP';
if(xpNums[1]) xpNums[1].textContent = STATE.level < 10 ? xpNext + ' XP' : 'MAX';
const lvEl = box.querySelector('.xp-level');
if(lvEl) lvEl.textContent = 'Ур. ' + STATE.level;
}
}
/* ── Streak ── */
function streakCorrect(){
STATE.streak++;
if(STATE.streak > STATE.maxStreak) STATE.maxStreak = STATE.streak;
saveProgress();
_updateStreakUI();
_checkStreakMilestone(STATE.streak);
}
function streakWrong(){
STATE.streak = 0;
saveProgress();
_updateStreakUI();
}
function _updateStreakUI(){
const box = document.getElementById('sidebar-content');
if(!box) return;
const vals = box.querySelectorAll('.streak-val');
if(vals[0]) vals[0].textContent = STATE.streak;
if(vals[1]) vals[1].textContent = STATE.maxStreak;
}
function _checkStreakMilestone(n){
const milestones = [3, 5, 7, 10];
if(!milestones.includes(n)) return;
const pop = document.getElementById('streak-popup');
if(pop){
document.getElementById('streak-popup-text').textContent = 'Streak \xd7' + n + '!';
pop.classList.add('show');
setTimeout(()=>pop.classList.remove('show'), 2200);
}
sounds.correct();
addXp(n * 2, 'streak');
const achMap = {3:'streak3',5:'streak5',7:'streak7',10:'streak10'};
if(achMap[n] && !STATE.achievements.has(achMap[n])){
achievement(achMap[n], ACH_LABELS[achMap[n]]);
}
}
/* ── Sound effects ── */
let _audioCtx = null;
function _getAudioCtx(){
if(!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return _audioCtx;
}
function _isMuted(){
return localStorage.getItem('algebra8_mute') === '1';
}
function toggleMute(){
const muted = !_isMuted();
localStorage.setItem('algebra8_mute', muted ? '1' : '0');
document.getElementById('sound-on-ic').style.display = muted ? 'none' : '';
document.getElementById('sound-off-ic').style.display = muted ? '' : 'none';
}
function playTone(freq, duration, type){
if(_isMuted()) return;
try{
const ctx = _getAudioCtx();
const o = ctx.createOscillator();
const g = ctx.createGain();
o.connect(g); g.connect(ctx.destination);
o.frequency.value = freq;
o.type = type || 'sine';
g.gain.setValueAtTime(0.18, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + (duration || 0.2));
o.start(ctx.currentTime);
o.stop(ctx.currentTime + (duration || 0.2));
}catch(e){}
}
const sounds = {
correct: ()=>playTone(880, 0.15),
wrong: ()=>playTone(220, 0.2, 'sawtooth'),
levelUp: ()=>{ playTone(523, 0.12); setTimeout(()=>playTone(659, 0.12), 110); setTimeout(()=>playTone(784, 0.18), 230); },
achievement: ()=>{ playTone(659, 0.1); setTimeout(()=>playTone(880, 0.15), 85); },
};
/* ── Spoiler XP ── */
function _initSpoilerXp(){
document.addEventListener('toggle', e=>{
if(e.target && e.target.classList && e.target.classList.contains('spoiler') && e.target.open){
if(!e.target._xpGiven){ e.target._xpGiven = true; addXp(2, 'spoiler'); }
}
}, true);
}
/* ── Daily Challenge ── */
const DAILY_TASKS = [
{q:'Вычислите: $\\sqrt{64 \\cdot 81}$', answer:72, type:'number', hint:'Свойство: √(a·b) = √a · √b'},
{q:'Сравните: $\\sqrt{37}$ и $6$. Выберите знак:', answer:'<', type:'select', opts:['<','>','='], hint:'6² = 36, 37 > 36, значит √37 > 6? Осторожно!'},
{q:'Найдите целое число, лежащее между $\\sqrt{51}$ и $\\sqrt{80}$. Введите одно такое число:', answer:[8], type:'number-any', hint:'7² = 49, 8² = 64, 9² = 81. Какие квадраты попадают в диапазон?'},
{q:'Упростите $\\sqrt{72}$ в форме $a\\sqrt{b}$ — введите значение $a$:', answer:6, type:'number', hint:'72 = 36 · 2, √72 = √36 · √2 = 6√2'},
{q:'При каком наименьшем целом $x$ выражение $\\sqrt{2x-5}$ имеет смысл? Введите число:', answer:3, type:'number', hint:'Нужно 2x 5 ≥ 0, то есть x ≥ 2,5. Наименьшее целое?'},
{q:'Какое из чисел иррационально?', answer:'√7', type:'select', opts:['0,5','√16','√7','1/3'], hint:'√16 = 4 — рациональное, √7 — нельзя записать в виде дроби'},
{q:'Чему равно $\\sqrt{(\\sqrt{5})^2}$?', answer:'√5', type:'select', opts:['5','√5','25','√25'], hint:'(√a)² = a, затем √(a) = √a'},
];
function _todayStr(){
const d = new Date();
return d.getFullYear() + '-' + (d.getMonth()+1) + '-' + d.getDate();
}
function _initDailyChallenge(){
const today = _todayStr();
if(STATE.dailyChallenge.date !== today){
STATE.dailyChallenge.date = today;
STATE.dailyChallenge.completed = false;
STATE.dailyChallenge.taskIdx = Math.floor(Math.random() * DAILY_TASKS.length);
saveProgress();
}
const dot = document.getElementById('daily-dot');
if(dot) dot.classList.toggle('show', !STATE.dailyChallenge.completed);
}
function openDailyChallenge(){
const modal = document.getElementById('daily-modal');
if(modal) modal.classList.add('open');
_renderDailyChallenge();
}
function closeDailyChallenge(){
const modal = document.getElementById('daily-modal');
if(modal) modal.classList.remove('open');
}
function _renderDailyChallenge(){
const box = document.getElementById('daily-content');
if(!box) return;
const t = DAILY_TASKS[STATE.dailyChallenge.taskIdx] || DAILY_TASKS[0];
if(STATE.dailyChallenge.completed){
box.innerHTML = `<div class="daily-done">
<div class="daily-done-icon">
<svg viewBox="0 0 24 24" style="width:48px;height:48px;stroke:#10b981;fill:none;stroke-width:2"><circle cx="12" cy="12" r="10"/><polyline points="9 12 11 14 15 10"/></svg>
</div>
<div style="font-weight:800;font-size:1rem;color:var(--ok)">Выполнено!</div>
<div style="font-size:.85rem;color:var(--muted);margin-top:6px">Приходите завтра за новой задачей.</div>
</div>`;
return;
}
let inputHtml = '';
if(t.type === 'select'){
inputHtml = `<div class="row-c" style="margin-top:12px">${t.opts.map(o=>`<button class="btn daily-opt" onclick="dailySubmit('${o}')" style="font-size:1rem;padding:9px 18px">${o}</button>`).join('')}</div>`;
} else {
inputHtml = `<div class="row-c" style="margin-top:12px">
<input id="daily-inp" class="inp num" type="number" placeholder="Ответ" style="width:120px;font-size:1.1rem">
<button class="btn primary" onclick="dailyCheckInput()">Сдать</button>
</div>`;
}
box.innerHTML = `
<div class="daily-q">${t.q}</div>
<div class="daily-hint">${t.hint}</div>
${inputHtml}
<div id="daily-fb" class="feedback" style="margin-top:10px"></div>`;
setTimeout(()=>{
if(window.renderMathInElement && box){
try{ renderMathInElement(box, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false}); }catch(e){}
}
}, 30);
}
function dailySubmit(answer){
const t = DAILY_TASKS[STATE.dailyChallenge.taskIdx] || DAILY_TASKS[0];
const fb = document.getElementById('daily-fb');
const correct = (String(answer).trim() === String(t.answer).trim());
if(fb){
fb.className = 'feedback ' + (correct ? 'ok' : 'fail');
fb.textContent = correct ? 'Верно! +30 XP' : 'Не точно. Попробуйте ещё!';
}
if(correct) _dailySuccess();
}
function dailyCheckInput(){
const inp = document.getElementById('daily-inp');
if(!inp) return;
const t = DAILY_TASKS[STATE.dailyChallenge.taskIdx] || DAILY_TASKS[0];
const v = parseFloat(inp.value);
let correct = false;
if(t.type === 'number-any'){
correct = Array.isArray(t.answer) ? t.answer.includes(v) : (v === t.answer);
} else {
correct = (Math.abs(v - t.answer) < 0.01);
}
const fb = document.getElementById('daily-fb');
if(fb){
fb.className = 'feedback ' + (correct ? 'ok' : 'fail');
fb.textContent = correct ? 'Верно! +30 XP' : 'Не точно. Попробуйте снова!';
}
if(correct) _dailySuccess();
}
function _dailySuccess(){
STATE.dailyChallenge.completed = true;
saveProgress();
const dot = document.getElementById('daily-dot');
if(dot) dot.classList.remove('show');
addXp(30, 'daily');
sounds.levelUp();
confetti();
if(!STATE.achievements.has('daily_1')){
achievement('daily_1', ACH_LABELS['daily_1']);
}
setTimeout(_renderDailyChallenge, 600);
}
/* ── Achievements Gallery ── */
const ACH_DEFS = [
{id:'start', name:'Начало пути', desc:'Открыл учебник впервые', icon:'star'},
{id:'ring36', name:'Чемпион ринга', desc:'Нашёл сторону ринга 36 м²', icon:'target'},
{id:'squares', name:'Знаток квадратов', desc:'Лучший результат «Таблица квадратов»',icon:'grid'},
{id:'exists', name:'Сортировщик', desc:'Правильно рассортировал корни', icon:'filter'},
{id:'classify',name:'Числовой эксперт', desc:'Классифицировал все числа', icon:'layers'},
{id:'rat', name:'Охотник на ирр.', desc:'Распознал иррациональные числа', icon:'zap'},
{id:'match', name:'Match-мастер', desc:'Соединил все выражения Match-игры', icon:'link'},
{id:'simp4', name:'Упроститель', desc:'Прошёл тренажёр упрощения корней', icon:'scissors'},
{id:'draw', name:'Чертёжник', desc:'Построил промежуток на оси', icon:'edit'},
{id:'tariff', name:'Экономист', desc:'Нашёл выгодный тариф', icon:'bar-chart'},
{id:'ass8', name:'Отличник', desc:'Набрал 8+/10 в самооценке', icon:'award'},
{id:'pr1', name:'Садовник', desc:'Решил задачу про дорожку с розами', icon:'flower'},
{id:'pr2', name:'Строитель', desc:'Нашёл количество мешков цемента', icon:'package'},
{id:'decode', name:'Дешифровщик', desc:'Расшифровал код ДРУЖБА', icon:'key'},
{id:'daily_1', name:'Ежедневная задача', desc:'Выполнил задачу дня', icon:'calendar'},
{id:'streak3', name:'Серия x3', desc:'Дал 3 правильных ответа подряд', icon:'flame3'},
{id:'streak5', name:'На огне!', desc:'Дал 5 правильных ответов подряд', icon:'flame5'},
{id:'streak7', name:'В ударе', desc:'Дал 7 правильных ответов подряд', icon:'flame7'},
{id:'streak10',name:'Легенда серии', desc:'Дал 10 правильных ответов подряд', icon:'flame10'},
{id:'lv5', name:'Уровень 5', desc:'Достиг 5-го уровня XP', icon:'trophy'},
];
const ACH_ICON_SVG = {
star: '<svg class="ic" viewBox="0 0 24 24"><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>',
target: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>',
grid: '<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
filter: '<svg class="ic" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>',
layers: '<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
zap: '<svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
link: '<svg class="ic" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
scissors: '<svg class="ic" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>',
edit: '<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
'bar-chart':'<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>',
award: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>',
flower: '<svg class="ic" viewBox="0 0 24 24"><path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z"/></svg>',
package: '<svg class="ic" viewBox="0 0 24 24"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>',
key: '<svg class="ic" viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>',
calendar: '<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
flame3: '<svg class="ic" viewBox="0 0 24 24"><path d="M13 2c0 4-4 5-4 9a4 4 0 0 0 8 0c0-4-4-5-4-9z"/><path d="M12 17a1 1 0 0 0 0 2 1 1 0 0 0 0-2z" fill="currentColor"/></svg>',
flame5: '<svg class="ic" viewBox="0 0 24 24"><path d="M13 2c0 4-4 5-4 9a4 4 0 0 0 8 0c0-4-4-5-4-9z"/><path d="M12 17a1 1 0 0 0 0 2 1 1 0 0 0 0-2z" fill="currentColor"/></svg>',
flame7: '<svg class="ic" viewBox="0 0 24 24"><path d="M13 2c0 4-4 5-4 9a4 4 0 0 0 8 0c0-4-4-5-4-9z"/><path d="M12 17a1 1 0 0 0 0 2 1 1 0 0 0 0-2z" fill="currentColor"/></svg>',
flame10: '<svg class="ic" viewBox="0 0 24 24"><path d="M13 2c0 4-4 5-4 9a4 4 0 0 0 8 0c0-4-4-5-4-9z"/><path d="M12 17a1 1 0 0 0 0 2 1 1 0 0 0 0-2z" fill="currentColor"/></svg>',
trophy: '<svg class="ic" viewBox="0 0 24 24"><path d="M6 9H4l-1-3h18l-1 3h-2M6 9l1 6h10l1-6M6 9h12"/><path d="M9 21h6M12 15v6"/></svg>',
};
function openAchGallery(){
const modal = document.getElementById('ach-gallery-modal');
if(modal) modal.classList.add('open');
_renderAchGallery();
}
function closeAchGallery(){
const modal = document.getElementById('ach-gallery-modal');
if(modal) modal.classList.remove('open');
}
function _renderAchGallery(){
const grid = document.getElementById('ach-gallery-grid');
const cnt = document.getElementById('ach-gallery-count');
if(!grid) return;
if(cnt) cnt.textContent = STATE.achievements.size + ' / ' + ACH_DEFS.length + ' получено';
grid.innerHTML = ACH_DEFS.map(def=>{
const earned = STATE.achievements.has(def.id);
const iconSvg = ACH_ICON_SVG[def.icon] || ACH_ICON_SVG['star'];
const dateNote = earned ? '<div class="ach-card-date">&#10003; Получено</div>' : '';
return `<div class="ach-card${earned?' earned':''}">
<div class="ach-card-icon">${iconSvg}</div>
<div class="ach-card-title">${def.name}</div>
<div class="ach-card-desc">${def.desc}</div>
${dateNote}
</div>`;
}).join('');
}
/* ── Final Chapter Modal ── */
function showFinalChapterModal(){
const modal = document.getElementById('final-chapter-modal');
if(!modal) return;
modal.classList.add('open');
const statsBox = document.getElementById('fc-stats-box');
if(statsBox){
statsBox.innerHTML = `
<div class="fc-stat"><div class="fc-stat-val">${STATE.xp}</div><div class="fc-stat-lab">XP всего</div></div>
<div class="fc-stat"><div class="fc-stat-val">${STATE.maxStreak}</div><div class="fc-stat-lab">Макс. серия</div></div>
<div class="fc-stat"><div class="fc-stat-val">${STATE.achievements.size}</div><div class="fc-stat-lab">Ачивок</div></div>`;
}
// Big confetti burst
for(let i=0;i<5;i++) setTimeout(()=>confetti(), i*200);
}
function closeFinalChapterModal(){
const modal = document.getElementById('final-chapter-modal');
if(modal) modal.classList.remove('open');
}
/* ── XP hooks via direct wrapping of key functions ── */
(function(){
// squaresAnswer +5 XP on correct
const _orig = window.squaresAnswer;
if(typeof _orig === 'function'){
window.squaresAnswer = function(picked, btn){
const wasCorrect = sqState && picked === sqState.answer;
_orig(picked, btn);
if(wasCorrect) addXp(5, 'squares');
};
}
// simpCheck +5 XP
const _origSimp = window.simpCheck;
if(typeof _origSimp === 'function'){
window.simpCheck = function(){
const t = SIMP_TASKS[simpIdx];
const v = parseFloat(document.getElementById('simp-ans').value.replace(',','.'));
const wasCorrect = !isNaN(v) && Math.abs(v - t.a) < 0.02;
_origSimp();
if(wasCorrect) addXp(5, 'trainer');
};
}
// simp4Check +5 XP
const _origSimp4 = window.simp4Check;
if(typeof _origSimp4 === 'function'){
window.simp4Check = function(){
const t = SIMP4_TASKS[simp4State.idx];
const a = +document.getElementById('simp4-a').value;
const b = +document.getElementById('simp4-b').value;
const wasCorrect = (a === t.a && b === t.b);
_origSimp4();
if(wasCorrect) addXp(5, 'trainer');
};
}
// compSet +5 XP
const _origComp = window.compSet;
if(typeof _origComp === 'function'){
window.compSet = function(pick){
const t = COMP_TASKS[compIdx];
const wasCorrect = (pick === 'a' && t.av > t.bv) || (pick === 'b' && t.bv > t.av);
_origComp(pick);
if(wasCorrect) addXp(5, 'trainer');
};
}
// picCheck +8 XP
const _origPic = window.picCheck;
if(typeof _origPic === 'function'){
window.picCheck = function(){
const t = PIC_TASKS[picIdx];
const n1 = +document.getElementById('pic-num1').value;
const n2 = +document.getElementById('pic-num2').value;
const r1 = document.getElementById('pic-rel1').value;
const r2 = document.getElementById('pic-rel2').value;
const expectR1 = t.lOpen ? '>' : '≥';
const expectR2 = t.rOpen ? '<' : '≤';
const wasCorrect = (n1===t.a&&r1===expectR1&&n2===t.b&&r2===expectR2)||(n2===t.a&&r2===expectR1&&n1===t.b&&r1===expectR2);
_origPic();
if(wasCorrect) addXp(8, 'task');
};
}
// drawCheck +8 XP
const _origDraw = window.drawCheck;
if(typeof _origDraw === 'function'){
window.drawCheck = function(){
const t = DRAW_TASKS[drawIdx];
const wasCorrect = (DR.l===t.a&&DR.r===t.b&&DR.lOpen===t.lOpen&&DR.rOpen===t.rOpen);
_origDraw();
if(wasCorrect) addXp(8, 'task');
};
}
// fiCheck +8 XP
const _origFi = window.fiCheck;
if(typeof _origFi === 'function'){
window.fiCheck = function(){
const t = FI_TASKS[fiIdx];
let correct = 0, wrong = 0;
document.querySelectorAll('#fi-grid button').forEach(b=>{
const n = +b.dataset.n;
const picked = b.dataset.picked === '1';
const inSol = t.sol.includes(n);
if(picked && inSol) correct++; else if(picked) wrong++; else if(inSol) wrong++;
});
const wasCorrect = (wrong===0 && correct===t.sol.length);
_origFi();
if(wasCorrect) addXp(8, 'task');
};
}
// matchCheck already fires feedback() which increments streak.
// But we also add +5 per pair matched.
const _origMatch = window.matchCheck;
if(typeof _origMatch === 'function'){
window.matchCheck = function(){
const prevDone = matchState ? matchState.done.length : 0;
_origMatch();
const curDone = matchState ? matchState.done.length : prevDone;
if(curDone > prevDone) addXp(5, 'match');
};
}
// assCheckAll — +10 per correct answer
const _origAss = window.assCheckAll;
if(typeof _origAss === 'function'){
window.assCheckAll = function(){
const prevXp = STATE.xp;
_origAss();
// count right answers: already calculated inside assCheckAll, we give bonus per right
// Read the score display
const scoreEl = document.getElementById('ass-score');
const right = scoreEl ? +scoreEl.textContent : 0;
addXp(right * 10, 'ass');
};
}
})();
/* ── Wrap feedback() for sounds + streak ── */
(function(){
const _origFeedback = window.feedback;
if(typeof _origFeedback !== 'function') return;
let _inFeedback = false;
window.feedback = function(elm, ok, text){
_origFeedback(elm, ok, text);
if(_inFeedback) return; // avoid re-entry from addXp→achievement→feedback
_inFeedback = true;
if(ok){ sounds.correct(); streakCorrect(); }
else { sounds.wrong(); streakWrong(); }
_inFeedback = false;
};
})();
/* ── Wave 4 INIT ── */
function initWave4(){
_initSpoilerXp();
_initDailyChallenge();
// mute state restore
if(_isMuted()){
const so = document.getElementById('sound-on-ic');
const sf = document.getElementById('sound-off-ic');
if(so) so.style.display = 'none';
if(sf) sf.style.display = '';
}
}
document.addEventListener('DOMContentLoaded', ()=>setTimeout(initWave4, 200));
</script>
</body>
</html>