@
feat(quantik-game): фаза 0 — слой целей в движке (goal/HUD/result) Декларативный блок 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) <noreply@anthropic.com> @
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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: '<bool expr>', // SimExpr: победа, когда станет истинным (≠0)
|
||||
title?: 'Цель уровня', // краткая формулировка цели для HUD (escape на сервере)
|
||||
hint?: 'текст подсказки', // показывается в HUD (escape на сервере)
|
||||
hold?: 0, // сек: сколько when должно держаться непрерывно (деф. 0)
|
||||
fail?: '<bool expr>', // опц.: мягкий проигрыш (вышел за поле/задел шип)
|
||||
stars?: [ // 0..3 доп.условий-«звёзд» (бонусы, «залипают» до reset)
|
||||
{ when:'<bool expr>', label?:'...' }
|
||||
]
|
||||
}
|
||||
// game?: {...} — зарезервированный блок мета-слоя (Фаза 1/5); сервер его пропускает.
|
||||
}
|
||||
Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также
|
||||
<objId>.x / <objId>.y для объектов, у которых заданы числовые/выраж. x,y.
|
||||
Для физических тел (body) в env кладутся <objId>.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() + '<span style="margin-left:7px;font-weight:700">Ещё раз</span>';
|
||||
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 '<svg viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="' + fill +
|
||||
'" stroke="' + stroke + '" stroke-width="1.6" stroke-linejoin="round">' +
|
||||
'<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
|
||||
};
|
||||
|
||||
/* Перерисовать 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) {
|
||||
|
||||
@@ -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`).
|
||||
@@ -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`
|
||||
@@ -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: '<bool expr>', // SimExpr: победа, когда станет истинным (≠0)
|
||||
hint?: 'текст подсказки', // показывается в HUD (escape на сервере)
|
||||
title?: 'Цель уровня', // краткая формулировка цели для HUD
|
||||
hold?: 0.0, // сек, сколько условие должно держаться (деф. 0 = мгновенно)
|
||||
stars?: [ // 0..3 доп.условий-«звёзд» (бонусы)
|
||||
{ when:'<bool expr>', label?:'...' }
|
||||
],
|
||||
fail?: '<bool expr>' // опц.: мгновенный проигрыш (вышел за поле/задел шип)
|
||||
}
|
||||
```
|
||||
- `when`/`stars[].when`/`fail` — компилируются ОДИН раз при mount (как все выражения), env тот же.
|
||||
- Доп. env-поля для целей: `t` (время), `tries` (число reset с начала), плюс всё что уже в env
|
||||
(`<obj>.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: '<bool SimExpr>', // ≤500 симв., НЕ исполняется на сервере
|
||||
title?: '...', hint?: '...', // sanitizeText: escape & < > + обрезка (title≤120, hint≤300)
|
||||
hold?: 0, // число (сек удержания when); не-число → 400
|
||||
fail?: '<bool SimExpr>', // ≤500 симв.
|
||||
stars?: [ { when:'<bool SimExpr>', 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`,
|
||||
`<obj>.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/∞/ошибке).
|
||||
@@ -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
|
||||
<!-- Заполняет агент-имплементер. -->
|
||||
@@ -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
|
||||
<!-- Заполняет агент-имплементер. -->
|
||||
@@ -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
|
||||
<!-- Заполняет агент-имплементер. -->
|
||||
@@ -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` ссылается на оба `<id>.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
|
||||
<!-- Заполняет агент-имплементер. -->
|
||||
@@ -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:<id>`); привязка к программе через `lab_sim_links` (`sim_id='custom:<id>'`).
|
||||
- [ ] Task 6: Deep-link `/quantik?level=custom:<id>` (паттерн Ф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
|
||||
<!-- Заполняет агент-имплементер. -->
|
||||
@@ -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
|
||||
<!-- Финальная фаза — далее комплексное ревью и мерж в feature/sim-builder. -->
|
||||
Reference in New Issue
Block a user