Files
Maxim Dolgolyov edb4c211a0 feat: universal sidebar via sidebar.js + stale ID cleanup
- Add js/sidebar.js: generates full sidebar HTML into #app-sidebar,
  handles role-based visibility, active link (with prefix matching),
  toggle wiring, collapsed state, board/features/notif init
- Replace <aside class="sidebar">...</aside> with <aside id="app-sidebar">
  across all 35 standard-layout pages via scripts/apply-sidebar.js
- Add notifications.js to 5 pages that were missing it
- Fix api.js initPage(): skip toggle re-wiring if data-sb-wired set,
  fix active link selector .sb-item → .sb-link
- Remove stale sbl-*/nav-admin/btn-upload-nav getElementById calls
  that crashed after sidebar replacement (lab, classes, collection,
  crossword, hangman, knowledge-map, library, pet, profile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:22:21 +03:00

826 lines
38 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Виселица — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<style>
.sb-content { padding: 0; }
.hm-wrap {
min-height: 100vh;
padding: 32px 24px 60px;
max-width: 900px; margin: 0 auto; width: 100%;
}
/* ── Header ── */
.hm-header { display:flex; align-items:center; gap:14px; margin-bottom:18px; }
.hm-icon {
width:52px; height:52px; border-radius:14px; flex-shrink:0;
background: linear-gradient(135deg,rgba(155,93,229,.25),rgba(249,65,68,.2));
border:1.5px solid rgba(255,255,255,.1);
display:flex; align-items:center; justify-content:center;
}
.hm-icon-main { width:26px; height:26px; stroke:#9B5DE5; stroke-width:1.8; fill:none; flex-shrink:0; }
.hm-title { font-family:'Unbounded',sans-serif; font-size:1.35rem; font-weight:800; letter-spacing:-.02em; }
.hm-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
.hm-header-right { margin-left:auto; display:flex; align-items:center; gap:8px; }
.hm-icon-btn {
width:34px; height:34px; border-radius:9px;
border:1.5px solid var(--border-h); background:transparent;
display:flex; align-items:center; justify-content:center;
cursor:pointer; transition:all .15s; color:var(--text-2); flex-shrink:0;
}
.hm-icon-btn:hover { border-color:var(--violet); color:var(--violet); }
.hm-icon-btn i { width:16px; height:16px; }
/* ── Stats bar ── */
.hm-statsbar { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:18px; }
.hm-stat {
display:flex; align-items:center; gap:6px;
padding:5px 13px; border-radius:99px;
background:var(--surface); border:1.5px solid var(--border);
font-size:.78rem; font-weight:600; color:var(--text-2); white-space:nowrap;
}
.hm-stat-val { color:var(--text); font-family:'Unbounded',sans-serif; font-size:.82rem; }
.hm-ico { width:14px; height:14px; stroke-width:2; flex-shrink:0; display:block; }
.hm-ico-sm { width:13px; height:13px; stroke-width:2; flex-shrink:0; display:block; }
/* ── Filters ── */
.hm-filters { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:20px; }
.hm-filter {
padding:5px 15px; border-radius:99px;
border:1.5px solid var(--border-h); background:var(--surface); color:var(--text-2);
font-family:'Manrope',sans-serif; font-size:.8rem; font-weight:700;
cursor:pointer; transition:all .15s;
display:inline-flex; align-items:center; gap:5px;
}
.hm-filter:hover { border-color:rgba(155,93,229,.4); color:var(--violet); }
.hm-filter.active { background:var(--violet); color:#fff; border-color:var(--violet); }
/* ── Card ── */
.hm-card {
width:100%; background:var(--surface);
border:1.5px solid var(--border); border-radius:22px;
padding:28px; position:relative; overflow:hidden;
}
/* Rainbow top stripe */
.hm-card::before {
content:''; position:absolute; top:0; left:0; right:0; height:3px;
background:linear-gradient(90deg,#9B5DE5 0%,#F94144 40%,#F9C74F 70%,#06D6A0 100%);
border-radius:22px 22px 0 0;
}
.hm-loading { text-align:center; padding:60px 0; color:var(--text-3); font-size:.88rem; }
/* ── Confetti ── */
#hm-confetti { position:absolute; inset:0; pointer-events:none; z-index:15; border-radius:20px; display:none; }
/* ── Result overlay ── */
.hm-result {
position:absolute; inset:0; border-radius:20px;
background:rgba(10,10,18,.93); backdrop-filter:blur(12px);
display:none; flex-direction:column; align-items:center; justify-content:center;
gap:14px; text-align:center; z-index:20; padding:40px;
}
.hm-result.show { display:flex; animation:resIn .35s cubic-bezier(.16,1,.3,1) both; }
@keyframes resIn { from{opacity:0;transform:scale(.95) translateY(10px)} to{opacity:1;transform:scale(1) translateY(0)} }
.hm-result-emoji { display:flex; align-items:center; justify-content:center; }
.hm-res-icon { width:72px; height:72px; stroke-width:1.4; fill:none; display:none; }
.hm-res-icon.visible { display:block; animation:emojiPop .6s cubic-bezier(.34,1.56,.64,1) .1s both; }
.hm-res-icon.win-icon { stroke:#06D6A0; }
.hm-res-icon.lose-icon { stroke:#F94144; }
@keyframes emojiPop { from{transform:scale(0) rotate(-20deg);opacity:0} to{transform:scale(1) rotate(0);opacity:1} }
.hm-result-title { font-family:'Unbounded',sans-serif; font-size:1.35rem; font-weight:800; }
.hm-result-title.win { color:#06D6A0; }
.hm-result-title.lose { color:#F94144; }
.hm-result-word {
font-family:'Unbounded',sans-serif; font-size:1rem; font-weight:700; color:var(--text);
background:rgba(255,255,255,.06); padding:8px 22px; border-radius:10px;
border:1.5px solid var(--border); letter-spacing:.04em;
}
.hm-result-msg { font-size:.85rem; color:var(--text-2); line-height:1.5; max-width:280px; }
.hm-xp-badge {
display:inline-flex; align-items:center; gap:6px;
padding:6px 18px; border-radius:99px;
background:rgba(155,93,229,.15); border:1.5px solid rgba(155,93,229,.4);
font-family:'Unbounded',sans-serif; font-size:.9rem; font-weight:700; color:var(--violet);
animation:xpPop .4s cubic-bezier(.34,1.56,.64,1) .6s both;
}
@keyframes xpPop { from{transform:scale(0);opacity:0} to{transform:scale(1);opacity:1} }
.hm-btn-next {
margin-top:4px; padding:11px 32px; border-radius:12px;
background:var(--violet); color:#fff; border:none;
font-family:'Manrope',sans-serif; font-size:.9rem; font-weight:700;
cursor:pointer; transition:opacity .15s,transform .1s;
}
.hm-btn-next:hover { opacity:.88; transform:translateY(-1px); }
/* ── Arena ── */
.hm-arena { display:flex; gap:32px; align-items:flex-start; }
/* ── Left: gallows ── */
.hm-left { flex-shrink:0; width:190px; display:flex; flex-direction:column; align-items:center; gap:16px; }
.hm-gallows { width:180px; height:195px; display:block; }
.gallows-struct { stroke:rgba(255,255,255,.18); stroke-width:2.5; fill:none; stroke-linecap:round; }
/* stroke-draw animation instead of opacity */
.gallows-part {
fill:none; stroke-linecap:round; stroke-linejoin:round; stroke-width:3.5;
transition: stroke-dashoffset .45s cubic-bezier(.4,0,.2,1);
}
#gp-0 { stroke:#F94144; stroke-dasharray:95; stroke-dashoffset:95; } /* head */
#gp-1 { stroke:#F94144; stroke-dasharray:50; stroke-dashoffset:50; } /* body */
#gp-2 { stroke:#F8961E; stroke-dasharray:32; stroke-dashoffset:32; } /* L arm */
#gp-3 { stroke:#F8961E; stroke-dasharray:32; stroke-dashoffset:32; } /* R arm */
#gp-4 { stroke:#F9C74F; stroke-dasharray:39; stroke-dashoffset:39; } /* L leg */
#gp-5 { stroke:#F9C74F; stroke-dasharray:39; stroke-dashoffset:39; } /* R leg */
/* !important needed: ID selectors (#gp-0) have higher specificity than .class.class */
.gallows-part.shown { stroke-dashoffset: 0 !important; }
/* ── Hearts ── */
.hm-hearts { display:flex; gap:5px; }
.hm-heart { width:24px; height:24px; flex-shrink:0; transition:transform .25s cubic-bezier(.34,1.56,.64,1),opacity .3s; }
.hm-heart svg { width:100%; height:100%; display:block; }
.hm-heart .heart-fill { fill:#F94144; transition:fill .25s; }
.hm-heart .heart-stroke { stroke:#F94144; transition:stroke .25s; }
.hm-heart.lost { opacity:.22; transform:scale(.72); }
.hm-heart.lost .heart-fill { fill:rgba(255,255,255,.1); }
.hm-heart.lost .heart-stroke { stroke:rgba(255,255,255,.25); }
/* ── Right ── */
.hm-right { flex:1; min-width:0; display:flex; flex-direction:column; gap:16px; }
/* meta row */
.hm-meta { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.hm-subj-badge {
display:inline-flex; align-items:center; gap:5px;
padding:3px 10px; border-radius:8px;
background:rgba(155,93,229,.12); border:1.5px solid rgba(155,93,229,.25);
font-size:.78rem; font-weight:700; color:var(--violet);
}
.hm-diff-badge { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:8px; font-size:.75rem; font-weight:700; }
.hm-diff-badge.easy { background:rgba(6,214,160,.1); color:#06D6A0; border:1.5px solid rgba(6,214,160,.25); }
.hm-diff-badge.med { background:rgba(249,168,37,.1); color:#F9A825; border:1.5px solid rgba(249,168,37,.25); }
.hm-diff-badge.hard { background:rgba(249,65,68,.1); color:#F94144; border:1.5px solid rgba(249,65,68,.25); }
.hm-len { font-size:.78rem; color:var(--text-3); font-weight:600; margin-left:auto; }
/* ── Word ── */
.hm-word { display:flex; gap:7px; flex-wrap:wrap; }
.hm-word.shake { animation:wordShake .45s ease; }
@keyframes wordShake {
0%,100% { transform:translateX(0); }
15% { transform:translateX(-9px); }
30% { transform:translateX(9px); }
50% { transform:translateX(-5px); }
70% { transform:translateX(5px); }
85% { transform:translateX(-2px); }
}
.hm-letter { display:flex; flex-direction:column; align-items:center; gap:4px; }
.hm-letter-char {
font-family:'Unbounded',sans-serif; font-size:1.15rem; font-weight:800;
min-width:28px; text-align:center; min-height:24px; line-height:1.1;
}
.hm-letter-char.reveal { animation:popIn .3s cubic-bezier(.34,1.56,.64,1) both; }
.hm-letter-char.win-letter { animation:winBounce .5s cubic-bezier(.34,1.56,.64,1) both; color:#06D6A0; }
@keyframes popIn { 0%{transform:scale(0) translateY(6px);opacity:0} 100%{transform:scale(1) translateY(0);opacity:1} }
@keyframes winBounce { 0%{transform:scale(1)} 40%{transform:scale(1.4) translateY(-8px)} 70%{transform:scale(.9)} 100%{transform:scale(1) translateY(0)} }
.hm-letter-line { min-width:28px; height:2px; background:var(--border-h); border-radius:2px; }
.hm-letter.space { width:12px; }
.hm-letter.space .hm-letter-line { opacity:0; }
.hm-letter.dash .hm-letter-char { color:var(--text-3); }
/* ── Progress bar ── */
.hm-progress { display:flex; align-items:center; gap:10px; }
.hm-prog-bar { flex:1; height:4px; background:rgba(255,255,255,.07); border-radius:2px; overflow:hidden; }
.hm-prog-fill { height:100%; border-radius:2px; width:0%; transition:width .35s cubic-bezier(.4,0,.2,1); background:linear-gradient(90deg,#9B5DE5,#06D6A0); }
.hm-prog-txt { font-size:.72rem; color:var(--text-3); font-weight:700; white-space:nowrap; font-family:'Unbounded',sans-serif; }
/* ── Actions ── */
.hm-actions { display:flex; gap:8px; align-items:center; }
.hm-hint-btn {
padding:6px 14px; border-radius:9px;
border:1.5px solid rgba(249,168,37,.4); background:rgba(249,168,37,.08); color:#F9A825;
font-family:'Manrope',sans-serif; font-size:.8rem; font-weight:700;
cursor:pointer; transition:all .15s; display:flex; align-items:center; gap:5px;
}
.hm-hint-btn:hover:not(:disabled) { background:rgba(249,168,37,.18); }
.hm-hint-btn:disabled { opacity:.3; cursor:default; }
.hm-skip-btn {
padding:6px 14px; border-radius:9px;
border:1.5px solid var(--border-h); background:transparent; color:var(--text-3);
font-family:'Manrope',sans-serif; font-size:.8rem; font-weight:700;
cursor:pointer; transition:all .15s; margin-left:auto;
display:inline-flex; align-items:center; gap:5px;
}
.hm-skip-btn:hover { border-color:var(--border); color:var(--text-2); }
/* ── Keyboard ── */
.hm-keyboard { display:flex; flex-direction:column; gap:5px; }
.hm-kb-row { display:flex; gap:4px; justify-content:center; }
.hm-key {
width:34px; height:40px; border-radius:8px;
border:1.5px solid var(--border-h); background:var(--bg);
color:var(--text); font-family:'Manrope',sans-serif; font-size:.85rem; font-weight:700;
cursor:pointer; transition:background .1s, border-color .1s, color .1s, box-shadow .1s;
display:flex; align-items:center; justify-content:center; flex-shrink:0; user-select:none;
}
.hm-key:hover:not(:disabled) {
border-color:var(--violet); color:var(--violet);
background:rgba(155,93,229,.08); transform:translateY(-2px);
box-shadow:0 4px 12px rgba(155,93,229,.2);
}
.hm-key:active:not(:disabled) { transform:scale(.9); }
.hm-key:disabled { cursor:default; }
.hm-key.wrong { background:rgba(249,65,68,.1); border-color:rgba(249,65,68,.35); color:#F94144; animation:keyWrong .35s ease; }
.hm-key.right { background:rgba(6,214,160,.12); border-color:rgba(6,214,160,.45); color:#06D6A0; animation:keyRight .3s cubic-bezier(.34,1.56,.64,1); }
@keyframes keyWrong { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-4px)} 75%{transform:translateX(4px)} }
@keyframes keyRight { 0%{transform:scale(1.3)} 100%{transform:scale(1)} }
/* ── Toast ── */
#hm-toast {
position:fixed; bottom:28px; left:50%; transform:translateX(-50%) translateY(90px);
background:linear-gradient(135deg,#9B5DE5,#F94144);
color:#fff; padding:10px 24px; border-radius:99px;
font-family:'Manrope',sans-serif; font-size:.85rem; font-weight:700;
transition:transform .35s cubic-bezier(.16,1,.3,1);
z-index:1000; white-space:nowrap; pointer-events:none;
box-shadow:0 8px 32px rgba(155,93,229,.5);
display:flex; align-items:center; gap:8px;
}
#hm-toast.show { transform:translateX(-50%) translateY(0); }
#hm-toast i { width:16px; height:16px; flex-shrink:0; }
/* ── Mobile ── */
@media (max-width:640px) {
.hm-arena { flex-direction:column; gap:16px; }
.hm-left { width:100%; flex-direction:row; justify-content:space-around; align-items:center; }
.hm-gallows { width:130px; height:145px; }
.hm-key { width:30px; height:36px; font-size:.78rem; border-radius:6px; }
}
</style>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<div class="hm-wrap">
<div class="hm-header">
<div class="hm-icon"><i data-lucide="target" class="hm-icon-main"></i></div>
<div>
<div class="hm-title">Виселица</div>
<div class="hm-sub">Угадай термин из учебной программы по буквам</div>
</div>
<div class="hm-header-right">
<button class="hm-icon-btn" id="hm-sound-btn" onclick="toggleSound()" title="Звук">
<i data-lucide="volume-2" style="width:16px;height:16px"></i>
</button>
</div>
</div>
<div class="hm-statsbar">
<div class="hm-stat"><i data-lucide="flame" class="hm-ico" style="stroke:#F8961E"></i>Серия: <span class="hm-stat-val" id="stat-streak">0</span></div>
<div class="hm-stat"><i data-lucide="circle-check" class="hm-ico" style="stroke:#06D6A0"></i>Победы: <span class="hm-stat-val" id="stat-wins">0</span></div>
<div class="hm-stat"><i data-lucide="circle-x" class="hm-ico" style="stroke:#F94144"></i>Поражений: <span class="hm-stat-val" id="stat-losses">0</span></div>
<div class="hm-stat"><i data-lucide="trophy" class="hm-ico" style="stroke:#F9C74F"></i>Рекорд: <span class="hm-stat-val" id="stat-best">0</span></div>
<div class="hm-stat"><i data-lucide="zap" class="hm-ico" style="stroke:#9B5DE5"></i>XP: <span class="hm-stat-val" id="stat-xp">0</span></div>
</div>
<div class="hm-filters" id="hm-filters">
<button class="hm-filter active" data-slug=""><i data-lucide="layers" class="hm-ico-sm"></i>Все предметы</button>
<button class="hm-filter" data-slug="bio"> <i data-lucide="dna" class="hm-ico-sm"></i>Биология</button>
<button class="hm-filter" data-slug="chem"><i data-lucide="flask-conical" class="hm-ico-sm"></i>Химия</button>
<button class="hm-filter" data-slug="math"><i data-lucide="calculator" class="hm-ico-sm"></i>Математика</button>
<button class="hm-filter" data-slug="phys"><i data-lucide="zap" class="hm-ico-sm"></i>Физика</button>
</div>
<div class="hm-card" id="hm-card">
<div class="hm-loading" id="hm-loading">Загрузка…</div>
<canvas id="hm-confetti"></canvas>
<div class="hm-result" id="hm-result">
<div class="hm-result-emoji">
<i data-lucide="trophy" class="hm-res-icon win-icon" id="res-win"></i>
<i data-lucide="frown" class="hm-res-icon lose-icon" id="res-lose"></i>
</div>
<div class="hm-result-title" id="hm-result-title">Победа!</div>
<div class="hm-result-word" id="hm-result-word"></div>
<div class="hm-result-msg" id="hm-result-msg"></div>
<div class="hm-xp-badge" id="hm-xp-badge" style="display:none">
<i data-lucide="zap" class="hm-ico"></i><span id="hm-xp-txt">+0 XP</span>
</div>
<button class="hm-btn-next" onclick="loadWord()">Следующее слово <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
</div>
<div id="hm-game" style="display:none">
<div class="hm-arena">
<div class="hm-left">
<svg class="hm-gallows" viewBox="0 0 180 200" xmlns="http://www.w3.org/2000/svg">
<line x1="20" y1="190" x2="160" y2="190" class="gallows-struct"/>
<line x1="60" y1="190" x2="60" y2="20" class="gallows-struct"/>
<line x1="60" y1="20" x2="120" y2="20" class="gallows-struct"/>
<line x1="120" y1="20" x2="120" y2="50" class="gallows-struct"/>
<circle cx="120" cy="52" r="3" fill="rgba(255,255,255,.2)"/>
<circle cx="120" cy="67" r="15" class="gallows-part" id="gp-0"/>
<line x1="120" y1="82" x2="120" y2="132" class="gallows-part" id="gp-1"/>
<line x1="120" y1="97" x2="95" y2="117" class="gallows-part" id="gp-2"/>
<line x1="120" y1="97" x2="145" y2="117" class="gallows-part" id="gp-3"/>
<line x1="120" y1="132" x2="95" y2="162" class="gallows-part" id="gp-4"/>
<line x1="120" y1="132" x2="145" y2="162" class="gallows-part" id="gp-5"/>
</svg>
<div class="hm-hearts" id="hm-hearts"></div>
</div>
<div class="hm-right">
<div class="hm-meta">
<span class="hm-subj-badge" id="hm-subj-badge"></span>
<span class="hm-diff-badge" id="hm-diff-badge"></span>
<span class="hm-len" id="hm-len">— букв</span>
</div>
<div class="hm-word" id="hm-word"></div>
<div class="hm-progress">
<div class="hm-prog-bar"><div class="hm-prog-fill" id="hm-prog-fill"></div></div>
<span class="hm-prog-txt" id="hm-prog-txt">0 / 0</span>
</div>
<div class="hm-actions">
<button class="hm-hint-btn" id="hm-hint-btn" onclick="useHint()">
<i data-lucide="lightbulb" class="hm-ico"></i><span id="hm-hint-txt">Подсказка</span>
</button>
<button class="hm-skip-btn" onclick="loadWord()">
<i data-lucide="skip-forward" class="hm-ico"></i>Пропустить
</button>
</div>
<div class="hm-keyboard" id="hm-keyboard"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div id="hm-toast"><i data-lucide="award" style="width:16px;height:16px"></i><span id="hm-toast-txt"></span></div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<script>
/* ── Keyboard rows ─────────────────────────────────────────────────────── */
const KB_ROWS = [
'ЙЦУКЕНГШЩЗХЪЁ',
'ФЫВАПРОЛДЖЭ',
'ЯЧСМИТЬБЮ',
];
/* ── Game state ────────────────────────────────────────────────────────── */
let _word = '';
let _guessed = new Set();
let _errors = 0;
let _finished = false;
let _hintUsed = false;
let _curSlug = '';
let _confettiRaf = null;
/* ── Session stats ─────────────────────────────────────────────────────── */
const _s = { wins:0, losses:0, streak:0, xp:0 };
let _bestStreak = parseInt(localStorage.getItem('hm_best_streak') || '0');
let _soundOn = localStorage.getItem('hm_sound') !== '0';
function updateStatsBar() {
document.getElementById('stat-streak').textContent = _s.streak;
document.getElementById('stat-wins').textContent = _s.wins;
document.getElementById('stat-losses').textContent = _s.losses;
document.getElementById('stat-xp').textContent = _s.xp;
document.getElementById('stat-best').textContent = _bestStreak;
}
/* ── Audio ─────────────────────────────────────────────────────────────── */
let _ac = null;
function _getAC() {
if (!_ac) _ac = new (window.AudioContext || window.webkitAudioContext)();
return _ac;
}
function _tone(freq, dur, type = 'sine', vol = .22) {
if (!_soundOn) return;
try {
const ac = _getAC();
if (ac.state === 'suspended') ac.resume();
const osc = ac.createOscillator();
const gain = ac.createGain();
osc.connect(gain); gain.connect(ac.destination);
osc.type = type; osc.frequency.value = freq;
gain.gain.setValueAtTime(vol, ac.currentTime);
gain.gain.exponentialRampToValueAtTime(.001, ac.currentTime + dur);
osc.start(); osc.stop(ac.currentTime + dur);
} catch {}
}
function sndCorrect() { _tone(660,.1); setTimeout(()=>_tone(880,.1),90); }
function sndWrong() { _tone(180,.25,'sawtooth',.18); }
function sndWin() { [523,659,784,1047].forEach((f,i)=>setTimeout(()=>_tone(f,.2),i*95)); }
function sndLose() { _tone(200,.3,'sawtooth'); setTimeout(()=>_tone(140,.5,'sawtooth'),200); }
function toggleSound() {
_soundOn = !_soundOn;
localStorage.setItem('hm_sound', _soundOn ? '1' : '0');
const btn = document.getElementById('hm-sound-btn');
btn.innerHTML = `<i data-lucide="${_soundOn ? 'volume-2' : 'volume-x'}" style="width:16px;height:16px"></i>`;
lucide.createIcons();
if (_soundOn) sndCorrect();
}
/* ── Toast ─────────────────────────────────────────────────────────────── */
let _toastTimer = null;
function showToast(msg) {
document.getElementById('hm-toast-txt').textContent = msg;
const t = document.getElementById('hm-toast');
t.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => t.classList.remove('show'), 2800);
}
/* ── Init ──────────────────────────────────────────────────────────────── */
(async () => {
if (!LS.requireAuth()) return;
const user = LS.getUser();
LS.initPage();
LS.showBoardIfAllowed();
LS.notif.init();
lucide.createIcons();
// Feature gate
const feats = await LS.loadFeatures();
if (feats.hangman === false) {
document.querySelector('.hm-wrap').innerHTML =
'<div style="color:var(--text-2);text-align:center;padding:80px 20px;font-size:1rem">Виселица отключена администратором.</div>';
LS.hideDisabledFeatures();
return;
}
LS.hideDisabledFeatures();
// Apply stored sound state
if (!_soundOn) {
const btn = document.getElementById('hm-sound-btn');
btn.innerHTML = `<i data-lucide="volume-x" style="width:16px;height:16px"></i>`;
lucide.createIcons();
}
updateStatsBar();
buildKeyboard();
initFilters();
await loadWord();
})();
/* ── Filters ───────────────────────────────────────────────────────────── */
function initFilters() {
document.getElementById('hm-filters').addEventListener('click', e => {
const btn = e.target.closest('.hm-filter');
if (!btn) return;
document.querySelectorAll('.hm-filter').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_curSlug = btn.dataset.slug || '';
loadWord();
});
}
/* ── Load word ─────────────────────────────────────────────────────────── */
async function loadWord() {
document.getElementById('hm-result').classList.remove('show');
document.getElementById('hm-loading').style.display = '';
document.getElementById('hm-game').style.display = 'none';
stopConfetti();
const qs = _curSlug ? `?subject_slug=${_curSlug}` : '';
const data = await LS.api(`/api/games/hangman/word${qs}`).catch(() => null);
if (!data || data.error) {
document.getElementById('hm-loading').textContent = data?.error === 'No topics found'
? 'Нет слов для этого предмета — попробуй другой.'
: 'Ошибка загрузки. Попробуй позже.';
return;
}
_word = data.word.toUpperCase();
_guessed = new Set();
_errors = 0;
_finished = false;
_hintUsed = false;
const letterCount = Array.from(_word).filter(c => /[А-ЯЁA-Z]/.test(c)).length;
// Difficulty
const diffEl = document.getElementById('hm-diff-badge');
if (letterCount <= 4) {
diffEl.innerHTML = '<i data-lucide="star" class="hm-ico-sm"></i>Лёгкое';
diffEl.className = 'hm-diff-badge easy';
} else if (letterCount <= 8) {
diffEl.innerHTML = '<i data-lucide="star" class="hm-ico-sm"></i>Среднее';
diffEl.className = 'hm-diff-badge med';
} else {
diffEl.innerHTML = '<i data-lucide="star" class="hm-ico-sm"></i>Сложное';
diffEl.className = 'hm-diff-badge hard';
}
lucide.createIcons();
document.getElementById('hm-subj-badge').textContent = data.hint;
document.getElementById('hm-len').textContent = `${letterCount} букв`;
// Reset gallows (stroke-dashoffset back to full = hidden)
for (let i = 0; i < 6; i++) document.getElementById(`gp-${i}`).classList.remove('shown');
buildHearts();
// Reset hint
document.getElementById('hm-hint-btn').disabled = false;
document.getElementById('hm-hint-txt').textContent = 'Подсказка';
buildWordDisplay();
resetKeyboard();
updateProgress();
document.getElementById('hm-loading').style.display = 'none';
document.getElementById('hm-game').style.display = 'flex';
}
/* ── Hearts ────────────────────────────────────────────────────────────── */
const HEART_SVG = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path class="heart-fill heart-stroke" stroke-width="1.5" stroke-linejoin="round"
d="M12 21C12 21 3 14.5 3 8.5C3 6 5 4 7.5 4C9.24 4 10.91 5.01 12 6.5C13.09 5.01 14.76 4 16.5 4C19 4 21 6 21 8.5C21 14.5 12 21 12 21Z"/>
</svg>`;
function buildHearts() {
const c = document.getElementById('hm-hearts');
c.innerHTML = '';
for (let i = 0; i < 6; i++) {
const s = document.createElement('span');
s.className = 'hm-heart'; s.id = `heart-${i}`; s.innerHTML = HEART_SVG;
c.appendChild(s);
}
}
function loseHeart(i) {
const el = document.getElementById(`heart-${i}`);
if (el) el.classList.add('lost');
}
/* ── Word display ──────────────────────────────────────────────────────── */
function buildWordDisplay() {
const container = document.getElementById('hm-word');
container.innerHTML = '';
for (const ch of _word) {
const cell = document.createElement('div');
if (ch === ' ') {
cell.className = 'hm-letter space';
cell.appendChild(Object.assign(document.createElement('div'), {className:'hm-letter-line'}));
} else if (!/[А-ЯЁA-Z]/.test(ch)) {
cell.className = 'hm-letter dash';
const cd = document.createElement('div'); cd.className = 'hm-letter-char'; cd.textContent = ch;
const ln = document.createElement('div'); ln.className = 'hm-letter-line';
cell.appendChild(cd); cell.appendChild(ln);
_guessed.add(ch);
} else {
cell.className = 'hm-letter';
const cd = document.createElement('div'); cd.className = 'hm-letter-char';
const ln = document.createElement('div'); ln.className = 'hm-letter-line';
cell.appendChild(cd); cell.appendChild(ln);
}
container.appendChild(cell);
}
refreshWordDisplay();
}
function refreshWordDisplay() {
const cells = document.getElementById('hm-word').children;
let i = 0;
for (const ch of _word) {
const cell = cells[i++];
if (ch === ' ' || !/[А-ЯЁA-Z]/.test(ch)) continue;
const cd = cell.querySelector('.hm-letter-char');
if (_guessed.has(ch)) {
if (!cd.textContent) {
cd.textContent = ch;
cd.classList.add('reveal');
setTimeout(() => cd.classList.remove('reveal'), 400);
}
} else {
cd.textContent = '';
}
}
updateProgress();
}
/* ── Progress bar ──────────────────────────────────────────────────────── */
function updateProgress() {
const letters = Array.from(_word).filter(c => /[А-ЯЁA-Z]/.test(c));
const revealed = letters.filter(c => _guessed.has(c)).length;
const total = letters.length;
const pct = total > 0 ? Math.round(revealed / total * 100) : 0;
document.getElementById('hm-prog-fill').style.width = pct + '%';
document.getElementById('hm-prog-txt').textContent = `${revealed} / ${total}`;
}
/* ── Keyboard ──────────────────────────────────────────────────────────── */
function buildKeyboard() {
const kb = document.getElementById('hm-keyboard');
KB_ROWS.forEach(row => {
const div = document.createElement('div');
div.className = 'hm-kb-row';
for (const ch of row) {
const btn = document.createElement('button');
btn.className = 'hm-key'; btn.textContent = ch; btn.dataset.letter = ch;
btn.onclick = () => guess(ch);
div.appendChild(btn);
}
kb.appendChild(div);
});
}
function resetKeyboard() {
document.querySelectorAll('.hm-key').forEach(btn => { btn.disabled = false; btn.className = 'hm-key'; });
}
/* ── Hint ──────────────────────────────────────────────────────────────── */
function useHint() {
if (_hintUsed || _finished) return;
const missing = [...new Set(Array.from(_word).filter(c => /[А-ЯЁA-Z]/.test(c) && !_guessed.has(c)))];
if (!missing.length) return;
_hintUsed = true;
document.getElementById('hm-hint-btn').disabled = true;
document.getElementById('hm-hint-txt').textContent = 'Использована';
guess(missing[Math.floor(Math.random() * missing.length)]);
}
/* ── Guess ─────────────────────────────────────────────────────────────── */
function guess(letter) {
if (_finished || _guessed.has(letter)) return;
_guessed.add(letter);
const btn = document.querySelector(`.hm-key[data-letter="${letter}"]`);
if (_word.includes(letter)) {
if (btn) { btn.classList.add('right'); btn.disabled = true; }
refreshWordDisplay();
sndCorrect();
const letters = Array.from(_word).filter(c => /[А-ЯЁA-Z]/.test(c));
if (letters.every(c => _guessed.has(c))) endGame(true);
} else {
if (btn) { btn.classList.add('wrong'); btn.disabled = true; }
loseHeart(_errors);
_errors++;
document.getElementById(`gp-${_errors - 1}`).classList.add('shown');
shakeWord();
sndWrong();
if (_errors >= 6) endGame(false);
}
}
/* ── Word shake ────────────────────────────────────────────────────────── */
function shakeWord() {
const el = document.getElementById('hm-word');
el.classList.remove('shake');
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('shake')));
setTimeout(() => el.classList.remove('shake'), 500);
}
/* ── Win cascade ───────────────────────────────────────────────────────── */
function winCascade() {
const cells = document.getElementById('hm-word').children;
let delay = 0;
for (let i = 0; i < cells.length; i++) {
const cd = cells[i].querySelector('.hm-letter-char');
if (cd?.textContent) {
const d = delay;
setTimeout(() => cd.classList.add('win-letter'), d);
delay += 55;
}
}
}
/* ── End game ──────────────────────────────────────────────────────────── */
async function endGame(won) {
_finished = true;
document.querySelectorAll('.hm-key').forEach(b => b.disabled = true);
document.getElementById('hm-hint-btn').disabled = true;
if (!won) {
for (const ch of _word) { if (/[А-ЯЁA-Z]/.test(ch)) _guessed.add(ch); }
refreshWordDisplay();
sndLose();
} else {
sndWin();
winCascade();
}
// Stats
if (won) {
_s.wins++; _s.streak++;
if (_s.streak > _bestStreak) {
_bestStreak = _s.streak;
localStorage.setItem('hm_best_streak', _bestStreak);
if (_bestStreak > 1) showToast(`Новый рекорд! Серия: ${_bestStreak}`);
}
} else {
_s.losses++; _s.streak = 0;
}
updateStatsBar();
// Result overlay
document.getElementById('res-win').classList.toggle('visible', won);
document.getElementById('res-lose').classList.toggle('visible', !won);
const titleEl = document.getElementById('hm-result-title');
titleEl.textContent = won ? 'Победа!' : 'Проигрыш';
titleEl.className = 'hm-result-title ' + (won ? 'win' : 'lose');
document.getElementById('hm-result-word').textContent = _word;
document.getElementById('hm-result-msg').textContent = won
? `Угадано за ${_errors} ${_errors===1?'ошибку':_errors<5?'ошибки':'ошибок'}. Серия побед: ${_s.streak}`
: 'Правильный ответ выше. Не сдавайся!';
const xpBadge = document.getElementById('hm-xp-badge');
if (won) {
const resp = await LS.api('/api/games/hangman/complete', {
method:'POST', body:JSON.stringify({ won:true, errors:_errors })
}).catch(() => null);
const xp = resp?.xp || 0;
if (xp > 0) {
_s.xp += xp; updateStatsBar();
document.getElementById('hm-xp-txt').textContent = `+${xp} XP`;
xpBadge.style.display = '';
} else { xpBadge.style.display = 'none'; }
} else {
xpBadge.style.display = 'none';
LS.api('/api/games/hangman/complete', {
method:'POST', body:JSON.stringify({ won:false, errors:_errors })
}).catch(() => null);
}
document.getElementById('hm-result').classList.add('show');
if (won) launchConfetti();
}
/* ── Confetti ──────────────────────────────────────────────────────────── */
const COLORS = ['#9B5DE5','#F94144','#06D6A0','#F9C74F','#F8961E','#4CC9F0','#FF6B9D'];
function launchConfetti() {
const card = document.getElementById('hm-card');
const cvs = document.getElementById('hm-confetti');
const W = card.offsetWidth, H = card.offsetHeight;
cvs.width = W; cvs.height = H; cvs.style.display = 'block';
const ctx = cvs.getContext('2d');
const p = Array.from({length:90}, () => ({
x: W*(.2+Math.random()*.6),
y: H*.3+Math.random()*H*.1,
vx: (Math.random()-.5)*6,
vy: -5-Math.random()*5,
sz: 5+Math.random()*7,
color: COLORS[Math.floor(Math.random()*COLORS.length)],
rot: Math.random()*Math.PI*2, vr:(Math.random()-.5)*.2,
shape: Math.random()>.5?'rect':'circle',
}));
const t0 = performance.now();
function draw(now) {
const t = (now-t0)/1000;
if (t > 3.5) { stopConfetti(); return; }
ctx.clearRect(0,0,W,H);
for (const q of p) {
q.x+=q.vx; q.vy+=.15; q.y+=q.vy; q.rot+=q.vr;
ctx.globalAlpha = Math.max(0, 1-t/3);
ctx.fillStyle = q.color;
ctx.save(); ctx.translate(q.x,q.y); ctx.rotate(q.rot);
if (q.shape==='rect') ctx.fillRect(-q.sz/2,-q.sz/4,q.sz,q.sz/2);
else { ctx.beginPath(); ctx.arc(0,0,q.sz/2,0,Math.PI*2); ctx.fill(); }
ctx.restore();
}
_confettiRaf = requestAnimationFrame(draw);
}
_confettiRaf = requestAnimationFrame(draw);
}
function stopConfetti() {
if (_confettiRaf) { cancelAnimationFrame(_confettiRaf); _confettiRaf = null; }
const cvs = document.getElementById('hm-confetti');
if (cvs) { cvs.style.display='none'; cvs.getContext('2d').clearRect(0,0,cvs.width,cvs.height); }
}
/* ── Physical keyboard ─────────────────────────────────────────────────── */
document.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const ch = e.key.toUpperCase();
if (/^[А-ЯЁ]$/.test(ch) && !_finished) guess(ch);
});
</script>
</body>
</html>