Files
Learn_System/frontend/crossword.html
Maxim Dolgolyov 5381679c68 chore: консолидация незакоммиченной работы (биохимия + System Health + lab/textbooks)
Зафиксирована накопленная незакоммиченная работа рабочего дерева, КРОМЕ файлов
учебника «Химия 7» (migration 046, chemistry_7_*.html, chem7_svg.js, тест —
оставлены незакоммиченными по запросу).

Включает: модуль биохимии (ядро BIO, 3D VSEPR, химдвижок, баланс, challenges,
пути из БД), System Health Level 1 (вердикт/мониторинг), а также frontend-
страницы и lab/textbooks-правки параллельной сессии.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:12:55 +03:00

780 lines
30 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" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" crossorigin="anonymous" />
<style>
.sb-content { padding: 0; }
.cw-wrap {
min-height: 100vh;
padding: 28px 24px 60px;
max-width: 1100px; margin: 0 auto; width: 100%;
}
/* ── Header ── */
.cw-header { display:flex; align-items:center; gap:14px; margin-bottom:20px; }
.cw-icon {
width:52px; height:52px; border-radius:14px; flex-shrink:0;
background: linear-gradient(135deg,rgba(6,214,224,.2),rgba(155,93,229,.2));
border:1.5px solid rgba(255,255,255,.1);
display:flex; align-items:center; justify-content:center;
}
.cw-icon svg { width:26px; height:26px; stroke:#06D6E0; stroke-width:1.8; fill:none; }
.cw-title { font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:800; letter-spacing:-.02em; }
.cw-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
.cw-header-right { margin-left:auto; display:flex; align-items:center; gap:8px; }
/* ── Subject filter ── */
.cw-filter { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:20px; }
.cw-pill {
padding:6px 16px; border-radius:99px; border:1.5px solid rgba(255,255,255,.12);
font-size:.8rem; font-weight:600; cursor:pointer; transition:all .15s;
background:transparent; color:var(--text-2);
}
.cw-pill:hover { border-color:rgba(6,214,224,.4); color:#06D6E0; }
.cw-pill.active { background:rgba(6,214,224,.15); border-color:#06D6E0; color:#06D6E0; }
/* ── Main layout ── */
.cw-main { display:flex; gap:20px; align-items:flex-start; }
/* ── Grid ── */
.cw-grid-wrap {
background:var(--surface); border:1.5px solid var(--border);
border-radius:16px; padding:14px; flex-shrink:0;
max-width:480px;
box-shadow: var(--shadow);
}
.cw-grid { display:inline-grid; gap:2px; }
.cw-cell {
width:30px; height:30px;
border-radius:5px;
position:relative; cursor:pointer;
display:flex; align-items:center; justify-content:center;
font-size:13px; font-weight:700;
font-family:'Manrope',sans-serif; text-transform:uppercase;
transition: background .1s, border-color .1s;
user-select:none;
}
.cw-cell.black { background:rgba(15,23,42,.1); cursor:default; }
.cw-cell.white {
background:#fff;
border:1.5px solid rgba(15,23,42,.22);
color:var(--text);
box-shadow:0 1px 3px rgba(15,23,42,.08);
}
.cw-cell.white:hover { background:rgba(6,214,224,.12); border-color:rgba(6,214,224,.5); }
.cw-cell.selected { background:rgba(6,214,224,.25) !important; border-color:#06D6E0 !important; }
.cw-cell.highlighted { background:rgba(6,214,224,.1); border-color:rgba(6,214,224,.35); }
.cw-cell.correct { background:rgba(56,217,90,.2) !important; border-color:#38D95A !important; color:#1a7a40 !important; }
.cw-cell.wrong { background:rgba(249,65,68,.15) !important; border-color:#F94144 !important; color:#c0392b !important; }
.cw-num {
position:absolute; top:2px; left:2px;
font-size:7px; font-weight:800; color:var(--text-2);
line-height:1; pointer-events:none;
}
.cw-letter { pointer-events:none; }
/* ── Clues panel ── */
.cw-clues {
flex:1; min-width:0;
background:var(--surface); border:1.5px solid var(--border);
border-radius:16px; padding:14px 16px;
max-height:260px; overflow-y:auto;
box-shadow: var(--shadow);
}
.cw-clues-title {
font-family:'Unbounded',sans-serif; font-size:.78rem; font-weight:800;
color:var(--text-2); text-transform:uppercase; letter-spacing:.05em;
margin-bottom:10px; padding-bottom:8px; border-bottom:1px solid rgba(255,255,255,.08);
}
.cw-clue-item {
display:flex; gap:8px; align-items:flex-start;
padding:7px 8px; border-radius:8px; cursor:pointer;
transition: background .1s;
margin-bottom:3px;
}
.cw-clue-item:hover { background:rgba(255,255,255,.05); }
.cw-clue-item.active { background:rgba(6,214,224,.12); }
.cw-clue-num {
font-family:'Unbounded',sans-serif; font-size:.7rem; font-weight:800;
color:#06D6E0; flex-shrink:0; min-width:18px; padding-top:1px;
}
.cw-clue-text { font-size:.82rem; color:var(--text); line-height:1.5; }
.cw-clue-meta { font-size:.72rem; color:var(--text-2); margin-top:2px; }
.cw-clue-done { text-decoration:line-through; opacity:.5; }
/* ── Controls ── */
.cw-controls {
display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; align-items:center;
}
.cw-btn {
padding:8px 18px; border-radius:99px;
border:1.5px solid rgba(255,255,255,.15);
font-family:'Manrope',sans-serif; font-size:.82rem; font-weight:700;
cursor:pointer; transition:all .15s; color:var(--text); background:transparent;
display:flex; align-items:center; gap:6px;
}
.cw-btn:hover { background:rgba(255,255,255,.08); }
.cw-btn.primary { background:#06D6E0; border-color:#06D6E0; color:#0d1117; }
.cw-btn.primary:hover { background:#04b8c0; }
.cw-btn.danger { border-color:rgba(249,65,68,.4); color:#F94144; }
.cw-btn.danger:hover { background:rgba(249,65,68,.1); }
.cw-btn svg { width:14px; height:14px; stroke:currentColor; fill:none; stroke-width:2; }
.cw-progress {
margin-left:auto; font-size:.82rem; color:var(--text-2); font-weight:600;
}
.cw-progress span { color:#06D6E0; font-family:'Unbounded',sans-serif; font-weight:800; }
/* ── Result overlay ── */
.cw-result {
position:fixed; inset:0; z-index:200;
background:rgba(0,0,0,.7); backdrop-filter:blur(8px);
display:none; align-items:center; justify-content:center;
}
.cw-result.visible { display:flex; }
.cw-result-card {
background:var(--surface); border:1.5px solid rgba(255,255,255,.12);
border-radius:24px; padding:36px 40px; text-align:center; max-width:420px;
animation: resIn .4s cubic-bezier(.34,1.56,.64,1);
}
@keyframes resIn { from{opacity:0;transform:scale(.7)} to{opacity:1;transform:scale(1)} }
.cw-res-icon { width:64px; height:64px; margin:0 auto 12px; }
.cw-res-icon svg { width:64px; height:64px; }
.cw-res-title { font-family:'Unbounded',sans-serif; font-size:1.4rem; font-weight:800; margin-bottom:8px; }
.cw-res-sub { font-size:.88rem; color:var(--text-2); margin-bottom:20px; }
.cw-res-xp { font-family:'Unbounded',sans-serif; font-size:1.1rem; font-weight:800; color:#F9C74F; margin-bottom:24px; }
.cw-res-actions { display:flex; gap:10px; justify-content:center; }
/* ── Loading / empty ── */
.cw-loading { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:60px; color:var(--text-2); }
.cw-loading-spin {
width:40px; height:40px; border-radius:50%;
border:3px solid rgba(6,214,224,.15); border-top-color:#06D6E0;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform:rotate(360deg); } }
/* ── Toast ── */
.cw-toast {
position:fixed; bottom:24px; left:50%; transform:translateX(-50%) translateY(80px);
background:var(--surface); border:1.5px solid rgba(255,255,255,.15);
border-radius:12px; padding:10px 20px;
font-size:.84rem; font-weight:600; color:var(--text);
transition:transform .3s cubic-bezier(.34,1.56,.64,1);
z-index:300; white-space:nowrap;
}
.cw-toast.show { transform:translateX(-50%) translateY(0); }
@media (max-width:768px) {
.cw-main { flex-direction:column; }
.cw-clues { max-height:300px; }
.cw-grid-wrap { max-width:100%; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<div class="cw-wrap">
<div class="cw-header">
<div class="cw-icon">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
</div>
<div>
<div class="cw-title">Кроссворд</div>
<div class="cw-sub">Угадай биологические термины по вопросам</div>
</div>
<div class="cw-header-right">
<button class="cw-btn primary" id="btn-new-game" onclick="loadCrossword()">
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Новый
</button>
</div>
</div>
<!-- Subject filter -->
<div class="cw-filter" id="cw-filter">
<button class="cw-pill active" data-slug="" onclick="setSubject(this,'')">Все предметы</button>
<button class="cw-pill" data-slug="bio" onclick="setSubject(this,'bio')">Биология</button>
<button class="cw-pill" data-slug="chem" onclick="setSubject(this,'chem')">Химия</button>
<button class="cw-pill" data-slug="math" onclick="setSubject(this,'math')">Математика</button>
<button class="cw-pill" data-slug="phys" onclick="setSubject(this,'phys')">Физика</button>
</div>
<!-- Controls -->
<div class="cw-controls" id="cw-controls" style="display:none">
<button class="cw-btn" id="btn-check" onclick="checkAnswers()">
<svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
Проверить
</button>
<button class="cw-btn" id="btn-reveal" onclick="revealSelected()">
<svg 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>
Открыть слово
</button>
<button class="cw-btn danger" id="btn-give-up" onclick="giveUp()">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Сдаться
</button>
<div class="cw-progress">Угадано: <span id="cw-solved">0</span>/<span id="cw-total">0</span></div>
</div>
<!-- Main content -->
<div id="cw-loading" class="cw-loading">
<div class="cw-loading-spin"></div>
<div>Генерируем кроссворд…</div>
</div>
<div class="cw-main" id="cw-main" style="display:none">
<div class="cw-grid-wrap">
<div class="cw-grid" id="cw-grid"></div>
</div>
<div style="flex:1; min-width:0; display:flex; flex-direction:column; gap:12px;">
<div class="cw-clues" id="cw-clues-across">
<div class="cw-clues-title"><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> По горизонтали</div>
<div id="clues-across-list"></div>
</div>
<div class="cw-clues" id="cw-clues-down">
<div class="cw-clues-title"><svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> По вертикали</div>
<div id="clues-down-list"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Result overlay -->
<div class="cw-result" id="cw-result">
<div class="cw-result-card">
<div class="cw-res-icon" id="res-icon"></div>
<div class="cw-res-title" id="res-title"></div>
<div class="cw-res-sub" id="res-sub"></div>
<div class="cw-res-xp" id="res-xp" style="display:none"></div>
<div class="cw-res-actions">
<button class="cw-btn primary" onclick="loadCrossword(); closeResult()">
<svg viewBox="0 0 24 24" style="width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Новый кроссворд
</button>
<button class="cw-btn" onclick="closeResult()">Остаться</button>
</div>
</div>
</div>
<!-- Toast -->
<div class="cw-toast" id="cw-toast"></div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script>
/* ── State (must be before the IIFE to avoid TDZ) ── */
let _data = null;
let _userGrid = [];
let _revealed = new Set();
let _sel = { r: -1, c: -1, dir: 'across' };
let _hintsUsed = 0;
let _solved = new Set();
let _finished = false;
let _subjectSlug = '';
(async () => {
if (!LS.requireAuth()) return;
const user = LS.getUser();
LS.applyRoleSidebar(user);
if (user) {
LS.renderNavAvatar(document.getElementById('nav-avatar'), user);
document.getElementById('nav-user').textContent = user.name || '—';
LS.showBoardIfAllowed();
}
LS.sidebar?.init();
lucide.createIcons();
// Feature gate
const feats = await LS.loadFeatures();
if (feats.crossword === false) {
document.getElementById('cw-loading').innerHTML =
'<div style="color:var(--text-2);text-align:center">Кроссворд отключён администратором.</div>';
LS.hideDisabledFeatures();
return;
}
LS.hideDisabledFeatures();
await loadCrossword();
})();
/* ── Load ── */
async function loadCrossword() {
_finished = false;
_data = null;
_userGrid = [];
_revealed = new Set();
_solved = new Set();
_hintsUsed = 0;
_sel = { r: -1, c: -1, dir: 'across' };
document.getElementById('cw-loading').style.display = '';
document.getElementById('cw-main').style.display = 'none';
document.getElementById('cw-controls').style.display = 'none';
const qs = _subjectSlug ? `?subject_slug=${_subjectSlug}` : '';
const data = await LS.api(`/api/games/crossword/generate${qs}`).catch(() => null);
if (!data || !data.grid) {
document.getElementById('cw-loading').innerHTML =
'<div style="color:var(--text-2);text-align:center">Недостаточно тем для кроссворда.<br>Попробуйте другой предмет.</div>';
return;
}
_data = data;
_userGrid = data.grid.map(row => row.map(cell => cell === null ? null : ''));
document.getElementById('cw-loading').style.display = 'none';
document.getElementById('cw-main').style.display = '';
document.getElementById('cw-controls').style.display = '';
renderGrid();
renderClues();
updateProgress();
}
function setSubject(el, slug) {
document.querySelectorAll('.cw-pill').forEach(p => p.classList.remove('active'));
el.classList.add('active');
_subjectSlug = slug;
loadCrossword();
}
/* ── Render grid ── */
function renderGrid() {
if (!_data) return;
const { grid, words } = _data;
const rows = grid.length, cols = grid[0].length;
// Build number map
const numMap = {};
for (const w of words) numMap[`${w.row},${w.col}`] = w.num;
// Dynamic cell size: fit grid in 452px (480px wrap 28px padding)
const isMobile = window.innerWidth <= 768;
const maxPx = isMobile ? Math.min(window.innerWidth - 40, 420) : 452;
const cs = Math.max(18, Math.min(32, Math.floor((maxPx - 4) / Math.max(cols, rows) - 2)));
const fs = Math.round(cs * 0.44); // letter font-size
const ns = Math.max(7, Math.round(cs * 0.24)); // number font-size
const container = document.getElementById('cw-grid');
container.style.gridTemplateColumns = `repeat(${cols}, ${cs}px)`;
container.innerHTML = '';
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cell = document.createElement('div');
cell.className = grid[r][c] === null ? 'cw-cell black' : 'cw-cell white';
cell.dataset.r = r;
cell.dataset.c = c;
cell.style.width = cs + 'px';
cell.style.height = cs + 'px';
cell.style.fontSize = fs + 'px';
if (grid[r][c] !== null) {
const num = numMap[`${r},${c}`];
if (num) {
const n = document.createElement('span');
n.className = 'cw-num';
n.textContent = num;
n.style.fontSize = ns + 'px';
cell.appendChild(n);
}
const ltr = document.createElement('span');
ltr.className = 'cw-letter';
ltr.id = `cell-${r}-${c}`;
ltr.textContent = _userGrid[r][c] || '';
cell.appendChild(ltr);
cell.addEventListener('click', () => onCellClick(r, c));
}
container.appendChild(cell);
}
}
document.addEventListener('keydown', onKeyDown);
}
function renderClues() {
const { across, down } = _data;
const buildList = (words, container) => {
container.innerHTML = '';
for (const w of words) {
const item = document.createElement('div');
item.className = 'cw-clue-item';
item.id = `clue-${w.dir}-${w.num}`;
item.dataset.num = w.num;
item.dataset.dir = w.dir;
item.onclick = () => selectWord(w);
item.innerHTML = `
<span class="cw-clue-num">${w.num}</span>
<div>
<div class="cw-clue-text">${escHtml(w.clue)}</div>
<div class="cw-clue-meta">${escHtml(w.subjectName)} · ${w.word.length} букв</div>
</div>`;
container.appendChild(item);
}
};
buildList(across, document.getElementById('clues-across-list'));
buildList(down, document.getElementById('clues-down-list'));
if (window.renderMathInElement) {
const mathOpts = {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
],
throwOnError: false,
};
renderMathInElement(document.getElementById('clues-across-list'), mathOpts);
renderMathInElement(document.getElementById('clues-down-list'), mathOpts);
}
}
function escHtml(s) {
return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
/* ── Selection ── */
function _findWordAt(r, c, dir) {
return _data.words.find(w =>
w.dir === dir &&
(dir === 'across'
? w.row === r && c >= w.col && c < w.col + w.word.length
: w.col === c && r >= w.row && r < w.row + w.word.length)
) || null;
}
function onCellClick(r, c) {
if (_data.grid[r][c] === null || _finished) return;
if (_sel.r === r && _sel.c === c) {
// Toggle direction — only if there's actually a word in the other direction
const other = _sel.dir === 'across' ? 'down' : 'across';
if (_findWordAt(r, c, other)) _sel.dir = other;
} else {
_sel.r = r; _sel.c = c;
// Auto-pick direction: keep current if valid, otherwise try the other
if (!_findWordAt(r, c, _sel.dir)) {
const other = _sel.dir === 'across' ? 'down' : 'across';
if (_findWordAt(r, c, other)) _sel.dir = other;
}
}
updateHighlights();
updateClueHighlight();
}
function selectWord(word) {
_sel.r = word.row; _sel.c = word.col; _sel.dir = word.dir;
updateHighlights();
updateClueHighlight();
}
function updateHighlights() {
// Clear all highlights
document.querySelectorAll('.cw-cell.highlighted, .cw-cell.selected').forEach(el => {
el.classList.remove('highlighted', 'selected');
});
if (_sel.r < 0) return;
// Find the word that contains the selected cell in the current direction
const word = _findWordAt(_sel.r, _sel.c, _sel.dir);
if (word) {
for (let i = 0; i < word.word.length; i++) {
const wr = word.dir === 'across' ? word.row : word.row + i;
const wc = word.dir === 'across' ? word.col + i : word.col;
const el = getCellEl(wr, wc);
if (el) el.classList.add('highlighted');
}
}
const selEl = getCellEl(_sel.r, _sel.c);
if (selEl) { selEl.classList.remove('highlighted'); selEl.classList.add('selected'); }
}
function updateClueHighlight() {
document.querySelectorAll('.cw-clue-item.active').forEach(el => el.classList.remove('active'));
if (_sel.r < 0) return;
const word = _findWordAt(_sel.r, _sel.c, _sel.dir);
if (word) {
const el = document.getElementById(`clue-${word.dir}-${word.num}`);
if (el) { el.classList.add('active'); el.scrollIntoView({ block: 'nearest' }); }
}
}
function getCellEl(r, c) {
return document.querySelector(`.cw-cell[data-r="${r}"][data-c="${c}"]`);
}
/* ── Keyboard input ── */
function onKeyDown(e) {
if (_finished || _sel.r < 0) return;
const { r, c, dir } = _sel;
if (e.key === 'Tab') {
e.preventDefault();
const words = _data.words.filter(w => w.dir === dir);
const cur = words.find(w =>
dir === 'across'
? w.row === r && c >= w.col && c < w.col + w.word.length
: w.col === c && r >= w.row && r < w.row + w.word.length
);
const idx = cur ? words.indexOf(cur) : -1;
const next = words[(idx + (e.shiftKey ? -1 : 1) + words.length) % words.length];
if (next) { _sel.r = next.row; _sel.c = next.col; updateHighlights(); updateClueHighlight(); }
return;
}
if (e.key === 'ArrowRight') { _sel.dir = 'across'; _sel.r = r; moveCursor(0, 1); return; }
if (e.key === 'ArrowLeft') { _sel.dir = 'across'; _sel.r = r; moveCursor(0,-1); return; }
if (e.key === 'ArrowDown') { _sel.dir = 'down'; _sel.c = c; moveCursor(1, 0); return; }
if (e.key === 'ArrowUp') { _sel.dir = 'down'; _sel.c = c; moveCursor(-1,0); return; }
if (e.key === 'Backspace') {
if (_userGrid[r][c]) {
_userGrid[r][c] = '';
updateCellDisplay(r, c);
removeCellState(r, c);
} else {
moveCursor(dir === 'across' ? 0 : -1, dir === 'across' ? -1 : 0);
const { r: nr, c: nc } = _sel;
if (_userGrid[nr]?.[nc] !== undefined && _userGrid[nr][nc] !== null) {
_userGrid[nr][nc] = '';
updateCellDisplay(nr, nc);
removeCellState(nr, nc);
}
}
return;
}
// Letter key
const ch = e.key.toUpperCase();
if (/^[А-ЯЁA-Z]$/.test(ch) && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
if (_data.grid[r][c] === null) return;
_userGrid[r][c] = ch;
updateCellDisplay(r, c);
removeCellState(r, c);
// Advance cursor
moveCursor(dir === 'across' ? 0 : 1, dir === 'across' ? 1 : 0);
// Auto-check word completion
checkWordCompletion();
}
}
function moveCursor(dr, dc) {
let nr = _sel.r + dr, nc = _sel.c + dc;
// Skip null/out-of-bounds cells
while (nr >= 0 && nc >= 0 && nr < _data.grid.length && nc < _data.grid[0].length && _data.grid[nr][nc] === null) {
nr += dr; nc += dc;
}
if (nr >= 0 && nc >= 0 && nr < _data.grid.length && nc < _data.grid[0].length && _data.grid[nr][nc] !== null) {
_sel.r = nr; _sel.c = nc;
}
updateHighlights();
updateClueHighlight();
}
function updateCellDisplay(r, c) {
const el = document.getElementById(`cell-${r}-${c}`);
if (el) el.textContent = _userGrid[r][c] || '';
}
function removeCellState(r, c) {
const el = getCellEl(r, c);
if (el) { el.classList.remove('correct', 'wrong'); }
}
/* ── Auto-check word ── */
function checkWordCompletion() {
for (const w of _data.words) {
if (_solved.has(`${w.dir}-${w.num}`)) continue;
let complete = true;
for (let i = 0; i < w.word.length; i++) {
const wr = w.dir === 'across' ? w.row : w.row + i;
const wc = w.dir === 'across' ? w.col + i : w.col;
if (!_userGrid[wr][wc]) { complete = false; break; }
}
if (!complete) continue;
// Check if correct
let correct = true;
for (let i = 0; i < w.word.length; i++) {
const wr = w.dir === 'across' ? w.row : w.row + i;
const wc = w.dir === 'across' ? w.col + i : w.col;
if (_userGrid[wr][wc] !== w.word[i]) { correct = false; break; }
}
if (correct) {
_solved.add(`${w.dir}-${w.num}`);
markWord(w, 'correct');
updateProgress();
showToast(`<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Слово #${w.num} угадано!`);
const clueEl = document.getElementById(`clue-${w.dir}-${w.num}`);
if (clueEl) clueEl.querySelector('.cw-clue-text')?.classList.add('cw-clue-done');
if (_solved.size === _data.words.length) setTimeout(finishGame, 400);
}
}
}
function markWord(word, state) {
for (let i = 0; i < word.word.length; i++) {
const wr = word.dir === 'across' ? word.row : word.row + i;
const wc = word.dir === 'across' ? word.col + i : word.col;
const el = getCellEl(wr, wc);
if (el) { el.classList.remove('correct','wrong'); el.classList.add(state); }
}
}
function updateProgress() {
document.getElementById('cw-solved').textContent = _solved.size;
document.getElementById('cw-total').textContent = _data?.words.length || 0;
}
/* ── Check answers ── */
function checkAnswers() {
if (!_data || _finished) return;
let anyWrong = false;
let filledCount = 0;
let emptyCount = 0;
for (const w of _data.words) {
if (_solved.has(`${w.dir}-${w.num}`)) continue;
for (let i = 0; i < w.word.length; i++) {
const wr = w.dir === 'across' ? w.row : w.row + i;
const wc = w.dir === 'across' ? w.col + i : w.col;
const val = _userGrid[wr][wc];
if (!val) { emptyCount++; continue; }
filledCount++;
const el = getCellEl(wr, wc);
if (!el) continue;
el.classList.remove('correct','wrong');
if (val === w.word[i]) el.classList.add('correct');
else { el.classList.add('wrong'); anyWrong = true; }
}
}
if (filledCount === 0) showToast('Сначала введите ответы');
else if (anyWrong) showToast('Есть ошибки — выделены красным');
else if (emptyCount > 0) showToast(`Верно! Осталось заполнить ${emptyCount} клеток`);
else showToast('Всё верно! <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>');
}
/* ── Reveal selected word ── */
function revealSelected() {
if (!_data || _finished) return;
if (_sel.r < 0) { showToast('Выберите клетку в кроссворде'); return; }
let word = _findWordAt(_sel.r, _sel.c, _sel.dir);
if (!word) {
const otherDir = _sel.dir === 'across' ? 'down' : 'across';
word = _findWordAt(_sel.r, _sel.c, otherDir);
if (word) _sel.dir = otherDir;
}
if (!word) { showToast('Выберите клетку со словом'); return; }
if (_solved.has(`${word.dir}-${word.num}`)) { showToast('Слово уже угадано'); return; }
_hintsUsed++;
_revealed.add(`${word.dir}-${word.num}`);
for (let i = 0; i < word.word.length; i++) {
const wr = word.dir === 'across' ? word.row : word.row + i;
const wc = word.dir === 'across' ? word.col + i : word.col;
_userGrid[wr][wc] = word.word[i];
updateCellDisplay(wr, wc);
const el = getCellEl(wr, wc);
if (el) { el.classList.remove('wrong','correct'); el.classList.add('correct'); }
}
_solved.add(`${word.dir}-${word.num}`);
updateProgress();
const clueEl = document.getElementById(`clue-${word.dir}-${word.num}`);
if (clueEl) clueEl.querySelector('.cw-clue-text')?.classList.add('cw-clue-done');
if (_solved.size === _data.words.length) setTimeout(finishGame, 300);
}
/* ── Give up ── */
function giveUp() {
if (!_data || _finished) return;
_finished = true;
// Reveal all words
for (const w of _data.words) {
if (_solved.has(`${w.dir}-${w.num}`)) continue;
for (let i = 0; i < w.word.length; i++) {
const wr = w.dir === 'across' ? w.row : w.row + i;
const wc = w.dir === 'across' ? w.col + i : w.col;
_userGrid[wr][wc] = w.word[i];
updateCellDisplay(wr, wc);
const el = getCellEl(wr, wc);
if (el) { el.classList.remove('wrong','selected','highlighted'); el.classList.add('correct'); }
}
}
LS.api('/api/games/crossword/complete', { method: 'POST', body: JSON.stringify({ completed: false, hintsUsed: _hintsUsed }) }).catch(()=>{});
showResult(false, _solved.size);
}
/* ── Finish ── */
async function finishGame() {
_finished = true;
const resp = await LS.api('/api/games/crossword/complete', {
method: 'POST',
body: JSON.stringify({ completed: true, hintsUsed: _hintsUsed }),
}).catch(() => ({ xp: 0 }));
showResult(true, _data.words.length, resp.xp || 0);
}
function showResult(won, solvedCount, xp = 0) {
const resIcon = document.getElementById('res-icon');
const resTitle = document.getElementById('res-title');
const resSub = document.getElementById('res-sub');
const resXP = document.getElementById('res-xp');
if (won) {
resIcon.innerHTML = `<svg viewBox="0 0 24 24" style="stroke:#F9C74F;fill:none;stroke-width:1.5">
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/>
<path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/>
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/>
<path d="M18 2H6v7a6 6 0 0 0 12 0V2z"/></svg>`;
resTitle.textContent = 'Кроссворд решён!';
resSub.textContent = _hintsUsed ? `Использовано подсказок: ${_hintsUsed}` : 'Без единой подсказки!';
if (xp) { resXP.textContent = `+${xp} XP`; resXP.style.display = ''; }
} else {
resIcon.innerHTML = `<svg viewBox="0 0 24 24" style="stroke:#F94144;fill:none;stroke-width:1.5">
<circle cx="12" cy="12" r="10"/><path d="M16 16s-1.5-2-4-2-4 2-4 2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>`;
resTitle.textContent = 'Не получилось';
resSub.textContent = `Угадано: ${solvedCount} из ${_data?.words.length || 0} слов`;
resXP.style.display = 'none';
}
document.getElementById('cw-result').classList.add('visible');
}
function closeResult() {
document.getElementById('cw-result').classList.remove('visible');
}
/* ── Toast ── */
function showToast(msg) {
const el = document.getElementById('cw-toast');
el.innerHTML = msg;
el.classList.add('show');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2500);
}
</script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>