edb4c211a0
- 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>
826 lines
38 KiB
HTML
826 lines
38 KiB
HTML
<!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>
|