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