@
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:
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user