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:
@@ -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>✓ Арифметический квадратный корень</li>
|
||||
<li>✓ Иррациональные и действительные числа</li>
|
||||
<li>✓ Свойства квадратных корней</li>
|
||||
<li>✓ Применение свойств (упрощение, сравнение)</li>
|
||||
<li>✓ Числовые промежутки, ∪ и ∩</li>
|
||||
<li>✓ Системы и совокупности неравенств</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)">✓ ${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">✓ Получено</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>
|
||||
|
||||
Reference in New Issue
Block a user