feat(quantik-game): фаза 1 — оболочка игры + физ-уровень + прогресс (MVP)

Страница /quantik монтирует уровень-спеку в SimEngine (игровой режим: HUD из
Ф0 + слайдеры закона + play/reset), на победу шлёт результат и показывает
экран успеха (звёзды/время/попытки, inline SVG). Уровень phys-artillery-1
как данные (levels.js): гравитация + запуск тела из угла/скорости, портал,
бонус-звезда. Бэкенд: миграция 076 game_progress (UNIQUE user+level),
/api/game/progress (GET свой / POST upsert best time/stars, attempts++,
auth-only, валидация входа), клиент LS.gameProgress*, пункт сайдбара.
game.test.js 13/13; npm test 251 pass/8 baseline; lint:routes 0.
Уровень проверен на реальном интеграторе (311 выигрышных комбо, 31 на 3★).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Maxim Dolgolyov
2026-06-13 15:31:25 +03:00
parent 4b5c8077d3
commit 351251d652
14 changed files with 679 additions and 28 deletions
+107
View File
@@ -0,0 +1,107 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · Реестр уровней (Фаза 1, MVP).
Уровень = СПЕКА SimForge (данные, не код) + блок `goal` (победа), который
движок (_sim_engine.js) умеет с Фазы 0. Игрок не управляет героем напрямую —
он «чинит закон мира»: крутит слайдеры params (угол/скорость), затем «Запуск»,
и симуляция проигрывается к цели.
ИСТОЧНИК УРОВНЕЙ (решение зафиксировано в CONTEXT.md):
— СЕЙЧАС (Фаза 1): встроенные данные здесь, window.QuantikLevels.
— ПОЗЖЕ (Фаза 5): уровни авторятся в sim-builder и хранятся в custom_sims
(cat='game'); реестр пополнится загрузкой опубликованных спек с сервера.
Форма записи уровня:
{ id, title, subject?, hint?, spec }
где spec — обычная спека SimForge с блоком goal. id == level_id для
/api/game/progress (LS.gameProgressSubmit(id, ...)).
⛔ Без eval/Function. Все «числовые» поля могут быть числом ИЛИ строкой-
выражением (их безопасно вычисляет SimExpr на клиенте).
════════════════════════════════════════════════════════════════════════ */
(function (global) {
/* ── Уровень 1: «Артиллерия Квантика» ──────────────────────────────────
Герой — светящаяся точка-тело (body) с кометной трассой (P2). Запускается
из начала координат под углом θ со скоростью v; гравитация тянет вниз.
Цель — попасть в портал; бонус-звезда — собрать кристалл по дороге.
Параметры подобраны так, чтобы уровень был ПРОХОДИМ в пределах слайдеров. */
var PORTAL_X = 8; // центр портала по X (мир)
var PORTAL_Y = 0; // центр портала по Y (на «земле» y=0)
var PORTAL_R = 0.7; // радиус попадания
var STAR_X = 4; // бонус-кристалл (на восходящей ветви хорошей дуги)
var STAR_Y = 2.6;
var STAR_R = 0.65;
var artillery1 = {
id: 'phys-artillery-1',
title: 'Артиллерия Квантика',
subject: 'physics',
hint: 'Подберите угол и скорость, чтобы Квантик долетел до портала. Соберите кристалл по дороге — это бонусная звезда.',
spec: {
specVersion: 1,
meta: { title: 'Артиллерия Квантика', desc: 'Закон движения: бросок под углом к горизонту.' },
viewport: { xmin: -1, xmax: 12, ymin: -1.2, ymax: 7, grid: true, axes: true, bg: '#0D0D1A' },
params: [
{ name: 'theta', label: 'Угол', min: 10, max: 80, step: 1, value: 45, unit: '°' },
{ name: 'v', label: 'Скорость', min: 5, max: 20, step: 0.5, value: 10, unit: 'м/с' }
],
physics: {
enabled: true,
gravity: { x: 0, y: -9.8 }
},
objects: [
// «Земля» — линия y=0 для ориентира.
{ type: 'segment', x1: -1, y1: 0, x2: 12, y2: 0, color: '#334155', width: 2 },
// Бонус-кристалл (звезда). Контурный кружок-маркер.
{ type: 'circle', x: STAR_X, y: STAR_Y, r: STAR_R, color: '#FBBF24', width: 2, glow: true },
{ type: 'label', x: STAR_X, y: STAR_Y + 0.7, text: 'кристалл', color: '#FBBF24', size: 12 },
// Портал — цель. Светящийся кружок.
{ type: 'circle', x: PORTAL_X, y: PORTAL_Y + PORTAL_R, r: PORTAL_R, color: '#22D3EE', width: 3, glow: true, glowColor: '#22D3EE' },
{ type: 'label', x: PORTAL_X, y: PORTAL_Y + 2.0, text: 'портал', color: '#22D3EE', size: 12 },
// Герой Квантик — физ-тело, стартует из (0,0) со скоростью (vx,vy).
// glow + кометная трасса (P2).
{
id: 'ball', type: 'point', r: 7, color: '#06D6E0',
x: 0, y: 0,
glow: true, glowColor: '#06D6E0', trail: true, trailColor: '#06D6E0',
body: {
mass: 1,
vx: 'v*cos(theta*pi/180)',
vy: 'v*sin(theta*pi/180)'
}
},
// Живые показания скорости (бейдж-оверлей).
{ type: 'readout', label: 'v', expr: 'v', unit: 'м/с', precision: 1 },
{ type: 'readout', label: 'θ', expr: 'theta', unit: '°', precision: 0 }
],
goal: {
title: 'Попади в портал',
hint: 'Квантик должен достичь портала. Бонус: собери кристалл по дороге.',
// Победа: герой в радиусе портала.
when: 'hypot(ball.x - ' + PORTAL_X + ', ball.y - ' + (PORTAL_Y + PORTAL_R) + ') < ' + PORTAL_R,
// Мягкий проигрыш: улетел далеко за поле (промах) — можно перезапустить.
fail: 'ball.x > 11.5 || ball.y < -1.0',
stars: [
{ when: 'hypot(ball.x - ' + STAR_X + ', ball.y - ' + STAR_Y + ') < ' + STAR_R, label: 'Собрал кристалл' }
]
}
}
};
var LEVELS = [artillery1];
function list() { return LEVELS.slice(); }
function get(id) {
for (var i = 0; i < LEVELS.length; i++) if (LEVELS[i].id === id) return LEVELS[i];
return null;
}
global.QuantikLevels = { list: list, get: get, LEVELS: LEVELS };
})(typeof window !== 'undefined' ? window : this);
+133
View File
@@ -0,0 +1,133 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · логика игровой страницы (Фаза 1, MVP).
Монтирует уровень-спеку через SimEngine.mount (тот же движок, что lab.html
и sim-builder.html). «Игровой режим» включается САМ наличием блока goal в
спеке (Фаза 0: HUD с целью/звёздами появляется автоматически). Управление —
собственные слайдеры params движка + кнопки Запуск/Сброс. На победу
(inst.onGoal) шлём результат на сервер и показываем экран успеха.
window.QuantikGame.start({ host, level }) -> инстанс движка (или null).
⛔ Без eval/Function. Уровни — данные из window.QuantikLevels.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var doc = global.document;
function el(tag, cls, html) {
var n = doc.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
/* Inline SVG звезды (заполненная / контур) — без эмодзи (правило проекта). */
function starSvg(filled) {
var fill = filled ? '#FBBF24' : 'none';
var stroke = filled ? '#FBBF24' : '#64748B';
return '<svg class="ic qg-star-svg" viewBox="0 0 24 24" width="34" height="34" fill="' + fill +
'" stroke="' + stroke + '" stroke-width="1.6" stroke-linejoin="round">' +
'<polygon points="12 2 15.1 8.6 22 9.3 17 14.1 18.2 21 12 17.6 5.8 21 7 14.1 2 9.3 8.9 8.6"/></svg>';
}
function fmtTime(ms) {
if (!ms && ms !== 0) return '—';
var s = ms / 1000;
return s.toFixed(2) + ' с';
}
/* ── Экран успеха (DOM-оверлей страницы, поверх сцены) ─────────────────── */
function buildSuccessOverlay(state) {
var got = (state && state.stars && state.stars.got) || 0;
var total = (state && state.stars && state.stars.total) || 0;
var overlay = el('div', 'qg-overlay');
var card = el('div', 'qg-card');
card.appendChild(el('div', 'qg-card-title', 'Уровень пройден!'));
// звёзды: total «слотов», got заполнено
var starsBox = el('div', 'qg-stars');
var slots = Math.max(total, got, 1);
for (var i = 0; i < slots; i++) {
var w = el('span', 'qg-star');
w.innerHTML = starSvg(i < got);
starsBox.appendChild(w);
}
card.appendChild(starsBox);
var stats = el('div', 'qg-stats');
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Время</span><span class="qg-stat-val">' + fmtTime(state && state.timeMs) + '</span>'));
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Звёзды</span><span class="qg-stat-val">' + got + ' / ' + (total || slots) + '</span>'));
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Попытки</span><span class="qg-stat-val">' + ((state && state.attempts) || 0) + '</span>'));
card.appendChild(stats);
var actions = el('div', 'qg-actions');
var btnAgain = el('button', 'btn-primary qg-btn', 'Ещё раз');
btnAgain.type = 'button';
var btnNext = el('button', 'btn-ghost qg-btn', 'Дальше');
btnNext.type = 'button';
btnNext.disabled = true; // MVP: следующий уровень появится в Фазе 2
btnNext.title = 'Скоро: больше уровней';
actions.appendChild(btnAgain);
actions.appendChild(btnNext);
card.appendChild(actions);
overlay.appendChild(card);
return { overlay: overlay, btnAgain: btnAgain, btnNext: btnNext };
}
/* ── Старт уровня ───────────────────────────────────────────────────────
host — DOM-контейнер сцены. level — запись из QuantikLevels (с .spec/.id). */
function start(opts) {
opts = opts || {};
var host = opts.host;
var level = opts.level;
if (!host || !level || !level.spec) return null;
if (!global.SimEngine || !global.SimExpr) return null;
var inst = global.SimEngine.mount(host, level.spec);
var overlayRef = null;
function clearOverlay() {
if (overlayRef && overlayRef.overlay && overlayRef.overlay.parentNode) {
overlayRef.overlay.parentNode.removeChild(overlayRef.overlay);
}
overlayRef = null;
}
function showSuccess(state) {
clearOverlay();
overlayRef = buildSuccessOverlay(state);
overlayRef.btnAgain.addEventListener('click', function () {
clearOverlay();
try { inst.reset(); } catch (_e) {}
});
// Дальше — заглушка для MVP (нет следующего уровня).
host.appendChild(overlayRef.overlay);
}
inst.onGoal(function (res) {
if (!res || !res.won) return;
var got = (res.stars && res.stars.got) || 0;
// Время победы — мировое t из движка (Ф0): res.timeMs.
var payload = { time_ms: res.timeMs, stars: got };
// Submit best-effort: экран успеха показываем независимо от сети.
try {
if (global.LS && global.LS.gameProgressSubmit) {
global.LS.gameProgressSubmit(level.id, payload).catch(function () { /* офлайн — ок */ });
}
} catch (_e) { /* нет клиента — всё равно показываем успех */ }
showSuccess(res);
});
return inst;
}
global.QuantikGame = { start: start, buildSuccessOverlay: buildSuccessOverlay };
})(typeof window !== 'undefined' ? window : this);
+119
View File
@@ -0,0 +1,119 @@
<!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; }
.qg-top {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--surface);
flex-shrink: 0;
}
.qg-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.05rem; color: var(--text); white-space: nowrap; }
.qg-sub { font-size: .8rem; color: var(--text-3); flex: 1; min-width: 0; }
.qg-pill { font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; padding: 3px 10px; border-radius: 99px; background: rgba(34,211,238,0.14); color: #0e7c8a; }
/* сцена: на всю площадь, тёмный фон (full-bleed root движка растягивается inset:0) */
.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-overlay {
position: absolute; inset: 0; z-index: 20;
display: flex; align-items: center; justify-content: center;
background: rgba(7, 7, 18, 0.72); backdrop-filter: blur(4px);
}
.qg-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 18px;
padding: 28px 30px; width: min(420px, 90vw); text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
animation: qg-pop .22s ease;
}
@keyframes qg-pop { from { transform: scale(.92); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.qg-card-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.3rem; color: var(--text); margin-bottom: 14px; }
.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-stats { display: flex; justify-content: center; gap: 22px; margin-bottom: 22px; }
.qg-stat { display: flex; flex-direction: column; gap: 3px; }
.qg-stat-lbl { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-3); }
.qg-stat-val { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.05rem; color: var(--text); font-variant-numeric: tabular-nums; }
.qg-actions { display: flex; justify-content: center; gap: 10px; }
.qg-btn { min-width: 110px; }
</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>
<span class="qg-pill">Физика</span>
</div>
<div class="qg-stage" id="qg-stage"></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>
<!-- движок спек-симуляций (тот же путь, что 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/quantik-game.js"></script>
<script>
(function () {
// Доступ: любой авторизованный пользователь (играют и ученики).
if (!LS.initPage()) { return; } // initPage сам редиректит на /login, если не авторизован
var stage = document.getElementById('qg-stage');
if (!window.SimEngine || !window.SimExpr || !window.QuantikLevels || !window.QuantikGame) {
stage.innerHTML = '<div class="qg-fallback">Движок игры не загрузился. Обновите страницу.</div>';
return;
}
// Уровень: ?level=<id> или первый из реестра (MVP — один уровень).
var params = new URLSearchParams(location.search);
var wantId = params.get('level');
var level = wantId ? window.QuantikLevels.get(wantId) : null;
if (!level) level = window.QuantikLevels.list()[0] || null;
if (!level) {
stage.innerHTML = '<div class="qg-fallback">Уровень не найден.</div>';
return;
}
document.getElementById('qg-title').textContent = level.title || 'Квантик';
document.getElementById('qg-sub').textContent = level.hint || '';
var inst = window.QuantikGame.start({ host: stage, level: level });
if (!inst) {
stage.innerHTML = '<div class="qg-fallback">Не удалось запустить уровень.</div>';
return;
}
window.__quantik = inst; // для отладки
})();
</script>
</body>
</html>