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