fd29acbbdd
Classroom performance: - WebSocket server (ws-server.js) for low-latency cursor & stroke preview Replaces HTTP POST per event → eliminates per-message auth overhead Session member cache (30s TTL) avoids SQLite query per WS message Fallback to HTTP POST when WS not connected - Cursor throttle reduced 100ms → 33ms (~30fps) - Stroke preview throttle reduced 50ms → 20ms - whiteboard.js: render() is now rAF-gated (_doRender/_rafPending) Multiple render() calls within one frame collapse into one repaint document.hidden check — zero CPU when tab is in background visibilitychange listener restores canvas on tab focus Guest board: - guestClassroom.js route: public token-based read-only access - guest-board.html: name entry + read-only whiteboard + SSE - SSE: addGuestClient/removeGuestClient/emitToGuests Screen share picker: - Discord-style modal with tab switching (screen/window/tab) - Live video preview before confirming share - useExistingScreenStream() in ClassroomRTC Fullscreen exit overlay: - #cr-fs-exit-overlay button inside cr-board-wrap - Visible only via CSS :fullscreen selector (touchpad users) File sharing from library: - Teacher picks file from library, sends as styled card in chat - crDownloadLibraryFile() fetches with Bearer auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
650 lines
37 KiB
HTML
650 lines
37 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Игры — Красная книга РБ</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
|
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;600;700;900&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
|
<link rel="stylesheet" href="/css/ls.css"/>
|
|
<style>
|
|
:root {
|
|
--rb-bg: #0a1a0d;
|
|
--rb-surface: #111d13;
|
|
--rb-border: #1e3523;
|
|
--rb-accent: #4ade80;
|
|
--rb-text: #e2f5e8;
|
|
--rb-muted: #6b9a74;
|
|
--rb-cr: #ef4444; --rb-en: #f97316; --rb-vu: #eab308;
|
|
--rb-nt: #22c55e; --rb-lc: #3b82f6;
|
|
--sb-width: 240px;
|
|
}
|
|
body { background: var(--rb-bg); color: var(--rb-text); font-family: 'Manrope', sans-serif; }
|
|
.app-layout { background: var(--rb-bg); }
|
|
.sb-content { padding: 0; }
|
|
|
|
/* Sidebar */
|
|
.app-layout > .sidebar { background: var(--rb-surface) !important; border-right: 1px solid var(--rb-border) !important; }
|
|
.sb-brand { border-bottom: 1px solid var(--rb-border); padding: 16px 12px 12px; }
|
|
.sb-link, a.sb-link { color: var(--rb-muted) !important; }
|
|
.sb-link:hover { background: rgba(74,222,128,.08) !important; color: var(--rb-text) !important; }
|
|
.sb-link.active { background: rgba(74,222,128,.14) !important; color: var(--rb-accent) !important; font-weight:700; }
|
|
.sb-link.active::before { background: var(--rb-accent) !important; }
|
|
.sb-toggle { color: var(--rb-muted) !important; border-color: var(--rb-border) !important; background: transparent !important; }
|
|
.sb-toggle:hover { background: rgba(74,222,128,.12) !important; color: var(--rb-accent) !important; }
|
|
.nav-user-chip { border-color: var(--rb-border) !important; }
|
|
.nav-avatar { background: linear-gradient(135deg, #166534, #15803d) !important; }
|
|
.nav-user-name { color: var(--rb-muted) !important; }
|
|
.rb-brand-link { display:flex;align-items:center;gap:9px;text-decoration:none;flex:1;min-width:0;overflow:hidden; }
|
|
.rb-brand-icon { font-size:24px;line-height:1;flex-shrink:0; }
|
|
.rb-brand-text { font-family:'Unbounded',sans-serif;font-size:10px;font-weight:700;color:var(--rb-accent);line-height:1.4;white-space:nowrap; }
|
|
.rb-sb-section { font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:var(--rb-muted);padding:14px 12px 4px;margin:0;opacity:.55; }
|
|
.app-layout.sb-collapsed .rb-sb-section { display:none; }
|
|
.rb-sb-divider { border:none;border-top:1px solid var(--rb-border);margin:8px 4px; }
|
|
.rb-back-link { opacity:.65;font-size:.82rem !important; }
|
|
.rb-back-link:hover { opacity:1 !important; }
|
|
|
|
/* Page */
|
|
.games-page { padding: 40px 40px 80px; max-width: 1000px; margin: 0 auto; }
|
|
|
|
/* Header */
|
|
.page-title { font-family: 'Unbounded', sans-serif; font-size: 24px; font-weight: 900; margin: 0 0 8px; }
|
|
.page-sub { color: var(--rb-muted); font-size: 13px; margin: 0 0 32px; }
|
|
|
|
/* Game selector */
|
|
.game-select { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; margin-bottom: 36px; }
|
|
.game-card {
|
|
background: var(--rb-surface); border: 1px solid var(--rb-border);
|
|
border-radius: 16px; padding: 24px 20px; cursor: pointer;
|
|
transition: all .2s; text-align: center;
|
|
}
|
|
.game-card:hover { border-color: var(--rb-accent); transform: translateY(-3px); box-shadow: 0 8px 24px rgba(0,0,0,.4); }
|
|
.game-card.active { border-color: var(--rb-accent); background: rgba(74,222,128,.08); }
|
|
.game-card-icon { font-size: 40px; margin-bottom: 12px; }
|
|
.game-card-title { font-family: 'Unbounded', sans-serif; font-size: 14px; font-weight: 700; margin: 0 0 6px; }
|
|
.game-card-desc { font-size: 12px; color: var(--rb-muted); margin: 0; line-height: 1.5; }
|
|
|
|
/* ── QUIZ ── */
|
|
#quiz-area { display:none; }
|
|
.quiz-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; }
|
|
.quiz-score { background: rgba(74,222,128,.1); border: 1px solid rgba(74,222,128,.3); color: var(--rb-accent); font-weight:700; font-size:13px; padding:6px 16px; border-radius:10px; }
|
|
.quiz-card {
|
|
background: var(--rb-surface); border: 1px solid var(--rb-border);
|
|
border-radius: 20px; padding: 32px; margin-bottom: 24px; text-align: center;
|
|
}
|
|
.quiz-icon { font-size: 64px; margin-bottom: 16px; display: block; }
|
|
.quiz-question { font-family: 'Unbounded', sans-serif; font-size: 18px; font-weight: 700; margin: 0 0 8px; color: #fff; }
|
|
.quiz-hint { font-size: 13px; color: var(--rb-muted); margin: 0 0 24px; }
|
|
.quiz-options { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
.quiz-opt {
|
|
background: rgba(255,255,255,.04); border: 1.5px solid var(--rb-border);
|
|
color: var(--rb-text); font-size: 13px; font-weight: 600;
|
|
padding: 14px 16px; border-radius: 12px; cursor: pointer;
|
|
transition: all .15s; text-align: left;
|
|
}
|
|
.quiz-opt:hover { border-color: var(--rb-accent); background: rgba(74,222,128,.07); }
|
|
.quiz-opt.correct { border-color: #22c55e; background: rgba(34,197,94,.15); color: #4ade80; }
|
|
.quiz-opt.wrong { border-color: #ef4444; background: rgba(239,68,68,.12); color: #fca5a5; }
|
|
.quiz-opt.disabled { cursor: default; pointer-events: none; }
|
|
.quiz-feedback {
|
|
background: rgba(74,222,128,.08); border: 1px solid rgba(74,222,128,.3);
|
|
border-radius: 12px; padding: 16px 20px; margin-top: 16px;
|
|
font-size: 13px; color: #c8e6ce; line-height: 1.6; display: none;
|
|
}
|
|
.quiz-feedback.wrong-bg { background: rgba(239,68,68,.08); border-color: rgba(239,68,68,.3); color: #fca5a5; }
|
|
.btn-next {
|
|
background: var(--rb-accent); color: #0a1a0d;
|
|
font-weight: 700; font-size: 14px; padding: 12px 28px;
|
|
border: none; border-radius: 12px; cursor: pointer; margin-top: 16px;
|
|
display: none;
|
|
}
|
|
.quiz-progress {
|
|
height: 4px; background: var(--rb-border); border-radius: 2px; margin-bottom: 20px;
|
|
}
|
|
.quiz-progress-fill { height: 100%; background: var(--rb-accent); border-radius: 2px; transition: width .4s; }
|
|
.quiz-result {
|
|
text-align: center; padding: 40px; display: none;
|
|
}
|
|
.quiz-result h2 { font-family: 'Unbounded', sans-serif; font-size: 24px; font-weight: 900; margin: 0 0 12px; }
|
|
.quiz-result p { font-size: 16px; color: var(--rb-muted); margin: 0 0 24px; }
|
|
|
|
/* ── FOOD CHAIN ── */
|
|
#chain-area { display:none; }
|
|
.chain-header { margin-bottom: 20px; }
|
|
.chain-header h3 { font-family: 'Unbounded', sans-serif; font-size: 16px; font-weight:700; margin:0 0 6px; }
|
|
.chain-header p { font-size:13px; color:var(--rb-muted); margin:0 0 16px; }
|
|
.chain-dropzones { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 28px; }
|
|
.chain-slot {
|
|
width: 110px; min-height: 90px;
|
|
background: rgba(255,255,255,.03); border: 2px dashed var(--rb-border);
|
|
border-radius: 14px; display: flex; flex-direction:column;
|
|
align-items: center; justify-content: center;
|
|
font-size: 11px; color: var(--rb-muted); font-weight:600;
|
|
transition: border-color .15s; cursor: pointer; padding: 8px;
|
|
text-align: center;
|
|
}
|
|
.chain-slot.over { border-color: var(--rb-accent); background: rgba(74,222,128,.08); }
|
|
.chain-slot.filled { border-style: solid; }
|
|
.chain-slot .slot-icon { font-size:28px; margin-bottom:4px; }
|
|
.chain-slot .slot-name { font-size:11px; color:var(--rb-text); font-weight:600; }
|
|
.chain-arrow { font-size: 20px; color: var(--rb-muted); flex-shrink: 0; }
|
|
.chain-pieces { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
.chain-piece {
|
|
display: flex; flex-direction:column; align-items:center; gap:4px;
|
|
background: var(--rb-surface); border: 1.5px solid var(--rb-border);
|
|
border-radius: 12px; padding: 12px 14px; cursor: grab;
|
|
transition: all .15s; user-select: none; min-width: 90px; text-align:center;
|
|
}
|
|
.chain-piece:hover { border-color: var(--rb-accent); transform: translateY(-2px); }
|
|
.chain-piece.dragging { opacity: .5; }
|
|
.chain-piece.used { opacity: .3; pointer-events:none; }
|
|
.chain-piece-icon { font-size: 28px; }
|
|
.chain-piece-name { font-size: 11px; font-weight: 600; }
|
|
.chain-check-btn {
|
|
background: var(--rb-accent); color: #0a1a0d;
|
|
font-weight:700; font-size:14px; padding:12px 28px;
|
|
border:none; border-radius:12px; cursor:pointer; margin-right:10px;
|
|
}
|
|
.chain-reset-btn {
|
|
background: transparent; border: 1px solid var(--rb-border);
|
|
color: var(--rb-muted); font-size:13px; padding:12px 20px;
|
|
border-radius:12px; cursor:pointer;
|
|
}
|
|
.chain-result {
|
|
padding: 16px 20px; border-radius:12px; margin-top:16px; font-size:13px; font-weight:600;
|
|
display:none;
|
|
}
|
|
.chain-result.ok { background:rgba(74,222,128,.1);border:1px solid rgba(74,222,128,.3);color:var(--rb-accent); }
|
|
.chain-result.err { background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);color:#fca5a5; }
|
|
|
|
/* Toasts */
|
|
#rb-toasts { position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:10px;pointer-events:none; }
|
|
.rb-toast { display:flex;align-items:center;gap:10px;background:#0d2210;border:1px solid var(--rb-border);color:var(--rb-text);font-size:13px;font-weight:600;padding:12px 18px;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.5);pointer-events:auto;animation:toastIn .3s both; }
|
|
.rb-toast.success { border-color:rgba(74,222,128,.4); }
|
|
.rb-toast.error { border-color:rgba(239,68,68,.4);color:#fca5a5; }
|
|
.rb-toast.out { animation:toastOut .3s both; }
|
|
@keyframes toastIn { from{opacity:0;transform:translateX(20px)}to{opacity:1;transform:translateX(0)} }
|
|
@keyframes toastOut { from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(20px)} }
|
|
|
|
/* ── Mobile ── */
|
|
@media (max-width: 768px) {
|
|
main.sb-content { overflow-y: auto; padding: 16px 14px 80px !important; }
|
|
.game-grid, .games-grid { grid-template-columns: 1fr !important; }
|
|
.chain-btns { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
.chain-check-btn, .chain-reset-btn { flex: 1; min-width: 120px; padding: 10px; font-size: 13px; }
|
|
#rb-toasts { right: 14px; bottom: 80px; }
|
|
}
|
|
@media (max-width: 480px) {
|
|
.chain-pieces { gap: 6px; }
|
|
.chain-piece { min-width: 60px; padding: 8px 10px; font-size: 12px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app-layout" id="app">
|
|
|
|
<!-- RB Sidebar -->
|
|
<nav class="sidebar">
|
|
<div class="sb-brand">
|
|
<a href="/red-book.html" class="rb-brand-link">
|
|
<span class="rb-brand-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg></span>
|
|
<span class="sb-lbl rb-brand-text">Красная<br>книга РБ</span>
|
|
</a>
|
|
<button class="sb-toggle" onclick="toggleSidebar()" title="Свернуть"><i data-lucide="panel-left-close"></i></button>
|
|
</div>
|
|
<nav class="sb-nav">
|
|
<p class="rb-sb-section">РАЗДЕЛЫ</p>
|
|
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Каталог видов</span></a>
|
|
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
|
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
|
|
<a href="/collection-rb.html" class="sb-link"><i data-lucide="star" class="sb-icon"></i><span class="sb-lbl">Моя коллекция</span></a>
|
|
<a href="/red-book-ecosystem.html" class="sb-link"><i data-lucide="git-fork" class="sb-icon"></i><span class="sb-lbl">Пищевые сети</span></a>
|
|
<a href="/red-book-biomes.html" class="sb-link"><i data-lucide="trees" class="sb-icon"></i><span class="sb-lbl">Биомы</span></a>
|
|
<a href="/red-book-games.html" class="sb-link active"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Игры</span></a>
|
|
<hr class="rb-sb-divider">
|
|
<a href="/dashboard.html" class="sb-link rb-back-link"><i data-lucide="chevron-left" class="sb-icon"></i><span class="sb-lbl">Назад</span></a>
|
|
<a href="/profile.html" class="sb-link"><i data-lucide="user" class="sb-icon"></i><span class="sb-lbl">Профиль</span></a>
|
|
</nav>
|
|
<div class="sb-footer">
|
|
<div class="nav-user-chip" onclick="location.href='/profile.html'">
|
|
<div class="nav-avatar" id="nav-avatar">LS</div>
|
|
<span class="nav-user-name" id="nav-user">—</span>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="sb-content">
|
|
<div class="games-page">
|
|
|
|
<h1 class="page-title"><svg class="ic" viewBox="0 0 24 24"><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="15" y1="13" x2="15.01" y2="13"/><line x1="18" y1="11" x2="18.01" y2="11"/><rect x="2" y="6" width="20" height="12" rx="2"/></svg> Игры</h1>
|
|
<p class="page-sub">Узнавай виды и восстанавливай экосистемы</p>
|
|
|
|
<!-- Game selector -->
|
|
<div class="game-select">
|
|
<div class="game-card" id="card-quiz" onclick="selectGame('quiz')">
|
|
<div class="game-card-icon"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></div>
|
|
<div class="game-card-title">Угадай вид</div>
|
|
<p class="game-card-desc">По иконке и подсказкам определи вид из Красной книги</p>
|
|
</div>
|
|
<div class="game-card" id="card-chain" onclick="selectGame('chain')">
|
|
<div class="game-card-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></div>
|
|
<div class="game-card-title">Пищевая цепочка</div>
|
|
<p class="game-card-desc">Расставь виды в правильном порядке от продуцента до хищника</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- QUIZ -->
|
|
<div id="quiz-area">
|
|
<div class="quiz-header">
|
|
<h3 style="margin:0;font-family:'Unbounded',sans-serif;font-size:16px">Угадай вид</h3>
|
|
<div style="display:flex;gap:10px;align-items:center">
|
|
<span id="quiz-progress-label" style="font-size:12px;color:var(--rb-muted)"></span>
|
|
<span class="quiz-score" id="quiz-score">0 / 0</span>
|
|
</div>
|
|
</div>
|
|
<div class="quiz-progress"><div class="quiz-progress-fill" id="quiz-progress-fill" style="width:0%"></div></div>
|
|
|
|
<div id="quiz-card-wrap">
|
|
<!-- filled by JS -->
|
|
</div>
|
|
|
|
<div id="quiz-result" class="quiz-result">
|
|
<div id="quiz-result-icon" style="font-size:64px;margin-bottom:16px"><svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg></div>
|
|
<h2 id="quiz-result-title">Игра завершена!</h2>
|
|
<p id="quiz-result-text"></p>
|
|
<button onclick="startQuiz()" style="background:var(--rb-accent);color:#0a1a0d;font-weight:700;font-size:14px;padding:12px 28px;border:none;border-radius:12px;cursor:pointer;">Сыграть ещё</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- FOOD CHAIN -->
|
|
<div id="chain-area">
|
|
<div class="chain-header">
|
|
<h3>Восстанови пищевую цепочку</h3>
|
|
<p>Расставь виды от продуцента (растения) до высшего хищника</p>
|
|
<div id="chain-level-selector" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;"></div>
|
|
</div>
|
|
|
|
<div class="chain-dropzones" id="chain-slots"></div>
|
|
|
|
<div style="margin-bottom:16px;">
|
|
<p style="font-size:12px;color:var(--rb-muted);margin:0 0 12px">Перетащи виды в нужный порядок:</p>
|
|
<div class="chain-pieces" id="chain-pieces"></div>
|
|
</div>
|
|
|
|
<button class="chain-check-btn" onclick="checkChain()"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Проверить</button>
|
|
<button class="chain-reset-btn" onclick="resetChain()"><svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg> Сбросить</button>
|
|
<div class="chain-result" id="chain-result"></div>
|
|
</div>
|
|
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<div id="rb-toasts"></div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
|
<script src="/js/api.js"></script>
|
|
<script src="/js/mobile.js"></script>
|
|
<script>
|
|
/* ══════════════════════════════════════════════════════════════════════════
|
|
Init
|
|
══════════════════════════════════════════════════════════════════════════ */
|
|
let allSpecies = [];
|
|
let currentGame = null;
|
|
|
|
async function init() {
|
|
LS.hideDisabledFeatures?.();
|
|
lucide.createIcons();
|
|
if (localStorage.getItem('ls_sb_collapsed') === '1') document.getElementById('app').classList.add('sb-collapsed');
|
|
const user = LS.getUser?.();
|
|
if (user) {
|
|
document.getElementById('nav-user').textContent = user.name?.split(' ')[0] || '—';
|
|
document.getElementById('nav-avatar').textContent = (user.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
|
|
}
|
|
const data = await LS.get('/api/red-book/species?limit=100').catch(() => ({ species: [] }));
|
|
allSpecies = data.species || [];
|
|
}
|
|
|
|
function selectGame(game) {
|
|
currentGame = game;
|
|
document.querySelectorAll('.game-card').forEach(c => c.classList.remove('active'));
|
|
document.getElementById(`card-${game}`).classList.add('active');
|
|
document.getElementById('quiz-area').style.display = game === 'quiz' ? 'block' : 'none';
|
|
document.getElementById('chain-area').style.display = game === 'chain' ? 'block' : 'none';
|
|
|
|
if (game === 'quiz') startQuiz();
|
|
if (game === 'chain') initChains();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════════════
|
|
QUIZ — Угадай вид
|
|
══════════════════════════════════════════════════════════════════════════ */
|
|
const QUIZ_TOTAL = 10;
|
|
let quizState = { questions: [], idx: 0, score: 0, answered: false };
|
|
|
|
function buildQuizQuestions() {
|
|
const pool = [...allSpecies].sort(() => Math.random() - 0.5).slice(0, QUIZ_TOTAL);
|
|
return pool.map(correct => {
|
|
// 3 wrong answers from remaining species
|
|
const others = allSpecies.filter(s => s.id !== correct.id)
|
|
.sort(() => Math.random() - 0.5).slice(0, 3);
|
|
const options = [correct, ...others].sort(() => Math.random() - 0.5);
|
|
// Quiz type: either by icon or by description snippet
|
|
const type = Math.random() < 0.5 ? 'icon' : 'desc';
|
|
return { correct, options, type };
|
|
});
|
|
}
|
|
|
|
function startQuiz() {
|
|
if (allSpecies.length < 4) { showToast('Загрузите данные...', 'error'); return; }
|
|
quizState = { questions: buildQuizQuestions(), idx: 0, score: 0, answered: false };
|
|
document.getElementById('quiz-result').style.display = 'none';
|
|
updateScoreDisplay();
|
|
renderQuizCard();
|
|
}
|
|
|
|
function updateScoreDisplay() {
|
|
const { idx, score } = quizState;
|
|
document.getElementById('quiz-score').textContent = `${score} / ${QUIZ_TOTAL}`;
|
|
document.getElementById('quiz-progress-label').textContent = `${idx + 1} / ${QUIZ_TOTAL}`;
|
|
document.getElementById('quiz-progress-fill').style.width = `${(idx / QUIZ_TOTAL) * 100}%`;
|
|
}
|
|
|
|
function renderQuizCard() {
|
|
const { questions, idx } = quizState;
|
|
if (idx >= questions.length) { showQuizResult(); return; }
|
|
const q = questions[idx];
|
|
const wrap = document.getElementById('quiz-card-wrap');
|
|
|
|
let questionHtml, hintHtml;
|
|
if (q.type === 'icon') {
|
|
questionHtml = `<span class="quiz-icon">${q.correct.group_icon || '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>'}</span>
|
|
<p class="quiz-question">Что это за вид?</p>`;
|
|
hintHtml = `<p class="quiz-hint">Категория: <b style="color:var(--rb-${q.correct.category?.toLowerCase() || 'nt'})">${q.correct.category}</b></p>`;
|
|
} else {
|
|
const desc = q.correct.interesting_fact || q.correct.description?.slice(0,120) || '';
|
|
questionHtml = `<span class="quiz-icon"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
|
|
<p class="quiz-question">Угадай вид по описанию:</p>`;
|
|
hintHtml = `<p class="quiz-hint" style="max-width:440px;margin-left:auto;margin-right:auto">"${desc}…"</p>`;
|
|
}
|
|
|
|
wrap.innerHTML = `
|
|
<div class="quiz-card">
|
|
${questionHtml}
|
|
${hintHtml}
|
|
<div class="quiz-options">
|
|
${q.options.map((sp, i) => `
|
|
<button class="quiz-opt" id="qopt-${i}" onclick="answerQuiz(${sp.id}, ${i})">
|
|
${sp.group_icon || ''} ${sp.name_ru}
|
|
<div style="font-size:10px;color:var(--rb-muted);font-style:italic">${sp.name_lat || ''}</div>
|
|
</button>`).join('')}
|
|
</div>
|
|
<div class="quiz-feedback" id="quiz-feedback"></div>
|
|
<button class="btn-next" id="btn-next" onclick="nextQuestion()">Далее <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>`;
|
|
quizState.answered = false;
|
|
}
|
|
|
|
function answerQuiz(selectedId, optIdx) {
|
|
if (quizState.answered) return;
|
|
quizState.answered = true;
|
|
const q = quizState.questions[quizState.idx];
|
|
const correct = selectedId === q.correct.id;
|
|
|
|
if (correct) quizState.score++;
|
|
|
|
// Highlight options
|
|
q.options.forEach((sp, i) => {
|
|
const btn = document.getElementById(`qopt-${i}`);
|
|
btn.classList.add('disabled');
|
|
if (sp.id === q.correct.id) btn.classList.add('correct');
|
|
else if (i === optIdx && !correct) btn.classList.add('wrong');
|
|
});
|
|
|
|
// Show feedback
|
|
const fb = document.getElementById('quiz-feedback');
|
|
fb.style.display = 'block';
|
|
if (correct) {
|
|
fb.className = 'quiz-feedback';
|
|
fb.innerHTML = `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Верно! ${q.correct.interesting_fact || q.correct.description?.slice(0,120) || ''}`;
|
|
} else {
|
|
fb.className = 'quiz-feedback wrong-bg';
|
|
fb.innerHTML = `<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> Неверно. Правильный ответ: ${q.correct.name_ru} (${q.correct.name_lat || ''})`;
|
|
}
|
|
|
|
document.getElementById('btn-next').style.display = 'inline-block';
|
|
updateScoreDisplay();
|
|
}
|
|
|
|
function nextQuestion() {
|
|
quizState.idx++;
|
|
if (quizState.idx >= quizState.questions.length) { showQuizResult(); return; }
|
|
document.getElementById('quiz-progress-fill').style.width = `${(quizState.idx / QUIZ_TOTAL) * 100}%`;
|
|
document.getElementById('quiz-progress-label').textContent = `${quizState.idx + 1} / ${QUIZ_TOTAL}`;
|
|
renderQuizCard();
|
|
}
|
|
|
|
function showQuizResult() {
|
|
document.getElementById('quiz-card-wrap').innerHTML = '';
|
|
const r = document.getElementById('quiz-result');
|
|
r.style.display = 'block';
|
|
const pct = Math.round(quizState.score / QUIZ_TOTAL * 100);
|
|
document.getElementById('quiz-result-icon').innerHTML = pct >= 80 ? '<svg class="ic" viewBox="0 0 24 24"><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>' : pct >= 50 ? '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>';
|
|
document.getElementById('quiz-result-title').textContent =
|
|
pct >= 80 ? 'Отлично! Ты знаток Красной книги!' :
|
|
pct >= 50 ? 'Хороший результат!' : 'Нужно больше изучать!';
|
|
document.getElementById('quiz-result-text').textContent =
|
|
`Правильных ответов: ${quizState.score} из ${QUIZ_TOTAL} (${pct}%)`;
|
|
document.getElementById('quiz-progress-fill').style.width = '100%';
|
|
document.getElementById('quiz-progress-label').textContent = `${QUIZ_TOTAL} / ${QUIZ_TOTAL}`;
|
|
// Award XP if logged in
|
|
if (quizState.score >= 5) {
|
|
const xp = quizState.score * 5;
|
|
LS.post('/api/red-book/species/1/collect', {}).catch(() => {}); // just as XP trigger
|
|
showToast(`+${xp} XP за игру!`, 'success');
|
|
}
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════════════
|
|
FOOD CHAIN GAME
|
|
══════════════════════════════════════════════════════════════════════════ */
|
|
const CHAINS = [
|
|
{
|
|
id: 'forest',
|
|
title: 'Лесная цепь',
|
|
desc: 'Широколиственный лес',
|
|
chain: [
|
|
{ name: 'Дуб (желуди)', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg>', level: 'Продуцент' },
|
|
{ name: 'Жук-олень', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M17 5C17 5 18 3 20 3s3 1 3 3-1 3-3 3h-2M17 5h-7M7 5C7 5 6 3 4 3S1 4 1 6s1 3 3 3h2M7 5v6M17 5v6M9 13a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2c0-2-1-5-1-7H10c0 2-1 5-1 7z"/><path d="M12 15v7"/></svg>', level: 'Консумент I' },
|
|
{ name: 'Воробьиный сыч', icon: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="14" r="4"/><path d="M12 4a4 4 0 0 0-4 4v2h8V8a4 4 0 0 0-4-4z"/><path d="M10 14h.01M14 14h.01"/></svg>', level: 'Консумент II' },
|
|
{ name: 'Рысь', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M12 5c.67 0 1.35.09 2 .26 1.78-2 5.03-2.84 6.42-2.26 1.4.58-.42 7-.42 7 .57 1.07 1 2.24 1 3.44C21 17.9 16.97 21 12 21s-9-3-9-7.56c0-1.25.5-2.4 1-3.44 0 0-1.89-6.42-.5-7 1.39-.58 4.72.23 6.5 2.23A9.04 9.04 0 0 1 12 5z"/><path d="M8 14v.5M16 14v.5"/></svg>', level: 'Консумент III' },
|
|
],
|
|
},
|
|
{
|
|
id: 'wetland',
|
|
title: 'Болотная цепь',
|
|
desc: 'Болото Беларуси',
|
|
chain: [
|
|
{ name: 'Камыш (водоросли)', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>', level: 'Продуцент' },
|
|
{ name: 'Тритон', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20c1.44-4.1 4.89-6 4.89-6s-2.95-2.11-4.89-6"/><path d="m2 13 2-5 5 5-5 3z"/><path d="M2 8s3 5 3 8"/><path d="M22 16c0 2.76-2.24 5-5 5s-5-2.24-5-5"/></svg>', level: 'Консумент I' },
|
|
{ name: 'Чёрный аист', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M16 7h.01"/><path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20"/><path d="m20 7 2 .5-2 .5"/><path d="M10 18v3M14 17.75v3.25"/><path d="M7 18a6 6 0 0 0 3.84-10.61"/></svg>', level: 'Консумент II' },
|
|
{ name: 'Орлан-белохвост', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M16 7h.01"/><path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20"/><path d="m20 7 2 .5-2 .5"/><path d="M10 18v3M14 17.75v3.25"/><path d="M7 18a6 6 0 0 0 3.84-10.61"/></svg>', level: 'Консумент III' },
|
|
],
|
|
},
|
|
{
|
|
id: 'river',
|
|
title: 'Речная цепь',
|
|
desc: 'Реки и озёра',
|
|
chain: [
|
|
{ name: 'Водоросли', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M7 20h10"/><path d="M10 20c5.5-2.5.8-6.4 3-10"/><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"/><path d="M14.1 6a7 7 0 0 1 1.5 4.7c-1.7 1.3-3 1.4-4 1.1a3 3 0 0 1-1.8-1.9c.8-2 2.3-3.3 4.3-3.9z"/></svg>', level: 'Продуцент' },
|
|
{ name: 'Миног', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M6.5 12c.94-3.46 4.94-6 8.5-6 3.56 0 6.06 2.54 7 6-.94 3.47-3.44 6-7 6s-7.56-2.53-8.5-6z"/><path d="M18 12v.5"/><path d="M16 17.93a9.77 9.77 0 0 1 0-11.86"/><path d="M7 10.67C7 8 5.58 5.97 2.73 5.5c-1 3.98-.23 7.23 1.95 8.05C6.09 14.26 7 13.06 7 11.5v-1.01z"/></svg>', level: 'Консумент I' },
|
|
{ name: 'Выдра', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M12 17c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"/><path d="M4 22 2 8l10 3 10-3-2 14"/></svg>', level: 'Консумент II' },
|
|
{ name: 'Волк', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M12 22c-4 0-8-2-10-6 0-4 2-8 5-10l2 4 2-2 1 3 2-4c3 2 5 6 5 10-2 4-6 5-7 5z"/></svg>', level: 'Консумент III' },
|
|
],
|
|
},
|
|
];
|
|
|
|
let chainState = { chainIdx: 0, slots: [], dragging: null };
|
|
|
|
function initChains() {
|
|
const sel = document.getElementById('chain-level-selector');
|
|
sel.innerHTML = CHAINS.map((c, i) => `
|
|
<button onclick="loadChain(${i})" id="chain-btn-${i}"
|
|
style="background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;font-weight:600;padding:6px 14px;border-radius:10px;cursor:pointer;">
|
|
${c.icon || ''} ${c.title}
|
|
</button>`).join('');
|
|
loadChain(0);
|
|
}
|
|
|
|
function loadChain(idx) {
|
|
chainState.chainIdx = idx;
|
|
chainState.slots = new Array(CHAINS[idx].chain.length).fill(null);
|
|
chainState.dragging = null;
|
|
|
|
// Update active button
|
|
CHAINS.forEach((_, i) => {
|
|
const btn = document.getElementById(`chain-btn-${i}`);
|
|
if (btn) {
|
|
btn.style.borderColor = i === idx ? 'var(--rb-accent)' : 'var(--rb-border)';
|
|
btn.style.color = i === idx ? 'var(--rb-accent)' : 'var(--rb-muted)';
|
|
}
|
|
});
|
|
|
|
const c = CHAINS[idx];
|
|
document.getElementById('chain-result').style.display = 'none';
|
|
|
|
// Render slots
|
|
const slotsEl = document.getElementById('chain-slots');
|
|
slotsEl.innerHTML = c.chain.map((_, i) => `
|
|
<div class="chain-slot" id="chain-slot-${i}"
|
|
ondragover="e.preventDefault();this.classList.add('over')"
|
|
ondragleave="this.classList.remove('over')"
|
|
ondrop="dropToSlot(event,${i})">
|
|
<span style="font-size:11px;color:var(--rb-muted)">${_.level}</span>
|
|
</div>
|
|
${i < c.chain.length - 1 ? '<div class="chain-arrow"><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>' : ''}`
|
|
).join('');
|
|
|
|
// Render shuffled pieces
|
|
const shuffled = [...c.chain].sort(() => Math.random() - 0.5);
|
|
const piecesEl = document.getElementById('chain-pieces');
|
|
piecesEl.innerHTML = shuffled.map((item, i) => `
|
|
<div class="chain-piece" id="chain-piece-${i}" draggable="true"
|
|
data-name="${item.name}" data-icon="${item.icon}"
|
|
ondragstart="dragStart(event,${i})">
|
|
<span class="chain-piece-icon">${item.icon}</span>
|
|
<span class="chain-piece-name">${item.name}</span>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function dragStart(e, pieceIdx) {
|
|
chainState.dragging = pieceIdx;
|
|
e.dataTransfer.setData('text/plain', pieceIdx);
|
|
document.getElementById(`chain-piece-${pieceIdx}`)?.classList.add('dragging');
|
|
}
|
|
|
|
function dropToSlot(e, slotIdx) {
|
|
e.preventDefault();
|
|
const slotEl = document.getElementById(`chain-slot-${slotIdx}`);
|
|
slotEl?.classList.remove('over');
|
|
const pieceIdx = parseInt(e.dataTransfer.getData('text/plain'));
|
|
const pieceEl = document.getElementById(`chain-piece-${pieceIdx}`);
|
|
if (!pieceEl) return;
|
|
|
|
const name = pieceEl.dataset.name;
|
|
const icon = pieceEl.dataset.icon;
|
|
|
|
// If slot already has something, put it back
|
|
const prev = chainState.slots[slotIdx];
|
|
if (prev !== null) {
|
|
// Find piece with that name and un-hide it
|
|
document.querySelectorAll('.chain-piece').forEach(p => {
|
|
if (p.dataset.name === chainState.slots[slotIdx]?.name) p.classList.remove('used');
|
|
});
|
|
}
|
|
|
|
chainState.slots[slotIdx] = { name, icon };
|
|
pieceEl.classList.add('used');
|
|
pieceEl.classList.remove('dragging');
|
|
|
|
// Update slot display
|
|
if (slotEl) {
|
|
slotEl.classList.add('filled');
|
|
slotEl.innerHTML = `
|
|
<div class="slot-icon">${icon}</div>
|
|
<div class="slot-name">${name}</div>
|
|
<span onclick="clearSlot(${slotIdx})" style="font-size:10px;color:var(--rb-muted);margin-top:4px;cursor:pointer"><svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>`;
|
|
}
|
|
}
|
|
|
|
function clearSlot(slotIdx) {
|
|
const slot = chainState.slots[slotIdx];
|
|
if (!slot) return;
|
|
chainState.slots[slotIdx] = null;
|
|
// Un-hide matching piece
|
|
document.querySelectorAll('.chain-piece').forEach(p => {
|
|
if (p.dataset.name === slot.name) p.classList.remove('used');
|
|
});
|
|
// Reset slot display
|
|
const c = CHAINS[chainState.chainIdx];
|
|
const slotEl = document.getElementById(`chain-slot-${slotIdx}`);
|
|
if (slotEl) {
|
|
slotEl.classList.remove('filled');
|
|
slotEl.innerHTML = `<span style="font-size:11px;color:var(--rb-muted)">${c.chain[slotIdx].level}</span>`;
|
|
}
|
|
}
|
|
|
|
function checkChain() {
|
|
const c = CHAINS[chainState.chainIdx];
|
|
const res = document.getElementById('chain-result');
|
|
|
|
// Check all filled
|
|
if (chainState.slots.some(s => s === null)) {
|
|
res.style.display = 'block';
|
|
res.className = 'chain-result err';
|
|
res.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> Заполни все ячейки цепочки';
|
|
return;
|
|
}
|
|
|
|
const correct = c.chain.every((item, i) => chainState.slots[i]?.name === item.name);
|
|
res.style.display = 'block';
|
|
if (correct) {
|
|
res.className = 'chain-result ok';
|
|
res.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Верно! Пищевая цепочка составлена правильно. +25 XP';
|
|
showToast('<svg class="ic" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> Цепочка верна! +25 XP', 'success');
|
|
} else {
|
|
res.className = 'chain-result err';
|
|
const correctOrder = c.chain.map(i => `${i.icon} ${i.name}`).join(' <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> ');
|
|
res.innerHTML = `<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> Неверно. Правильный порядок: ${correctOrder}`;
|
|
}
|
|
}
|
|
|
|
function resetChain() {
|
|
loadChain(chainState.chainIdx);
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════════════
|
|
Helpers
|
|
══════════════════════════════════════════════════════════════════════════ */
|
|
function toggleSidebar() {
|
|
const app = document.getElementById('app');
|
|
app.classList.toggle('sb-collapsed');
|
|
localStorage.setItem('ls_sb_collapsed', app.classList.contains('sb-collapsed') ? '1' : '0');
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function showToast(msg, type = 'success', duration = 3000) {
|
|
const wrap = document.getElementById('rb-toasts');
|
|
const t = document.createElement('div');
|
|
t.className = `rb-toast ${type}`;
|
|
t.innerHTML = msg;
|
|
wrap.appendChild(t);
|
|
setTimeout(() => { t.classList.add('out'); t.addEventListener('animationend', () => t.remove()); }, duration);
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|