Files
Learn_System/frontend/js/game/quantik-game.js
T
Maxim Dolgolyov 351251d652 @
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>
@
2026-06-13 15:31:25 +03:00

134 lines
6.2 KiB
JavaScript
Raw 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.
'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);