Files
Maxim Dolgolyov 69df2f8190 @
chore(quantik-game): полировка по финальному ревью + security-review

Финальное ревью: READY TO MERGE (0 блокеров). Security: SECURE (0 critical).
Применены дешёвые фиксы из ревью:
- validateSpec: блок game{} санитизируется ПОИМЁННО (chapter/subject →
  sanitizeText, order/par_ms/unlockStars → проверка типа, неизвестные ключи
  отбрасываются) — закрыт латентный хранимый XSS (раньше clean.game=spec.game).
- quantik.html: @media (prefers-reduced-motion) делает анимации мгновенными
  (не выключает — иначе forwards-появление узлов оставило бы их скрытыми).
- progress-logic.js: фикс комментария isUnlocked (сумма звёзд по ВСЕМ уровням
  с меньшим глобальным order, а не «той же главы»).
План: Ф6 (лидерборд/гонка) удалена (Amendment 1, решение пользователя);
финальные гейты отмечены; deferred-бэклог зафиксирован.
Затронутые тесты 45/45; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 17:00:13 +03:00

585 lines
33 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>Квантик — Законы Мира</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" />
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
/* ════════════════════ Игровая страница «Квантик» ════════════════════ */
.qg-wrap { display: flex; flex-direction: column; height: 100vh; min-height: 0; }
/* ── Topbar ── */
.qg-top {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 11px 20px; border-bottom: 1px solid rgba(148,163,184,0.18);
background: #11132A; flex-shrink: 0; position: relative; z-index: 3;
}
.qg-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.02rem; color: #E2E8F0; white-space: nowrap; }
.qg-sub { font-size: .8rem; color: #94A3B8; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.qg-pill { font-size: .66rem; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; padding: 3px 10px; border-radius: 99px; background: rgba(34,211,238,0.16); color: #67E8F9; }
.qg-back {
display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .82rem; font-weight: 600;
color: #CBD5E1; background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.2);
border-radius: 99px; padding: 5px 13px; cursor: pointer; transition: .16s;
}
.qg-back:hover { background: rgba(34,211,238,0.16); color: #67E8F9; border-color: rgba(34,211,238,0.35); }
.qg-back .ic { width: 15px; height: 15px; }
/* ════════════════════ Карта-созвездие ════════════════════ */
.qm-root {
flex: 1; min-height: 0; position: relative; overflow-y: auto; overflow-x: hidden;
background:
radial-gradient(1100px 700px at 78% -10%, rgba(167,139,250,0.16), transparent 60%),
radial-gradient(900px 600px at 12% 8%, rgba(34,211,238,0.13), transparent 55%),
radial-gradient(700px 800px at 50% 120%, rgba(244,114,182,0.10), transparent 60%),
linear-gradient(180deg, #0B0B1A 0%, #0D0D1F 55%, #0A0A16 100%);
}
/* атмосферное зерно поверх фона */
.qm-root::before {
content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .5;
background-image: radial-gradient(rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 3px 3px;
}
/* ── Шапка карты (нарратор + XP + скины) ── */
.qm-header { position: relative; z-index: 2; padding: 22px 26px 8px; }
.qm-header-inner {
display: grid; grid-template-columns: minmax(240px, 1.4fr) minmax(280px, 1.1fr) auto;
gap: 18px; align-items: center; max-width: 1180px; margin: 0 auto;
}
@media (max-width: 920px) { .qm-header-inner { grid-template-columns: 1fr; } }
.qm-narrator { display: flex; align-items: center; gap: 14px; }
.qm-pet { width: 76px; height: 80px; flex-shrink: 0; filter: drop-shadow(0 8px 22px rgba(34,211,238,0.28)); }
.qm-pet svg { width: 100%; height: 100%; }
.qm-bubble {
position: relative; background: rgba(20,22,44,0.78); border: 1px solid rgba(148,163,184,0.18);
border-radius: 14px; padding: 12px 15px; backdrop-filter: blur(6px); box-shadow: 0 10px 30px rgba(0,0,0,0.35);
}
.qm-bubble::before {
content: ''; position: absolute; left: -7px; top: 50%; transform: translateY(-50%) rotate(45deg);
width: 12px; height: 12px; background: rgba(20,22,44,0.78); border-left: 1px solid rgba(148,163,184,0.18); border-bottom: 1px solid rgba(148,163,184,0.18);
}
.qm-bubble-t { color: #DCE3EE; font-size: .86rem; line-height: 1.45; }
.qm-stats { display: flex; align-items: center; gap: 18px; }
.qm-level { display: flex; flex-direction: column; align-items: center; line-height: 1; flex-shrink: 0; }
.qm-level-num {
font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 2rem;
background: linear-gradient(135deg, #67E8F9, #A78BFA); -webkit-background-clip: text; background-clip: text; color: transparent;
}
.qm-level-lbl { font-size: .62rem; text-transform: uppercase; letter-spacing: .06em; color: #8B9AAE; margin-top: 4px; max-width: 84px; text-align: center; }
.qm-xpbox { flex: 1; min-width: 160px; }
.qm-xp-head { display: flex; justify-content: space-between; font-size: .74rem; color: #CBD5E1; margin-bottom: 6px; font-weight: 600; font-variant-numeric: tabular-nums; }
.qm-xp-next { color: #8B9AAE; font-weight: 500; }
.qm-xp-bar { height: 9px; border-radius: 99px; background: rgba(148,163,184,0.18); overflow: hidden; }
.qm-xp-fill {
height: 100%; border-radius: 99px; width: 0;
background: linear-gradient(90deg, #22D3EE, #A78BFA);
box-shadow: 0 0 14px rgba(103,232,249,0.5);
transition: width .9s cubic-bezier(.22,.61,.36,1);
}
.qm-starcount {
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1rem; color: #FBBF24; font-variant-numeric: tabular-nums;
}
/* ── Скины ── */
.qm-skins { display: flex; flex-direction: column; gap: 7px; }
.qm-skins-lbl { font-size: .62rem; text-transform: uppercase; letter-spacing: .06em; color: #8B9AAE; font-weight: 700; }
.qm-skins-row { display: flex; gap: 7px; flex-wrap: wrap; max-width: 220px; }
.qm-skin {
width: 30px; height: 30px; border-radius: 50%; cursor: pointer; padding: 0;
background: radial-gradient(circle at 34% 30%, color-mix(in srgb, var(--sk) 70%, #fff), var(--sk) 70%);
border: 2px solid rgba(255,255,255,0.18); position: relative; transition: transform .14s, box-shadow .14s, border-color .14s;
}
.qm-skin:hover:not(.locked) { transform: scale(1.12); }
.qm-skin.active { border-color: #fff; box-shadow: 0 0 0 2px var(--sk), 0 0 14px var(--sk); }
.qm-skin.locked { filter: grayscale(.7) brightness(.5); cursor: not-allowed; }
.qm-skin-lock { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.qm-skin-lock .ic { width: 12px; height: 12px; }
/* ── Тело: созвездия ── */
.qm-body { position: relative; z-index: 1; max-width: 1180px; margin: 0 auto; padding: 8px 26px 60px; }
.qm-constellation { margin-top: 18px; }
.qm-con-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; padding-left: 4px; }
.qm-con-title {
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.1rem; color: #E8EDF5;
position: relative; padding-left: 16px;
}
.qm-con-title::before {
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent);
}
.qm-con-sub { font-size: .8rem; color: #8B9AAE; flex: 1; }
.qm-con-stars { display: inline-flex; align-items: center; gap: 4px; font-size: .78rem; color: #FBBF24; font-weight: 700; font-variant-numeric: tabular-nums; }
.qm-field { position: relative; height: 220px; margin-top: 2px; }
@media (max-width: 620px) { .qm-field { height: 280px; } }
.qm-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.qm-link { stroke: rgba(148,163,184,0.22); stroke-width: .35; stroke-dasharray: 1.4 1.6; }
.qm-link.on { stroke: var(--accent); stroke-opacity: .55; stroke-dasharray: none; stroke-width: .45; }
.qm-tw { animation: qmTwinkle var(--tw, 2.4s) ease-in-out var(--td, 0s) infinite; transform-box: fill-box; transform-origin: center; }
@keyframes qmTwinkle { 0%,100% { opacity: .25; } 50% { opacity: .9; } }
/* ── Узлы ── */
.qm-node {
position: absolute; transform: translate(-50%, -50%); z-index: 2;
display: flex; flex-direction: column; align-items: center; gap: 6px;
background: none; border: none; cursor: pointer; font: inherit; padding: 0;
transition: transform .2s;
}
.qm-node-core {
width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
position: relative; transition: transform .18s, box-shadow .18s;
}
.qm-node-core .ic { color: #fff; }
.qm-node-label { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: .76rem; color: #CBD5E1; white-space: nowrap; text-shadow: 0 1px 4px rgba(0,0,0,.6); }
.qm-node-stars { display: inline-flex; gap: 1px; }
.qm-node-need { display: inline-flex; align-items: center; gap: 3px; font-size: .68rem; color: #94A3B8; font-weight: 600; }
/* доступный узел */
.qm-available .qm-node-core {
background: radial-gradient(circle at 35% 30%, #34D399, #0E9F6E);
box-shadow: 0 0 0 4px rgba(52,211,153,0.18), 0 8px 24px rgba(16,185,129,0.4);
animation: qmPulse 2.4s ease-in-out infinite;
}
@keyframes qmPulse {
0%,100% { box-shadow: 0 0 0 4px rgba(52,211,153,0.18), 0 8px 24px rgba(16,185,129,0.4); }
50% { box-shadow: 0 0 0 8px rgba(52,211,153,0.10), 0 10px 32px rgba(16,185,129,0.55); }
}
.qm-available:hover { transform: translate(-50%, -50%) scale(1.07); }
.qm-available:hover .qm-node-core { transform: scale(1.05); }
/* пройденный узел */
.qm-completed .qm-node-core {
background: radial-gradient(circle at 35% 30%, #67E8F9, #2563EB);
box-shadow: 0 0 0 3px rgba(34,211,238,0.2), 0 6px 20px rgba(37,99,235,0.4);
}
.qm-completed:hover { transform: translate(-50%, -50%) scale(1.06); }
.qm-node-order { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.15rem; color: #fff; }
/* заблокированный узел */
.qm-locked { cursor: not-allowed; }
.qm-locked .qm-node-core {
background: rgba(30,33,58,0.85); border: 1.5px solid rgba(148,163,184,0.22);
box-shadow: inset 0 2px 10px rgba(0,0,0,0.4);
}
.qm-locked .qm-node-label { color: #6B7A90; }
/* фокус для клавиатуры */
.qm-node:focus-visible { outline: none; }
.qm-node:focus-visible .qm-node-core { box-shadow: 0 0 0 3px #fff, 0 0 0 6px var(--accent, #22D3EE); }
/* поэтапное появление */
.qm-node.qm-pre { opacity: 0; transform: translate(-50%, -40%) scale(.6); }
.qm-node.qm-in { animation: qmNodeIn .5s cubic-bezier(.22,1.2,.4,1) forwards; }
@keyframes qmNodeIn {
from { opacity: 0; transform: translate(-50%, -40%) scale(.6); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
/* ════════════════════ Сцена уровня ════════════════════ */
.qg-stage { flex: 1; min-height: 0; position: relative; background: #0D0D1A; overflow: hidden; }
.qg-stage .sim-spec-root { position: absolute; inset: 0; }
.qg-fallback { padding: 40px; color: #cbd5e1; font-family: 'Manrope', sans-serif; max-width: 520px; }
.qg-view { display: none; flex: 1; min-height: 0; }
.qg-view.show { display: flex; flex-direction: column; }
/* ── Оверлеи (интро / успех) ── */
.qg-overlay {
position: absolute; inset: 0; z-index: 20;
display: flex; align-items: center; justify-content: center;
background: rgba(7, 7, 18, 0.74); backdrop-filter: blur(5px);
}
.qg-card {
background: linear-gradient(180deg, #15173099, #0F1024EE), #14152C;
border: 1px solid rgba(148,163,184,0.18); border-radius: 20px;
padding: 26px 30px 24px; width: min(440px, 92vw); text-align: center;
box-shadow: 0 24px 70px rgba(0,0,0,0.55);
animation: qg-pop .26s cubic-bezier(.22,1.1,.4,1);
}
@keyframes qg-pop { from { transform: scale(.9) translateY(8px); opacity: 0; } to { transform: scale(1) translateY(0); opacity: 1; } }
.qg-card-kicker { font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; color: #67E8F9; margin-bottom: 4px; }
.qg-card-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.32rem; color: #EAF0F8; margin-bottom: 12px; }
.qg-intro-pet, .qg-success-pet { width: 92px; height: 96px; margin: 0 auto 6px; filter: drop-shadow(0 10px 26px rgba(34,211,238,0.3)); }
.qg-intro-pet svg, .qg-success-pet svg { width: 100%; height: 100%; }
.qg-success-pet { animation: qgBob 1.6s ease-in-out infinite; }
@keyframes qgBob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
.qg-intro-goal { font-weight: 700; font-size: .98rem; color: #DCE3EE; margin-bottom: 8px; }
.qg-intro-hint { font-size: .85rem; color: #A8B4C6; line-height: 1.5; margin-bottom: 18px; max-width: 360px; margin-left: auto; margin-right: auto; }
.qg-stars { display: flex; justify-content: center; gap: 6px; margin-bottom: 18px; }
.qg-star { display: inline-flex; }
.qg-star-svg { filter: drop-shadow(0 2px 6px rgba(251,191,36,0.4)); }
.qg-star-on { animation: qgStarPop .45s cubic-bezier(.22,1.3,.4,1) backwards; animation-delay: calc(.12s * var(--si, 0) + .15s); }
@keyframes qgStarPop { 0% { transform: scale(0) rotate(-30deg); opacity: 0; } 70% { transform: scale(1.25) rotate(6deg); } 100% { transform: scale(1) rotate(0); opacity: 1; } }
/* Доступность: уважаем prefers-reduced-motion. Делаем анимации мгновенными
(а не выключаем) — иначе forwards-анимация появления узлов (.qm-in/qmNodeIn)
не применит конечное состояние и узлы останутся скрытыми. Циклы (пульс/мерцание/
покачивание) при этом фактически останавливаются (1 итерация мгновенно). */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
.qg-stats { display: flex; justify-content: center; gap: 22px; margin-bottom: 20px; }
.qg-stat { display: flex; flex-direction: column; gap: 3px; }
.qg-stat-lbl { font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #8B9AAE; }
.qg-stat-val { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.05rem; color: #EAF0F8; font-variant-numeric: tabular-nums; }
.qg-actions { display: flex; justify-content: center; gap: 10px; }
.qg-btn { min-width: 118px; }
/* ════════════════════ Квантовые способности (Фаза 4) ════════════════════ */
/* Панель способностей + HUD энергии — оверлеем поверх сцены уровня. */
.qa-bar {
position: absolute; right: 12px; bottom: 12px; z-index: 12;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;
pointer-events: auto; max-width: calc(100% - 24px);
}
.qa-energy {
display: inline-flex; align-items: center; gap: 5px;
background: rgba(17,19,42,0.86); border: 1px solid rgba(251,191,36,0.4);
border-radius: 99px; padding: 6px 12px 6px 10px; color: #FBBF24;
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: .9rem; font-variant-numeric: tabular-nums;
box-shadow: 0 6px 18px rgba(0,0,0,0.4);
}
.qa-energy .ic { width: 16px; height: 16px; color: #FBBF24; }
.qa-btn {
display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .82rem; font-weight: 600;
color: #E2E8F0; background: rgba(17,19,42,0.86); border: 1px solid rgba(148,163,184,0.28);
border-radius: 99px; padding: 7px 13px; cursor: pointer; transition: .16s;
box-shadow: 0 6px 18px rgba(0,0,0,0.4);
}
.qa-btn .ic { width: 16px; height: 16px; }
.qa-btn:hover:not(:disabled) { border-color: rgba(196,181,253,0.6); color: #fff; background: rgba(30,33,66,0.94); }
.qa-btn:disabled { opacity: .42; cursor: not-allowed; }
.qa-rest { color: #67E8F9; border-color: rgba(34,211,238,0.32); }
.qa-rest:hover:not(:disabled) { border-color: rgba(34,211,238,0.6); }
.qa-tunnel { color: #F0ABFC; border-color: rgba(244,114,182,0.34); }
.qa-aim { color: #7DD3FC; border-color: rgba(56,189,248,0.34); }
.qa-cost { display: inline-flex; align-items: center; gap: 2px; font-size: .74rem; color: #FBBF24; font-weight: 800; }
.qa-cost .ic { width: 12px; height: 12px; color: #FBBF24; }
.qa-ability.qa-on {
color: #fff; border-color: rgba(196,181,253,0.85);
box-shadow: 0 0 0 2px rgba(196,181,253,0.35), 0 6px 18px rgba(167,139,250,0.4);
}
/* всплывающая подсказка способности */
.qa-toast {
position: absolute; left: 50%; bottom: 70px; transform: translateX(-50%) translateY(8px); z-index: 14;
background: rgba(13,13,26,0.94); border: 1px solid rgba(196,181,253,0.5); color: #E8EDF5;
padding: 9px 16px; border-radius: 12px; font-size: .85rem; font-weight: 600;
box-shadow: 0 12px 34px rgba(0,0,0,0.5); opacity: 0; transition: opacity .28s, transform .28s; pointer-events: none;
max-width: 84%; text-align: center;
}
.qa-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ── SR-комната (модалка повторения) ── */
.qa-overlay {
position: fixed; inset: 0; z-index: 60;
display: flex; align-items: center; justify-content: center;
background: rgba(7,7,18,0.78); backdrop-filter: blur(6px); padding: 16px;
}
.qa-modal {
background: linear-gradient(180deg, #15173099, #0F1024EE), #14152C;
border: 1px solid rgba(148,163,184,0.2); border-radius: 18px;
width: min(460px, 96vw); max-height: 92vh; overflow: hidden;
display: flex; flex-direction: column; box-shadow: 0 24px 70px rgba(0,0,0,0.6);
animation: qg-pop .24s cubic-bezier(.22,1.1,.4,1);
}
.qa-modal-head {
display: flex; align-items: center; gap: 10px; padding: 14px 16px;
border-bottom: 1px solid rgba(148,163,184,0.16); flex-shrink: 0;
}
.qa-modal-title { display: inline-flex; align-items: center; gap: 8px; font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1rem; color: #EAF0F8; flex: 1; min-width: 0; }
.qa-modal-title .ic { width: 18px; height: 18px; color: #67E8F9; flex-shrink: 0; }
.qa-modal-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.qa-modal-energy { display: inline-flex; align-items: center; gap: 4px; color: #FBBF24; font-weight: 800; font-variant-numeric: tabular-nums; }
.qa-modal-energy .ic { width: 15px; height: 15px; color: #FBBF24; }
.qa-modal-x { background: none; border: none; color: #94A3B8; font-size: 1.5rem; line-height: 1; cursor: pointer; padding: 0 4px; }
.qa-modal-x:hover { color: #fff; }
.qa-modal-body { padding: 18px 18px 20px; overflow-y: auto; }
.qa-loading { text-align: center; color: #94A3B8; padding: 30px 0; }
.qa-empty-title { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.1rem; color: #EAF0F8; text-align: center; margin-bottom: 8px; }
.qa-empty-msg { color: #A8B4C6; text-align: center; line-height: 1.5; margin-bottom: 18px; }
.qa-modal-actions { display: flex; justify-content: center; gap: 10px; flex-wrap: wrap; }
.qa-modal-btn { min-width: 130px; text-align: center; text-decoration: none; }
.qa-deck-list { display: flex; flex-direction: column; gap: 8px; }
.qa-deck {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: rgba(30,33,58,0.6); border: 1px solid rgba(148,163,184,0.18); border-radius: 12px;
padding: 11px 13px; cursor: pointer; font: inherit; transition: .15s;
}
.qa-deck:hover { border-color: var(--dk, #9B5DE5); background: rgba(40,44,78,0.7); }
.qa-deck-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--dk, #9B5DE5); flex-shrink: 0; }
.qa-deck-title { color: #E2E8F0; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.qa-deck-due { color: #67E8F9; font-size: .76rem; font-weight: 700; flex-shrink: 0; }
/* сессия повторения */
.qa-prog { height: 6px; border-radius: 99px; background: rgba(148,163,184,0.2); overflow: hidden; margin-bottom: 6px; }
.qa-prog-fill { height: 100%; background: linear-gradient(90deg, #22D3EE, #A78BFA); border-radius: 99px; transition: width .3s; }
.qa-prog-count { text-align: right; font-size: .72rem; color: #8B9AAE; margin-bottom: 12px; font-variant-numeric: tabular-nums; }
.qa-card {
min-height: 130px; background: rgba(20,22,44,0.72); border: 1px solid rgba(148,163,184,0.18);
border-radius: 14px; padding: 20px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 12px;
}
.qa-card-side { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.qa-card-back { border-top: 1px dashed rgba(148,163,184,0.3); padding-top: 12px; }
.qa-card-text { color: #E8EDF5; font-size: 1.02rem; line-height: 1.45; text-align: center; word-break: break-word; }
.qa-card-empty { color: #64748B; }
.qa-card-img { max-width: 100%; max-height: 160px; border-radius: 10px; }
.qa-flip { width: 100%; }
.qa-grades { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.qa-grade { font: inherit; font-size: .82rem; font-weight: 700; color: #fff; border: none; border-radius: 10px; padding: 10px 4px; cursor: pointer; transition: filter .14s; }
.qa-grade:hover { filter: brightness(1.12); }
.qa-g-again { background: #DC2626; }
.qa-g-hard { background: #D97706; }
.qa-g-good { background: #2563EB; }
.qa-g-easy { background: #16A34A; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="qg-wrap">
<div class="qg-top">
<span class="qg-title" id="qg-title">Квантик — Законы Мира</span>
<span class="qg-sub" id="qg-sub"></span>
<button class="qg-back" id="qg-back" type="button" style="display:none">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18 9 12 15 6"/></svg>
К карте
</button>
<span class="qg-pill" id="qg-pill">Физика</span>
</div>
<!-- Вид карты -->
<div class="qg-view show" id="qg-map-view">
<div class="qm-root">
<div class="qm-header" id="qg-map-header"></div>
<div class="qm-body" id="qg-map-body"></div>
</div>
</div>
<!-- Вид уровня -->
<div class="qg-view" id="qg-level-view">
<div class="qg-stage" id="qg-stage"></div>
</div>
</div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<!-- модель питомца (нарратор-Квантик) -->
<script src="/js/pet-sprite.js"></script>
<!-- движок спек-симуляций (тот же путь, что lab.html / sim-builder.html) -->
<script src="/js/labs/_sim_expr.js"></script>
<script src="/js/labs/_sim_engine.js"></script>
<!-- KaTeX для подписей сцены -->
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<!-- уровни (данные) + логика прогресса + карта + способности + игра -->
<script src="/js/game/levels.js"></script>
<script src="/js/game/progress-logic.js"></script>
<script src="/js/game/map.js"></script>
<script src="/js/game/quantik-abilities.js"></script>
<script src="/js/game/quantik-game.js"></script>
<script>
(function () {
// Доступ: любой авторизованный пользователь (играют и ученики).
if (!LS.initPage()) { return; }
var mapView = document.getElementById('qg-map-view');
var lvlView = document.getElementById('qg-level-view');
var stage = document.getElementById('qg-stage');
var backBtn = document.getElementById('qg-back');
var titleEl = document.getElementById('qg-title');
var subEl = document.getElementById('qg-sub');
var pillEl = document.getElementById('qg-pill');
// Бейдж темы по предмету уровня (аддитивно; граф-уровни — «Алгебра»).
var SUBJECT_LABEL = { physics: 'Физика', algebra: 'Алгебра', math: 'Математика' };
function setPill(level) {
if (!pillEl) return;
pillEl.textContent = SUBJECT_LABEL[level && level.subject] || 'Физика';
}
if (!window.SimEngine || !window.SimExpr || !window.QuantikLevels ||
!window.QuantikGame || !window.QuantikMap || !window.QuantikProgress) {
stage.innerHTML = '<div class="qg-fallback">Движок игры не загрузился. Обновите страницу.</div>';
lvlView.classList.add('show'); mapView.classList.remove('show');
return;
}
var progressMap = {}; // { level_id: row }
var curInst = null; // текущий инстанс движка уровня
var map = null;
function loadProgress() {
if (window.LS && window.LS.gameProgressList) {
return window.LS.gameProgressList()
.then(function (r) { progressMap = window.QuantikProgress.fromProgressList(r && r.progress); })
.catch(function () { progressMap = {}; });
}
return Promise.resolve();
}
function destroyLevel() {
if (curInst) { try { curInst.destroy(); } catch (_e) {} curInst = null; }
stage.innerHTML = '';
}
/* ── Показать карту ── */
function showMap() {
destroyLevel();
lvlView.classList.remove('show');
mapView.classList.add('show');
backBtn.style.display = 'none';
titleEl.textContent = 'Квантик — Законы Мира';
subEl.textContent = 'Карта мира — выбери уровень и почини закон';
if (pillEl) pillEl.textContent = 'Физика';
history.replaceState(null, '', '/quantik');
// перезагрузить прогресс (мог обновиться после победы) и перерисовать
loadProgress().then(function () { map.render(progressMap); });
}
/* ── Запустить уровень (после интро) ── */
function launchLevel(level) {
destroyLevel();
mapView.classList.remove('show');
lvlView.classList.add('show');
backBtn.style.display = '';
titleEl.textContent = level.title || 'Квантик';
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || level.hint || '';
setPill(level);
history.replaceState(null, '', '/quantik?level=' + encodeURIComponent(level.id));
// Pre-win значение (фолбэк, если пересчёт после победы недоступен).
var nextLevel = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
curInst = window.QuantikGame.start({
host: stage,
level: level,
skin: window.QuantikGame.getSkin(),
hasNext: !!nextLevel,
// Победа разблокирует след. уровень → перезагружаем прогресс и пересчитываем
// «следующий доступный» на свежей карте, чтобы экран успеха показал «Дальше».
resolveNext: function () {
return loadProgress().then(function () {
var nx = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
return { hasNext: !!nx, next: nx };
});
},
onNext: function () {
// прогресс уже перезагружен в resolveNext → берём след. доступный из свежей карты
var nx = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
if (nx) openLevel(nx); else showMap();
},
onMap: showMap
});
if (!curInst) {
stage.innerHTML = '<div class="qg-fallback">Не удалось запустить уровень.</div>';
}
}
/* ── Открыть уровень: показать интро-карточку, потом launch ── */
function openLevel(level) {
destroyLevel();
mapView.classList.remove('show');
lvlView.classList.add('show');
backBtn.style.display = '';
titleEl.textContent = level.title || 'Квантик';
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || '';
setPill(level);
var intro = window.QuantikGame.buildIntro(level, window.QuantikGame.getSkin());
intro.btnGo.addEventListener('click', function () {
if (intro.overlay.parentNode) intro.overlay.parentNode.removeChild(intro.overlay);
launchLevel(level);
});
intro.btnBack.addEventListener('click', function () {
if (intro.overlay.parentNode) intro.overlay.parentNode.removeChild(intro.overlay);
showMap();
});
stage.appendChild(intro.overlay);
}
/* ── Карта ── */
map = window.QuantikMap.create({
host: document.getElementById('qg-map-body'),
headerHost: document.getElementById('qg-map-header'),
onPlay: function (level) { openLevel(level); },
getSkin: function () { return window.QuantikGame.getSkin(); },
onSkin: function (key) {
window.QuantikGame.setSkin(key);
map.render(progressMap); // перерисовать (нарратор + активный свотч)
}
});
backBtn.addEventListener('click', showMap);
// Подмешать авторённые уровни (custom_sims cat='game') до рендера карты (Ф5).
function ensureCustomLevels() {
if (window.QuantikLevels.ensureCustom) {
return window.QuantikLevels.ensureCustom().catch(function () {});
}
return Promise.resolve();
}
// Старт: если ?level=<id> в URL и уровень доступен — открыть его, иначе карта.
// Сначала грузим прогресс И авторённые уровни (параллельно), затем deep-link.
Promise.all([loadProgress(), ensureCustomLevels()]).then(function () {
map.render(progressMap);
var params = new URLSearchParams(location.search);
var wantId = params.get('level');
if (wantId) {
// custom:<id> может быть свой draft (нет в списке) — резолвим асинхронно с
// проверкой доступа на сервере (own|published|admin → иначе 404/403 → карта).
var resolve = window.QuantikLevels.getAsync
? window.QuantikLevels.getAsync(wantId)
: Promise.resolve(window.QuantikLevels.get(wantId));
resolve.then(function (lvl) {
// Авторённый уровень (deep-link) — открываем без гейта unlockStars
// (учитель/получатель ссылки заходит прямо в него). Встроенный — как раньше.
var isCustom = /^custom:/.test(wantId);
if (lvl && (isCustom || window.QuantikProgress.isUnlocked(lvl, progressMap, window.QuantikLevels.list()))) {
openLevel(lvl);
} else {
showMapNoReload();
}
});
return;
}
showMapNoReload();
});
// показать карту без повторной загрузки прогресса (стартовый случай)
function showMapNoReload() {
lvlView.classList.remove('show');
mapView.classList.add('show');
backBtn.style.display = 'none';
titleEl.textContent = 'Квантик — Законы Мира';
subEl.textContent = 'Карта мира — выбери уровень и почини закон';
}
window.__quantik = { map: map, getInst: function () { return curInst; } };
})();
</script>
</body>
</html>