From 4b5c8077d30487caed73fa1ddb154dbdac7e9b26 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 13 Jun 2026 15:13:02 +0300 Subject: [PATCH 01/10] =?UTF-8?q?@=20feat(quantik-game):=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=200=20=E2=80=94=20=D1=81=D0=BB=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=86=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B2=20=D0=B4=D0=B2=D0=B8?= =?UTF-8?q?=D0=B6=D0=BA=D0=B5=20(goal/HUD/result)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Декларативный блок goal в спеке SimForge (булево SimExpr-условие победы), вычисляемый каждый кадр: фиксация результата (победа/время/попытки/звёзды), callback onGoal, HUD-оверлей (цель/звёзды/подсказка/баннер, inline SVG). API инстанса: onGoal/getResult/resetResult. Серверный validateSpec пропускает goal/game (длина выражений + escape текста, без исполнения). Аддитивно: спека без goal ведёт себя как раньше. Смоук 40/40; npm test 238 pass/8 baseline; lint:routes 0. План фичи (7 фаз) + CONTEXT. Co-Authored-By: Claude Opus 4.8 (1M context) @ --- CLAUDE.md | 28 ++ .../src/controllers/customSimController.js | 37 +++ frontend/js/labs/_sim_engine.js | 311 ++++++++++++++++++ plans/quantik-game/CONTEXT.md | 61 ++++ plans/quantik-game/PLAN.md | 91 +++++ plans/quantik-game/phase-0-objective-layer.md | 135 ++++++++ .../quantik-game/phase-1-shell-first-level.md | 63 ++++ plans/quantik-game/phase-2-map-world-xp.md | 56 ++++ plans/quantik-game/phase-3-graph-levels.md | 49 +++ .../phase-4-quantum-abilities-sr.md | 48 +++ .../quantik-game/phase-5-authoring-sharing.md | 49 +++ .../quantik-game/phase-6-leaderboard-live.md | 43 +++ 12 files changed, 971 insertions(+) create mode 100644 plans/quantik-game/CONTEXT.md create mode 100644 plans/quantik-game/PLAN.md create mode 100644 plans/quantik-game/phase-0-objective-layer.md create mode 100644 plans/quantik-game/phase-1-shell-first-level.md create mode 100644 plans/quantik-game/phase-2-map-world-xp.md create mode 100644 plans/quantik-game/phase-3-graph-levels.md create mode 100644 plans/quantik-game/phase-4-quantum-abilities-sr.md create mode 100644 plans/quantik-game/phase-5-authoring-sharing.md create mode 100644 plans/quantik-game/phase-6-leaderboard-live.md diff --git a/CLAUDE.md b/CLAUDE.md index fc1df3d..6e5c15f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,3 +185,31 @@ git push origin master - **Новые ICON** (inline SVG `.ic`, ⛔ без эмодзи): up/down/copy/eye/eyeOff/clearX. Новые CSS-классы в ls.css-стиле; заголовок объекта `flex-wrap` + 26px-кнопки; медиа ≤920px (была) + новый ≤560px (поля/стили в один столбец). - **Верификация P4**: `node --check` sim-builder.js + извлечённого инлайна html — OK; эмодзи/eval/new Function — 0 (скан кодпойнтов обоих файлов); headless vm-смоук (DOM/SimExpr-стаб) 27+12+2 PASS: стили объекта в спеке, round-trip объектов ×2 идемпотентен, plot с 2 кривыми (все поля) + round-trip ×2, легаси-одиночная→легаси-форма, hidden исключён, z-order=порядок, дефолты-стрип, шаблонные легаси-plot save→load→save стабильны. Temp удалены. git status: только sim-builder.html и sim-builder.js. - **На P5 (прямое манипулирование + история)**: drag сейчас только x/y point/circle/label/readout/rect + конец segment/vector (`bindPreviewDrag` через `inst._toWorld`). Расширять до всех типов + snap-к-сетке + выравнивание (нужны хит-тесты/ручки в `_sim_engine.js`). Undo/redo: `this.st` сериализуем JSON → стек снапшотов в Builder, restore + `renderPanels`/`scheduleRemount`. + +### SimForge improvements — P5 (Прямое манипулирование + история) — ФИНАЛ раунда — Learnings + +Всё в `frontend/js/sim-builder.js`. **`_sim_engine.js` НЕ тронут** — вопреки прогнозу IMPROVEMENTS, хук в движке не понадобился: `_toWorld`/`_toPx`/`_niceStep(targetPx)` уже публичны на инстансе, их хватает для хит-теста/перевода координат/шага сетки прямо из билдера. + +- **Ручки вместо «drag только x/y» (`bindPreviewDrag` переписан).** `handlesOf(obj)` строит список ручек `{label, blocked, wx, wy, set(x,y)}` по типу: point/circle/label/readout/rect → одна ручка (x,y); segment/vector → `origin`(x1,y1) + `end` (x2,y2 ИЛИ, если у объекта `dx`/`dy` без `x2`/`y2` — origin+dx/dy: ручка пишет `dx=x-x1`, `dy=y-y1`); polyline/path → по ручке на каждую числовую вершину `points` (её `set` ре-парсит JSON-строку и пишет свой индекс). `pickHandle` — ближайшая незаблокированная ручка в 14px (через `_toPx`). pointerdown-режимы: `handle` (драг ручки), `place` (единств. ручка — клик СТАВИТ точку, сохранён исходный смысл), `body` (несколько ручек — относительный сдвиг всех от стартовой мир-точки), `none`. +- **Выражения не затираются.** `numField(obj,key)` → число, либо `null` если значение — строка-выражение (не парсится как число) → ручка `blocked` (не двигается; молча в спеку не пишется). `refreshObjFields` расширен на x1/y1/x2/y2/dx/dy/points. +- **Snap-к-сетке = шаг движка.** Тумблер в тулбаре (`_snap`, `toggleSnap`, `ICON.grid`; активность — инлайн `SNAP_ACTIVE_CSS`, без зависимости от CSS-класса). При вкл координаты округляются к `inst._niceStep(34)` (минорный шаг видимой сетки; fallback 0.5), при выкл — `round2`. Выравнивание к чужим координатам/осям не делалось (бонус; snap достаточно — частично). +- **Undo/Redo без библиотек.** Снапшот = `JSON.stringify(this.st)` (`this.st` уже сериализуемо). `pushHistory` снимает ДО мутации (без дублей верхушки; чистит redo; глубина `_undoMax=50`). **Гранулярность правки поля**: `snapField` снимает ОДИН снапшот на сессию (флаг `_fieldSnapTaken` сбрасывается на `focusin` поля; первый input/change снимает) → Ctrl+Z откатывает значение целиком, не посимвольно. Структурные операции (add/del/z-order/dup/hide/тумблеры — объекты/plot/curve/wall/spring/физика) — снапшот сразу. Drag — один на сессию (pushHistory в pointerdown; no-op-снапшот без изменений откатывается в `end()`). Кнопки undo/redo (SVG `.ic`) + клавиши Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts` на `document`, вешается один раз, игнорит фокус в INPUT/TEXTAREA/SELECT). `loadFromSim` обнуляет историю; `_restoreSnapshot` → `renderPanels`+`scheduleRemount` (гочи: захватить `this._selObjId` в локальную переменную — иначе `this` теряется в колбэке `.some()`). +- **Верификация P5**: `node --check` OK; эмодзи/eval/new Function — 0 (скан кодпойнтов); headless vm-смоук (DOM/SimExpr/SimEngine-стаб с линейным `_toPx`/`_toWorld`) **38/38 PASS**: drag point/circle, оба конца segment, vector origin+dx/dy, вершина polyline, body-move polyline и segment, snap к 0.5, выражение-поле не затирается, undo/redo drag и onAdd, лимит стека, round-trip buildSpec идемпотентен ×2, no-op-drag не плодит историю. Temp удалён. git status: тронут только sim-builder.js (`_sim_engine.js` в статусе — чужой коммит «goal/game» параллельной сессии, мной не редактировался). + +## Feature: Квантик — Законы Мира (игра) + +2D физика-головоломка поверх SimForge. План: `plans/quantik-game/`. Уровень = спека SimForge + блок `goal`. + +### Phase 0 — Learnings (Слой целей в движке) + +- **«Атом» игры = верхнеуровневый блок `goal` в спеке** (формат — в шапке `_sim_engine.js`): `goal:{ when, title?, hint?, hold?:0, fail?, stars?:[{when,label?}] }` (звёзд ≤3). Аддитивно: нет `goal` → `_goal=null`, HUD не создаётся, в rAF ветка `if(self._goal)` пропускается → **поведение спеки без goal не меняется** (нет накладных вычислений побед, нет DOM-узлов). +- **Компиляция один раз** через `SimExpr.compile(src).fn` (как все выражения движка; кривое выражение → fn возвращает 0, не бросает). Истинность булева = `_truthy` (модульный хелпер): конечное ненулевое число. Без `eval`/`Function`. +- **Env цели = весь env кадра + ЕДИНСТВЕННЫЙ доп.идентификатор `tries`** (= `attempts`). Не вводить других новых идентификаторов — контракт безопасности шаренных выражений. `env.tries` ставится и в `_evalGoal` (rAF), и в `_renderFrame` (star-accumulation на паузе/предпросмотре) для консистентности. +- **Оценка в rAF-кадре**: `_evalGoal(self._buildEnv(), dt)` ПОСЛЕ `_stepPhysics`, ДО `_renderFrame`. Порядок: накопить звёзды (залипают до reset) → `fail` (мягкий проигрыш, приоритет, НЕ победа) → `when` с учётом `hold` (таймер `_goalHoldT` копит мировые секунды; условие пропало → сброс таймера). Победа → `timeMs = max(1, round(t*1000))` (мировое `t`, детерминизм), `won=true`, `pause()`, `_fireGoal()` (onGoal один раз). +- **onGoal не задваивается**: победа делает `pause()` внутри кадра; уже-заквигованный следующий rAF выходит по `if(!self._running) return`. Повторный `play()` после победы не перезапускает (уже won, paused). +- **attempts**: инкрементится только на пользовательском `reset()` (флаг `_goalInited` — первый авто-reset при mount НЕ считается). `resetResult()` сбрасывает результат, но attempts сохраняет (НЕ попытка). +- **HUD = DOM-оверлей** (НЕ canvas), стиль `_readoutBadgeCss` (тёмная плашка). Контейнеры `pointer-events:none` (не крадёт pan/drag), кнопка «Ещё раз» — `pointer-events:auto` → `inst.reset()`. Звёзды — inline SVG (`_starIcon`: заполненная #FBBF24 / контур), без эмодзи. `destroy()` снимает click-слушатель кнопки + removeChild HUD-узлов (баланс add/remove; узлы и так внутри `inst.el`, который удаляется — belt-and-suspenders). +- **Публичное API инстанса**: `onGoal(cb)` (chainable), `getResult()`→`{won,failed,timeMs,attempts,stars:{got,total}}` (без goal → `null`), `resetResult()`. Полный перезапуск уровня = `reset()` (физика+время+attempts++). +- **Сервер** `customSimController.validateSpec`: `goal` (объект) + `game` (резерв Ф1/5) разрешены на верхнем уровне. `when`/`fail`/`stars[].when` → `checkExpr` (длина ≤500, НЕ исполняются); `title`/`hint`/`stars[].label` → `sanitizeText` (escape `& < >` + обрезка); `stars`>3 → 400; `hold` не-число → 400. `cat='game'` уже в `CATS`. Санитизированный `goal`/`game` пишется в `clean`. +- **Верификация P0**: `node --check` обоих файлов OK; headless vm-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`, rAF-очередь степается вручную, `performance.now()` = виртуальные часы) **40/40 PASS**: when→win+timeMs>0, звёзды копятся+залипают+сброс на reset, fail без won, hold требует удержания + сброс при лапсе, спека без goal без HUD/без throw, onGoal ровно 1 раз, destroy баланс add/remove, серверный validateSpec (escape/>3 звезды/длина/hold/без-goal). `npm test` 238 pass / 8 baseline fail; lint:routes 0. Temp удалён. Эмодзи/eval/new Function — 0 (new Function только в пре-существующем комментарии стр.15). +- **На Phase 1**: использовать `onGoal`/`getResult`/`resetResult`; HUD включается сам наличием `goal`. Уровни хранятся в `custom_sims` (cat='game'). `game{}`-блок зарезервирован под мета (узел карты/мир/XP). diff --git a/backend/src/controllers/customSimController.js b/backend/src/controllers/customSimController.js index 4f2878d..12bacba 100644 --- a/backend/src/controllers/customSimController.js +++ b/backend/src/controllers/customSimController.js @@ -235,6 +235,43 @@ function validateSpec(spec) { clean.physics = cph; } + // goal{} — слой цели/победы (Квантик, Фаза 0). Выражения НЕ исполняем (длина), + // текст — sanitizeText (escape + обрезка), не более 3 звёзд, hold — число. + if (spec.goal && typeof spec.goal === 'object' && !Array.isArray(spec.goal)) { + const g = spec.goal; + const cg = {}; + if (g.when !== undefined) { checkExpr(g.when, 'goal.when', errs); cg.when = g.when; } + if (g.fail !== undefined) { checkExpr(g.fail, 'goal.fail', errs); cg.fail = g.fail; } + if (g.title !== undefined) cg.title = sanitizeText(g.title, 120); + if (g.hint !== undefined) cg.hint = sanitizeText(g.hint, 300); + if (g.hold !== undefined) { + if (typeof g.hold !== 'number') errs.push('goal.hold должно быть числом'); + else cg.hold = g.hold; + } + if (g.stars !== undefined) { + if (!Array.isArray(g.stars)) { + errs.push('goal.stars должно быть массивом'); + } else if (g.stars.length > 3) { + return { ok: false, error: 'goal.stars > 3' }; + } else { + cg.stars = g.stars.map((s, i) => { + if (!s || typeof s !== 'object') { errs.push(`goal.stars[${i}]: не объект`); return {}; } + const os = {}; + if (s.when !== undefined) { checkExpr(s.when, `goal.stars[${i}].when`, errs); os.when = s.when; } + if (s.label !== undefined) os.label = sanitizeText(s.label, 120); + return os; + }); + } + } + clean.goal = cg; + } + + // game{} — зарезервированный блок мета-слоя (Фаза 1/5). Пропускаем как есть + // (проверен общими лимитами: размер/глубина). Не исполняем. + if (spec.game && typeof spec.game === 'object' && !Array.isArray(spec.game)) { + clean.game = spec.game; + } + if (errs.length) return { ok: false, error: errs.slice(0, 8).join('; ') }; return { ok: true, clean }; } diff --git a/frontend/js/labs/_sim_engine.js b/frontend/js/labs/_sim_engine.js index fdb6d6e..d802b9f 100644 --- a/frontend/js/labs/_sim_engine.js +++ b/frontend/js/labs/_sim_engine.js @@ -79,13 +79,29 @@ { a:'ballId'|[x,y], b:'ballId'|[x,y], // концы: id тела ИЛИ якорь-точка k:40, length:2, damping?:0.5 } ] + }, + + // ── ЦЕЛЬ / ИГРА (Квантик, Фаза 0) ── декларативный слой победы. + // Аддитивно: спека БЕЗ goal ведёт себя как раньше (нет HUD, нет вычислений побед). + goal: { + when: '', // SimExpr: победа, когда станет истинным (≠0) + title?: 'Цель уровня', // краткая формулировка цели для HUD (escape на сервере) + hint?: 'текст подсказки', // показывается в HUD (escape на сервере) + hold?: 0, // сек: сколько when должно держаться непрерывно (деф. 0) + fail?: '', // опц.: мягкий проигрыш (вышел за поле/задел шип) + stars?: [ // 0..3 доп.условий-«звёзд» (бонусы, «залипают» до reset) + { when:'', label?:'...' } + ] } + // game?: {...} — зарезервированный блок мета-слоя (Фаза 1/5); сервер его пропускает. } Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также .x / .y для объектов, у которых заданы числовые/выраж. x,y. Для физических тел (body) в env кладутся .x/.y/.vx/.vy ИЗ СОСТОЯНИЯ интегратора (а не из выражения) — это снимает проблему forward-ref однопроходного env для тел: их позиция/скорость не пересчитываются формулой каждый кадр. + Выражения цели (goal.when/fail/stars[].when) видят ВЕСЬ env кадра ПЛЮС `tries` + (число пользовательских reset с начала). Новых небезопасных идентификаторов не вводится. ── ИНТЕРАКЦИИ (Фаза 1) ────────────────────────────────────────────────── Объект с полем drag:{param, axis, min?, max?, paramY?} становится ручкой: @@ -103,6 +119,10 @@ inst.isRunning() -> bool inst.destroy() inst.el -> корневой DOM-узел (для скрытия/показа адаптером) + // ── цель/игра (Фаза 0) ── + inst.onGoal(cb) -> подписка: cb(getResult()) при первой победе + inst.getResult() -> { won, failed, timeMs, attempts, stars:{got,total} } + inst.resetResult() -> сбросить состояние результата (как новый уровень) ════════════════════════════════════════════════════════════════════════ */ (function (global) { @@ -362,6 +382,12 @@ this._phys = null; // состояние интегратора { bodies, springs, walls, opts, dt, acc } this._bodyById = {}; // objId -> body (для drag/env/пружин) this._dragBody = null; // активный захват физ-тела { body, lastW, lastT, vx, vy } + // ── цель/игра (Фаза 0 «Квантик») ── + this._goal = null; // скомпилированный блок цели { whenFn, failFn, hold, stars:[{fn,label}], title, hint } | null + this._goalState = null; // { won, failed, timeMs, attempts, starsGot:[], firstWinT } | null (только при наличии goal) + this._goalHoldT = 0; // сколько секунд (мирового t) условие when держится непрерывно + this._goalCbs = []; // подписчики onGoal + this._hud = null; // DOM-узлы HUD-оверлея (только при наличии goal) this._build(); } @@ -502,6 +528,11 @@ // подготовить объекты (компиляция привязок один раз) this._prepareObjects(); + // подготовить цель/игру (компиляция when/fail/stars один раз) + HUD-оверлей. + // Аддитивно: при отсутствии goal в спеке _goal остаётся null и HUD не создаётся. + this._prepareGoal(); + if (this._goal) this._buildHud(stage); + // resize if (global.ResizeObserver) { this._ro = new ResizeObserver(function () { self._fit(); self._renderFrame(); }); @@ -749,6 +780,208 @@ this._objs = out; }; + /* ════════════════════ Цель / игра (Фаза 0 «Квантик») ════════════════════ + Декларативный слой победы: булевы SimExpr-выражения, компилируемые ОДИН РАЗ + (как все выражения движка). В rAF после построения env — оценка. Безопасно: + никакого eval, выражения исполняет SimExpr (кривое выражение -> 0, не бросает). */ + + /* Скомпилировать блок goal (when/fail/каждое stars[].when) один раз при mount. + Спека без goal -> _goal остаётся null (полная аддитивность). */ + SimEngineInstance.prototype._prepareGoal = function () { + var g = this.spec.goal; + if (!g || typeof g !== 'object' || Array.isArray(g)) { this._goal = null; this._goalState = null; return; } + var compile = (global.SimExpr && global.SimExpr.compile) + ? global.SimExpr.compile + : function () { return { fn: function () { return 0; }, ast: null, error: null }; }; + + var whenC = compile(g.when != null ? g.when : '0'); + var failC = (g.fail != null) ? compile(g.fail) : null; + var rawStars = Array.isArray(g.stars) ? g.stars.slice(0, 3) : []; // не более 3 звёзд + var stars = rawStars.map(function (s) { + s = (s && typeof s === 'object') ? s : { when: s }; + var c = compile(s.when != null ? s.when : '0'); + return { fn: c.fn, label: (s.label != null) ? String(s.label) : '' }; + }); + + this._goal = { + whenFn: whenC.fn, + failFn: failC ? failC.fn : null, + hold: (typeof g.hold === 'number' && isFinite(g.hold) && g.hold > 0) ? g.hold : 0, + stars: stars, + title: (g.title != null) ? String(g.title) : '', + hint: (g.hint != null) ? String(g.hint) : '' + }; + // первичное состояние результата (attempts=0; первый mount/авто-reset попыткой не считается) + this._goalState = { + won: false, failed: false, timeMs: 0, + attempts: 0, starsGot: stars.map(function () { return false; }), firstWinT: null + }; + this._goalHoldT = 0; + }; + + /* Оценить цель за кадр (после построения env и шага физики). Накапливает звёзды, + проверяет fail (мягкий проигрыш), then when с учётом hold (удержание). При победе + фиксирует timeMs (мировое t, детерминизм), ставит won, ставит на паузу, дёргает onGoal. */ + SimEngineInstance.prototype._evalGoal = function (env, dt) { + var g = this._goal, st = this._goalState; + if (!g || !st) return; + // tries — число пользовательских reset; добавляем ТОЛЬКО его (безопасность контракта). + env.tries = st.attempts; + + // звёзды «залипают»: однажды истинное условие остаётся засчитанным до reset. + for (var i = 0; i < g.stars.length; i++) { + if (!st.starsGot[i] && _truthy(g.stars[i].fn(env))) st.starsGot[i] = true; + } + + if (st.won || st.failed) return; // итог зафиксирован — больше не пересчитываем + + // мягкий проигрыш: fail имеет приоритет над when (НЕ победа) + if (g.failFn && _truthy(g.failFn(env))) { + st.failed = true; + this._goalHoldT = 0; + this.pause(); + this._renderHud(); + return; + } + + // победа: when (с учётом hold — условие должно держаться hold секунд) + if (_truthy(g.whenFn(env))) { + this._goalHoldT += (typeof dt === 'number' && dt > 0) ? dt : 0; + if (this._goalHoldT >= g.hold) { + st.won = true; + st.firstWinT = this._t; + // время победы: мировое t от старта уровня (детерминизм, headless-тест) + st.timeMs = Math.max(1, Math.round(this._t * 1000)); + this.pause(); + this._fireGoal(); + this._renderHud(); + } + } else { + this._goalHoldT = 0; // условие пропало до удержания — сброс таймера + } + }; + + /* Вызвать onGoal-подписчиков один раз (после первой победы). */ + SimEngineInstance.prototype._fireGoal = function () { + var res = this.getResult(); + var cbs = this._goalCbs.slice(); + for (var i = 0; i < cbs.length; i++) { + try { cbs[i](res); } catch (e) { /* подписчик не должен ронять цикл */ } + } + }; + + /* ════════════════════ HUD цели (DOM-оверлей) ════════════════════ + Появляется ТОЛЬКО при наличии goal. Контейнер — pointer-events:none (не крадёт + pan/drag сцены), интерактивные кнопки — pointer-events:auto. Стиль — тёмная + плашка как у readout-бейджей. Без эмодзи: звёзды/иконки — inline SVG. */ + SimEngineInstance.prototype._buildHud = function (stage) { + var self = this; + var hud = {}; + + // ── верхняя плашка: цель + звёзды (по центру сверху) ── + var top = document.createElement('div'); + top.style.cssText = 'position:absolute;left:50%;top:10px;transform:translateX(-50%);z-index:6;' + + 'pointer-events:none;display:flex;flex-direction:column;gap:5px;align-items:center;max-width:80%'; + + var objLine = document.createElement('div'); + objLine.style.cssText = 'display:flex;align-items:center;gap:8px;' + _readoutBadgeCss('#fff') + + ';font-size:.82rem;font-weight:600;pointer-events:none'; + var titleSpan = document.createElement('span'); + var starsWrap = document.createElement('span'); + starsWrap.style.cssText = 'display:inline-flex;gap:3px;align-items:center'; + objLine.appendChild(titleSpan); + objLine.appendChild(starsWrap); + top.appendChild(objLine); + hud.titleSpan = titleSpan; + hud.starsWrap = starsWrap; + + var hintEl = document.createElement('div'); + hintEl.style.cssText = _readoutBadgeCss('rgba(255,255,255,0.72)') + + ';font-size:.74rem;pointer-events:none;max-width:100%;white-space:normal;text-align:center'; + top.appendChild(hintEl); + hud.hintEl = hintEl; + + stage.appendChild(top); + hud.top = top; + + // ── центральный баннер «Победа» / «Ещё раз» (скрыт по умолчанию) ── + var banner = document.createElement('div'); + banner.style.cssText = 'position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:7;' + + 'display:none;flex-direction:column;align-items:center;gap:10px;pointer-events:none;' + + 'background:rgba(13,13,26,0.92);border:1px solid rgba(255,255,255,0.16);border-radius:16px;' + + 'padding:18px 24px;box-shadow:0 12px 40px rgba(0,0,0,0.5);text-align:center'; + var bannerTitle = document.createElement('div'); + bannerTitle.style.cssText = 'font-size:1.1rem;font-weight:800;letter-spacing:.3px'; + var bannerStars = document.createElement('div'); + bannerStars.style.cssText = 'display:flex;gap:4px;align-items:center'; + var btnRetry = this._btn(this._resetIcon(), 'Ещё раз'); + btnRetry.style.pointerEvents = 'auto'; + btnRetry.style.minWidth = '120px'; + btnRetry.innerHTML = this._resetIcon() + 'Ещё раз'; + this._onHudRetry = function () { self.reset(); }; + btnRetry.addEventListener('click', this._onHudRetry); + banner.appendChild(bannerTitle); + banner.appendChild(bannerStars); + banner.appendChild(btnRetry); + stage.appendChild(banner); + hud.banner = banner; + hud.bannerTitle = bannerTitle; + hud.bannerStars = bannerStars; + hud.btnRetry = btnRetry; + + this._hud = hud; + this._renderHud(); + }; + + /* SVG-звезда: заполненная (got) или контурная (ещё не получена). Без эмодзи. */ + SimEngineInstance.prototype._starIcon = function (got, size) { + var s = size || 15; + var fill = got ? '#FBBF24' : 'none'; + var stroke = got ? '#FBBF24' : 'rgba(255,255,255,0.42)'; + return '' + + ''; + }; + + /* Перерисовать HUD по текущему состоянию цели (вызывается каждый кадр + при reset). */ + SimEngineInstance.prototype._renderHud = function () { + var hud = this._hud, g = this._goal, st = this._goalState; + if (!hud || !g || !st) return; + + // строка цели + hud.titleSpan.textContent = g.title || 'Цель'; + // индикаторы звёзд (только если есть звёзды) + var starsHtml = ''; + for (var i = 0; i < g.stars.length; i++) starsHtml += this._starIcon(st.starsGot[i], 15); + hud.starsWrap.innerHTML = starsHtml; + + // подсказка + if (g.hint) { hud.hintEl.style.display = ''; hud.hintEl.textContent = g.hint; } + else hud.hintEl.style.display = 'none'; + + // баннер итога + if (st.won || st.failed) { + hud.banner.style.display = 'flex'; + if (st.won) { + var got = 0; + for (var k = 0; k < st.starsGot.length; k++) if (st.starsGot[k]) got++; + hud.bannerTitle.textContent = 'Победа!'; + hud.bannerTitle.style.color = '#34D399'; + var bs = ''; + for (var j = 0; j < g.stars.length; j++) bs += this._starIcon(st.starsGot[j], 22); + hud.bannerStars.innerHTML = bs; + hud.bannerStars.style.display = g.stars.length ? 'flex' : 'none'; + } else { + hud.bannerTitle.textContent = 'Не вышло'; + hud.bannerTitle.style.color = '#FB7185'; + hud.bannerStars.innerHTML = ''; + hud.bannerStars.style.display = 'none'; + } + } else { + hud.banner.style.display = 'none'; + } + }; + /* ── физика: есть ли в спеке тела/включён ли интегратор ── */ SimEngineInstance.prototype._physEnabled = function () { var ph = this.spec.physics; @@ -1295,6 +1528,17 @@ for (var j = 0; j < this._objs.length; j++) { this._drawObject(ctx, this._objs[j], env); } + + // HUD цели (звёзды могут засчитываться и на паузе/предпросмотре по текущему env) + if (this._goal && this._goalState) { + env.tries = this._goalState.attempts; // тот же доп. идентификатор, что в _evalGoal + for (var gi = 0; gi < this._goal.stars.length; gi++) { + if (!this._goalState.starsGot[gi] && _truthy(this._goal.stars[gi].fn(env))) { + this._goalState.starsGot[gi] = true; + } + } + this._renderHud(); + } }; /* пружины как зигзаг между концами (наглядно для маятника/осциллятора) */ @@ -1945,6 +2189,8 @@ } // продвинуть физику фиксированными подшагами (если есть) if (self._phys) self._stepPhysics(dt); + // оценить цель после шага (env строится из актуального состояния); победа -> pause + if (self._goal) self._evalGoal(self._buildEnv(), dt); self._renderFrame(); self._raf = global.requestAnimationFrame(frame); } @@ -1974,9 +2220,31 @@ this._trails = {}; this._dragBody = null; this._preparePhysics(); // пересобрать тела/пружины с нач. условиями из params + // сбросить состояние цели: attempts++ только на ПОЛЬЗОВАТЕЛЬСКОМ reset + // (первый авто-reset при mount попыткой не считается). + if (this._goalState) { + var userReset = this._goalInited === true; + this._goalInited = true; + this._resetGoalState(userReset); + } else { + this._goalInited = true; + } this._renderFrame(); }; + /* Сбросить состояние результата к началу уровня. bumpAttempt=true -> attempts++. */ + SimEngineInstance.prototype._resetGoalState = function (bumpAttempt) { + if (!this._goal) return; + var prevAttempts = this._goalState ? this._goalState.attempts : 0; + this._goalState = { + won: false, failed: false, timeMs: 0, + attempts: prevAttempts + (bumpAttempt ? 1 : 0), + starsGot: this._goal.stars.map(function () { return false; }), + firstWinT: null + }; + this._goalHoldT = 0; + }; + SimEngineInstance.prototype.setParam = function (name, value) { var v = parseFloat(value); if (!isFinite(v)) return; @@ -1989,6 +2257,33 @@ SimEngineInstance.prototype.getParam = function (name) { return this.params[name]; }; SimEngineInstance.prototype.isRunning = function () { return this._running; }; + /* ════════════════════ Цель / игра: публичное API ════════════════════ */ + /* Подписаться на победу: cb(getResult()) вызывается один раз при первой победе. */ + SimEngineInstance.prototype.onGoal = function (cb) { + if (typeof cb === 'function') this._goalCbs.push(cb); + return this; + }; + /* Текущий результат уровня. Для спеки без goal -> null. */ + SimEngineInstance.prototype.getResult = function () { + var st = this._goalState; + if (!st) return null; + var total = this._goal ? this._goal.stars.length : 0; + var got = 0; + for (var i = 0; i < st.starsGot.length; i++) if (st.starsGot[i]) got++; + return { + won: st.won, failed: st.failed, timeMs: st.timeMs, + attempts: st.attempts, stars: { got: got, total: total } + }; + }; + /* Сбросить результат (как новый уровень) — НЕ считается попыткой. */ + SimEngineInstance.prototype.resetResult = function () { + if (!this._goal) return; + var keep = this._goalState ? this._goalState.attempts : 0; + this._resetGoalState(false); + if (this._goalState) this._goalState.attempts = keep; + this._renderHud(); + }; + SimEngineInstance.prototype.destroy = function () { this.pause(); this._destroyed = true; @@ -2018,6 +2313,19 @@ this._dragBody = null; this._phys = null; this._bodyById = {}; + // снять HUD-слушатели/узлы (нет утечек — баланс add/removeEventListener) + if (this._hud) { + if (this._hud.btnRetry && this._onHudRetry) { + this._hud.btnRetry.removeEventListener('click', this._onHudRetry); + } + if (this._hud.top && this._hud.top.parentNode) this._hud.top.parentNode.removeChild(this._hud.top); + if (this._hud.banner && this._hud.banner.parentNode) this._hud.banner.parentNode.removeChild(this._hud.banner); + this._hud = null; + } + this._onHudRetry = null; + this._goal = null; + this._goalState = null; + this._goalCbs = []; if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el); this.el = null; this.canvas = null; this.ctx = null; }; @@ -2060,6 +2368,9 @@ } function _clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); } function _nowMs() { return (global.performance && global.performance.now) ? global.performance.now() : Date.now(); } + /* истинность булева SimExpr-результата: SimExpr.fn возвращает число (NaN/∞ -> 0), + истина = любое конечное ненулевое значение. */ + function _truthy(v) { return typeof v === 'number' && isFinite(v) && v !== 0; } /* ════════════════════ public ════════════════════ */ function mount(host, spec) { diff --git a/plans/quantik-game/CONTEXT.md b/plans/quantik-game/CONTEXT.md new file mode 100644 index 0000000..01c977d --- /dev/null +++ b/plans/quantik-game/CONTEXT.md @@ -0,0 +1,61 @@ +# Feature Context: Квантик — Законы Мира + +## Current State +- Ветка `feature/quantik-game` ответвлена от `feature/sim-builder` (движок P1–P3 там, не в master). +- При ответвлении унаследован **чужой uncommitted WIP** sim-builder: `frontend/js/sim-builder.js`, + `frontend/sim-builder.html`, `.claude/settings.json` + множество untracked `tmp_*`/мусорных файлов. + ⛔ НЕ трогать и НЕ коммитить этот WIP — стейджить только свои файлы поимённо. +- **Phase 0 реализован** (pending review): слой целей в движке `_sim_engine.js` (блок `goal`, + компиляция when/fail/stars через SimExpr, состояние результата, HUD-оверлей, API + `onGoal/getResult/resetResult`) + серверный гейт `validateSpec` пропускает `goal`/`game`. + Изменены: `frontend/js/labs/_sim_engine.js`, `backend/src/controllers/customSimController.js`. + Аддитивно: спека без `goal` ведёт себя ровно как раньше (HUD не создаётся, побед не считается). + Смоук 40/40; `npm test` 238 pass / 8 baseline fail; lint:routes 0. + +## Key Architecture Decisions +- **«Атом» = блок `goal` в спеке** (булево SimExpr). Любой уровень = спека SimForge + `goal`. + Движок вычисляет `goal.when` каждый кадр; победа → result + callback. Нет `goal` → no-op. +- **Уровни хранятся в `custom_sims`** (cat='game'), а не в новой таблице. Реюз авторинга/шаринга/embed. + Новые таблицы — только под ПРОГРЕСС игрока и лидерборд (мигр.). +- **Герой Квантик**: в уровне = engine point с `body` + glow + trail (визуал P2). На карте/в + диалогах = `PetSprite.render(level, mood, accessories, colorKey, streak, pattern)` (DOM SVG). +- **Управление = чинить закон**, а не WASD: игрок крутит `params`-слайдеры движка (угол/скорость/ + гравитация) или собирает `f(x)`; затем «Запуск» — симуляция проигрывается к цели. +- **Безопасность**: цвета — только в canvas-стоки; текст спеки — escape (`& < >`); выражения — + только длина на сервере, исполняет безопасный SimExpr на клиенте. + +## Engine touch-points (Phase 0) +- Спека v1 формат — в шапке `_sim_engine.js`. Добавить `goal`/`game` СЮДА (документировать). +- rAF-цикл `_renderFrame` (вычисляет env). Добавить `_evalGoal()` после построения env. +- `mount()` возвращает инстанс — добавить `onGoal`, `getResult`, `resetResult`. +- HUD — DOM-оверлей `_labelLayer`/новый слой (как readout-бейджи). Без эмодзи, inline SVG. +- Серверный гейт `customSimController.validateSpec` (:93) — разрешить `goal`/`stars`/`hint`/`game`. + +## Cross-Phase Dependencies +- Phase 1+ зависят от `goal`/`getResult`/`onGoal` из Phase 0. +- Phase 2 (XP/скины) зависит от прогресса Phase 1. +- Phase 4 (туннелирование) зависит от флешкарт-SR API. +- Phase 5 (авторинг) трогает sim-builder — к этому моменту чужой P4-WIP должен быть смержен в + sim-builder; свериться перед стартом фазы (возможен мерж base-ветки). +- Phase 6 (живая гонка) зависит от моста `sim_state` (Ф7 sim-builder) — он на base-ветке. + +## Temporary Workarounds +(пока нет) + +## Phase 0 — API/гочи (для следующих фаз) +- Движковое API цели: `inst.onGoal(cb)` (1 раз при победе, cb получает `getResult()`), + `inst.getResult()` → `{won,failed,timeMs,attempts,stars:{got,total}}` (без goal → `null`), + `inst.resetResult()` (сброс результата, НЕ считается попыткой). `inst.reset()` = полный + перезапуск уровня + `attempts++` (пользовательская попытка; первый авто-reset при mount НЕ считается). +- HUD появляется **автоматически** при наличии `goal` в спеке (отдельного флага «game mode» нет). +- `timeMs` = **мировое время** `t` от старта (`max(1, round(t*1000))`), детерминизм; не wallclock. +- Env цели = весь env кадра + единственный доп.идентификатор **`tries`** (= attempts). Других не вводить. +- Серверный `validateSpec` принимает `goal{when,title,hint,hold,fail,stars≤3}` и `game{...}` (резерв Ф1/5); + выражения не исполняются (только длина ≤500), текст escape+обрезка. +- Победа делает `pause()` в кадре; следующий queued-rAF выходит рано → `onGoal` не задвоится. + +## Open Questions / Notes +- Категория `cat='game'` — проверить список `CATS` в customSimController.js, расширить при необходимости. +- Ассеты: разрешены CC0/открытые из интернета (выбор пользователя) — фиксировать источник+лицензию + в коммите/доке; визуальная база остаётся in-house (PetSprite/canvas/FLUX). +- Маршрут страницы игры: clean URL `/quantik` (паттерн `/sim-builder`, `/lab`). diff --git a/plans/quantik-game/PLAN.md b/plans/quantik-game/PLAN.md new file mode 100644 index 0000000..38af6fb --- /dev/null +++ b/plans/quantik-game/PLAN.md @@ -0,0 +1,91 @@ +# Feature: Квантик — Законы Мира (образовательная 2D-игра) + +**Branch:** `feature/quantik-game` +**Base branch:** `feature/sim-builder` (движок P1–P3 и фазы sim-builder ещё не в master) +**Created:** 2026-06-13 +**Status:** 🟡 In Progress +**Strategy:** Incremental +**Mode:** Automated +**Execution:** Orchestrator + +## Summary +2D физика-головоломка-платформер поверх движка **SimForge** (`_sim_engine.js`). Герой — +**Квантик** (существующий питомец `PetSprite`): в уровне он светящаяся точка с glow и +кометной трассой (P2), на карте/в диалогах — SVG-блоб `PetSprite.render`. Игрок не рулит +героем напрямую, а **чинит «закон мира»**: задаёт скорость/угол/гравитацию (физ-уровни на +`SimPhysics`), собирает `f(x)` для движения по кривой (граф-уровни на `plot`/`SimExpr`), +открывает «ворота» уравниванием реакций/дробей. **Условие победы — булев блок `goal` +(SimExpr) в спеке** — это «атом», переиспользуемый всеми типами уровней. + +Уровень = спека SimForge + блок `game/goal` → авторится в sim-builder, хранится в +`custom_sims`, открывается тем же конвейером, что и обычные симуляции. Всё новое — +**аддитивно и безопасно** (без `eval`/`Function`; нет блока `goal` → движок ведёт себя +как раньше). + +Мета-слой: карта-созвездие, XP/скины Квантика, разблокировка по звёздам, класс-лидерборд +через classroom SSE. Квантовые способности: суперпозиция, коллапс/пауза, туннелирование +(энергия из быстрого SR-повторения флешкарт). + +**MVP играбелен после Фазы 2.** + +## Build & Test Commands +- **Build:** нет (vanilla JS, без бандлера; статика через Express) +- **Test:** `npm test` в `backend/` (`node --test tests/*.test.js`) +- **Lint:** `npm run lint:routes` в `backend/` +- ⚠️ После роутов/миграций: `npm run migrate` (живая БД `backend/data/learnspace.db`) + рестарт сервера. +- ⚠️ baseline: 3 pre-existing fail (`auth.test.js` — bcrypt/JWT в тест-окружении) + 5 page-тестов (`jsdom` не установлен). Хук толерантен. + +## Project Constraints (соблюдают ВСЕ агенты) +- ⛔ Никаких эмодзи в коде — только inline SVG `.ic`. +- ⛔ Никакого `eval`/`new Function`. Выражения — ТОЛЬКО через `SimExpr` (безопасный парсер). +- Поиск по коду: `ast-index` (символы/usages/callers) + `vex` (semantic). НЕ Grep tool. +- БД — встроенный `node:sqlite` (`DatabaseSync`), НЕ better-sqlite3. +- Frontend — vanilla JS, `window.LS.*` (js/api.js), без бандлера. +- Стейджить файлы **поимённо** (НЕ `git add -A` — в репо много мусорных untracked + чужой WIP sim-builder). +- Аддитивность: новые блоки/типы в спеке не ломают существующие симуляции и каталог. +- Ассеты: база — in-house (PetSprite + canvas/SVG + встроенный FLUX `/api/imggen`). + Разрешены внешние **CC0/открытые** ассеты (звук/арт) с указанием источника/лицензии. + +## Reuse Map (что переиспользуем) +- `frontend/js/labs/_sim_engine.js` — рантайм (SimPhysics, plot, glow/trails, zoom/pan, drag). +- `frontend/js/labs/_sim_expr.js` — `SimExpr.compile/evalSafe` для `goal`/`stars`. +- `frontend/js/pet-sprite.js` — `PetSprite.render(...)` Квантик + палитры → скины/нарратор. +- `custom_sims` + `customSimController.validateSpec` — хранение уровней + серверный гейт. +- `sim-builder.html`/`sim-builder.js` — авторинг уровней (Фаза 5). +- Флешкарты Tier-1 SR (мигр.074) — энергия туннелирования (Фаза 4). +- classroom SSE + мост `sim_state`/`apply_sim_state` (Ф7 sim-builder) — живая гонка (Фаза 6). +- Паттерн раздачи классу + `pushNotif` + `lab_sim_links` (Ф6 sim-builder). + +## Phases + +- [x] Phase 0: Слой целей в движке (goal/HUD/result) [domain: frontend] → [subplan](./phase-0-objective-layer.md) +- [ ] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md) +- [ ] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md) +- [ ] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md) +- [ ] Phase 4: Квантовые способности + SR-комнаты [domain: fullstack] → [subplan](./phase-4-quantum-abilities-sr.md) +- [ ] Phase 5: Авторинг уровней в sim-builder + раздача классу [domain: fullstack] → [subplan](./phase-5-authoring-sharing.md) +- [ ] Phase 6: Класс-лидерборд / живая гонка (classroom SSE) [domain: fullstack] → [subplan](./phase-6-leaderboard-live.md) + +## Phase Progress Log + +| Phase | Domain | Status | Review | Build | Committed | +|-------|--------|--------|--------|-------|-----------| +| Phase 0: Слой целей в движке | frontend | ✅ Done | ✅ | ✅ | ✅ | +| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 2: Карта + мир + XP/скины | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 3: Граф-уровни + зоны | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 4: Квантовые способности + SR | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 5: Авторинг + раздача | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 6: Лидерборд / живая гонка | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | + +## MVP boundary +После **Phase 2** игра играбельна и отгружаема: один полный мир физ-уровней с картой, +прогрессом, XP и скинами. Фазы 3–6 — расширение (новые типы уровней, способности, +авторинг, мультиплеер). + +## Final Review +- [ ] Comprehensive code review (final-reviewer) +- [ ] Security review (новые API: прогресс/лидерборд, user-input) +- [ ] `npm test` без новых регрессий (поверх baseline) +- [ ] `npm run lint:routes` baseline 0 +- [ ] Merged to `feature/sim-builder` diff --git a/plans/quantik-game/phase-0-objective-layer.md b/plans/quantik-game/phase-0-objective-layer.md new file mode 100644 index 0000000..ae4492d --- /dev/null +++ b/plans/quantik-game/phase-0-objective-layer.md @@ -0,0 +1,135 @@ +# Phase 0: Слой целей в движке (goal / HUD / result) + +**Status:** ✅ Done (reviewed — PASS, committed) +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Ввести в `_sim_engine.js` **«атом» игры** — декларативный блок `goal` (условие победы как +булево SimExpr-выражение), вычисляемый каждый кадр, с фиксацией результата (победа/время/ +попытки/звёзды), callback'ом и HUD-оверлеем. Расширить серверный гейт `validateSpec`, чтобы +блок проходил валидацию. Всё аддитивно: спека без `goal` ведёт себя как раньше. + +## Спека (контракт, документировать в шапке `_sim_engine.js`) +``` +goal: { + when: '', // SimExpr: победа, когда станет истинным (≠0) + hint?: 'текст подсказки', // показывается в HUD (escape на сервере) + title?: 'Цель уровня', // краткая формулировка цели для HUD + hold?: 0.0, // сек, сколько условие должно держаться (деф. 0 = мгновенно) + stars?: [ // 0..3 доп.условий-«звёзд» (бонусы) + { when:'', label?:'...' } + ], + fail?: '' // опц.: мгновенный проигрыш (вышел за поле/задел шип) +} +``` +- `when`/`stars[].when`/`fail` — компилируются ОДИН раз при mount (как все выражения), env тот же. +- Доп. env-поля для целей: `t` (время), `tries` (число reset с начала), плюс всё что уже в env + (`.x/.y/.vx/.vy`, params, w/h, xmin..ymax). НЕ вводить новых небезопасных идентификаторов. +- Звезда «залипает»: однажды истинное условие звезды остаётся засчитанным до reset (накопитель). + +## Tasks +- [x] Task 1: В шапке `_sim_engine.js` задокументировать блок `goal` (формат v1, как сделано для physics/plot). +- [x] Task 2: В `prepare`/mount компилировать `goal.when`, `goal.fail`, каждое `stars[].when` + через `SimExpr.compile` (хранить fn + error; кривое выражение → никогда не бросает). +- [x] Task 3: Добавить состояние результата на инстанс: `_goalState = { won, failed, timeMs, + attempts, starsGot:[], firstWinT }`. Сбрасывать в `reset()` (attempts++ на reset, кроме первого). +- [x] Task 4: В rAF-цикле (`_renderFrame`/`_stepPhysics`-соседство) после построения env и шага: + вычислить звёзды (накопить), `fail` (→ мягкий проигрыш-оверлей, не победа), `when` + (учесть `hold` — таймер удержания) → при победе зафиксировать `timeMs` (мировое t или + wallclock от старта play), выставить `won=true`, остановить (`pause`) и вызвать `onGoal` callback. +- [x] Task 5: HUD-оверлей (DOM, поверх canvas, как слой readout): строка цели (`title`), + индикатор звёзд (inline SVG звезда — заполненная/контур), подсказка (`hint`), + баннер «Победа»/«Ещё раз» с кнопкой Reset. Стиль — тёмная плашка как у readout; без эмодзи. + HUD появляется ТОЛЬКО при наличии `goal` в спеке. +- [x] Task 6: Публичное API инстанса: `onGoal(cb)` (cb получает `getResult()`), `getResult()` + → `{ won, failed, timeMs, attempts, stars:{got,total} }`, `resetResult()`. +- [x] Task 7: `destroy()` снимает HUD-узлы/слушатели кнопок (нет утечек). +- [x] Task 8: Сервер `backend/src/controllers/customSimController.js` `validateSpec`: + разрешить ключ `goal` (объект) и `game` (объект, см. Phase 1/5) на верхнем уровне спеки; + проверять `when`/`fail`/`stars[].when` через `checkExpr` (длина ≤ MAX_EXPR_LEN); + `hint`/`title`/`stars[].label` — `sanitizeText` (escape + обрезка); `stars` ≤ 3; `hold` число. + НЕ исполнять выражения. Обновить whitelist при необходимости. +- [x] Task 9: Headless-смоук (vm + ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`, + как в P1–P3): (а) спека с `goal.when` достигает победы → `getResult().won`, `timeMs>0`; + (б) звёзды накапливаются и не сбрасываются до reset; (в) `fail` ставит failed без won; + (г) `hold` требует удержания; (д) спека БЕЗ goal — поведение без изменений, HUD не создан; + (е) `onGoal` зовётся один раз; (ж) destroy снимает HUD-слушатели (баланс add/remove). + Удалить temp-смоук после прогона. → 40/40 PASS, удалён. + +## Files to Modify/Create +- `frontend/js/labs/_sim_engine.js` — блок goal: документация, компиляция, eval в цикле, HUD, API. +- `backend/src/controllers/customSimController.js` — `validateSpec`: разрешить `goal`/`game`. +- (temp) headless-смоук — создать, прогнать, удалить. + +## Acceptance Criteria +- Спека с `goal` показывает цель/звёзды/победу; `getResult()` корректен; `onGoal` срабатывает. +- Спека без `goal` рендерится и ведёт себя ровно как раньше (нет HUD, нет накладных вычислений побед). +- `validateSpec` пропускает корректный `goal`, режет переразмер/длинные выражения, экранирует текст. +- `node --check` обоих файлов OK; headless-смоук зелёный; эмодзи нет; `eval`/`new Function` нет. +- `cd backend && npm test` — без новых регрессий; `npm run lint:routes` — без новых ошибок. + +## Notes +- Время победы: предпочесть **мировое `t`** (детерминизм, headless-тест), плюс можно хранить wallclock. +- `attempts` = число `reset()` (первый mount/авто-reset не считать попыткой; считать пользовательские). +- HUD не должен перехватывать pan/drag сцены вне своих интерактивных элементов (pointer-events: none + на контейнере, auto — на кнопках), как сделано с overlay-панелью в P1. +- Не вводить новые env-идентификаторы помимо `t`/`tries` — безопасность контракта выражений. + +## Review Checklist +- [x] Все задачи выполнены +- [x] Код следует конвенциям движка (хелперы модульного уровня `_truthy`, инстанс-методы, без рисования по canvas — HUD это DOM-оверлей как readout) +- [x] Аддитивность: существующие симуляции/каталог не затронуты (нет goal → `_goal=null`, HUD не создаётся, в rAF ветка `if (self._goal)` пропускается) +- [x] Без эмодзи; без eval/Function (звёзды/иконки — inline SVG; выражения только через `SimExpr.compile`) +- [x] Build (node --check) и тесты проходят (смоук 40/40; `npm test` 238 pass / 8 baseline fail; lint:routes 0) + +## Handoff to Next Phase + +### Движковое API цели (для Phase 1) +Всё в `frontend/js/labs/_sim_engine.js`. `var inst = SimEngine.mount(host, spec)`: + +- **`inst.onGoal(cb)`** — подписка. `cb` получает `getResult()` и вызывается РОВНО ОДИН РАЗ при + первой победе (после `pause()`). Возвращает `inst` (chainable). Можно подписать несколько cb. +- **`inst.getResult()`** → `{ won:bool, failed:bool, timeMs:number, attempts:number, stars:{got:number, total:number} }`. + Для спеки БЕЗ `goal` возвращает **`null`** — проверяйте на null перед использованием. +- **`inst.resetResult()`** — сбросить результат как новый уровень (won/failed/звёзды/таймер обнуляются), + но **НЕ считается попыткой** (attempts сохраняется). Для перезапуска уровня используйте `inst.reset()` + (это И сбрасывает физику/время И инкрементит attempts как пользовательскую попытку). + +### Как включить «игровой режим» / HUD +HUD появляется **автоматически**, как только в спеке есть верхнеуровневый блок `goal`. Отдельного +флага «game mode» в движке НЕТ. Никакого вызова не нужно — `mount()` сам создаёт HUD при наличии `goal`. +HUD = DOM-оверлей внутри `inst.el` (контейнер `pointer-events:none`, кнопка «Ещё раз» — `pointer-events:auto`, +дёргает `inst.reset()`). Спрятать HUD = убрать `goal` из спеки. + +### Как измеряется timeMs +**Мировое время** `t` от старта уровня (детерминизм для headless/реплеев): при победе +`timeMs = max(1, round(t*1000))`. Это НЕ wallclock — паузы/тротлинг rAF не влияют. `firstWinT` +(сырое мировое `t` победы) хранится внутри в `_goalState`, наружу отдаётся только `timeMs`. + +### Форма блока goal/game (что сервер теперь принимает) +`validateSpec` (`backend/src/controllers/customSimController.js`) пропускает на верхнем уровне: +``` +goal: { + when: '', // ≤500 симв., НЕ исполняется на сервере + title?: '...', hint?: '...', // sanitizeText: escape & < > + обрезка (title≤120, hint≤300) + hold?: 0, // число (сек удержания when); не-число → 400 + fail?: '', // ≤500 симв. + stars?: [ { when:'', label?:'...' } ] // ≤3 (иначе 400); label sanitizeText ≤120 +} +game?: { ... } // зарезервирован под мета-слой Ф1/5; проходит как есть + // (под общими лимитами размер/глубина), НЕ исполняется +``` +Категория `cat='game'` уже в `CATS` (math/phys/chem/bio/game) — расширять не нужно. + +### Env-контракт цели (безопасность) +Выражения `goal.when`/`fail`/`stars[].when` видят ВЕСЬ env кадра (params, `t`, `w/h`, `xmin..ymax`, +`.x/.y/.vx/.vy`) ПЛЮС единственный доп.идентификатор **`tries`** (= `attempts`). НЕ вводить +новых идентификаторов в env цели — это контракт безопасности шаренных выражений. + +### Гочи для Phase 1 +- Победа ставит `pause()` внутри rAF-кадра; следующий queued-кадр выходит по `if (!self._running) return` + → `onGoal` не задвоится. НЕ вызывайте `play()` в `onGoal`-колбэке без `resetResult()`/`reset()`. +- `goal.when` без `hold` срабатывает на ПЕРВОМ же кадре, где условие истинно (мгновенно). +- Звёзды «залипают»: засчитываются и во время play (rAF), и на паузе/предпросмотре (`_renderFrame`). +- Истинность булева = `_truthy`: конечное ненулевое число (SimExpr возвращает 0 при NaN/∞/ошибке). diff --git a/plans/quantik-game/phase-1-shell-first-level.md b/plans/quantik-game/phase-1-shell-first-level.md new file mode 100644 index 0000000..2d06091 --- /dev/null +++ b/plans/quantik-game/phase-1-shell-first-level.md @@ -0,0 +1,63 @@ +# Phase 1: Оболочка игры + 1 физ-уровень + прогресс (MVP) + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Сквозной играбельный срез: страница `/quantik` грузит уровень-спеку, монтирует движок в +«игровом режиме» (управление = слайдеры закона + кнопка «Запуск»), на победу шлёт результат +на сервер, показывает экран успеха со звёздами/временем. Прогресс сохраняется в БД. +Первый уровень — «Артиллерия Квантика»: угол+скорость, попасть в портал, собрать звезду. + +## Tasks +- [ ] Task 1: Миграция (следующий свободный номер) `game_progress`: `id, user_id, level_id TEXT, + best_time_ms INTEGER, best_stars INTEGER, attempts INTEGER, completed_at`. Индекс по (user_id, level_id) UNIQUE. +- [ ] Task 2: Контроллер `gameController.js` + роутер `game.js`, смонтировать в `server.js` + (после `/api/custom-sims`). Эндпоинты: `GET /api/game/progress` (свой прогресс по всем + уровням), `POST /api/game/progress` `{level_id, time_ms, stars}` (upsert: пишем лучший + результат — min time / max stars; attempts++). auth-only; валидация входа. +- [ ] Task 3: Клиент `LS.gameProgressList()` / `LS.gameProgressSubmit(levelId, {time_ms, stars})` в js/api.js. +- [ ] Task 4: Уровень как ДАННЫЕ: модуль `frontend/js/game/levels.js` (или сид в `custom_sims`). + Для MVP — встроенная спека уровня `phys-artillery-1` (physics + goal + 1 star + portal/star объекты). + Решение источника уровней зафиксировать в CONTEXT.md (встроенные данные сейчас; custom_sims в Ф5). +- [ ] Task 5: Страница `frontend/quantik.html` + `frontend/js/game/quantik-game.js`: + доступ всем авторизованным (LS.initPage()); подключает `_sim_expr.js`+`_sim_engine.js` + тем же путём, что lab.html/sim-builder.html. Монтирует уровень, ставит `onGoal` → submit + экран успеха. +- [ ] Task 6: «Игровой режим» движка/обёртки: цель видна (HUD из Ф0), управление = существующие + слайдеры params; кнопки «Запуск»(play)/«Сброс»(reset). Без редакторских панелей. +- [ ] Task 7: Экран успеха (DOM-оверлей страницы): звёзды, время, попытки, кнопки «Ещё раз»/«Дальше» + (для MVP «Дальше» неактивна/возврат). Inline SVG, без эмодзи. +- [ ] Task 8: Пункт в сайдбаре `js/sidebar.js` — `/quantik` в группе practice (по примеру `/sim-builder`), + видимость по роли (доступно ученикам — это игра). `isActive('/quantik')` подсветка. +- [ ] Task 9: Тест бэкенда `backend/tests/game.test.js` (паттерн lab-links.test.js: свой app.use + нового роутера, getToken/inject): submit пишет лучший результат, не ухудшает, attempts++, + требует auth, валидирует вход. Headless-смоук страницы по возможности (vm + стаб), иначе ручная проверка логики. + +## Files to Modify/Create +- `backend/src/db/migrations/0NN_game_progress.sql` — таблица прогресса. +- `backend/src/controllers/gameController.js`, `backend/src/routes/game.js` — API. +- `backend/src/server.js` — монтаж роутера. +- `frontend/quantik.html`, `frontend/js/game/quantik-game.js`, `frontend/js/game/levels.js` — клиент+уровень. +- `frontend/js/api.js` — `LS.gameProgress*`. +- `frontend/js/sidebar.js` — пункт меню. +- `backend/tests/game.test.js` — тест. + +## Acceptance Criteria +- `/quantik` грузится, монтирует уровень, цель видна; «Запуск» проигрывает физику. +- Попадание в портал (+звезда) → экран успеха с временем/звёздами; результат записан в `game_progress`. +- Повторный худший результат не перезаписывает лучший; attempts растёт. +- `npm run migrate` применяет миграцию; `npm test` зелёный (+ новый тест); `lint:routes` baseline 0. + +## Notes +- Маршрутизация `/js/game/*`: помнить гочу sim-builder — `/js` мапится на корневой `js/`, а файлы + лежат во `frontend/js/game/` → отдаются через `express.static(frontendDir)`. Не трогать server.js static. +- Роуты `:id` прикрыть `authMiddleware` на уровне роутера (lint:routes baseline 0). +- Время — из `getResult().timeMs` (Ф0). + +## Review Checklist +- [ ] Все задачи; конвенции (ownership/auth как studentMaterials/customSim); без эмодзи/eval +- [ ] Миграция применяется; API безопасен; тест зелёный; lint baseline 0; existing тесты не сломаны + +## Handoff to Next Phase + diff --git a/plans/quantik-game/phase-2-map-world-xp.md b/plans/quantik-game/phase-2-map-world-xp.md new file mode 100644 index 0000000..9bf9e62 --- /dev/null +++ b/plans/quantik-game/phase-2-map-world-xp.md @@ -0,0 +1,56 @@ +# Phase 2: Карта-созвездие + мир физ-уровней + XP/скины (MVP-мир) + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Превратить одиночный уровень в **играбельный мир**: карта-созвездие из ~5–6 физ-уровней, +разблокировка по звёздам, XP, выбор скина Квантика, нарратор-Квантик (`PetSprite`) на интро/ +победе. После этой фазы игра полноценно отгружаема. + +## Tasks +- [ ] Task 1: Контент — ~5–6 физ-уровней-спек (данные в `levels.js`), нарастающая сложность: + артиллерия → перелёт через стену → отскок (restitution) → пружина/маятник → орбита/гравитация. + Каждый: `goal` + 1–3 звезды + норматив времени (`par_ms`) для 3-й звезды. +- [ ] Task 2: Структура «мир/глава»: метаданные уровня (id, title, chapter, order, par_ms, hint). + Карта группирует по главам (созвездиям). +- [ ] Task 3: Карта-созвездие `frontend/js/game/map.js` (+ разметка в quantik.html): узлы-уровни + на SVG/canvas-фоне, линии-связи, статус (заблокирован/доступен/пройден + число звёзд). + Разблокировка: уровень открыт, если набрано ≥ threshold звёзд в предыдущих (правило в данных). +- [ ] Task 4: XP/уровень игрока: XP = сумма звёзд × коэффициент (+ бонус за par). Хранить в + прогрессе (расширить `game_progress` агрегацией на клиенте ИЛИ доб. поле/таблицу `game_player`). + Полоса XP + «уровень Квантика» в шапке карты. +- [ ] Task 5: Скины Квантика: выбор `colorKey` из палитр `PetSprite` (+ позже паттерны). Скин + влияет на цвет glow-точки героя в уровне (param/проп движка) и на `PetSprite` на карте. + Хранить выбор (localStorage сейчас; серверно — опц.). Разблокировка скинов по XP/звёздам. +- [ ] Task 6: Нарратор: `PetSprite.render(...)` в интро уровня (краткая формулировка «почини закон…») + и на экране победы (реакция по числу звёзд: happy/ecstatic). Реюз mood из pet-sprite.js. +- [ ] Task 7: Навигация: карта → уровень → результат → возврат на карту с обновлённым статусом/XP. +- [ ] Task 8: Тесты: разблокировка (логика чистой функцией — юнит-тест), агрегация XP; смоук карты. + +## Files to Modify/Create +- `frontend/js/game/levels.js` — контент мира (расширить). +- `frontend/js/game/map.js` — карта-созвездие. +- `frontend/js/game/quantik-game.js` — навигация карта↔уровень, XP/скин в шапке. +- `frontend/quantik.html` — разметка карты/шапки. +- (опц.) `backend` — поле/агрегация игрока, если решим серверно; иначе клиентская агрегация прогресса. +- тест(ы) разблокировки/XP. + +## Acceptance Criteria +- Карта показывает мир, статусы и звёзды; пройденные уровни открывают следующие. +- XP/уровень Квантика растут; смена скина видна и на карте, и в уровне. +- Нарратор-Квантик появляется на интро/победе с корректным настроением. +- Тесты разблокировки/XP зелёные; lint baseline 0; existing тесты не сломаны. + +## Notes +- Без эмодзи — звёзды/иконки только inline SVG (`.ic`). +- Разблокировку держать **данными/чистой функцией** (легко тестировать и переносить на сервер). +- Не плодить серверные таблицы без нужды: прогресс уже в `game_progress` (Ф1); XP можно агрегировать. + +## Review Checklist +- [ ] Все задачи; чистая функция разблокировки покрыта тестом; без эмодзи/eval +- [ ] Карта/навигация работают; existing тесты целы; lint baseline 0 + +## Handoff to Next Phase + diff --git a/plans/quantik-game/phase-3-graph-levels.md b/plans/quantik-game/phase-3-graph-levels.md new file mode 100644 index 0000000..41aec98 --- /dev/null +++ b/plans/quantik-game/phase-3-graph-levels.md @@ -0,0 +1,49 @@ +# Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Новый тип уровня: Квантик движется по кривой `y=f(x)`, которую **собирает игрок** (настраивает +параметры/выбирает выражение). Препятствия — «запретные зоны»; цель/звёзды/проигрыш — выражения. +Реюз `plot` + `SimExpr`. Сид граф-главы. + +## Tasks +- [ ] Task 1: «Бегунок по кривой»: герой-точка с `x` = функция t (напр. линейный проход xmin→xmax), + `y = f(x)` через ту же скомпилированную функцию, что у `plot`. Кривая рисуется (P3 plot), + герой едет по ней с glow/trail. Без физики (кинематический проход), либо мягкая физика — на выбор уровня. +- [ ] Task 2: Тип объекта/поле «зона» (forbidden/target): прямоугольник/круг в мире + удобные + env-предикаты (или документированный паттерн: `fail:'inzone(...)'`). Реализовать helper-предикаты + БЕЗ расширения небезопасного синтаксиса — предпочесть готовить булевы поля зон в env + (напр. `zone1.hit`) на основе позиции героя, чтобы `goal`/`fail` ссылались на них. +- [ ] Task 3: Цель = добраться до конца/в целевую зону, не задев запретные (`fail`). Звёзды: пройти + под нормативом, собрать бонус-точки (зоны-сборы). +- [ ] Task 4: Управление: слайдеры коэффициентов `f(x)` (a·sin(b·x+c)+d и т.п.) ИЛИ выбор/набор + выражения с inline-проверкой `SimExpr.compile(...).error` (как в sim-builder). Безопасно. +- [ ] Task 5: Контент: сид граф-главы (~4–5 уровней): синус под мостом, парабола над ямой, + кусочная подгонка, экспонента/логарифм — растущая сложность, привязка к темам алгебры. +- [ ] Task 6: Интеграция в карту (Ф2): новая глава-созвездие; общий конвейер результата/XP. +- [ ] Task 7: Тесты: проход по кривой достигает цели; задевание зоны → fail; смоук рендера кривой+героя. + +## Files to Modify/Create +- `frontend/js/labs/_sim_engine.js` — поддержка «бегунка по кривой» (если не выразимо текущими полями) + и подготовка булевых полей зон в env. Аддитивно, документировать в шапке. +- `frontend/js/game/levels.js` — граф-глава. +- `frontend/js/game/quantik-game.js` / `map.js` — новая глава, управление коэффициентами. +- тест(ы). + +## Acceptance Criteria +- Квантик едет по собранной игроком кривой; правильная `f(x)` проводит между препятствиями к цели. +- Задевание запретной зоны → проигрыш; норматив/сборы дают звёзды. +- Кривая безопасна (SimExpr, без eval); existing симуляции/уровни не затронуты; тесты зелёные. + +## Notes +- НЕ вводить произвольные функции-предикаты в синтаксис выражений (безопасность). Зоны → булевы env-поля. +- Переиспользовать P3 plot (несколько кривых, заливка, маркеры) для визуала «земли»/препятствий. + +## Review Checklist +- [ ] Все задачи; аддитивность движка; без эмодзи/eval; тесты зелёные; lint baseline 0 + +## Handoff to Next Phase + diff --git a/plans/quantik-game/phase-4-quantum-abilities-sr.md b/plans/quantik-game/phase-4-quantum-abilities-sr.md new file mode 100644 index 0000000..f97e248 --- /dev/null +++ b/plans/quantik-game/phase-4-quantum-abilities-sr.md @@ -0,0 +1,48 @@ +# Phase 4: Квантовые способности + SR-комнаты + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Фирменные «квантовые» механики, дающие герою идентичность, плюс связка с флешкарт-SR: +**суперпозиция** (раздвоение), **коллапс/пауза** (точный прицел), **туннелирование** +(проход сквозь тонкую стену за «энергию», которую даёт быстрое SR-повторение). + +## Tasks +- [ ] Task 1: Суперпозиция: уровень с двумя телами-копиями Квантика; общий «закон» (params) рулит + обеими; цель — обе достигают порталов/условий (`goal.when` ссылается на оба `.x/.y`). + Реюз существующей мульти-body физики. Визуал — две glow-точки (полупрозрачные «фантомы»). +- [ ] Task 2: Коллапс/пауза-прицел: на паузе показать предсказанную траекторию (`plot trace`/ + пунктир) текущего закона до запуска — «прицеливание». Реюз предпросмотра старта (P1/P2). +- [ ] Task 3: Туннелирование: «энергетический заряд» расходуется, чтобы пройти сквозь помеченную + `tunnelable:true` стену (стена временно проницаема). Энергия в HUD. +- [ ] Task 4: SR-комната: перед/в уровне — мини-сессия повторения флешкарт (реюз Tier-1 SR API, + мигр.074). Правильные ответы дают «энергию туннелирования». Открыть существующий движок + повторения в модалке/панели игры; начислять заряды по результату. +- [ ] Task 5: Контент: 2–3 уровня под каждую способность (обучающий + применение). +- [ ] Task 6: Тесты: суперпозиция (оба тела в `goal`), расход/начисление энергии (чистая логика), + проницаемость стены при заряде; смоук. + +## Files to Modify/Create +- `frontend/js/labs/_sim_engine.js` — поле `tunnelable` у стены + расход энергии (аддитивно, документировать). +- `frontend/js/game/quantik-game.js` — способности, HUD энергии, SR-комната-модалка. +- интеграция с флешкарт-SR (клиентский модуль повторения / `LS` API). +- `frontend/js/game/levels.js` — уровни способностей. +- тест(ы). + +## Acceptance Criteria +- Суперпозиция: победа только когда обе копии выполнили условие. +- Коллапс: на паузе виден предсказанный путь. +- Туннелирование тратит энергию; SR-повторение её пополняет; стена проницаема только при заряде. +- Без eval/эмодзи; existing симуляции/SR не сломаны; тесты зелёные; lint baseline 0. + +## Notes +- Имя param `e` зарезервировано (число Эйлера в SimExpr) — для энергии брать `energy`/`charge`. +- SR-движок повторения уже существует — переиспользовать, не дублировать расписание. + +## Review Checklist +- [ ] Все задачи; аддитивность; без эмодзи/eval; тесты зелёные; lint baseline 0 + +## Handoff to Next Phase + diff --git a/plans/quantik-game/phase-5-authoring-sharing.md b/plans/quantik-game/phase-5-authoring-sharing.md new file mode 100644 index 0000000..543fdd3 --- /dev/null +++ b/plans/quantik-game/phase-5-authoring-sharing.md @@ -0,0 +1,49 @@ +# Phase 5: Авторинг уровней в sim-builder + раздача классу + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Дать учителю собирать **игровые уровни без кода** в существующем sim-builder: задать цель/звёзды/ +подсказку/главу/норматив, сохранить как `custom_sims` с `cat='game'`, опубликовать и раздать +классу. Игра начинает грузить уровни из БД (а не только встроенные). + +⚠️ ПЕРЕД СТАРТОМ: свериться с base-веткой `feature/sim-builder` — чужой P4-WIP билдера должен +быть смержен. При необходимости влить base в `feature/quantik-game` и разрешить конфликты. + +## Tasks +- [ ] Task 1: Режим «Игровой уровень» в `sim-builder.js`/`.html`: панель цели (`goal.when`, + `title`, `hint`, `hold`, `fail`), список звёзд (add/del: `when`+`label`), глава/порядок/`par_ms`. + Inline-проверка выражений через `SimExpr.compile().error` (как остальные поля билдера). +- [ ] Task 2: `buildSpec()` материализует блок `goal`/`game`; `loadFromSim()` раскладывает обратно + (round-trip), как сделано с plot-range в Ф4 билдера. +- [ ] Task 3: Кнопка «Играть» в билдере — открыть текущую спеку в игровом режиме (тест уровня автором). +- [ ] Task 4: Каталог уровней: игра грузит `custom_sims` c `cat='game'` (свои+published) — реюз + `LS.customSimsList`/`Get`. Категория `game` в списке `CATS` (customSimController) + фильтр. +- [ ] Task 5: Раздача классу: реюз паттерна Ф6 sim-builder (авто-публикация + `pushNotif` ученикам, + ссылка `/quantik?level=custom:`); привязка к программе через `lab_sim_links` (`sim_id='custom:'`). +- [ ] Task 6: Deep-link `/quantik?level=custom:` (паттерн Ф5/Ф7 sim-builder, доступ own|published|admin). +- [ ] Task 7: Тесты: round-trip goal в билдере (headless как Ф4 sim-builder); доступ к чужому + draft запрещён; published-уровень виден; раздача шлёт уведомление. + +## Files to Modify/Create +- `frontend/sim-builder.html`, `frontend/js/sim-builder.js` — режим игрового уровня (аддитивно). +- `backend/src/controllers/customSimController.js` — `CATS` += 'game'; (goal уже в validateSpec из Ф0). +- `frontend/js/game/quantik-game.js` — загрузка уровней из custom_sims + deep-link. +- тест(ы). + +## Acceptance Criteria +- Учитель собирает уровень с целью/звёздами, тестирует «Играть», сохраняет/публикует. +- Игра грузит уровни из БД; deep-link открывает конкретный уровень с проверкой доступа. +- Раздача классу публикует + уведомляет; round-trip спеки без потерь; тесты зелёные; lint baseline 0. + +## Notes +- Билдер — зона, где мог идти параллельный P4-WIP; правки строго аддитивны, свериться с base. +- Санитизация goal-полей — уже на сервере (Ф0). Клиентская валидация зеркалит её (как в Ф4 билдера). + +## Review Checklist +- [ ] Все задачи; аддитивность билдера; ownership/доступ корректны; без эмодзи/eval; тесты зелёные + +## Handoff to Next Phase + diff --git a/plans/quantik-game/phase-6-leaderboard-live.md b/plans/quantik-game/phase-6-leaderboard-live.md new file mode 100644 index 0000000..bd0f66d --- /dev/null +++ b/plans/quantik-game/phase-6-leaderboard-live.md @@ -0,0 +1,43 @@ +# Phase 6: Класс-лидерборд / живая гонка (classroom SSE) + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Соревновательный слой: лидерборды по уровню/классу и опциональная **живая гонка** в онлайн-уроке +(реюз classroom SSE + моста `sim_state`/`apply_sim_state` из Ф7 sim-builder). + +## Tasks +- [ ] Task 1: API лидерборда: `GET /api/game/leaderboard?level_id=...&scope=class|global` — топ по + времени/звёздам. Источник — `game_progress` (best per user). Доступ: класс — только своему классу. +- [ ] Task 2: UI лидерборда: на экране уровня/победы и на карте — топ класса (имена/время/звёзды), + позиция игрока. Inline SVG-медали, без эмодзи. +- [ ] Task 3: Живая гонка (опц.): учитель в classroom запускает уровень классу; ученики решают + одновременно; прогресс/финиши транслируются через существующий SSE-relay. Реюз iframe-конвейера + `/lab?embed=...` НЕ требуется — гонка может жить на `/quantik` с гоночной комнатой по classId. +- [ ] Task 4: Сервер: relay результатов гонки (минимальный, поверх существующего SSE), без новых + тяжёлых таблиц — эфемерное состояние гонки в памяти/коротком хранилище. +- [ ] Task 5: Тесты: лидерборд отдаёт корректный топ и режет чужой класс; submit обновляет позицию; + смоук UI. + +## Files to Modify/Create +- `backend/src/controllers/gameController.js`, `routes/game.js` — leaderboard (+ гонка-relay). +- `frontend/js/game/quantik-game.js` / `map.js` — UI лидерборда + гоночная комната. +- (опц.) интеграция кнопки запуска гонки в classroom.html (аддитивно, как Ф7 sim-builder). +- тест(ы). + +## Acceptance Criteria +- Лидерборд по классу/глобально корректен и изолирован по классу; позиция игрока видна. +- Живая гонка (если включена) синхронит финиши классу через SSE; закрытие чистое. +- Без эмодзи/eval; existing функционал цел; тесты зелёные; lint baseline 0. + +## Notes +- Реюз durable-уведомлений `pushNotif` для приглашения в гонку; эфемерный прогресс — через SSE. +- classroom.html — большой; искать через vex по DOM-id, точечный Read (ast-index не индексит inline-script). + +## Review Checklist +- [ ] Все задачи; изоляция по классу; аддитивность classroom; без эмодзи/eval; тесты зелёные + +## Handoff to Next Phase + From 351251d652eb2fbb5fd5bd51b77174deadf13e61 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 13 Jun 2026 15:31:25 +0300 Subject: [PATCH 02/10] =?UTF-8?q?@=20feat(quantik-game):=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=201=20=E2=80=94=20=D0=BE=D0=B1=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=20=D0=B8=D0=B3=D1=80=D1=8B=20+=20=D1=84?= =?UTF-8?q?=D0=B8=D0=B7-=D1=83=D1=80=D0=BE=D0=B2=D0=B5=D0=BD=D1=8C=20+=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81=20(MVP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Страница /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) @ --- CLAUDE.md | 12 ++ backend/src/controllers/gameController.js | 84 +++++++++++ .../src/db/migrations/076_game_progress.sql | 25 ++++ backend/src/routes/game.js | 16 +++ backend/src/server.js | 1 + backend/tests/game.test.js | 108 ++++++++++++++ frontend/js/game/levels.js | 107 ++++++++++++++ frontend/js/game/quantik-game.js | 133 ++++++++++++++++++ frontend/quantik.html | 119 ++++++++++++++++ js/api.js | 3 + js/sidebar.js | 1 + plans/quantik-game/CONTEXT.md | 17 +++ plans/quantik-game/PLAN.md | 4 +- .../quantik-game/phase-1-shell-first-level.md | 77 ++++++---- 14 files changed, 679 insertions(+), 28 deletions(-) create mode 100644 backend/src/controllers/gameController.js create mode 100644 backend/src/db/migrations/076_game_progress.sql create mode 100644 backend/src/routes/game.js create mode 100644 backend/tests/game.test.js create mode 100644 frontend/js/game/levels.js create mode 100644 frontend/js/game/quantik-game.js create mode 100644 frontend/quantik.html diff --git a/CLAUDE.md b/CLAUDE.md index 6e5c15f..2813140 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,3 +213,15 @@ git push origin master - **Сервер** `customSimController.validateSpec`: `goal` (объект) + `game` (резерв Ф1/5) разрешены на верхнем уровне. `when`/`fail`/`stars[].when` → `checkExpr` (длина ≤500, НЕ исполняются); `title`/`hint`/`stars[].label` → `sanitizeText` (escape `& < >` + обрезка); `stars`>3 → 400; `hold` не-число → 400. `cat='game'` уже в `CATS`. Санитизированный `goal`/`game` пишется в `clean`. - **Верификация P0**: `node --check` обоих файлов OK; headless vm-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`, rAF-очередь степается вручную, `performance.now()` = виртуальные часы) **40/40 PASS**: when→win+timeMs>0, звёзды копятся+залипают+сброс на reset, fail без won, hold требует удержания + сброс при лапсе, спека без goal без HUD/без throw, onGoal ровно 1 раз, destroy баланс add/remove, серверный validateSpec (escape/>3 звезды/длина/hold/без-goal). `npm test` 238 pass / 8 baseline fail; lint:routes 0. Temp удалён. Эмодзи/eval/new Function — 0 (new Function только в пре-существующем комментарии стр.15). - **На Phase 1**: использовать `onGoal`/`getResult`/`resetResult`; HUD включается сам наличием `goal`. Уровни хранятся в `custom_sims` (cat='game'). `game{}`-блок зарезервирован под мета (узел карты/мир/XP). + +### Phase 1 — Learnings (Оболочка игры + 1 уровень + прогресс) + +- **Сквозной MVP-срез играбелен.** Страница `/quantik` (`frontend/quantik.html` + `frontend/js/game/quantik-game.js`): `QuantikGame.start({host, level})` → `SimEngine.mount(host, level.spec)` → `inst`. «Игровой режим» НЕ требует флага — HUD из Ф0 появляется сам по наличию `goal` в спеке; управление = собственные слайдеры params движка + play/reset (внутри `inst.el`). Победа: `inst.onGoal(res => { LS.gameProgressSubmit(level.id, {time_ms:res.timeMs, stars:res.stars.got}); showSuccess(res); })`. +- **Уровни = ДАННЫЕ, встроенные (MVP).** `frontend/js/game/levels.js` → `window.QuantikLevels.{list,get,LEVELS}`. Запись `{ id, title, subject?, hint?, spec }`, `id`==`level_id`. Один уровень `phys-artillery-1`: physics-гравитация + body-запуск (`point` с `body.vx='v*cos(theta*pi/180)'`, `vy='v*sin(...)'`), портал-цель (`goal.when:'hypot(ball.x-PX,ball.y-PY)` quantik.html. Кнопки — классы `btn-primary`/`btn-ghost` (НЕ `ls-btn-*` — таких в ls.css нет). +- **Сайдбар**: `/quantik` (icon `rocket`) в группе practice ПЕРЕД `/sim-builder`, БЕЗ `hidden` (видно ученикам — это игра, в отличие от teacher-only sim-builder). `isActive('/quantik')` подсвечивает на clean URL. +- **Доступ страницы**: `LS.initPage()` (без `{requireLogin:false}`) сам редиректит на `/login` если не авторизован и возвращает null → бутстрап выходит. Любой авторизованный играет. +- **Верификация P1**: `node --check` всех новых/изменённых JS — OK; `npm run migrate` 076 применяется чисто; `npm test` 251 pass / 8 baseline fail (3 auth + 5 jsdom page-тестов — пре-существующие; **game.test.js 13/13 PASS**); `lint:routes` 247 :id-роутов, 0 unprotected (baseline 0). Эмодзи в коде нет (флагуются только `→`/`⛔` в комментариях — конвенция проекта); eval/new Function — 0. Спека без goal по-прежнему работает (Ф0 не задет). +- **На Phase 2 (карта/мир/XP)**: реестр уровней расширяемый (добавить запись в `LEVELS`); `game_progress`-API готов; экран успеха `buildSuccessOverlay` переиспользуем (расширить «следующим уровнем», активировать «Дальше»); при смене уровня без перезагрузки — `inst.destroy()` перед новым mount. diff --git a/backend/src/controllers/gameController.js b/backend/src/controllers/gameController.js new file mode 100644 index 0000000..ad7878d --- /dev/null +++ b/backend/src/controllers/gameController.js @@ -0,0 +1,84 @@ +'use strict'; +/* Game progress ("Квантик — Законы Мира", Фаза 1). + * + * Прогресс игрока по уровням. Уровень = спека SimForge с блоком goal; + * идентифицируется строковым level_id. На победу клиент шлёт результат + * (time_ms, stars); сервер делает upsert, сохраняя ЛУЧШИЙ результат + * (минимальное время, максимум звёзд) и инкрементируя attempts. + * + * Стиль следует customSimController / studentMaterialsController: + * node:sqlite db.prepare, auth-only (роутер ставит authMiddleware), + * валидация входа без исполнения, статусы 400. + */ +const db = require('../db/db'); + +const MAX_LEVEL_ID = 120; // длина level_id (TEXT) +const MAX_TIME_MS = 24 * 60 * 60 * 1000; // санитарный потолок: сутки в мс + +/* Целое неотрицательное число (отвергаем NaN/Infinity/дробь/отрицательное). */ +function isNonNegInt(v) { + return typeof v === 'number' && Number.isInteger(v) && v >= 0; +} + +/* GET /api/game/progress — прогресс текущего пользователя по всем уровням. */ +function listProgress(req, res) { + const uid = req.user.id; + const rows = db.prepare(` + SELECT level_id, best_time_ms, best_stars, attempts, completed_at + FROM game_progress + WHERE user_id = ? + ORDER BY completed_at DESC, id DESC + `).all(uid); + res.json({ progress: rows }); +} + +/* POST /api/game/progress body: { level_id, time_ms, stars } + * Upsert: сохраняем ЛУЧШИЙ результат (min time_ms, max stars); attempts++. + * Валидация: level_id строка ≤120; time_ms/stars — неотрицательные целые; + * stars 0..3. БЕЗ исполнения чего-либо. */ +function submitProgress(req, res) { + const uid = req.user.id; + const b = req.body || {}; + + const levelId = typeof b.level_id === 'string' ? b.level_id.trim() : ''; + if (!levelId) return res.status(400).json({ error: 'level_id обязателен' }); + if (levelId.length > MAX_LEVEL_ID) { + return res.status(400).json({ error: `level_id длиннее ${MAX_LEVEL_ID} символов` }); + } + + const timeMs = b.time_ms; + const stars = b.stars; + if (!isNonNegInt(timeMs)) return res.status(400).json({ error: 'time_ms должно быть неотрицательным целым' }); + if (timeMs > MAX_TIME_MS) return res.status(400).json({ error: 'time_ms вне допустимого диапазона' }); + if (!isNonNegInt(stars)) return res.status(400).json({ error: 'stars должно быть неотрицательным целым' }); + if (stars > 3) return res.status(400).json({ error: 'stars вне диапазона 0..3' }); + + const existing = db.prepare( + 'SELECT id, best_time_ms, best_stars FROM game_progress WHERE user_id = ? AND level_id = ?' + ).get(uid, levelId); + + if (!existing) { + db.prepare(` + INSERT INTO game_progress (user_id, level_id, best_time_ms, best_stars, attempts) + VALUES (?, ?, ?, ?, 1) + `).run(uid, levelId, timeMs, stars); + } else { + // Лучшее время = минимум (null трактуем как «нет результата»); лучшие звёзды = максимум. + const bestTime = (existing.best_time_ms == null) + ? timeMs + : Math.min(existing.best_time_ms, timeMs); + const bestStars = Math.max(existing.best_stars || 0, stars); + db.prepare(` + UPDATE game_progress + SET best_time_ms = ?, best_stars = ?, attempts = attempts + 1 + WHERE id = ? + `).run(bestTime, bestStars, existing.id); + } + + const row = db.prepare( + 'SELECT level_id, best_time_ms, best_stars, attempts, completed_at FROM game_progress WHERE user_id = ? AND level_id = ?' + ).get(uid, levelId); + res.json({ ok: true, progress: row }); +} + +module.exports = { listProgress, submitProgress }; diff --git a/backend/src/db/migrations/076_game_progress.sql b/backend/src/db/migrations/076_game_progress.sql new file mode 100644 index 0000000..822e29c --- /dev/null +++ b/backend/src/db/migrations/076_game_progress.sql @@ -0,0 +1,25 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 076: Game progress (Квантик — Законы Мира, Фаза 1). +-- +-- Прогресс игрока по уровням игры «Квантик». Уровень идентифицируется +-- строковым level_id (напр. 'phys-artillery-1'); сами уровни — это спеки +-- SimForge (встроенные данные сейчас, custom_sims cat='game' в Ф5). +-- +-- Upsert хранит ЛУЧШИЙ результат: best_time_ms (минимальное время прохождения), +-- best_stars (максимум собранных звёзд 0..3). attempts растёт на каждый submit. +-- UNIQUE(user_id, level_id) — одна строка прогресса на пару игрок-уровень. +-- user_id ON DELETE CASCADE — прогресс удаляется вместе с игроком. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE IF NOT EXISTS game_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + level_id TEXT NOT NULL, -- идентификатор уровня (спека) + best_time_ms INTEGER, -- лучшее (минимальное) время, мс + best_stars INTEGER NOT NULL DEFAULT 0, -- лучшее число звёзд 0..3 + attempts INTEGER NOT NULL DEFAULT 0, -- число попыток (++ на submit) + completed_at TEXT DEFAULT (datetime('now')), -- время первого прохождения + UNIQUE (user_id, level_id) +); + +CREATE INDEX IF NOT EXISTS idx_game_progress_user ON game_progress (user_id); diff --git a/backend/src/routes/game.js b/backend/src/routes/game.js new file mode 100644 index 0000000..8331a28 --- /dev/null +++ b/backend/src/routes/game.js @@ -0,0 +1,16 @@ +'use strict'; +/* /api/game — прогресс игрока в игре «Квантик — Законы Мира» (Фаза 1). + * Все роуты — auth-only (играют и ученики). router.use(authMiddleware) + * → lint:routes baseline 0. Прогресс всегда принадлежит req.user — нет + * межпользовательских роутов, проверка владения не требуется. */ +const express = require('express'); +const router = express.Router(); +const { authMiddleware } = require('../middleware/auth'); +const c = require('../controllers/gameController'); + +router.use(authMiddleware); + +router.get('/progress', c.listProgress); +router.post('/progress', c.submitProgress); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index cdce198..be2061f 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -197,6 +197,7 @@ app.use('/api/teacher-students', teacherStudentsRoutes); app.use('/api/lab', labRoutes); app.use('/api/materials', require('./routes/materials')); app.use('/api/custom-sims', require('./routes/customSims')); +app.use('/api/game', require('./routes/game')); app.use('/api/dashboard', require('./routes/dashboard')); /* ── Public features endpoint (merges global + per-class for authenticated students) ── */ diff --git a/backend/tests/game.test.js b/backend/tests/game.test.js new file mode 100644 index 0000000..eff1ab4 --- /dev/null +++ b/backend/tests/game.test.js @@ -0,0 +1,108 @@ +'use strict'; +/** + * Integration tests: /api/game — прогресс игрока «Квантик» (Фаза 1). + * Covers: submit создаёт строку; лучший результат перезаписывает, худший — нет; + * attempts++; auth-only (401 без токена); валидация входа (400). + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { app, inject, getToken, cleanup } = require('./setup'); + +// Mount /api/game on the shared test app (setup.js не монтирует новые роуты). +app.use('/api/game', require('../src/routes/game')); + +after(() => cleanup()); + +const LVL = 'phys-artillery-1'; + +describe('/api/game progress', () => { + let token; + + before(async () => { + token = (await getToken('student')).token; + }); + + it('GET /progress requires auth (401)', async () => { + const res = await inject('GET', '/api/game/progress', null, null); + assert.equal(res.status, 401, `got ${res.status}`); + }); + + it('POST /progress requires auth (401)', async () => { + const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: 1 }, null); + assert.equal(res.status, 401, `got ${res.status}`); + }); + + it('submit creates a progress row', async () => { + const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 5000, stars: 1 }, token); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.ok, true); + assert.equal(res.body.progress.level_id, LVL); + assert.equal(res.body.progress.best_time_ms, 5000); + assert.equal(res.body.progress.best_stars, 1); + assert.equal(res.body.progress.attempts, 1); + }); + + it('GET /progress lists the row', async () => { + const res = await inject('GET', '/api/game/progress', null, token); + assert.equal(res.status, 200, `got ${res.status}`); + assert.ok(Array.isArray(res.body.progress), 'progress is array'); + const row = res.body.progress.find(r => r.level_id === LVL); + assert.ok(row, 'level row present'); + assert.equal(row.best_time_ms, 5000); + }); + + it('better result (less time, more stars) overwrites best; attempts++', async () => { + const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 3200, stars: 2 }, token); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.progress.best_time_ms, 3200, 'time improved'); + assert.equal(res.body.progress.best_stars, 2, 'stars improved'); + assert.equal(res.body.progress.attempts, 2, 'attempts incremented'); + }); + + it('worse result does NOT overwrite best, but still counts an attempt', async () => { + const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 9999, stars: 0 }, token); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.progress.best_time_ms, 3200, 'best time kept'); + assert.equal(res.body.progress.best_stars, 2, 'best stars kept'); + assert.equal(res.body.progress.attempts, 3, 'attempts still incremented'); + }); + + it('progress is per-user (другой игрок начинает с нуля)', async () => { + const other = (await getToken('student')).token; + const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 7000, stars: 1 }, other); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.progress.attempts, 1, 'fresh user has attempts=1'); + assert.equal(res.body.progress.best_time_ms, 7000); + }); + + it('validation: missing level_id → 400', async () => { + const res = await inject('POST', '/api/game/progress', { time_ms: 1000, stars: 1 }, token); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('validation: negative time_ms → 400', async () => { + const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: -5, stars: 1 }, token); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('validation: non-integer time_ms → 400', async () => { + const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 12.5, stars: 1 }, token); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('validation: stars out of range (>3) → 400', async () => { + const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: 4 }, token); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('validation: negative stars → 400', async () => { + const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: -1 }, token); + assert.equal(res.status, 400, `got ${res.status}`); + }); + + it('validation: level_id too long → 400', async () => { + const res = await inject('POST', '/api/game/progress', + { level_id: 'x'.repeat(200), time_ms: 1000, stars: 1 }, token); + assert.equal(res.status, 400, `got ${res.status}`); + }); +}); diff --git a/frontend/js/game/levels.js b/frontend/js/game/levels.js new file mode 100644 index 0000000..2ec6884 --- /dev/null +++ b/frontend/js/game/levels.js @@ -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); diff --git a/frontend/js/game/quantik-game.js b/frontend/js/game/quantik-game.js new file mode 100644 index 0000000..6971ca4 --- /dev/null +++ b/frontend/js/game/quantik-game.js @@ -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 '' + + ''; + } + + 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', + 'Время' + fmtTime(state && state.timeMs) + '')); + stats.appendChild(el('div', 'qg-stat', + 'Звёзды' + got + ' / ' + (total || slots) + '')); + stats.appendChild(el('div', 'qg-stat', + 'Попытки' + ((state && state.attempts) || 0) + '')); + 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); diff --git a/frontend/quantik.html b/frontend/quantik.html new file mode 100644 index 0000000..ea1cad7 --- /dev/null +++ b/frontend/quantik.html @@ -0,0 +1,119 @@ + + + + + + Квантик — Законы Мира + + + + + + + + +
+ +
+
+
+ Квантик — Законы Мира + + Физика +
+
+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/js/api.js b/js/api.js index 4f19df5..b129340 100644 --- a/js/api.js +++ b/js/api.js @@ -1041,6 +1041,7 @@ window.LS = { createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete, customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink, + gameProgressList, gameProgressSubmit, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, @@ -1271,6 +1272,8 @@ async function customSimClone(id) { return req('POST', `/custom-sims/${i async function customSimRelated(id) { return req('GET', `/custom-sims/${id}/related`); } async function customSimAddLink(id, d) { return req('POST', `/custom-sims/${id}/links`, d); } async function customSimDelLink(id, lid){ return req('DELETE', `/custom-sims/${id}/links/${lid}`); } +async function gameProgressList() { return req('GET', '/game/progress'); } +async function gameProgressSubmit(levelId, d) { return req('POST', '/game/progress', { level_id: levelId, time_ms: d && d.time_ms, stars: d && d.stars }); } async function assistantContext() { return req('GET', '/assistant/context'); } async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); } async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); } diff --git a/js/sidebar.js b/js/sidebar.js index 4828464..6b108e1 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -87,6 +87,7 @@ ${G('practice', 'Практика и игры', ` ${L('/lab', 'atom', 'Лаборатория')} + ${L('/quantik', 'rocket', 'Квантик: Законы Мира')} ${L('/sim-builder', 'pencil-ruler', 'Конструктор симуляций', { cls: 'sb-teacher-only', hidden: !isTch })} ${L('/biochem', 'flask-conical', 'Биохимия')} ${L('/red-book', 'leaf', 'Красная книга')} diff --git a/plans/quantik-game/CONTEXT.md b/plans/quantik-game/CONTEXT.md index 01c977d..51b0e60 100644 --- a/plans/quantik-game/CONTEXT.md +++ b/plans/quantik-game/CONTEXT.md @@ -11,12 +11,29 @@ Изменены: `frontend/js/labs/_sim_engine.js`, `backend/src/controllers/customSimController.js`. Аддитивно: спека без `goal` ведёт себя ровно как раньше (HUD не создаётся, побед не считается). Смоук 40/40; `npm test` 238 pass / 8 baseline fail; lint:routes 0. +- **Phase 1 реализован** (pending review): сквозной играбельный срез. Страница `/quantik` + (`frontend/quantik.html` + `frontend/js/game/quantik-game.js`) монтирует уровень-спеку через + `SimEngine.mount`; «игровой режим» = HUD из Ф0 (сам по наличию `goal`) + слайдеры params + + play/reset. Уровень `phys-artillery-1` — данные в `frontend/js/game/levels.js` + (`window.QuantikLevels`): physics-гравитация + body-запуск под углом θ/скоростью v, портал-цель, + бонус-звезда. На победу `onGoal` → `LS.gameProgressSubmit` + DOM-оверлей успеха (звёзды/время/попытки). + Прогресс: таблица `game_progress` (мигр.**076**), API `/api/game/progress` (GET/POST, + `gameController.js`+`routes/game.js`, смонтировано в `server.js` после `/api/custom-sims`), + клиент `LS.gameProgressList/Submit`. Сайдбар: `/quantik` (icon `rocket`) виден всем. + Новые: `076_game_progress.sql`, `gameController.js`, `routes/game.js`, `quantik.html`, + `js/game/levels.js`, `js/game/quantik-game.js`, `tests/game.test.js`. Изменены: `server.js`, + `js/api.js`, `js/sidebar.js`. `npm test` 251 pass / 8 baseline fail (game.test.js 13/13); + lint:routes 0; миграция применяется чисто. ## Key Architecture Decisions - **«Атом» = блок `goal` в спеке** (булево SimExpr). Любой уровень = спека SimForge + `goal`. Движок вычисляет `goal.when` каждый кадр; победа → result + callback. Нет `goal` → no-op. - **Уровни хранятся в `custom_sims`** (cat='game'), а не в новой таблице. Реюз авторинга/шаринга/embed. Новые таблицы — только под ПРОГРЕСС игрока и лидерборд (мигр.). + - **Уточнение Ф1**: для MVP уровни — ВСТРОЕННЫЕ ДАННЫЕ в `frontend/js/game/levels.js` + (`window.QuantikLevels`, форма `{ id, title, subject?, hint?, spec }`), а не записи `custom_sims`. + `custom_sims` cat='game' остаётся целевым хранилищем для авторённых уровней (Ф5); реестр тогда + станет асинхронным (загрузка опубликованных + слияние со встроенными той же формы записи). - **Герой Квантик**: в уровне = engine point с `body` + glow + trail (визуал P2). На карте/в диалогах = `PetSprite.render(level, mood, accessories, colorKey, streak, pattern)` (DOM SVG). - **Управление = чинить закон**, а не WASD: игрок крутит `params`-слайдеры движка (угол/скорость/ diff --git a/plans/quantik-game/PLAN.md b/plans/quantik-game/PLAN.md index 38af6fb..636ce24 100644 --- a/plans/quantik-game/PLAN.md +++ b/plans/quantik-game/PLAN.md @@ -59,7 +59,7 @@ ## Phases - [x] Phase 0: Слой целей в движке (goal/HUD/result) [domain: frontend] → [subplan](./phase-0-objective-layer.md) -- [ ] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md) +- [x] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md) - [ ] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md) - [ ] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md) - [ ] Phase 4: Квантовые способности + SR-комнаты [domain: fullstack] → [subplan](./phase-4-quantum-abilities-sr.md) @@ -71,7 +71,7 @@ | Phase | Domain | Status | Review | Build | Committed | |-------|--------|--------|--------|-------|-----------| | Phase 0: Слой целей в движке | frontend | ✅ Done | ✅ | ✅ | ✅ | -| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ✅ Done | ✅ | ✅ | ✅ | | Phase 2: Карта + мир + XP/скины | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 3: Граф-уровни + зоны | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 4: Квантовые способности + SR | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/quantik-game/phase-1-shell-first-level.md b/plans/quantik-game/phase-1-shell-first-level.md index 2d06091..6799cb9 100644 --- a/plans/quantik-game/phase-1-shell-first-level.md +++ b/plans/quantik-game/phase-1-shell-first-level.md @@ -1,6 +1,6 @@ # Phase 1: Оболочка игры + 1 физ-уровень + прогресс (MVP) -**Status:** ⬜ Not Started +**Status:** ✅ Done (reviewed — PASS, committed) **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -11,28 +11,27 @@ Первый уровень — «Артиллерия Квантика»: угол+скорость, попасть в портал, собрать звезду. ## Tasks -- [ ] Task 1: Миграция (следующий свободный номер) `game_progress`: `id, user_id, level_id TEXT, - best_time_ms INTEGER, best_stars INTEGER, attempts INTEGER, completed_at`. Индекс по (user_id, level_id) UNIQUE. -- [ ] Task 2: Контроллер `gameController.js` + роутер `game.js`, смонтировать в `server.js` - (после `/api/custom-sims`). Эндпоинты: `GET /api/game/progress` (свой прогресс по всем - уровням), `POST /api/game/progress` `{level_id, time_ms, stars}` (upsert: пишем лучший - результат — min time / max stars; attempts++). auth-only; валидация входа. -- [ ] Task 3: Клиент `LS.gameProgressList()` / `LS.gameProgressSubmit(levelId, {time_ms, stars})` в js/api.js. -- [ ] Task 4: Уровень как ДАННЫЕ: модуль `frontend/js/game/levels.js` (или сид в `custom_sims`). - Для MVP — встроенная спека уровня `phys-artillery-1` (physics + goal + 1 star + portal/star объекты). - Решение источника уровней зафиксировать в CONTEXT.md (встроенные данные сейчас; custom_sims в Ф5). -- [ ] Task 5: Страница `frontend/quantik.html` + `frontend/js/game/quantik-game.js`: - доступ всем авторизованным (LS.initPage()); подключает `_sim_expr.js`+`_sim_engine.js` - тем же путём, что lab.html/sim-builder.html. Монтирует уровень, ставит `onGoal` → submit + экран успеха. -- [ ] Task 6: «Игровой режим» движка/обёртки: цель видна (HUD из Ф0), управление = существующие - слайдеры params; кнопки «Запуск»(play)/«Сброс»(reset). Без редакторских панелей. -- [ ] Task 7: Экран успеха (DOM-оверлей страницы): звёзды, время, попытки, кнопки «Ещё раз»/«Дальше» - (для MVP «Дальше» неактивна/возврат). Inline SVG, без эмодзи. -- [ ] Task 8: Пункт в сайдбаре `js/sidebar.js` — `/quantik` в группе practice (по примеру `/sim-builder`), - видимость по роли (доступно ученикам — это игра). `isActive('/quantik')` подсветка. -- [ ] Task 9: Тест бэкенда `backend/tests/game.test.js` (паттерн lab-links.test.js: свой app.use - нового роутера, getToken/inject): submit пишет лучший результат, не ухудшает, attempts++, - требует auth, валидирует вход. Headless-смоук страницы по возможности (vm + стаб), иначе ручная проверка логики. +- [x] Task 1: Миграция `076_game_progress.sql` `game_progress`: `id, user_id, level_id TEXT, + best_time_ms INTEGER, best_stars INTEGER, attempts INTEGER, completed_at`. UNIQUE(user_id, level_id). +- [x] Task 2: Контроллер `gameController.js` + роутер `game.js`, смонтирован в `server.js` + (после `/api/custom-sims`). `GET /api/game/progress` (свой прогресс), `POST /api/game/progress` + `{level_id, time_ms, stars}` (upsert: min time / max stars; attempts++). auth-only; валидация входа. +- [x] Task 3: Клиент `LS.gameProgressList()` / `LS.gameProgressSubmit(levelId, {time_ms, stars})` в js/api.js. +- [x] Task 4: Уровень как ДАННЫЕ: `frontend/js/game/levels.js` (`window.QuantikLevels`), встроенная + спека `phys-artillery-1` (physics gravity + body launch + goal + 1 star + portal). Источник + уровней зафиксирован в CONTEXT.md (встроенные данные сейчас; custom_sims в Ф5). +- [x] Task 5: `frontend/quantik.html` + `frontend/js/game/quantik-game.js`: доступ всем авторизованным + (LS.initPage()); подключает `_sim_expr.js`+`_sim_engine.js` тем же путём, что lab/sim-builder. + Монтирует уровень, `onGoal` → submit + экран успеха. +- [x] Task 6: «Игровой режим» — HUD из Ф0 включается сам наличием `goal`; управление = слайдеры params + движка + кнопки play/reset (встроены в `inst.el`). Редакторских панелей нет. +- [x] Task 7: Экран успеха (DOM-оверлей страницы): звёзды (inline SVG), время, попытки, «Ещё раз» + (inst.reset) / «Дальше» (disabled-заглушка для MVP). Без эмодзи. +- [x] Task 8: Пункт сайдбара `js/sidebar.js` — `/quantik` в группе practice (icon `rocket`), видим всем. + `isActive('/quantik')` подсветка работает на clean URL. +- [x] Task 9: Тест `backend/tests/game.test.js` (паттерн lab-links.test.js): submit создаёт строку, + лучший перезаписывает / худший нет, attempts++, per-user, требует auth (401), валидирует вход (400). + 13/13 PASS. ## Files to Modify/Create - `backend/src/db/migrations/0NN_game_progress.sql` — таблица прогресса. @@ -56,8 +55,34 @@ - Время — из `getResult().timeMs` (Ф0). ## Review Checklist -- [ ] Все задачи; конвенции (ownership/auth как studentMaterials/customSim); без эмодзи/eval -- [ ] Миграция применяется; API безопасен; тест зелёный; lint baseline 0; existing тесты не сломаны +- [x] Все задачи; конвенции (ownership/auth как studentMaterials/customSim); без эмодзи/eval +- [x] Миграция применяется; API безопасен; тест зелёный; lint baseline 0; existing тесты не сломаны ## Handoff to Next Phase - + +### Реестр уровней (форма данных) +`frontend/js/game/levels.js` → `window.QuantikLevels`: +- `QuantikLevels.list()` → массив записей уровней (копия); `QuantikLevels.get(id)` → одна запись или null; `QuantikLevels.LEVELS` — сырой массив. +- **Запись уровня**: `{ id, title, subject?, hint?, spec }`. `id` == `level_id` для API прогресса. + `spec` — обычная спека SimForge с верхнеуровневым блоком `goal` (Ф0). Сейчас один уровень `phys-artillery-1`. +- **Добавить уровень** = добавить запись в `LEVELS` (или, в Ф5, подгрузить опубликованные `custom_sims` cat='game' и смержить в реестр — той же формы записи). Источник = данные, не код. +- Уровень мог бы прийти и из `custom_sims` (cat='game'): `spec` уже валидируется сервером (validateSpec пропускает goal/game). Реестр в Ф2/Ф5 может стать асинхронным (загрузка + слияние со встроенными). + +### Контракт API прогресса +- `GET /api/game/progress` (auth) → `{ progress: [ { level_id, best_time_ms, best_stars, attempts, completed_at } ] }` — все уровни текущего игрока. +- `POST /api/game/progress` (auth) body `{ level_id, time_ms, stars }` → `{ ok:true, progress:{...одна строка...} }`. Upsert: best_time_ms=min, best_stars=max, attempts++. Валидация: level_id строка ≤120; time_ms/stars неотрицательные целые; stars 0..3 (иначе 400). +- Клиент: `LS.gameProgressList()`, `LS.gameProgressSubmit(levelId, { time_ms, stars })`. +- Таблица `game_progress` — миграция **076**, UNIQUE(user_id, level_id), user_id ON DELETE CASCADE. +- На Ф6 (лидерборд) — этой таблицы достаточно для «лучшее время по уровню»; агрегаты по классу — JOIN на class_members. + +### Где живёт экран успеха / как монтируется уровень +- Монтаж: `QuantikGame.start({ host, level })` → `SimEngine.mount(host, level.spec)` → возвращает `inst`. «Игровой режим» включается САМ (HUD появляется, т.к. в спеке есть `goal`). Управление — слайдеры params + play/reset движка (внутри `inst.el`). +- Победа: `inst.onGoal(res => …)` (Ф0; срабатывает 1 раз). В колбэке: `LS.gameProgressSubmit(level.id, { time_ms: res.timeMs, stars: res.stars.got })` (best-effort, .catch офлайн) + экран успеха. +- **Экран успеха** = DOM-оверлей `.qg-overlay`, добавляется в `host` (=`#qg-stage`), `QuantikGame.buildSuccessOverlay(state)` строит карточку (звёзды inline SVG, время/звёзды/попытки, кнопки). «Ещё раз» → убрать оверлей + `inst.reset()`. «Дальше» — disabled-заглушка (нет следующего уровня в MVP); Ф2 (карта/мир) активирует её переходом к следующему узлу. +- CSS оверлея — в ` @@ -60,9 +236,25 @@
Квантик — Законы Мира + Физика
-
+ + +
+
+
+
+
+
+ + +
+
+
@@ -71,48 +263,168 @@ + + - + + + diff --git a/plans/quantik-game/CONTEXT.md b/plans/quantik-game/CONTEXT.md index 51b0e60..5669e5c 100644 --- a/plans/quantik-game/CONTEXT.md +++ b/plans/quantik-game/CONTEXT.md @@ -24,6 +24,20 @@ `js/game/levels.js`, `js/game/quantik-game.js`, `tests/game.test.js`. Изменены: `server.js`, `js/api.js`, `js/sidebar.js`. `npm test` 251 pass / 8 baseline fail (game.test.js 13/13); lint:routes 0; миграция применяется чисто. +- **Phase 2 реализован** (pending review): одиночный уровень превращён в **играбельный мир**. + Карта-созвездие (`frontend/js/game/map.js`, `window.QuantikMap`) на звёздном фоне: 6 физ-уровней + в 2 главах (Кинематика 1–3, Динамика 4–6), узлы-«звёзды» со статусом (locked/available/completed+ + звёзды), линии-связи, поэтапное появление. Шапка: нарратор-Квантик (`PetSprite`), XP-бар + «уровень + Квантика», всего звёзд, скин-пикер (8 скинов, часть за XP/звёзды). Контент уровней расширен в + `levels.js` (метаданные `chapter/order/par_ms/unlockStars`, по 2 звезды: кристалл + норматив времени). + Разблокировка/XP/группировка — ЧИСТЫЕ функции в новом `frontend/js/game/progress-logic.js` + (`window.QuantikProgress`), покрыты тестом. Навигация: карта→интро(нарратор)→уровень→успех + (нарратор по звёздам)→карта; «Дальше» активирована (`nextPlayable`); скин тинтует героя+нарратора + (localStorage `quantik-skin`). **Backend НЕ тронут** — XP клиентская агрегация из `game_progress`. + Новые: `js/game/map.js`, `js/game/progress-logic.js`. Изменены: `quantik.html`, `js/game/levels.js`, + `js/game/quantik-game.js`. `node --check` все OK; смоуки (логика 16/16, рендер 7/7, winnability 6/6 + на реальном движке) зелёные и удалены; `npm test` 259/251 pass / 8 baseline fail (без изменений); + lint:routes 0. ## Key Architecture Decisions - **«Атом» = блок `goal` в спеке** (булево SimExpr). Любой уровень = спека SimForge + `goal`. diff --git a/plans/quantik-game/PLAN.md b/plans/quantik-game/PLAN.md index 636ce24..b5940f9 100644 --- a/plans/quantik-game/PLAN.md +++ b/plans/quantik-game/PLAN.md @@ -60,7 +60,7 @@ - [x] Phase 0: Слой целей в движке (goal/HUD/result) [domain: frontend] → [subplan](./phase-0-objective-layer.md) - [x] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md) -- [ ] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md) +- [x] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md) - [ ] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md) - [ ] Phase 4: Квантовые способности + SR-комнаты [domain: fullstack] → [subplan](./phase-4-quantum-abilities-sr.md) - [ ] Phase 5: Авторинг уровней в sim-builder + раздача классу [domain: fullstack] → [subplan](./phase-5-authoring-sharing.md) @@ -72,7 +72,7 @@ |-------|--------|--------|--------|-------|-----------| | Phase 0: Слой целей в движке | frontend | ✅ Done | ✅ | ✅ | ✅ | | Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ✅ Done | ✅ | ✅ | ✅ | -| Phase 2: Карта + мир + XP/скины | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 2: Карта + мир + XP/скины | fullstack | ✅ Done | ✅ (1 🟡 fixed) | ✅ | ✅ | | Phase 3: Граф-уровни + зоны | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 4: Квантовые способности + SR | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Авторинг + раздача | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/quantik-game/phase-2-map-world-xp.md b/plans/quantik-game/phase-2-map-world-xp.md index 9bf9e62..ad283f3 100644 --- a/plans/quantik-game/phase-2-map-world-xp.md +++ b/plans/quantik-game/phase-2-map-world-xp.md @@ -1,6 +1,6 @@ # Phase 2: Карта-созвездие + мир физ-уровней + XP/скины (MVP-мир) -**Status:** ⬜ Not Started +**Status:** ✅ Done (reviewed — PASS w/ notes; «Дальше» stale-hasNext 🟡 fixed; committed) **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -10,24 +10,24 @@ победе. После этой фазы игра полноценно отгружаема. ## Tasks -- [ ] Task 1: Контент — ~5–6 физ-уровней-спек (данные в `levels.js`), нарастающая сложность: +- [x] Task 1: Контент — ~5–6 физ-уровней-спек (данные в `levels.js`), нарастающая сложность: артиллерия → перелёт через стену → отскок (restitution) → пружина/маятник → орбита/гравитация. Каждый: `goal` + 1–3 звезды + норматив времени (`par_ms`) для 3-й звезды. -- [ ] Task 2: Структура «мир/глава»: метаданные уровня (id, title, chapter, order, par_ms, hint). +- [x] Task 2: Структура «мир/глава»: метаданные уровня (id, title, chapter, order, par_ms, hint). Карта группирует по главам (созвездиям). -- [ ] Task 3: Карта-созвездие `frontend/js/game/map.js` (+ разметка в quantik.html): узлы-уровни +- [x] Task 3: Карта-созвездие `frontend/js/game/map.js` (+ разметка в quantik.html): узлы-уровни на SVG/canvas-фоне, линии-связи, статус (заблокирован/доступен/пройден + число звёзд). Разблокировка: уровень открыт, если набрано ≥ threshold звёзд в предыдущих (правило в данных). -- [ ] Task 4: XP/уровень игрока: XP = сумма звёзд × коэффициент (+ бонус за par). Хранить в +- [x] Task 4: XP/уровень игрока: XP = сумма звёзд × коэффициент (+ бонус за par). Хранить в прогрессе (расширить `game_progress` агрегацией на клиенте ИЛИ доб. поле/таблицу `game_player`). Полоса XP + «уровень Квантика» в шапке карты. -- [ ] Task 5: Скины Квантика: выбор `colorKey` из палитр `PetSprite` (+ позже паттерны). Скин +- [x] Task 5: Скины Квантика: выбор `colorKey` из палитр `PetSprite` (+ позже паттерны). Скин влияет на цвет glow-точки героя в уровне (param/проп движка) и на `PetSprite` на карте. Хранить выбор (localStorage сейчас; серверно — опц.). Разблокировка скинов по XP/звёздам. -- [ ] Task 6: Нарратор: `PetSprite.render(...)` в интро уровня (краткая формулировка «почини закон…») +- [x] Task 6: Нарратор: `PetSprite.render(...)` в интро уровня (краткая формулировка «почини закон…») и на экране победы (реакция по числу звёзд: happy/ecstatic). Реюз mood из pet-sprite.js. -- [ ] Task 7: Навигация: карта → уровень → результат → возврат на карту с обновлённым статусом/XP. -- [ ] Task 8: Тесты: разблокировка (логика чистой функцией — юнит-тест), агрегация XP; смоук карты. +- [x] Task 7: Навигация: карта → уровень → результат → возврат на карту с обновлённым статусом/XP. +- [x] Task 8: Тесты: разблокировка (логика чистой функцией — юнит-тест), агрегация XP; смоук карты. ## Files to Modify/Create - `frontend/js/game/levels.js` — контент мира (расширить). @@ -53,4 +53,61 @@ - [ ] Карта/навигация работают; existing тесты целы; lint baseline 0 ## Handoff to Next Phase - + +### Архитектура карты (`frontend/js/game/map.js`) +- `window.QuantikMap.create({ host, headerHost, onPlay(level), getSkin()->key, onSkin(key) }) -> { render(progressMap), destroy() }`. + - `render(progressMap)` рисует шапку (нарратор + XP-бар + всего звёзд + скин-пикер) в `headerHost` + и созвездия в `host`. `progressMap` — `{ [level_id]: row }` (см. `QuantikProgress.fromProgressList`). + - Узел созвездия (`buildNode`) — `