6 Commits

Author SHA1 Message Date
Maxim Dolgolyov 0f3e12426a @
feat(quantik-game): фаза 2 — карта-созвездие + мир + XP/скины (MVP-мир)

Одиночный уровень → играбельный мир: карта-созвездие из 6 физ-уровней
(2 главы, нарастающая сложность), разблокировка по звёздам, клиентский
XP/уровень игрока, пикер из 8 скинов (тинт героя+нарратора), нарратор
PetSprite на интро/победе (mood по звёздам). Навигация карта→интро→игра→
успех→карта/дальше; кнопка «Дальше» пересчитывает nextPlayable после
дозагрузки прогресса (фикс stale-hasNext). Логика прогресса — чистый
модуль progress-logic.js (unlock/XP/группировка). Только фронт, без
бэкенда: XP агрегируется из game_progress (Ф1). Каждый уровень проверен
на реальном движке (выигрываем + обе звезды достижимы); цепочка
разблокировки доказуемо проходима. npm test 251/8 baseline; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-13 16:24:31 +03:00
Maxim Dolgolyov 351251d652 @
feat(quantik-game): фаза 1 — оболочка игры + физ-уровень + прогресс (MVP)

Страница /quantik монтирует уровень-спеку в SimEngine (игровой режим: HUD из
Ф0 + слайдеры закона + play/reset), на победу шлёт результат и показывает
экран успеха (звёзды/время/попытки, inline SVG). Уровень phys-artillery-1
как данные (levels.js): гравитация + запуск тела из угла/скорости, портал,
бонус-звезда. Бэкенд: миграция 076 game_progress (UNIQUE user+level),
/api/game/progress (GET свой / POST upsert best time/stars, attempts++,
auth-only, валидация входа), клиент LS.gameProgress*, пункт сайдбара.
game.test.js 13/13; npm test 251 pass/8 baseline; lint:routes 0.
Уровень проверен на реальном интеграторе (311 выигрышных комбо, 31 на 3★).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-13 15:31:25 +03:00
Maxim Dolgolyov 4b5c8077d3 @
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>
@
2026-06-13 15:13:02 +03:00
Maxim Dolgolyov 6743dfcbce feat(sim-builder): улучшение P5 — прямое манипулирование (drag всех типов, snap) + undo/redo в билдере 2026-06-13 15:08:09 +03:00
Maxim Dolgolyov b6f854fc77 feat(sim-builder): улучшение P4 — UI билдера: color-пикеры, контролы стиля, редактор кривых, z-order/дубль/видимость 2026-06-13 14:46:14 +03:00
Maxim Dolgolyov 69e219ae8c feat(sim-builder): улучшение P3 — графики: несколько кривых, заливка под кривой, маркеры, легенда 2026-06-13 14:26:36 +03:00
39 changed files with 4313 additions and 820 deletions
+80
View File
@@ -158,3 +158,83 @@ git push origin master
- **Палитра по умолчанию** `DEFAULT_PALETTE` (8 холодно-ярких тонов) — циклически `[i % 8]` в `_prepareObjects`, только если `color` не задан в спеке; явный color сохраняется.
- **Верификация P2**: `node --check` OK; headless-смоук (vm + DOM/canvas-стаб со счётчиками вызовов и проверкой баланса save/restore + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`) 23/23: 18-объектная спека (все типы + все новые поля) ×4 кадра без throw; **ctx не протекает** (depth=0, globalAlpha→1, shadowBlur→0, lineDash→[] после кадра); setLineDash/createLinearGradient/fill/stroke/arc вызваны; поля прочитаны; палитра+явный color; трасса накоплена; destroy чист. Эмодзи нет (скан: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только в комментарии стр.15.
- **На P3 (графики/диаграммы)**: `_drawPlot` уже зовёт `_applyStroke`. Расширять `_drawPlot` — оси-деления plot, несколько кривых, заливка под кривой, маркеры (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint` готовы к переиспользованию.
### SimForge improvements — P3 (Графики/диаграммы) — Learnings
Всё в `frontend/js/labs/_sim_engine.js`. Расширен `_drawPlot` + ветка `type==='plot'` в `_prepareObjects`. Оси/сетка/подписи уже из P1 — в P3 не дублировались.
- **Несколько кривых.** Нормализуются в `prep.curves[]` с приоритетом источника: `curves:[{...}]``exprs:['sin(x)','x^2']` → одиночный `expr` (легаси, обратная совместимость). Каждой кривой свой цвет: явный `color` или `DEFAULT_PALETTE[i%8]`. `prep.exprFn` оставлен = первой кривой (нужен trace-режиму `_accumPlotTrace`).
- **Поля кривой** (`curves[i]`): `expr`, `color`, `label`(→легенда), `width`, `lineStyle`(solid|dashed|dotted), `opacity`(0..1), `fill`(true→полупрозр. цвет кривой / строка цвета), `marker`(none|dot|ring). Не заданные наследуют plot-уровень (`width/lineStyle/opacity`). **Plot-уровневые `fill`/`marker`** — дефолт для всех кривых.
- **Заливка под кривой** — `_fillUnderCurve(ctx,pts,baseY)`: между кривой и осью `y=0` (baseY клиппится к canvas), посегментно — разрывы у не-finite точек НЕ сливаются в один полигон. `fill:true``_fillAlpha(color,0.18)` (#RGB/#RRGGBB→rgba; прочие форматы как есть, alpha через globalAlpha).
- **Маркеры узлов** — `_drawCurveMarkers` переиспользует `_drawPoint` (dot→filled, ring→hollow), прорежены ~28px по экрану (не сотни точек на 200 сэмплах).
- **Легенда** — `_drawLegend` на canvas (НЕ DOM): тёмная плашка (`roundRect` с фолбэком на `fillRect`) + цветной свотч (strokeStyle цвета кривой) + светлый `fillText`. Верх-право, не наезжает на бар кнопок вида. Авто при наличии `label`; `legend:false` отключает. ⛔ Пользовательский цвет — только canvas-сток; текст легенды — фикс. светлый.
- **Качество кривой** — пропуск не-finite (разрывы через `started=false`), переиспользован equidistant sampling (`samples` 200/макс 2000), `_applyStroke` даёт dash/opacity/glow/round-стыки. Каждая кривая в своём `ctx.save()/restore()`, легенда — на внешнем уровне → состояние не протекает.
- **Новые хелперы модульного уровня** (рядом с `_dashFor`/`_opacity`): `_markerStyle(v)` (none|dot|ring), `_fillAlpha(color,a)` (hex→rgba для заливки).
- **Верификация P3**: `node --check` OK; headless vm-смоук (canvas-стаб со счётчиком save/restore + РЕАЛЬНЫЕ `_sim_expr`+`_sim_engine`) 10/10: легаси одиночный expr, exprs[], curves[]+fill+marker+legend, наследование plot-уровня, не-finite (1/x, tan) без throw, legend:false, trace±range, fillUnder+markers с null-разрывами, регресс point/vector/circle/rect — все PASS; **ctx сбалансирован** (depth→0, нет restore-underflow). Эмодзи нет (только пре-существующие → в комментариях); eval=0. Temp-смоук удалён.
- **На P4 (билдер)**: дать полям контролы — список кривых (add/del, expr+color+label+width+lineStyle+opacity+fill+marker), plot-уровневые fill/marker, тумблер легенды.
### SimForge improvements — P4 (UI билдера + контролы стиля) — Learnings
Всё в `frontend/sim-builder.html` (CSS) + `frontend/js/sim-builder.js` (логика). `_sim_engine.js`/`js/api.js`/lab.* НЕ тронуты — билдер только генерит спеку, которую движок P2/P3 уже рисует.
- **Контролы стиля = data-driven хелперы** (рядом с `field`/`miniField`): `colorCtl(label,attr,val,clearable)` (нативный `<input type=color>` + текст + опц.очистка), `rangeCtl` (слайдер 0..1 для opacity), `selectCtl` (lineStyle/pointStyle/marker). Блок «Стиль» в каждом объекте — `Builder.styleBlock(o)`, набор полей решает `STYLE_FOR[type]` ({opacity,line,point,glow,grad}).
- **Цвет: текст — источник истины, не нативный пикер.** Нативный `<input type=color>` умеет только `#rrggbb`; rgba()/named он бы потерял. Поэтому пикер лишь ПИШЕТ в текстовое поле и диспатчит `input` (его ловит основной `data-of`/`data-cvf`-обработчик). `Builder.wireColorControls(row)` связывает пикер↔текст↔очистку. `toHexColor(v)` приводит #rgb/#rrggbb#rrggbb (иначе #000000) для нативного пикера. Очистка (fill/trailColor) = пустая строка → `stripObj` выбрасывает → «нет заливки».
- **Round-trip как чинили range в Ф4: дефолты НЕ сериализуем.** `stripObj.isDefaultStyle(k,v)` выбрасывает `hidden`, `glow:false`, `lineStyle:'solid'`, `pointStyle:'filled'`, `opacity:1`, `trail/closed:false`. Спека минимальна, а save→load→save идемпотентен (loadFromSim восстанавливает дефолты из контролов). Селекты хранят дефолтную строку в `st`, но она не уходит в спеку. Проверено vm-смоуком.
- **Plot теперь — кривые.** UI-модель plot = `{var,range_a/b,samples,trace,legend,plotFill,plotMarker,curves:[{_uid,expr,color,label,width,lineStyle,opacity,fill(bool),fillColor,marker}]}`. `plotEditor`+`curveEditor` рисуют, `loadPlot` (spec→UI: `curves[]``exprs[]`→легаси `expr`; легаси plot-level width/lineStyle/opacity наследуются кривой), `normalizePlotForSpec`+`stripCurve` (UI→spec). **Одиночная «простая» кривая (только expr+color, без plot-fill/marker) → легаси `{expr,color}`**, иначе `curves:[...]` — не ломает обратную совместимость. `legend:false` эмитится только при выкл (движок включает легенду авто при label). Валидация: каждая кривая + границы range через `SimExpr.compile`.
- **z-order / видимость / дублирование — чисто в билдере** (движок не трогали): z-order = порядок массива `st.objects`/`st.plots` (кнопки вверх/вниз свапают, крайние disabled). Видимость `hidden:true` — билдерский флаг, `buildSpec` фильтрует hidden из спеки (движок про hidden не знает). Дублирование — `JSON.parse(JSON.stringify(o))` + новый `_uid` + `id+'_copy'`, вставка после оригинала. Аналогично у plot.
- **Новые 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).
### 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)<R'`), бонус-звезда (`stars[].when`), `fail` при промахе за поле. Подобран ПРОХОДИМЫМ в пределах слайдеров (θ 10..80°, v 5..20 м/с; портал x=8, дальность v²·sin2θ/g ≈ 6..10 м). custom_sims cat='game' остаётся для авторённых уровней (Ф5) — реестр тогда станет асинхронным со слиянием.
- **API прогресса**: таблица `game_progress` (мигр.**076**, UNIQUE(user_id,level_id), user_id ON DELETE CASCADE), контроллер `gameController.js` + роутер `routes/game.js` (`router.use(authMiddleware)` → lint:routes 0), смонтировано в `server.js` после `/api/custom-sims`. `GET /api/game/progress``{progress:[…]}`; `POST` `{level_id,time_ms,stars}` → upsert best (min time / max stars) + attempts++. Валидация: level_id строка ≤120, time_ms/stars неотрицательные ЦЕЛЫЕ (`Number.isInteger`, отвергает дробь/NaN/∞), stars 0..3. Прогресс всегда `req.user` — нет межпользовательских роутов, ownership-проверка не нужна. Клиент `LS.gameProgressList()`/`LS.gameProgressSubmit(levelId,{time_ms,stars})` (стиль customSim*-врапперов в js/api.js).
- **Маршрутизация без правок server.js**: `/quantik``quantik.html` через `express.static(frontendDir,{extensions:['html']})` (как все clean URL). `/js/game/*` и `/js/labs/*` отдаются тем же static (гоча `/js`→корневой `js/` касается только api.js/sidebar.js, не подпапок). Подключение движка — копия sim-builder.html: `/js/labs/_sim_expr.js` + `/js/labs/_sim_engine.js`.
- **Экран успеха** = DOM-оверлей страницы `.qg-overlay` (НЕ HUD движка), `QuantikGame.buildSuccessOverlay(state)` строит карточку: звёзды inline SVG (заполн./контур, без эмодзи), время/звёзды/попытки, «Ещё раз» (убрать оверлей + `inst.reset()`) / «Дальше» (disabled-заглушка MVP — Ф2 активирует). CSS `.qg-*` в `<style>` 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.
### Phase 2 — Learnings (Карта-созвездие + мир физ-уровней + XP/скины)
- **Phase 2 = FRONTEND-ONLY** (осознанное решение): XP/уровень игрока агрегируются на КЛИЕНТЕ из `game_progress` (Ф1), скин — localStorage. Без новых таблиц/роутов/миграций → `lint:routes` baseline 0 не тронут, `npm test` ровно как в Ф1 (259 tests / 251 pass / 8 baseline fail). Перенос XP на сервер позже тривиален — те же чистые функции `progress-logic.js`.
- **Чистая логика в отдельном модуле `frontend/js/game/progress-logic.js`** (`window.QuantikProgress`, без DOM/сети/eval — тестируемо в изоляции): `isUnlocked(level,map,levels)` (Σ звёзд во всех уровнях с меньшим `order``level.unlockStars`; порог в ДАННЫХ уровня), `computeXp`(звёзды·100+40/пройден), `playerLevel(xp)` (квадратичная шкала `xpForLevel(L)=240·(L-1)L/2`), `groupByChapter`, `nextPlayable`, `fromProgressList`, `starsFor/starsToUnlock/nodeStatus`. Гоча тестов: `assert.deepEqual` через `vm`-границу сравнивает массивы РАЗНЫХ реалмов (прототипы ≠) → ложный fail; сравнивать через `JSON.stringify`.
- **Карта `frontend/js/game/map.js`** (`window.QuantikMap.create({host,headerHost,onPlay,getSkin,onSkin})->{render(progressMap),destroy()}`): созвездия по главам (`groupByChapter`), узлы — `<button class="qm-node qm-{locked|available|completed}">`, позиция в % через `layoutNodes` (зигзаг-дуга), статус из `nodeStatus`. Звёздное небо — SVG `<circle class="qm-tw">` (CSS-мерцание, seeded `mulberry32`), линии-связи `<line>`. Поэтапное появление — `staggerReveal` (`.qm-pre``.qm-in`, setTimeout 70 мс). Тип спеки уровня карте безразличен — читает только метаданные → Ф3 граф-уровни = НОВАЯ глава без правок map.js.
- **Метаданные уровня (Ф2)**: `{ id, title, chapter, order, unlockStars?, par_ms?, hint, spec }`. Главы — `QuantikLevels.CHAPTERS` (`{key,title,subtitle,accent}`). 6 уровней: Кинематика (артиллерия/перелёт-через-стену/отскок-от-стены) + Динамика (маятник-на-пружине/орбита-в-колодце/гравиманёвр). По 2 звезды: кристалл (`stars[0]`) + норматив времени `t*1000<=par_ms` (`stars[1]` — par-звезда выражается через мировое `t`, идентификатор `tries` для неё НЕ нужен).
- **Физика «силовых» уровней через ПРУЖИНУ** (движок не имеет central-gravity): маятник — пружина к якорю-точке с короткой `length` (растянута → сильный возврат) + горизонтальный толчок; орбита — пружина к центру с `length:0` (== гармонический осциллятор `F=-k·r` == эллиптические орбиты); гравиманёвр — гравитация вниз + пружина-«колодец» к центру. k/толчок/сила = params-слайдеры.
- **Скин: тинт без исполнения.** `tintHeroSpec(spec,key)` — глубокая JSON-копия спеки (данные!), переписывает `color/glowColor/trailColor` объекта `id:'ball'` цветом из `PetSprite.PALETTES[key]`. localStorage ключ **`quantik-skin`** (валидируется при чтении). Скин тинтует и героя, и нарратора (`PetSprite.render(...,colorKey,...)`). Гейты — массив `SKIN_GATES` (needStars/needXp).
- **Нарратор = `PetSprite.render(level,mood,[],skin,0,'none')`** на карте-шапке (mood по уровню игрока), интро (`buildIntro`, happy) и успехе (`buildSuccessOverlay`, ecstatic при всех звёздах≥2 / happy при ≥1). `quantik.html` грузит `/js/pet-sprite.js` (как dashboard/pet).
- **Навигация (inline-bootstrap quantik.html)**: 2 вида `#qg-map-view`/`#qg-level-view` (класс `.show`). `showMap` перезагружает прогресс (`LS.gameProgressList`) → `map.render`. `openLevel→интро→launchLevel→onGoal→успех→onNext(nextPlayable)|onMap`. **Смена уровня ВСЕГДА через `destroyLevel()` (=`inst.destroy()`)** до нового mount (гоча Ф1). Deep-link `?level=` открывает только разблокированный.
- **Per-level winnability обязательна** (как Ф1): harness грузит РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js` в `vm`, свипует слайдеры через движок, проверяет `getResult().won`. Гоча OOM: **переиспользовать ОДИН `inst` через `reset()` по сотням комбо ТЕЧЁТ** (накопление через goal-state/bodyById-замыкания) → mount+`destroy()` СВЕЖИЙ inst на каждое комбо (leak-proof). Headless `_renderFrame` рано выходит при `_cw/_ch==0` (рендер не нужен, физика/`_evalGoal` идут в `play`-кадре независимо); для point-радиуса в физике выставить `inst._scale`. Виртуальные часы синхронны с `performance.now()`/rAF-timestamp. Результат: ВСЕ 6 winnable, у всех достижимы обе звезды (combos: artillery 28/196, arc 5/196, bounce 92/343, pendulum 189/196, orbit 94/196, gravimanёvr 170/343).
- **Верификация P2**: `node --check` всех новых/изменённых JS + inline-`<script>` quantik.html — OK; смоуки (логика 16/16, рендер карты/оверлеев 7/7, winnability 6/6) зелёные и удалены; `npm test` 259/251 pass/8 baseline fail (без новых падений); `lint:routes` baseline 0. Эмодзи/`★`/eval/new Function — 0 (звёзды UI — inline SVG; в комментариях `★` заменён на «зв.»).
@@ -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 };
}
+8 -34
View File
@@ -1,7 +1,6 @@
const db = require('../db/db');
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const { UPLOADS_DIR } = require('../config');
const { checkMagicBytes } = require('../utils/magic');
@@ -174,41 +173,16 @@ function uploadFile(req, res) {
* teacher library upload above. Image-only; saved into uploads/materials and
* served statically (public), so the returned URL renders in <img>, opens in
* a new tab and downloads without an auth header. Returns { url }. */
async function uploadPersonalFile(req, res) {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
function uploadPersonalFile(req, res) {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename);
if (!checkMagicBytes(filePath, req.file.mimetype)) {
try { fs.unlinkSync(filePath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
}
// Per-user storage quota: reject before the file becomes usable. Accounting
// is by student_materials.bytes (the uploaded file is not a material yet).
const used = db.prepare('SELECT COALESCE(SUM(bytes),0) AS b FROM student_materials WHERE user_id = ?').get(req.user.id);
const maxBytes = Number(process.env.MATERIALS_MAX_BYTES) || 300 * 1024 * 1024; // 300 MB
if (used.b + (req.file.size || 0) > maxBytes) {
try { fs.unlinkSync(filePath); } catch {}
return res.status(413).json({ error: 'Превышен лимит хранилища материалов' });
}
const url = '/uploads/materials/' + req.file.filename;
// Server-side thumbnail (downscaled webp) for fast grid rendering; the full
// image stays for viewing/annotating/download. Best-effort — on any failure
// (animated gif, decode error) thumbUrl is null and the client uses `url`.
let thumbUrl = null;
try {
const thumbName = path.basename(req.file.filename, path.extname(req.file.filename)) + '_thumb.webp';
const thumbPath = path.resolve(UPLOADS_DIR, 'materials', thumbName);
await sharp(filePath).rotate().resize(480, 480, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 78 }).toFile(thumbPath);
thumbUrl = '/uploads/materials/' + thumbName;
} catch (e) { thumbUrl = null; }
return res.status(201).json({ url, thumbUrl });
} catch (e) {
return res.status(500).json({ error: 'Upload failed' });
const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename);
if (!checkMagicBytes(filePath, req.file.mimetype)) {
try { fs.unlinkSync(filePath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
}
res.status(201).json({ url: '/uploads/materials/' + req.file.filename });
}
/* ── GET /api/files/:id/download ─────────────────────────────────────── */
+84
View File
@@ -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 };
@@ -2,69 +2,16 @@
/* Student-owned personal materials ("Мои материалы").
* A user keeps copies of items saved from live lessons; the copies are
* independent of the session lifecycle. */
const fs = require('fs');
const path = require('path');
const db = require('../db/db');
const { emit } = require('../sse');
const KINDS = ['board', 'note', 'link', 'image'];
// Personal uploads live here (mirrors fileController MATERIALS_DIR). Used for
// reference-counted file cleanup when the material(s) pointing at a file go away.
const MATERIALS_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'materials');
// Soft per-user cap on the number of materials. Read at call time so tests can
// lower it via env. Byte quota is enforced separately at the upload endpoint.
function maxItems() { return Number(process.env.MATERIALS_MAX_ITEMS) || 2000; }
// Storable URLs are app-relative ("/…", not protocol-relative "//host") or
// http(s). Everything else (javascript:, data:, mailto:, …) is rejected: a saved
// link is rendered as <a href> on the owner's page AND can be handed out to a
// whole class via /share, so a bad scheme would be stored XSS.
// Returns the (length-capped) url, '' for empty, or undefined when invalid.
function safeUrl(raw) {
const u = String(raw == null ? '' : raw).trim();
if (!u) return '';
if (/^https?:\/\//i.test(u)) return u.slice(0, 2000);
if (u[0] === '/' && u[1] !== '/') return u.slice(0, 2000);
return undefined;
}
// Size on disk of a local materials file (0 if absent / non-local).
function fileBytes(u) {
if (typeof u !== 'string' || !u.startsWith('/uploads/materials/')) return 0;
try { return fs.statSync(path.join(MATERIALS_DIR, path.basename(u))).size; } catch (e) { return 0; }
}
// Bytes attributed to a material for quota accounting (server-measured): for
// image/board it's the full file + its thumbnail.
function measureBytes(kind, url, body, thumbUrl) {
if (kind === 'note') return Buffer.byteLength(String(body || ''), 'utf8');
if (kind === 'image' || kind === 'board') return fileBytes(url) + fileBytes(thumbUrl);
return 0;
}
// Reference-counted cleanup: unlink the file backing `url` only when NO material
// row references it any more — as either its `url` OR its `thumb_url` (share/
// annotate can alias one physical file across rows and columns). Call AFTER the
// delete/url-update so the freed row no longer counts. Exported for tests.
function releaseFileForUrl(url) {
if (typeof url !== 'string' || !url.startsWith('/uploads/materials/')) return;
if (db.prepare('SELECT 1 FROM student_materials WHERE url = ? OR thumb_url = ? LIMIT 1').get(url, url)) return;
const fp = path.join(MATERIALS_DIR, path.basename(url));
if (fp.startsWith(MATERIALS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch (e) { /* already gone */ } }
}
/* GET /api/materials — list the current user's saved materials + their collections */
function list(req, res) {
const uid = req.user.id;
// Return only a body PREVIEW (first 1000 chars) to keep the payload small for
// note-heavy users; the full text is fetched on demand via GET /:id. body_trunc
// tells the client a lazy fetch is needed before viewing/editing the note.
const materials = db.prepare(`
SELECT id, kind, title, substr(body, 1, 1000) AS body,
(CASE WHEN body IS NOT NULL AND length(body) > 1000 THEN 1 ELSE 0 END) AS body_trunc,
url, thumb_url, source_session_id, source_title, collection_id, tags, created_at
SELECT id, kind, title, body, url, source_session_id, source_title, collection_id, tags, created_at
FROM student_materials
WHERE user_id = ?
ORDER BY created_at DESC, id DESC
@@ -79,19 +26,6 @@ function list(req, res) {
res.json({ materials, collections });
}
/* GET /api/materials/:id — one material with its FULL body (lazy-loaded by the
client when a note's preview was truncated). Owner-only. */
function getOne(req, res) {
const row = db.prepare(`
SELECT id, user_id, kind, title, body, url, thumb_url, source_session_id, source_title, collection_id, tags, created_at
FROM student_materials WHERE id = ?
`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not found' });
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
delete row.user_id;
res.json(row);
}
/* Validate that a collection id belongs to the user; returns null if unset/invalid. */
function ownCollectionId(raw, uid) {
if (raw === null || raw === '' || raw === undefined) return null;
@@ -108,21 +42,9 @@ function create(req, res) {
const kind = String(b.kind || '');
if (!KINDS.includes(kind)) return res.status(400).json({ error: 'invalid kind' });
if (db.prepare('SELECT COUNT(*) AS n FROM student_materials WHERE user_id = ?').get(req.user.id).n >= maxItems())
return res.status(413).json({ error: 'Достигнут лимит числа материалов' });
const title = String(b.title || '').slice(0, 300);
const body = b.body != null ? String(b.body).slice(0, 60000) : null;
let url = null;
if (b.url != null && b.url !== '') {
url = safeUrl(b.url);
if (url === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' });
}
let thumbUrl = null;
if (b.thumbUrl != null && b.thumbUrl !== '') {
thumbUrl = safeUrl(b.thumbUrl);
if (thumbUrl === undefined) return res.status(400).json({ error: 'Недопустимый адрес миниатюры' });
}
const url = b.url != null ? String(b.url).slice(0, 2000) : null;
if ((kind === 'note') && !body) return res.status(400).json({ error: 'body required for note' });
if ((kind === 'board' || kind === 'image' || kind === 'link') && !url)
return res.status(400).json({ error: 'url required' });
@@ -136,56 +58,38 @@ function create(req, res) {
const collectionId = ownCollectionId(b.collection_id, req.user.id);
const tags = b.tags != null ? String(b.tags).slice(0, 500) : null;
const bytes = measureBytes(kind, url, body, thumbUrl);
const r = db.prepare(`
INSERT INTO student_materials (user_id, kind, title, body, url, thumb_url, source_session_id, source_title, collection_id, tags, bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(req.user.id, kind, title, body, url, thumbUrl, sourceSessionId, sourceTitle, collectionId, tags, bytes);
INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle, collectionId, tags);
res.status(201).json({ id: Number(r.lastInsertRowid) });
}
/* PATCH /api/materials/:id — rename / edit one of the current user's items.
Editable: title, body, url (e.g. re-saving an annotated image), collection_id, tags. */
function update(req, res) {
const row = db.prepare('SELECT user_id, url, thumb_url FROM student_materials WHERE id = ?').get(req.params.id);
const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'not found' });
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
const b = req.body || {};
const fields = [], args = [];
if (b.title !== undefined) { fields.push('title = ?'); args.push(String(b.title || '').slice(0, 300)); }
if (b.body !== undefined) { fields.push('body = ?'); args.push(b.body != null ? String(b.body).slice(0, 60000) : null); }
if (b.url !== undefined) {
let nu = null;
if (b.url != null && b.url !== '') { nu = safeUrl(b.url); if (nu === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' }); }
fields.push('url = ?'); args.push(nu);
}
if (b.thumbUrl !== undefined) {
let nt = null;
if (b.thumbUrl != null && b.thumbUrl !== '') { nt = safeUrl(b.thumbUrl); if (nt === undefined) return res.status(400).json({ error: 'Недопустимый адрес миниатюры' }); }
fields.push('thumb_url = ?'); args.push(nt);
}
if (b.url !== undefined) { fields.push('url = ?'); args.push(b.url != null ? String(b.url).slice(0, 2000) : null); }
if (b.collection_id !== undefined) { fields.push('collection_id = ?'); args.push(ownCollectionId(b.collection_id, req.user.id)); }
if (b.tags !== undefined) { fields.push('tags = ?'); args.push(b.tags != null ? String(b.tags).slice(0, 500) : null); }
if (!fields.length) return res.json({ ok: true });
args.push(req.params.id);
db.prepare(`UPDATE student_materials SET ${fields.join(', ')} WHERE id = ?`).run(...args);
// Recompute quota bytes from the persisted row; free the old file(s) if the url
// or thumbnail changed (annotate overwrites both) and nothing else references them.
const cur = db.prepare('SELECT kind, url, thumb_url, body FROM student_materials WHERE id = ?').get(req.params.id);
db.prepare('UPDATE student_materials SET bytes = ? WHERE id = ?').run(measureBytes(cur.kind, cur.url, cur.body, cur.thumb_url), req.params.id);
if (b.url !== undefined && row.url && row.url !== cur.url) releaseFileForUrl(row.url);
if (b.thumbUrl !== undefined && row.thumb_url && row.thumb_url !== cur.thumb_url) releaseFileForUrl(row.thumb_url);
res.json({ ok: true });
}
/* DELETE /api/materials/:id — remove one of the current user's items */
function remove(req, res) {
const row = db.prepare('SELECT user_id, url, thumb_url FROM student_materials WHERE id = ?').get(req.params.id);
const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'not found' });
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
db.prepare('DELETE FROM student_materials WHERE id = ?').run(req.params.id);
releaseFileForUrl(row.url); // unlink the full image if no other material aliases it
releaseFileForUrl(row.thumb_url); // …and its thumbnail
res.json({ ok: true });
}
@@ -231,7 +135,7 @@ function deleteCollection(req, res) {
a student. Each recipient gets an independent COPY (survives later edits/
deletes by the teacher). Body: { classId } | { userId }. */
function share(req, res) {
const mat = db.prepare('SELECT user_id, kind, title, body, url, thumb_url FROM student_materials WHERE id = ?').get(req.params.id);
const mat = db.prepare('SELECT user_id, kind, title, body, url FROM student_materials WHERE id = ?').get(req.params.id);
if (!mat) return res.status(404).json({ error: 'not found' });
if (mat.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
@@ -260,13 +164,12 @@ function share(req, res) {
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
const srcTitle = 'Раздатка: ' + teacherName;
const bytes = measureBytes(mat.kind, mat.url, mat.body, mat.thumb_url); // each copy counts toward the recipient's quota
const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, thumb_url, source_session_id, source_title, bytes) VALUES (?,?,?,?,?,?,NULL,?,?)`);
const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title) VALUES (?,?,?,?,?,NULL,?)`);
let sent = 0;
db.transaction(() => {
for (const uid of recipients) {
if (!uid || uid === req.user.id) continue;
ins.run(uid, mat.kind, mat.title, mat.body, mat.url, mat.thumb_url, srcTitle, bytes);
ins.run(uid, mat.kind, mat.title, mat.body, mat.url, srcTitle);
try {
emit(uid, { type: 'notification', notif_type: 'material_shared',
message: `Новый материал от ${teacherName}: «${mat.title || 'материал'}»`, link: '/my-materials' });
@@ -277,6 +180,4 @@ function share(req, res) {
res.json({ ok: true, sent });
}
module.exports = { list, getOne, create, update, remove, createCollection, updateCollection, deleteCollection, share,
// exported for tests / reuse
safeUrl, measureBytes, releaseFileForUrl };
module.exports = { list, create, update, remove, createCollection, updateCollection, deleteCollection, share };
@@ -1,12 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 073: Storage accounting for «Мои материалы»
--
-- Per-material byte size, used to enforce a per-user storage quota and to free
-- orphaned files. Populated on create/update:
-- image|board → size of the file on disk (server-measured)
-- note → text length
-- link → 0
-- Existing rows default to 0 (the next edit recomputes them; quota is a soft cap).
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE student_materials ADD COLUMN bytes INTEGER NOT NULL DEFAULT 0;
@@ -1,10 +0,0 @@
-- ═══════════════════════════════════════════════════════════════
-- 074: Thumbnail URL for image/board materials
--
-- Server-generated downscaled preview (sharp → webp, ≤480px) shown in the grid;
-- the full image is still used for viewing / annotating / download. NULL when no
-- thumb exists (generation failed, animated gif, or a non-uploaded url) — the
-- client falls back to the full `url`.
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE student_materials ADD COLUMN thumb_url TEXT;
@@ -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);
+16
View File
@@ -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;
-2
View File
@@ -19,8 +19,6 @@ router.patch('/collections/:id', c.updateCollection);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.delete('/collections/:id', c.deleteCollection);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.get('/:id', c.getOne);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.patch('/:id', c.update);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
+1
View File
@@ -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) ── */
+108
View File
@@ -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}`);
});
});
-242
View File
@@ -1,242 +0,0 @@
'use strict';
/**
* Integration tests: /api/materials — «Мои материалы» (v2 hardening).
* Covers: auth, CRUD happy-path, ownership (чужой PATCH/DELETE → 403, 404),
* collections (create / move / delete keeps material), share-копия (роль + owner
* + привязка ученика), URL-allowlist (javascript: → 400), лимит числа материалов,
* и ссылочно-подсчётную чистку файла (releaseFileForUrl на временном файле).
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { app, db, inject, getToken, cleanup } = require('./setup');
// setup.js не монтирует /api/materials — монтируем на общий тест-app.
app.use('/api/materials', require('../src/routes/materials'));
const ctrl = require('../src/controllers/studentMaterialsController');
after(() => cleanup());
describe('/api/materials', () => {
let studentToken, studentId, otherToken, teacherToken, teacherId;
before(async () => {
const s = await getToken('student'); studentToken = s.token; studentId = s.userId;
otherToken = (await getToken('student')).token;
const t = await getToken('teacher'); teacherToken = t.token; teacherId = t.userId;
});
it('GET requires auth (401 without token)', async () => {
const res = await inject('GET', '/api/materials', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
let noteId;
it('student can create a note (201) and list it back', async () => {
const c = await inject('POST', '/api/materials', { kind: 'note', title: 'Закон Ома', body: 'U=IR' }, studentToken);
assert.equal(c.status, 201, JSON.stringify(c.body));
noteId = c.body.id;
const l = await inject('GET', '/api/materials', null, studentToken);
assert.equal(l.status, 200);
assert.ok(l.body.materials.some(m => m.id === noteId && m.title === 'Закон Ома'));
});
it('accepts http(s) and app-relative link urls', async () => {
const a = await inject('POST', '/api/materials', { kind: 'link', url: 'https://example.com/x' }, studentToken);
assert.equal(a.status, 201, JSON.stringify(a.body));
const b = await inject('POST', '/api/materials', { kind: 'link', url: '/textbook/phys7#sec-1' }, studentToken);
assert.equal(b.status, 201, JSON.stringify(b.body));
});
it('rejects a link with javascript: scheme (400) — stored-XSS guard', async () => {
const res = await inject('POST', '/api/materials', { kind: 'link', url: 'javascript:alert(1)' }, studentToken);
assert.equal(res.status, 400, JSON.stringify(res.body));
});
it('rejects a protocol-relative url (400)', async () => {
const res = await inject('POST', '/api/materials', { kind: 'link', url: '//evil.example.com' }, studentToken);
assert.equal(res.status, 400, JSON.stringify(res.body));
});
it('PATCH cannot smuggle a javascript: url (400)', async () => {
const res = await inject('PATCH', `/api/materials/${noteId}`, { url: 'javascript:alert(1)' }, studentToken);
assert.equal(res.status, 400, JSON.stringify(res.body));
});
it('owner can rename; others get 403; missing → 404', async () => {
const ok = await inject('PATCH', `/api/materials/${noteId}`, { title: 'Ом' }, studentToken);
assert.equal(ok.status, 200);
const forbidden = await inject('PATCH', `/api/materials/${noteId}`, { title: 'hack' }, otherToken);
assert.equal(forbidden.status, 403);
const missing = await inject('PATCH', '/api/materials/999999', { title: 'x' }, studentToken);
assert.equal(missing.status, 404);
});
it('list returns a 1000-char body preview; GET /:id returns the full body (owner only)', async () => {
const big = 'x'.repeat(1500);
const c = await inject('POST', '/api/materials', { kind: 'note', body: big }, studentToken);
const id = c.body.id;
const l = await inject('GET', '/api/materials', null, studentToken);
const row = l.body.materials.find(m => m.id === id);
assert.equal(row.body.length, 1000, 'preview trimmed');
assert.equal(row.body_trunc, 1, 'truncation flagged');
const one = await inject('GET', `/api/materials/${id}`, null, studentToken);
assert.equal(one.status, 200);
assert.equal(one.body.body.length, 1500, 'full body returned');
assert.equal(one.body.user_id, undefined, 'user_id not leaked');
const forbidden = await inject('GET', `/api/materials/${id}`, null, otherToken);
assert.equal(forbidden.status, 403);
const missing = await inject('GET', '/api/materials/999999', null, studentToken);
assert.equal(missing.status, 404);
});
it('collections: create, move material in, delete keeps material (uncategorised)', async () => {
const col = await inject('POST', '/api/materials/collections', { name: 'Физика' }, studentToken);
assert.equal(col.status, 201, JSON.stringify(col.body));
const cid = col.body.id;
const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: cid }, studentToken);
assert.equal(mv.status, 200);
let l = await inject('GET', '/api/materials', null, studentToken);
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, cid);
assert.equal(l.body.collections.find(c => c.id === cid).count, 1);
const del = await inject('DELETE', `/api/materials/collections/${cid}`, null, studentToken);
assert.equal(del.status, 200);
l = await inject('GET', '/api/materials', null, studentToken);
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null, 'material survives folder delete');
});
it('moving into another user\'s collection is ignored (collection_id stays null)', async () => {
const col = await inject('POST', '/api/materials/collections', { name: 'Чужая' }, otherToken);
const foreignCid = col.body.id;
const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: foreignCid }, studentToken);
assert.equal(mv.status, 200);
const l = await inject('GET', '/api/materials', null, studentToken);
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null);
});
it('share: student is role-gated (403)', async () => {
const res = await inject('POST', `/api/materials/${noteId}/share`, { userId: studentId }, studentToken);
assert.equal(res.status, 403, JSON.stringify(res.body));
});
it('share: teacher → linked student copies the material; unlinked → 403', async () => {
const tNote = await inject('POST', '/api/materials', { kind: 'note', title: 'Раздатка', body: 'привет' }, teacherToken);
const tId = tNote.body.id;
// not linked yet
const denied = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken);
assert.equal(denied.status, 403, JSON.stringify(denied.body));
// link teacher → student, then share
db.prepare('INSERT INTO teacher_students (teacher_id, student_id) VALUES (?, ?)').run(teacherId, studentId);
const ok = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken);
assert.equal(ok.status, 200, JSON.stringify(ok.body));
assert.equal(ok.body.sent, 1);
const l = await inject('GET', '/api/materials', null, studentToken);
assert.ok(l.body.materials.some(m => m.title === 'Раздатка' && /Раздатка:/.test(m.source_title || '')), 'student received a copy');
});
it('enforces the per-user item cap (413)', async () => {
const q = await getToken('student');
const prev = process.env.MATERIALS_MAX_ITEMS;
process.env.MATERIALS_MAX_ITEMS = '3';
try {
for (let i = 0; i < 3; i++) {
const r = await inject('POST', '/api/materials', { kind: 'note', body: 'n' + i }, q.token);
assert.equal(r.status, 201, `create #${i}: ${JSON.stringify(r.body)}`);
}
const over = await inject('POST', '/api/materials', { kind: 'note', body: 'overflow' }, q.token);
assert.equal(over.status, 413, JSON.stringify(over.body));
} finally {
if (prev === undefined) delete process.env.MATERIALS_MAX_ITEMS; else process.env.MATERIALS_MAX_ITEMS = prev;
}
});
it('delete removes the row; owner only', async () => {
const m = await inject('POST', '/api/materials', { kind: 'note', body: 'temp' }, studentToken);
const id = m.body.id;
const forbidden = await inject('DELETE', `/api/materials/${id}`, null, otherToken);
assert.equal(forbidden.status, 403);
const ok = await inject('DELETE', `/api/materials/${id}`, null, studentToken);
assert.equal(ok.status, 200);
const l = await inject('GET', '/api/materials', null, studentToken);
assert.ok(!l.body.materials.some(x => x.id === id));
});
it('releaseFileForUrl: unlinks the file only when no material references it', () => {
const dir = path.join(__dirname, '..', 'uploads', 'materials');
fs.mkdirSync(dir, { recursive: true });
const fname = 'test_' + Date.now() + '.png';
const fpath = path.join(dir, fname);
const url = '/uploads/materials/' + fname;
fs.writeFileSync(fpath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const ins = db.prepare('INSERT INTO student_materials (user_id, kind, url) VALUES (?, ?, ?)');
const r1 = ins.run(studentId, 'image', url).lastInsertRowid;
const r2 = ins.run(studentId, 'image', url).lastInsertRowid; // aliasing copy (как при share)
ctrl.releaseFileForUrl(url);
assert.ok(fs.existsSync(fpath), 'file kept while two rows reference it');
db.prepare('DELETE FROM student_materials WHERE id = ?').run(r1);
ctrl.releaseFileForUrl(url);
assert.ok(fs.existsSync(fpath), 'file kept while one row still references it');
db.prepare('DELETE FROM student_materials WHERE id = ?').run(r2);
ctrl.releaseFileForUrl(url);
assert.ok(!fs.existsSync(fpath), 'file unlinked once orphaned');
});
it('safeUrl / measureBytes behave as documented', () => {
assert.equal(ctrl.safeUrl('https://a.b/c'), 'https://a.b/c');
assert.equal(ctrl.safeUrl('/textbook/x'), '/textbook/x');
assert.equal(ctrl.safeUrl(''), '');
assert.equal(ctrl.safeUrl('javascript:x'), undefined);
assert.equal(ctrl.safeUrl('//host'), undefined);
assert.equal(ctrl.measureBytes('note', null, 'abc'), 3);
assert.equal(ctrl.measureBytes('link', 'https://x', null), 0);
});
it('thumb_url: create stores it, list/getOne return it; bad scheme → 400', async () => {
const ok = await inject('POST', '/api/materials',
{ kind: 'image', url: '/uploads/materials/a.png', thumbUrl: '/uploads/materials/a_thumb.webp' }, studentToken);
assert.equal(ok.status, 201, JSON.stringify(ok.body));
const id = ok.body.id;
const l = await inject('GET', '/api/materials', null, studentToken);
assert.equal(l.body.materials.find(m => m.id === id).thumb_url, '/uploads/materials/a_thumb.webp');
const one = await inject('GET', `/api/materials/${id}`, null, studentToken);
assert.equal(one.body.thumb_url, '/uploads/materials/a_thumb.webp');
const bad = await inject('POST', '/api/materials',
{ kind: 'image', url: '/uploads/materials/b.png', thumbUrl: 'javascript:alert(1)' }, studentToken);
assert.equal(bad.status, 400, JSON.stringify(bad.body));
});
it('releaseFileForUrl ref-counts files referenced as a thumbnail (thumb_url column)', () => {
const dir = path.join(__dirname, '..', 'uploads', 'materials');
fs.mkdirSync(dir, { recursive: true });
const fname = 'th_' + Date.now() + '.webp';
const fpath = path.join(dir, fname);
const url = '/uploads/materials/' + fname;
fs.writeFileSync(fpath, Buffer.from([0x52, 0x49, 0x46, 0x46]));
const rid = db.prepare('INSERT INTO student_materials (user_id, kind, thumb_url) VALUES (?, ?, ?)').run(studentId, 'image', url).lastInsertRowid;
ctrl.releaseFileForUrl(url);
assert.ok(fs.existsSync(fpath), 'kept while a row references it as thumb_url');
db.prepare('DELETE FROM student_materials WHERE id = ?').run(rid);
ctrl.releaseFileForUrl(url);
assert.ok(!fs.existsSync(fpath), 'unlinked once orphaned');
});
it('DELETE removes the material\'s full image AND thumbnail files', async () => {
const dir = path.join(__dirname, '..', 'uploads', 'materials');
fs.mkdirSync(dir, { recursive: true });
const base = 'del_' + Date.now();
const fFull = path.join(dir, base + '.png'), fThumb = path.join(dir, base + '_thumb.webp');
fs.writeFileSync(fFull, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
fs.writeFileSync(fThumb, Buffer.from([0x52, 0x49, 0x46, 0x46]));
const c = await inject('POST', '/api/materials',
{ kind: 'image', url: '/uploads/materials/' + base + '.png', thumbUrl: '/uploads/materials/' + base + '_thumb.webp' }, studentToken);
assert.equal(c.status, 201);
const d = await inject('DELETE', `/api/materials/${c.body.id}`, null, studentToken);
assert.equal(d.status, 200);
assert.ok(!fs.existsSync(fFull), 'full image unlinked');
assert.ok(!fs.existsSync(fThumb), 'thumbnail unlinked');
});
});
+7 -7
View File
@@ -20,14 +20,14 @@
async function uploadBlob(blob, name) {
const fd = new FormData();
fd.append('file', blob, name);
return await LS.uploadMaterialFile(fd); // { url, thumbUrl }
const up = await LS.uploadMaterialFile(fd);
return up.url;
}
async function persist(meta, kind, url, thumbUrl) {
async function persist(meta, kind, url) {
await LS.saveMaterial({
kind: kind,
url: url,
thumbUrl: thumbUrl || null,
title: titleFor(meta, kind === 'image' ? ' · фрагмент' : ''),
sourceSessionId: meta && meta.sourceSessionId,
sourceTitle: meta && meta.sourceTitle,
@@ -40,8 +40,8 @@
wb.exportBlob(async function (blob) {
try {
if (!blob) throw new Error('Не удалось снять доску');
const up = await uploadBlob(blob, 'board.png');
await persist(meta, 'board', up.url, up.thumbUrl);
const url = await uploadBlob(blob, 'board.png');
await persist(meta, 'board', url);
LS.toast('Страница сохранена в «Мои материалы»', 'success');
} catch (e) {
LS.toast(e.message || 'Ошибка сохранения', 'error');
@@ -147,8 +147,8 @@
off.getContext('2d').drawImage(img, Math.round(rect.x * sx), Math.round(rect.y * sy), cw, ch, 0, 0, cw, ch);
const cblob = await new Promise(function (res) { off.toBlob(res, 'image/png'); });
if (!cblob) throw new Error('Не удалось обрезать область');
const up = await uploadBlob(cblob, 'board-region.png');
await persist(meta, 'image', up.url, up.thumbUrl);
const cropUrl = await uploadBlob(cblob, 'board-region.png');
await persist(meta, 'image', cropUrl);
close();
LS.toast('Фрагмент сохранён в «Мои материалы»', 'success');
} catch (e) {
+401
View File
@@ -0,0 +1,401 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · Реестр уровней (Фаза 2 — мир из 6 физ-уровней).
Уровень = СПЕКА SimForge (данные, не код) + блок `goal` (победа), который
движок (_sim_engine.js) умеет с Фазы 0. Игрок не управляет героем напрямую —
он «чинит закон мира»: крутит слайдеры params (угол/скорость/жёсткость…),
затем «Запуск», и симуляция проигрывается к цели.
── МЕТАДАННЫЕ УРОВНЯ (Фаза 2) ───────────────────────────────────────────
{
id, // == level_id для /api/game/progress
title, // отображаемое имя узла карты
chapter, // ключ главы-созвездия (группировка на карте)
order, // глобальный порядок (для «предыдущих» при разблокировке и «Дальше»)
unlockStars, // порог: сумма звёзд во ВСЕХ предыдущих уровнях, чтобы открыть (деф. 0)
par_ms?, // норматив времени для 3-й звезды (мс мирового времени)
subject?, // тема (физика)
hint?, // подсказка-нарратив для интро
spec // обычная спека SimForge с блоком goal
}
Звёзды: зв.1 — достичь цели; зв.2 — собрать бонус (кристалл); зв.3 — уложиться
в par_ms. par-звезда выражается напрямую через мировое время t: `t*1000 <= PAR`
(вычисляется в момент победы; идентификатор tries для неё не нужен).
ИСТОЧНИК УРОВНЕЙ: встроенные данные здесь (window.QuantikLevels). Авторённые
уровни (custom_sims cat='game') подмешаются в Фазе 5 (реестр станет асинхронным).
⛔ Без eval/Function. Все «числовые» поля могут быть числом ИЛИ строкой-
выражением (их безопасно вычисляет SimExpr на клиенте).
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var BG = '#0D0D1A';
var GROUND = '#334155';
var HERO = '#22D3EE'; // дефолтный цвет героя (тинтуется скином — см. quantik-game.js)
var PORTAL = '#A78BFA'; // фиолет — цель
var CRYSTAL = '#F472B6'; // розовый — бонус
/* helper: общий объект «герой-тело» (point с body) — стартует из (sx,sy). */
function hero(sx, sy, vxExpr, vyExpr) {
return {
id: 'ball', type: 'point', r: 7, color: HERO,
x: sx, y: sy,
glow: true, glowColor: HERO, trail: true, trailColor: HERO, trailFade: true,
body: { mass: 1, vx: vxExpr, vy: vyExpr }
};
}
/* helper: светящийся портал-кольцо (визуал цели). */
function portalObjs(px, py, r) {
return [
{ type: 'circle', x: px, y: py, r: r, color: PORTAL, width: 3, glow: true, glowColor: PORTAL },
{ type: 'circle', x: px, y: py, r: r * 0.45, color: PORTAL, width: 2, opacity: 0.7 },
{ type: 'label', x: px, y: py + r + 0.9, text: 'портал', color: PORTAL, size: 12 }
];
}
function crystalObjs(cx, cy, r) {
return [
{ type: 'circle', x: cx, y: cy, r: r, color: CRYSTAL, width: 2, glow: true, glowColor: CRYSTAL },
{ type: 'label', x: cx, y: cy + r + 0.8, text: 'кристалл', color: CRYSTAL, size: 11 }
];
}
/* ─────────────────────────────────────────────────────────────────────────
Глава I — «Кинематика» (созвездие): полёт под действием гравитации.
──────────────────────────────────────────────────────────────────────── */
/* Уровень 1: «Артиллерия Квантика» — базовый бросок под углом. */
var L1_PX = 8, L1_PY = 0.7, L1_PR = 0.75;
var L1_CX = 4, L1_CY = 2.7, L1_CR = 0.7;
var artillery1 = {
id: 'phys-artillery-1',
title: 'Артиллерия',
chapter: 'kinematics',
order: 1,
unlockStars: 0,
par_ms: 1500,
subject: 'physics',
hint: 'Подбери угол и скорость, чтобы Квантик долетел до портала. Собери кристалл по дороге — это вторая звезда. Быстрый бросок даст третью.',
spec: {
specVersion: 1,
meta: { title: 'Артиллерия Квантика', desc: 'Закон движения: бросок под углом к горизонту.' },
viewport: { xmin: -1, xmax: 12, ymin: -1.2, ymax: 7, grid: true, axes: true, bg: BG },
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: [
{ type: 'segment', x1: -1, y1: 0, x2: 12, y2: 0, color: GROUND, width: 2 }
].concat(crystalObjs(L1_CX, L1_CY, L1_CR), portalObjs(L1_PX, L1_PY, L1_PR), [
hero(0, 0, 'v*cos(theta*pi/180)', '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 - ' + L1_PX + ', ball.y - ' + L1_PY + ') < ' + L1_PR,
fail: 'ball.x > 11.5 || ball.y < -1.0',
stars: [
{ when: 'hypot(ball.x - ' + L1_CX + ', ball.y - ' + L1_CY + ') < ' + L1_CR, label: 'Собрал кристалл' },
{ when: 't*1000 <= 1500', label: 'Быстро (≤1.5 с)' }
]
}
}
};
/* Уровень 2: «Перелёт через стену» — между стартом и порталом стоит высокая
стена; нужно перебросить Квантика по дуге. Кристалл — на вершине дуги. */
var L2_PX = 9.5, L2_PY = 0.7, L2_PR = 0.8;
var L2_WALLX = 5, L2_WALLH = 3.6; // вертикальная стена-препятствие
var L2_CX = 5, L2_CY = 4.4, L2_CR = 0.7; // кристалл над стеной
var arc2 = {
id: 'phys-arc-2',
title: 'Перелёт через стену',
chapter: 'kinematics',
order: 2,
unlockStars: 1,
par_ms: 1800,
subject: 'physics',
hint: 'Стена преграждает прямой путь. Подбери крутую дугу — переброс Квантика через гребень в портал. Кристалл ждёт на вершине.',
spec: {
specVersion: 1,
meta: { title: 'Перелёт через стену', desc: 'Дальность и высота броска: перебрось препятствие.' },
viewport: { xmin: -1, xmax: 13, ymin: -1.2, ymax: 8, grid: true, axes: true, bg: BG },
params: [
{ name: 'theta', label: 'Угол', min: 20, max: 85, step: 1, value: 60, unit: '°' },
{ name: 'v', label: 'Скорость', min: 6, max: 22, step: 0.5, value: 12, unit: 'м/с' }
],
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
objects: [
{ type: 'segment', x1: -1, y1: 0, x2: 13, y2: 0, color: GROUND, width: 2 },
// стена-препятствие
{ type: 'segment', x1: L2_WALLX, y1: 0, x2: L2_WALLX, y2: L2_WALLH, color: '#475569', width: 6 },
{ type: 'label', x: L2_WALLX, y: L2_WALLH + 0.5, text: 'стена', color: '#94A3B8', size: 11 }
].concat(crystalObjs(L2_CX, L2_CY, L2_CR), portalObjs(L2_PX, L2_PY, L2_PR), [
hero(0, 0, 'v*cos(theta*pi/180)', '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 - ' + L2_PX + ', ball.y - ' + L2_PY + ') < ' + L2_PR,
// проигрыш: врезался в стену (рядом с ней и ниже её верха) либо улетел/упал за поле
fail: '(abs(ball.x - ' + L2_WALLX + ') < 0.22 && ball.y < ' + L2_WALLH + ') || ball.x > 12.5 || ball.y < -1.0',
stars: [
{ when: 'hypot(ball.x - ' + L2_CX + ', ball.y - ' + L2_CY + ') < ' + L2_CR, label: 'Собрал кристалл' },
{ when: 't*1000 <= 1800', label: 'Быстро (≤1.8 с)' }
]
}
}
};
/* Уровень 3: «Отскок» — рабочая зона как упругий ящик (пол + правая стена).
Прямого пути к высокому порталу слева-вверху нет: брось вправо, упругий
отскок от правой стены и пола приводит Квантика по ломаной к порталу.
Кристалл — у правой стены (на дуге до отскока). Тюнинг угла/скорости/упругости. */
var L3_PX = 1.6, L3_PY = 4.6, L3_PR = 0.95; // портал слева-вверху
var L3_CX = 8.4, L3_CY = 3.4, L3_CR = 0.85; // кристалл у правой стены
var bounce3 = {
id: 'phys-bounce-3',
title: 'Отскок',
chapter: 'kinematics',
order: 3,
unlockStars: 2,
par_ms: 2800,
subject: 'physics',
hint: 'Портал слева-вверху, прямого пути нет. Брось вправо — упругая стена отразит Квантика обратно. Поиграй упругостью: чем жёстче отскок, тем выше дуга назад.',
spec: {
specVersion: 1,
meta: { title: 'Отскок', desc: 'Упругое столкновение: отскок от стены.' },
viewport: { xmin: -1, xmax: 11, ymin: -1.0, ymax: 8, grid: true, axes: true, bg: BG },
params: [
{ name: 'theta', label: 'Угол', min: 20, max: 75, step: 1, value: 50, unit: '°' },
{ name: 'v', label: 'Скорость', min: 8, max: 24, step: 0.5, value: 16, unit: 'м/с' },
{ name: 'el', label: 'Упругость', min: 0.55, max: 0.95, step: 0.01, value: 0.8 }
],
physics: {
enabled: true,
gravity: { x: 0, y: -9.8 },
restitution: 'el',
walls: [{ side: 'bottom' }, { side: 'right' }]
},
objects: [
{ type: 'segment', x1: -1, y1: 0, x2: 11, y2: 0, color: GROUND, width: 3 },
{ type: 'segment', x1: 11, y1: 0, x2: 11, y2: 8, color: '#475569', width: 4 },
{ type: 'label', x: 10.2, y: 7.2, text: 'упр. стена', color: '#94A3B8', size: 11 }
].concat(crystalObjs(L3_CX, L3_CY, L3_CR), portalObjs(L3_PX, L3_PY, L3_PR), [
hero(0, 0.2, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
{ type: 'readout', label: 'упр', expr: 'el', precision: 2 },
{ type: 'readout', label: 'v', expr: 'v', unit: 'м/с', precision: 1 }
]),
goal: {
title: 'Отскоком в портал',
hint: 'Отрази Квантика от правой стены так, чтобы он вернулся в портал слева-вверху. Бонус: задень кристалл у стены.',
when: 'hypot(ball.x - ' + L3_PX + ', ball.y - ' + L3_PY + ') < ' + L3_PR,
fail: 't > 8',
stars: [
{ when: 'hypot(ball.x - ' + L3_CX + ', ball.y - ' + L3_CY + ') < ' + L3_CR, label: 'Собрал кристалл' },
{ when: 't*1000 <= 2800', label: 'Быстро (≤2.8 с)' }
]
}
}
};
/* ─────────────────────────────────────────────────────────────────────────
Глава II — «Динамика» (созвездие): силы, пружины, орбиты.
──────────────────────────────────────────────────────────────────────── */
/* Уровень 4: «Маятник» — Квантик подвешен на пружине к якорю сверху; даём ему
горизонтальный толчок. Пружина (закон Гука) тянет назад — он качается дугой.
Подбери начальную скорость и жёсткость, чтобы нижняя точка дуги прошла через
портал. Без гравитации вниз — чистая пружинная динамика к центру. */
var L4_ANCHOR_X = 4, L4_ANCHOR_Y = 7.4;
var L4_REST = 1.2; // короткая длина покоя -> пружина растянута -> сильный возврат
var L4_START_Y = 2.6; // тело висит ниже якоря (растяжение ~3.6)
var L4_PX = 7.4, L4_PY = 4.6, L4_PR = 1.0; // портал на правом плече дуги
var L4_CX = 4.0, L4_CY = 6.0, L4_CR = 0.9; // кристалл у верхней точки качания (ближе к якорю)
var pendulum4 = {
id: 'phys-pendulum-4',
title: 'Маятник',
chapter: 'dynamics',
order: 4,
unlockStars: 4,
par_ms: 3600,
subject: 'physics',
hint: 'Квантик висит на растянутой пружине у якоря. Толкни его вбок — закон Гука раскачает дугу вверх. Подбери толчок и жёсткость, чтобы плечо дуги достало портал.',
spec: {
specVersion: 1,
meta: { title: 'Маятник на пружине', desc: 'Закон Гука: гармонические колебания.' },
viewport: { xmin: -2, xmax: 11, ymin: -1, ymax: 9, grid: true, axes: true, bg: BG },
params: [
{ name: 'push', label: 'Толчок', min: 4, max: 18, step: 0.5, value: 10, unit: 'м/с' },
{ name: 'k', label: 'Жёсткость', min: 8, max: 50, step: 1, value: 24 }
],
physics: {
enabled: true,
gravity: { x: 0, y: 0 }, // чисто пружинная динамика (анализ закона Гука)
springs: [
{ a: [L4_ANCHOR_X, L4_ANCHOR_Y], b: 'ball', k: 'k', length: L4_REST }
]
},
objects: [
// якорь
{ type: 'circle', x: L4_ANCHOR_X, y: L4_ANCHOR_Y, r: 0.18, color: '#94A3B8', width: 0, fill: '#94A3B8' },
{ type: 'label', x: L4_ANCHOR_X, y: L4_ANCHOR_Y + 0.6, text: 'якорь', color: '#94A3B8', size: 11 },
// линия-пружина (визуальная связь якорь→тело)
{ type: 'segment', x1: L4_ANCHOR_X, y1: L4_ANCHOR_Y, x2: 'ball.x', y2: 'ball.y', color: '#475569', width: 1, lineStyle: 'dashed' }
].concat(crystalObjs(L4_CX, L4_CY, L4_CR), portalObjs(L4_PX, L4_PY, L4_PR), [
// тело висит ниже якоря, горизонтальный толчок вправо
hero(L4_ANCHOR_X, L4_START_Y, 'push', '0'),
{ type: 'readout', label: 'толчок', expr: 'push', unit: 'м/с', precision: 1 },
{ type: 'readout', label: 'k', expr: 'k', precision: 0 }
]),
goal: {
title: 'Качни в портал',
hint: 'Раскачай Квантика на пружине так, чтобы дуга прошла через портал. Бонус: задень кристалл у верхней точки.',
when: 'hypot(ball.x - ' + L4_PX + ', ball.y - ' + L4_PY + ') < ' + L4_PR,
fail: 't > 12',
stars: [
{ when: 'hypot(ball.x - ' + L4_CX + ', ball.y - ' + L4_CY + ') < ' + L4_CR, label: 'Собрал кристалл' },
{ when: 't*1000 <= 3600', label: 'Быстро (≤3.6 с)' }
]
}
}
};
/* Уровень 5: «Орбита» — центральная пружина-«гравитационный колодец» к центру
(закон Гука к центру == гармонический осциллятор == эллиптические орбиты).
Даём тангенциальную скорость; подбери её и силу колодца, чтобы орбита прошла
через портал-кольцо на её пути. */
var L5_CENTER_X = 4, L5_CENTER_Y = 3;
var L5_START_X = 4, L5_START_Y = 6; // старт над центром (радиус 3)
var L5_PX = 6.6, L5_PY = 3, L5_PR = 0.95; // портал на правом плече орбиты (внутри замкнутого витка)
var L5_CX = 4, L5_CY = 0.1, L5_CR = 0.9; // кристалл в нижней точке орбиты
var orbit5 = {
id: 'phys-orbit-5',
title: 'Орбита',
chapter: 'dynamics',
order: 5,
unlockStars: 6,
par_ms: 4200,
subject: 'physics',
hint: 'Колодец притягивает Квантика к центру (закон Гука). Дай ему боковой разгон — он выйдет на орбиту. Подбери скорость и силу колодца, чтобы виток прошёл сквозь портал.',
spec: {
specVersion: 1,
meta: { title: 'Орбита', desc: 'Центральная сила: замкнутая орбита через цель.' },
viewport: { xmin: -2, xmax: 11, ymin: -3, ymax: 9, grid: true, axes: true, bg: BG },
params: [
{ name: 'vt', label: 'Боковой разгон', min: 2, max: 14, step: 0.25, value: 6, unit: 'м/с' },
{ name: 'g', label: 'Сила колодца', min: 4, max: 30, step: 0.5, value: 12 }
],
physics: {
enabled: true,
gravity: { x: 0, y: 0 },
// пружина к центру с нулевой длиной покоя == центральная гармоническая сила F=-k·r
springs: [
{ a: [L5_CENTER_X, L5_CENTER_Y], b: 'ball', k: 'g', length: 0 }
]
},
objects: [
// центр-колодец
{ type: 'circle', x: L5_CENTER_X, y: L5_CENTER_Y, r: 0.3, color: '#F59E0B', width: 0, fill: '#F59E0B', glow: true, glowColor: '#F59E0B' },
{ type: 'label', x: L5_CENTER_X, y: L5_CENTER_Y - 0.9, text: 'колодец', color: '#F59E0B', size: 11 }
].concat(crystalObjs(L5_CX, L5_CY, L5_CR), portalObjs(L5_PX, L5_PY, L5_PR), [
// старт над центром, скорость вправо (vt) -> орбита по часовой
hero(L5_START_X, L5_START_Y, 'vt', '0'),
{ type: 'readout', label: 'разгон', expr: 'vt', unit: 'м/с', precision: 2 },
{ type: 'readout', label: 'сила', expr: 'g', precision: 1 }
]),
goal: {
title: 'Выйди на орбиту через портал',
hint: 'Орбита Квантика должна пройти через портал-кольцо. Бонус: задень кристалл в дальней точке.',
when: 'hypot(ball.x - ' + L5_PX + ', ball.y - ' + L5_PY + ') < ' + L5_PR,
fail: 't > 14',
stars: [
{ when: 'hypot(ball.x - ' + L5_CX + ', ball.y - ' + L5_CY + ') < ' + L5_CR, label: 'Собрал кристалл' },
{ when: 't*1000 <= 4200', label: 'Быстро (≤4.2 с)' }
]
}
}
};
/* Уровень 6: «Гравитационный манёвр» — капстоун главы. Гравитация тянет вниз,
но в центре поля — притягивающий колодец (пружина к центру), искривляющий путь.
Брось Квантика так, чтобы колодец завернул его дугу в портал в дальнем верхнем
углу. Комбинируем бросок под углом, гравитацию и центральную силу. */
var L6_WELL_X = 5, L6_WELL_Y = 3;
var L6_PX = 9.4, L6_PY = 5.6, L6_PR = 1.0; // портал в дальнем верхнем углу
var L6_CX = 5, L6_CY = 4.3, L6_CR = 0.85; // кристалл над колодцем (на восходящей дуге)
var slingshot6 = {
id: 'phys-slingshot-6',
title: 'Гравиманёвр',
chapter: 'dynamics',
order: 6,
unlockStars: 8,
par_ms: 3400,
subject: 'physics',
hint: 'Гравитация тянет вниз, а колодец в центре притягивает к себе. Брось Квантика так, чтобы колодец завернул его дугу в портал в дальнем верхнем углу. Подбери угол, скорость и силу колодца.',
spec: {
specVersion: 1,
meta: { title: 'Гравитационный манёвр', desc: 'Гравитация + центральная сила: манёвр у колодца.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8, grid: true, axes: true, bg: BG },
params: [
{ name: 'theta', label: 'Угол', min: 20, max: 80, step: 1, value: 55, unit: '°' },
{ name: 'v', label: 'Скорость', min: 8, max: 22, step: 0.5, value: 14, unit: 'м/с' },
{ name: 'g', label: 'Сила колодца', min: 0, max: 16, step: 0.5, value: 6 }
],
physics: {
enabled: true,
gravity: { x: 0, y: -9.8 },
springs: [
{ a: [L6_WELL_X, L6_WELL_Y], b: 'ball', k: 'g', length: 0 }
]
},
objects: [
{ type: 'segment', x1: -1, y1: 0, x2: 11, y2: 0, color: GROUND, width: 3 },
// колодец-масса
{ type: 'circle', x: L6_WELL_X, y: L6_WELL_Y, r: 0.3, color: '#F59E0B', width: 0, fill: '#F59E0B', glow: true, glowColor: '#F59E0B' },
{ type: 'label', x: L6_WELL_X, y: L6_WELL_Y - 0.9, text: 'колодец', color: '#F59E0B', size: 11 }
].concat(crystalObjs(L6_CX, L6_CY, L6_CR), portalObjs(L6_PX, L6_PY, L6_PR), [
hero(0, 0.2, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
{ type: 'readout', label: 'сила', expr: 'g', precision: 1 },
{ type: 'readout', label: 'θ', expr: 'theta', unit: '°', precision: 0 }
]),
goal: {
title: 'Манёвром в портал',
hint: 'Используй колодец, чтобы завернуть дугу Квантика в дальний верхний портал. Бонус: задень кристалл на восходящей дуге.',
when: 'hypot(ball.x - ' + L6_PX + ', ball.y - ' + L6_PY + ') < ' + L6_PR,
fail: 'ball.y < -1 || t > 10',
stars: [
{ when: 'hypot(ball.x - ' + L6_CX + ', ball.y - ' + L6_CY + ') < ' + L6_CR, label: 'Собрал кристалл' },
{ when: 't*1000 <= 3400', label: 'Быстро (≤3.4 с)' }
]
}
}
};
var LEVELS = [artillery1, arc2, bounce3, pendulum4, orbit5, slingshot6];
/* Метаданные глав (созвездий) — для заголовков/оформления карты. */
var CHAPTERS = {
kinematics: { key: 'kinematics', title: 'Кинематика', subtitle: 'Полёт и гравитация', accent: '#22D3EE' },
dynamics: { key: 'dynamics', title: 'Динамика', subtitle: 'Силы, пружины, орбиты', accent: '#A78BFA' }
};
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;
}
function chapter(key) { return CHAPTERS[key] || { key: key, title: key, subtitle: '', accent: '#22D3EE' }; }
global.QuantikLevels = {
list: list, get: get, chapter: chapter,
LEVELS: LEVELS, CHAPTERS: CHAPTERS
};
})(typeof window !== 'undefined' ? window : this);
+385
View File
@@ -0,0 +1,385 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · Карта-созвездие (Фаза 2).
Рисует мир как звёздную карту: каждая глава (chapter) — отдельное созвездие,
уровни — узлы-«звёзды», соединённые линиями по порядку. Узел показывает статус
(заблокирован / доступен / пройден + число звёзд). По клику на доступный узел —
колбэк onPlay(level).
Зависит от:
window.QuantikLevels — реестр уровней (Ф1/Ф2)
window.QuantikProgress — чистая логика прогресса/разблокировки/XP (Ф2)
window.PetSprite — нарратор-Квантик (SVG)
window.QuantikMap.create({ host, headerHost, onPlay, getSkin, onSkin }) -> {
render(progressMap), // перерисовать карту + шапку под новый прогресс
destroy()
}
⛔ Без эмодзи — звёзды/замки/иконки только inline SVG. Без eval/Function.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var doc = global.document;
var NS = 'http://www.w3.org/2000/svg';
function el(tag, cls, html) {
var n = doc.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function svgEl(tag, attrs) {
var n = doc.createElementNS(NS, tag);
if (attrs) for (var k in attrs) if (attrs.hasOwnProperty(k)) n.setAttribute(k, attrs[k]);
return n;
}
/* ── inline SVG иконки (без эмодзи) ── */
function starPath() { return 'M12 2 15.1 8.6 22 9.3 17 14.1 18.2 21 12 17.6 5.8 21 7 14.1 2 9.3 8.9 8.6 Z'; }
function starSvg(filled, size) {
var s = size || 16;
var fill = filled ? '#FBBF24' : 'none';
var stroke = filled ? '#FBBF24' : 'rgba(148,163,184,0.55)';
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="' + fill +
'" stroke="' + stroke + '" stroke-width="1.5" stroke-linejoin="round"><path d="' + starPath() + '"/></svg>';
}
function lockSvg(size) {
var s = size || 18;
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" ' +
'stroke="rgba(226,232,240,0.85)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>';
}
function playSvg(size) {
var s = size || 18;
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="currentColor" ' +
'stroke="none"><path d="M8 5.5 19 12 8 18.5 Z"/></svg>';
}
function checkSvg(size) {
var s = size || 18;
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" ' +
'stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M4 12.5 10 18.5 20 6"/></svg>';
}
/* ── Раскладка узлов созвездия ──────────────────────────────────────────
Для каждой главы раскладываем её уровни по «созвездию»: лёгкая зигзаг-дуга
внутри своего вертикального пояса. Координаты в % ширины ленты главы. */
function layoutNodes(levels) {
var n = levels.length;
var pts = [];
for (var i = 0; i < n; i++) {
// x идёт слева-направо, y — мягкий зигзаг (созвездие, не прямая)
var x = n === 1 ? 50 : (12 + (76 * i / (n - 1)));
var y = 50 + (i % 2 === 0 ? -16 : 16) + (i % 3 === 0 ? 6 : -4);
pts.push({ x: x, y: y });
}
return pts;
}
/* ── Звёздное небо (статичные точки на canvas-фоне через SVG) ──────────── */
function buildStarfield(seedCount) {
var g = svgEl('g', { class: 'qm-stars' });
var rnd = mulberry32(0x51ec7 + seedCount);
for (var i = 0; i < seedCount; i++) {
var cx = rnd() * 100, cy = rnd() * 100;
var r = 0.08 + rnd() * 0.22;
var op = 0.25 + rnd() * 0.55;
var c = svgEl('circle', { cx: cx, cy: cy, r: r, fill: '#E2E8F0', opacity: op.toFixed(2) });
c.style.setProperty('--tw', (1.6 + rnd() * 3).toFixed(2) + 's');
c.style.setProperty('--td', (rnd() * 3).toFixed(2) + 's');
c.classList.add('qm-tw');
g.appendChild(c);
}
return g;
}
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/* ════════════════════════ Создание карты ════════════════════════ */
function create(opts) {
opts = opts || {};
var host = opts.host;
var headerHost = opts.headerHost;
var onPlay = typeof opts.onPlay === 'function' ? opts.onPlay : function () {};
var getSkin = typeof opts.getSkin === 'function' ? opts.getSkin : function () { return 'cyan'; };
var onSkin = typeof opts.onSkin === 'function' ? opts.onSkin : function () {};
if (!host) return null;
var Levels = global.QuantikLevels;
var Prog = global.QuantikProgress;
if (!Levels || !Prog) return null;
var revealTimer = null;
function clearReveal() { if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; } }
/* ── Шапка: нарратор + XP-бар + всего звёзд + скины ── */
function renderHeader(progressMap) {
if (!headerHost) return;
headerHost.innerHTML = '';
var levels = Levels.list();
var xp = Prog.computeXp(levels, progressMap);
var pl = Prog.playerLevel(xp);
var tStars = Prog.totalStars(levels, progressMap);
var maxStars = levels.reduce(function (s, L) { return s + (L.spec && L.spec.goal && L.spec.goal.stars ? L.spec.goal.stars.length : 0); }, 0);
var wrap = el('div', 'qm-header-inner');
// Нарратор-Квантик (mood по уровню игрока)
var mood = pl.level >= 5 ? 'ecstatic' : (pl.level >= 2 ? 'happy' : 'neutral');
var narr = el('div', 'qm-narrator');
if (global.PetSprite) {
var petLvl = Math.min(8, Math.max(1, pl.level));
narr.innerHTML = '<div class="qm-pet">' + global.PetSprite.render(petLvl, mood, [], getSkin(), 0, 'none') + '</div>';
}
var bubble = el('div', 'qm-bubble');
bubble.appendChild(el('div', 'qm-bubble-t', narrLine(pl, tStars, maxStars)));
narr.appendChild(bubble);
wrap.appendChild(narr);
// XP / уровень игрока
var stats = el('div', 'qm-stats');
var lvlBox = el('div', 'qm-level');
lvlBox.innerHTML = '<span class="qm-level-num">' + pl.level + '</span><span class="qm-level-lbl">уровень Квантика</span>';
stats.appendChild(lvlBox);
var xpBox = el('div', 'qm-xpbox');
var xpHead = el('div', 'qm-xp-head');
xpHead.innerHTML = '<span>' + xp + ' XP</span><span class="qm-xp-next">' +
(pl.xpForNext > 0 ? ('до ур. ' + (pl.level + 1) + ': ' + Math.max(0, pl.xpForNext - pl.xpInto) + ' XP') : 'максимум') + '</span>';
xpBox.appendChild(xpHead);
var bar = el('div', 'qm-xp-bar');
var fill = el('div', 'qm-xp-fill');
fill.style.width = '0%';
bar.appendChild(fill);
xpBox.appendChild(bar);
stats.appendChild(xpBox);
// всего звёзд
var starBox = el('div', 'qm-starcount');
starBox.innerHTML = starSvg(true, 18) + '<span>' + tStars + ' / ' + maxStars + '</span>';
stats.appendChild(starBox);
wrap.appendChild(stats);
// Скины
wrap.appendChild(buildSkinPicker(xp, tStars));
headerHost.appendChild(wrap);
// анимация XP-бара (после вставки в DOM)
requestAnimationFrame(function () {
fill.style.width = (pl.progress01 * 100).toFixed(1) + '%';
});
}
function narrLine(pl, tStars, maxStars) {
if (tStars === 0) return 'Привет! Я — Квантик. Помоги мне починить законы мира — выбери уровень и подкрути формулы.';
if (tStars >= maxStars) return 'Все звёзды собраны! Ты настоящий мастер законов мира.';
if (pl.level >= 5) return 'Невероятно! Уровень ' + pl.level + '. Осталось всего ' + (maxStars - tStars) + ' звёзд.';
if (pl.level >= 3) return 'Отлично идём — уровень ' + pl.level + '. Звёзды открывают новые созвездия.';
return 'Уже ' + tStars + ' звёзд! Собирай больше, чтобы открыть новые уровни.';
}
/* Палитра скинов: первые открыты, остальные — за XP/звёзды. */
var SKIN_GATES = [
{ key: 'cyan', name: 'Циан', need: 0 },
{ key: 'purple', name: 'Аметист', need: 0 },
{ key: 'green', name: 'Изумруд', needStars: 2 },
{ key: 'pink', name: 'Магента', needStars: 4 },
{ key: 'gold', name: 'Золото', needStars: 7 },
{ key: 'blue', name: 'Сапфир', needXp: 600 },
{ key: 'orange', name: 'Янтарь', needXp: 1000 },
{ key: 'indigo', name: 'Индиго', needStars: 11 }
];
function skinUnlocked(g, xp, stars) {
if (g.needStars && stars < g.needStars) return false;
if (g.needXp && xp < g.needXp) return false;
if (g.need && stars < g.need) return false;
return true;
}
function buildSkinPicker(xp, stars) {
var box = el('div', 'qm-skins');
box.appendChild(el('div', 'qm-skins-lbl', 'Скин'));
var row = el('div', 'qm-skins-row');
var cur = getSkin();
var pal = (global.PetSprite && global.PetSprite.PALETTES) || {};
SKIN_GATES.forEach(function (g) {
var unlocked = skinUnlocked(g, xp, stars);
var sw = el('button', 'qm-skin' + (cur === g.key ? ' active' : '') + (unlocked ? '' : ' locked'));
sw.type = 'button';
sw.style.setProperty('--sk', pal[g.key] || '#06D6E0');
sw.title = unlocked ? g.name : (g.name + ' — ' + skinReq(g));
sw.setAttribute('aria-label', g.name + (unlocked ? '' : ' (заблокирован)'));
if (!unlocked) sw.innerHTML = '<span class="qm-skin-lock">' + lockSvg(12) + '</span>';
if (unlocked) {
sw.addEventListener('click', function () { onSkin(g.key); });
} else {
sw.disabled = true;
}
row.appendChild(sw);
});
box.appendChild(row);
return box;
}
function skinReq(g) {
if (g.needStars) return 'нужно ' + g.needStars + ' звёзд';
if (g.needXp) return 'нужно ' + g.needXp + ' XP';
return 'заблокирован';
}
/* ── Тело карты: созвездия по главам ── */
function renderMap(progressMap) {
clearReveal();
host.innerHTML = '';
var groups = Prog.groupByChapter(Levels.list());
var allLevels = Levels.list();
var revealOrder = []; // узлы для поэтапного появления
groups.forEach(function (grp, gi) {
var meta = Levels.chapter(grp.chapter);
var section = el('section', 'qm-constellation');
section.style.setProperty('--accent', meta.accent || '#22D3EE');
// заголовок главы
var head = el('div', 'qm-con-head');
head.innerHTML = '<span class="qm-con-title">' + escapeHtml(meta.title) + '</span>' +
'<span class="qm-con-sub">' + escapeHtml(meta.subtitle || '') + '</span>';
// прогресс главы
var cStars = 0, cMax = 0;
grp.levels.forEach(function (L) {
cStars += Prog.starsFor(L.id, progressMap);
cMax += (L.spec && L.spec.goal && L.spec.goal.stars) ? L.spec.goal.stars.length : 0;
});
var cbadge = el('span', 'qm-con-stars', starSvg(true, 14) + ' ' + cStars + '/' + cMax);
head.appendChild(cbadge);
section.appendChild(head);
// поле созвездия
var field = el('div', 'qm-field');
var pts = layoutNodes(grp.levels);
// SVG-слой: звёздное небо + линии-связи
var svg = svgEl('svg', { class: 'qm-svg', viewBox: '0 0 100 100', preserveAspectRatio: 'none' });
svg.appendChild(buildStarfield(46 + gi * 7));
// линии между последовательными узлами
for (var li = 0; li < pts.length - 1; li++) {
var a = pts[li], b = pts[li + 1];
var nextUnlocked = Prog.isUnlocked(grp.levels[li + 1], progressMap, allLevels);
var line = svgEl('line', {
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
class: 'qm-link' + (nextUnlocked ? ' on' : '')
});
svg.appendChild(line);
}
field.appendChild(svg);
// узлы-уровни
grp.levels.forEach(function (L, idx) {
var status = Prog.nodeStatus(L, progressMap, allLevels);
var node = buildNode(L, status, progressMap, allLevels, pts[idx]);
field.appendChild(node);
revealOrder.push(node);
});
section.appendChild(field);
host.appendChild(section);
});
// поэтапное появление узлов
staggerReveal(revealOrder);
}
function buildNode(level, status, progressMap, allLevels, pt) {
var stars = Prog.starsFor(level.id, progressMap);
var total = (level.spec && level.spec.goal && level.spec.goal.stars) ? level.spec.goal.stars.length : 0;
var node = el('button', 'qm-node qm-' + status);
node.type = 'button';
node.style.left = pt.x + '%';
node.style.top = pt.y + '%';
node.setAttribute('data-level', level.id);
// ядро узла
var core = el('span', 'qm-node-core');
var icon = status === 'locked' ? lockSvg(20)
: (status === 'completed' ? '<span class="qm-node-order">' + level.order + '</span>' : playSvg(18));
core.innerHTML = icon;
node.appendChild(core);
// подпись
var label = el('span', 'qm-node-label', escapeHtml(level.title));
node.appendChild(label);
// звёзды узла (для пройденных) или порог (для заблокированных)
if (status === 'completed' && total > 0) {
var sb = el('span', 'qm-node-stars');
var html = '';
for (var i = 0; i < total; i++) html += starSvg(i < stars, 13);
sb.innerHTML = html;
node.appendChild(sb);
} else if (status === 'locked') {
var need = Prog.starsToUnlock(level, progressMap, allLevels);
var hint = el('span', 'qm-node-need', starSvg(true, 11) + ' ещё ' + need);
node.appendChild(hint);
}
if (status === 'locked') {
node.disabled = true;
node.setAttribute('aria-disabled', 'true');
node.title = 'Заблокировано — собери больше звёзд в предыдущих уровнях';
} else {
node.title = level.title + (status === 'completed' ? ' — пройдено' : ' — играть');
node.addEventListener('click', function () { onPlay(level); });
}
node.setAttribute('aria-label', level.title + ' (' +
(status === 'locked' ? 'заблокировано' : status === 'completed' ? ('пройдено, ' + stars + ' из ' + total + ' звёзд') : 'доступно') + ')');
return node;
}
function staggerReveal(nodes) {
nodes.forEach(function (n) { n.classList.add('qm-pre'); });
var i = 0;
function step() {
if (i >= nodes.length) { revealTimer = null; return; }
nodes[i].classList.remove('qm-pre');
nodes[i].classList.add('qm-in');
i++;
revealTimer = setTimeout(step, 70);
}
revealTimer = setTimeout(step, 120);
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function render(progressMap) {
progressMap = progressMap || {};
renderHeader(progressMap);
renderMap(progressMap);
}
function destroy() {
clearReveal();
if (host) host.innerHTML = '';
if (headerHost) headerHost.innerHTML = '';
}
return { render: render, destroy: destroy };
}
global.QuantikMap = { create: create };
})(typeof window !== 'undefined' ? window : this);
+195
View File
@@ -0,0 +1,195 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · ЧИСТАЯ логика прогресса (Фаза 2).
Никакого DOM/сети/движка — только функции над данными. Это делает их
тривиально тестируемыми (headless vm) и переносимыми на сервер позже.
ВХОД везде:
levels — массив записей уровней (форма QuantikLevels): { id, chapter,
order, par_ms?, unlockStars?, ... }.
progressMap — объект { [level_id]: { best_stars, best_time_ms, attempts } },
агрегируется из LS.gameProgressList() (см. fromProgressList).
⛔ Без eval/Function. Без побочных эффектов.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
/* Превратить ответ /api/game/progress ([{level_id, best_stars, ...}]) в карту. */
function fromProgressList(list) {
var map = {};
if (!Array.isArray(list)) return map;
for (var i = 0; i < list.length; i++) {
var row = list[i];
if (row && row.level_id != null) map[row.level_id] = row;
}
return map;
}
/* Лучшее число звёзд по уровню (0, если не пройден). */
function starsFor(levelId, progressMap) {
var p = progressMap && progressMap[levelId];
var s = p ? p.best_stars : 0;
return (typeof s === 'number' && s > 0) ? s : 0;
}
/* Пройден ли уровень (есть хотя бы одна звезда == достигнута цель). */
function isCompleted(levelId, progressMap) {
return starsFor(levelId, progressMap) > 0;
}
/* Сумма лучших звёзд по всем уровням. */
function totalStars(levels, progressMap) {
var sum = 0;
for (var i = 0; i < levels.length; i++) sum += starsFor(levels[i].id, progressMap);
return sum;
}
/* ── Разблокировка ────────────────────────────────────────────────────────
Уровень открыт, если СУММА звёзд во ВСЕХ предыдущих уровнях той же главы
(по полю order) ≥ level.unlockStars. Первый уровень главы (минимальный order
или unlockStars==0) открыт всегда. Глава открывается, если открыт её первый
уровень — он гейтится суммой звёзд предыдущих глав через unlockStars==0
первого уровня (по умолчанию) ИЛИ явным порогом.
Чистая функция: вход — уровень + карта прогресса + ВЕСЬ список (для подсчёта
«предыдущих» по order). Возвращает bool. */
function isUnlocked(level, progressMap, levels) {
if (!level) return false;
var need = (typeof level.unlockStars === 'number') ? level.unlockStars : 0;
if (need <= 0) return true; // нет порога — всегда доступен
// звёзды, набранные во всех уровнях с меньшим глобальным order
var have = 0;
for (var i = 0; i < levels.length; i++) {
var L = levels[i];
if (L.id === level.id) continue;
if ((L.order || 0) < (level.order || 0)) {
have += starsFor(L.id, progressMap);
}
}
return have >= need;
}
/* Статус узла для карты: 'completed' | 'available' | 'locked'. */
function nodeStatus(level, progressMap, levels) {
if (isCompleted(level.id, progressMap)) return 'completed';
if (isUnlocked(level, progressMap, levels)) return 'available';
return 'locked';
}
/* ── XP ────────────────────────────────────────────────────────────────────
XP = сумма (звёзды × STAR_XP) + бонус за каждый пройденный уровень
(COMPLETE_XP) + бонус за «par» (3-я звезда == уложился в норматив времени,
она и так считается звездой; дополнительный PAR_BONUS за первое прохождение
уровня в принципе). Детерминированная функция от карты прогресса. */
var STAR_XP = 100; // за каждую звезду
var COMPLETE_XP = 40; // за факт прохождения уровня (≥1 звезда)
function computeXp(levels, progressMap) {
var xp = 0;
for (var i = 0; i < levels.length; i++) {
var id = levels[i].id;
var s = starsFor(id, progressMap);
if (s > 0) {
xp += s * STAR_XP + COMPLETE_XP;
}
}
return xp;
}
/* ── Уровень игрока ──────────────────────────────────────────────────────
Порог уровня растёт линейно-нарастающе: уровень N требует кумулятивно
XP_PER_LEVEL_BASE·N·(N+1)/2 … упрощаем до квадратичной обратной формулы.
playerLevel(xp) -> { level, xpInto, xpForNext, progress01, totalForLevel }.
level начинается с 1. */
var XP_STEP = 240; // базовый шаг XP (level n требует n*XP_STEP суммарно для перехода)
// Кумулятивный XP, нужный чтобы ДОСТИЧЬ уровня L (L>=1). level 1 = 0 XP.
function xpForLevel(L) {
if (L <= 1) return 0;
// сумма k=1..L-1 of k*XP_STEP = XP_STEP * (L-1)*L/2
return XP_STEP * (L - 1) * L / 2;
}
function playerLevel(xp) {
if (!(xp > 0)) xp = 0;
var L = 1;
// найти максимальный L, чей порог <= xp
while (xpForLevel(L + 1) <= xp) L++;
var base = xpForLevel(L);
var next = xpForLevel(L + 1);
var span = next - base;
var into = xp - base;
return {
level: L,
xp: xp,
xpInto: into,
xpForNext: span,
totalForNext: next,
progress01: span > 0 ? Math.min(1, into / span) : 1
};
}
/* ── Группировка по главам ────────────────────────────────────────────────
Возвращает массив { chapter, levels:[...] } в порядке появления глав;
уровни внутри главы сортируются по order. */
function groupByChapter(levels) {
var order = [];
var byKey = {};
for (var i = 0; i < levels.length; i++) {
var L = levels[i];
var key = L.chapter || 'misc';
if (!byKey[key]) { byKey[key] = { chapter: key, levels: [] }; order.push(key); }
byKey[key].levels.push(L);
}
return order.map(function (k) {
var g = byKey[k];
g.levels = g.levels.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
return g;
});
}
/* Следующий разблокированный непройденный уровень после данного (по глоб. order),
или null. Используется кнопкой «Дальше». */
function nextPlayable(currentId, levels, progressMap) {
var sorted = levels.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
var idx = -1;
for (var i = 0; i < sorted.length; i++) if (sorted[i].id === currentId) { idx = i; break; }
// сначала ищем следующий по порядку доступный (предпочтительно непройденный)
for (var j = idx + 1; j < sorted.length; j++) {
if (isUnlocked(sorted[j], progressMap, levels)) return sorted[j];
}
return null;
}
/* Сколько ещё звёзд нужно, чтобы открыть уровень (для подсказки на замке). */
function starsToUnlock(level, progressMap, levels) {
var need = (typeof level.unlockStars === 'number') ? level.unlockStars : 0;
if (need <= 0) return 0;
var have = 0;
for (var i = 0; i < levels.length; i++) {
var L = levels[i];
if (L.id === level.id) continue;
if ((L.order || 0) < (level.order || 0)) have += starsFor(L.id, progressMap);
}
return Math.max(0, need - have);
}
global.QuantikProgress = {
fromProgressList: fromProgressList,
starsFor: starsFor,
isCompleted: isCompleted,
totalStars: totalStars,
isUnlocked: isUnlocked,
nodeStatus: nodeStatus,
computeXp: computeXp,
playerLevel: playerLevel,
xpForLevel: xpForLevel,
groupByChapter: groupByChapter,
nextPlayable: nextPlayable,
starsToUnlock: starsToUnlock,
// константы (для отображения/тестов)
STAR_XP: STAR_XP, COMPLETE_XP: COMPLETE_XP, XP_STEP: XP_STEP
};
})(typeof window !== 'undefined' ? window : this);
+259
View File
@@ -0,0 +1,259 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · логика игрового уровня (Фаза 2).
Монтирует уровень-спеку через SimEngine.mount (тот же движок, что lab.html
и sim-builder.html). «Игровой режим» включается САМ наличием блока goal в
спеке (Фаза 0). На победу (inst.onGoal) шлём результат на сервер и показываем
экран успеха с нарратором-Квантиком; реакция нарратора зависит от числа звёзд.
Фаза 2:
- Скин Квантика (colorKey из палитр PetSprite, localStorage 'quantik-skin')
тинтует glow-точку героя в уровне и нарратора.
- Экран успеха активирует «Дальше» (переход к следующему уровню) через колбэк.
- Интро-карточка с нарратором перед стартом уровня.
window.QuantikGame.start({ host, level, skin?, onNext?, onMap?, hasNext?, resolveNext? }) -> инстанс.
⛔ Без eval/Function. Уровни — данные из window.QuantikLevels.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var doc = global.document;
var SKIN_KEY = 'quantik-skin';
var DEFAULT_SKIN = 'cyan';
function el(tag, cls, html) {
var n = doc.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
/* ── Скин ──────────────────────────────────────────────────────────────── */
function getSkin() {
try {
var v = global.localStorage && global.localStorage.getItem(SKIN_KEY);
if (v && global.PetSprite && global.PetSprite.PALETTES && global.PetSprite.PALETTES[v]) return v;
} catch (_e) {}
return DEFAULT_SKIN;
}
function setSkin(key) {
try { if (global.localStorage) global.localStorage.setItem(SKIN_KEY, key); } catch (_e) {}
}
function skinColor(key) {
var pal = (global.PetSprite && global.PetSprite.PALETTES) || {};
return pal[key || getSkin()] || '#06D6E0';
}
/* Тинтуем героя уровня (объект с id 'ball') цветом скина — БЕЗ исполнения,
просто переписываем цветовые поля спеки-копии перед монтированием. */
function tintHeroSpec(spec, skinKey) {
var color = skinColor(skinKey);
// глубокая копия (спека — данные, без функций) чтобы не мутировать реестр
var copy = JSON.parse(JSON.stringify(spec));
if (Array.isArray(copy.objects)) {
for (var i = 0; i < copy.objects.length; i++) {
var o = copy.objects[i];
if (o && o.id === 'ball') {
o.color = color;
if (o.glow) o.glowColor = color;
if (o.trail) o.trailColor = color;
}
}
}
return copy;
}
/* ── Inline SVG звезды ── */
function starSvg(filled) {
var fill = filled ? '#FBBF24' : 'none';
var stroke = filled ? '#FBBF24' : '#64748B';
return '<svg class="ic qg-star-svg" viewBox="0 0 24 24" width="34" height="34" fill="' + fill +
'" stroke="' + stroke + '" stroke-width="1.6" stroke-linejoin="round">' +
'<polygon points="12 2 15.1 8.6 22 9.3 17 14.1 18.2 21 12 17.6 5.8 21 7 14.1 2 9.3 8.9 8.6"/></svg>';
}
function fmtTime(ms) {
if (!ms && ms !== 0) return '—';
return (ms / 1000).toFixed(2) + ' с';
}
function petSvg(mood, skinKey) {
if (!global.PetSprite) return '';
return global.PetSprite.render(4, mood, [], skinKey || getSkin(), 0, 'none');
}
/* ── Интро-карточка уровня (нарратор «почини закон…») ───────────────────── */
function buildIntro(level, skinKey) {
var overlay = el('div', 'qg-overlay qg-intro');
var card = el('div', 'qg-card qg-card-intro');
var pet = el('div', 'qg-intro-pet', petSvg('happy', skinKey));
card.appendChild(pet);
card.appendChild(el('div', 'qg-card-kicker', 'Почини закон'));
card.appendChild(el('div', 'qg-card-title', escapeText(level.title)));
var goalT = (level.spec && level.spec.goal && level.spec.goal.title) || '';
if (goalT) card.appendChild(el('div', 'qg-intro-goal', escapeText(goalT)));
if (level.hint) card.appendChild(el('div', 'qg-intro-hint', escapeText(level.hint)));
var actions = el('div', 'qg-actions');
var btnGo = el('button', 'btn-primary qg-btn', 'Начать');
btnGo.type = 'button';
var btnBack = el('button', 'btn-ghost qg-btn', 'К карте');
btnBack.type = 'button';
actions.appendChild(btnGo);
actions.appendChild(btnBack);
card.appendChild(actions);
overlay.appendChild(card);
return { overlay: overlay, btnGo: btnGo, btnBack: btnBack };
}
/* ── Экран успеха ───────────────────────────────────────────────────────── */
function buildSuccessOverlay(state, ctx) {
ctx = ctx || {};
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');
// нарратор: все звёзды (>=2) -> ecstatic, иначе happy
var mood = (total > 0 && got >= total && total >= 2) ? 'ecstatic' : (got >= 1 ? 'happy' : 'neutral');
if (global.PetSprite) {
var pet = el('div', 'qg-success-pet', petSvg(mood, ctx.skin));
card.appendChild(pet);
}
card.appendChild(el('div', 'qg-card-title', 'Уровень пройден!'));
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' + (i < got ? ' qg-star-on' : ''));
w.style.setProperty('--si', i);
w.innerHTML = starSvg(i < got);
starsBox.appendChild(w);
}
card.appendChild(starsBox);
var stats = el('div', 'qg-stats');
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Время</span><span class="qg-stat-val">' + fmtTime(state && state.timeMs) + '</span>'));
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Звёзды</span><span class="qg-stat-val">' + got + ' / ' + (total || slots) + '</span>'));
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Попытки</span><span class="qg-stat-val">' + ((state && state.attempts) || 0) + '</span>'));
card.appendChild(stats);
var actions = el('div', 'qg-actions');
var btnAgain = el('button', 'btn-ghost qg-btn', 'Ещё раз');
btnAgain.type = 'button';
var btnNext = el('button', 'btn-primary qg-btn', ctx.hasNext ? 'Дальше' : 'К карте');
btnNext.type = 'button';
actions.appendChild(btnAgain);
actions.appendChild(btnNext);
card.appendChild(actions);
overlay.appendChild(card);
return { overlay: overlay, btnAgain: btnAgain, btnNext: btnNext };
}
function escapeText(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* ── Старт уровня ───────────────────────────────────────────────────────
opts: { host, level, skin?, onNext?(level), onMap?(), hasNext?, resolveNext? }
resolveNext?() -> Promise<{ hasNext, next }>: пересчитать следующий уровень
ПОСЛЕ перезагрузки прогресса (победа разблокирует след. уровень). Если не
задан / упал — откатываемся к pre-win opts.hasNext (ровно прежнее поведение). */
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 skin = opts.skin || getSkin();
var spec = tintHeroSpec(level.spec, skin);
var inst = global.SimEngine.mount(host, spec);
var overlayRef = null;
function clearOverlay() {
if (overlayRef && overlayRef.overlay && overlayRef.overlay.parentNode) {
overlayRef.overlay.parentNode.removeChild(overlayRef.overlay);
}
overlayRef = null;
}
// submitDone — promise сабмита прогресса (или null, если сабмита нет).
// Экран успеха показываем СРАЗУ (без ожидания сети) с pre-win hasNext, затем
// ОБНОВЛЯЕМ кнопку «Дальше/К карте», когда пересчёт после победы (resolveNext)
// увидит свежеразблокированный уровень. Это чинит «мёртвую Дальше» на первом
// прохождении (0 звёзд → доступен только L1 → pre-win nextPlayable == null).
function showSuccess(state, submitDone) {
clearOverlay();
// Текущее решение кнопки. Замыкания ниже читают его «живьём» (мутируем var),
// поэтому если игрок успеет нажать раньше пересчёта — отработает фолбэк,
// а после пересчёта та же кнопка уже ведёт «Дальше».
var canNext = typeof opts.onNext === 'function' && !!opts.hasNext;
overlayRef = buildSuccessOverlay(state, { skin: skin, hasNext: canNext });
overlayRef.btnAgain.addEventListener('click', function () {
clearOverlay();
try { inst.reset(); } catch (_e) {}
});
overlayRef.btnNext.addEventListener('click', function () {
clearOverlay();
if (canNext) opts.onNext(level);
else if (typeof opts.onMap === 'function') opts.onMap();
});
host.appendChild(overlayRef.overlay);
if (typeof opts.resolveNext !== 'function') return;
var btn = overlayRef.btnNext;
// Пересчёт идёт ПОСЛЕ сабмита: победа сначала сохраняется на сервере, и только
// затем перезагрузка прогресса увидит разблокированный уровень.
Promise.resolve(submitDone)
.catch(function () {}) // сабмит best-effort: даже при ошибке пробуем пересчёт
.then(function () { return opts.resolveNext(); })
.then(function (r) {
// overlayRef мог смениться/закрыться, пока шла сеть — обновляем только «свою» кнопку.
if (!r || !overlayRef || overlayRef.btnNext !== btn) return;
var next = typeof opts.onNext === 'function' && !!r.hasNext;
if (next === canNext) return; // ничего не изменилось
canNext = next;
btn.textContent = next ? 'Дальше' : 'К карте';
})
.catch(function () {}); // пересчёт упал → остаёмся на pre-win решении
}
inst.onGoal(function (res) {
if (!res || !res.won) return;
var got = (res.stars && res.stars.got) || 0;
var payload = { time_ms: res.timeMs, stars: got };
var submitDone = null;
try {
if (global.LS && global.LS.gameProgressSubmit) {
submitDone = global.LS.gameProgressSubmit(level.id, payload);
if (submitDone && typeof submitDone.catch === 'function') submitDone.catch(function () {});
}
} catch (_e) {}
showSuccess(res, submitDone);
});
return inst;
}
global.QuantikGame = {
start: start,
buildSuccessOverlay: buildSuccessOverlay,
buildIntro: buildIntro,
getSkin: getSkin,
setSkin: setSkin,
skinColor: skinColor,
SKIN_KEY: SKIN_KEY
};
})(typeof window !== 'undefined' ? window : this);
+506 -17
View File
@@ -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) {
@@ -137,6 +157,26 @@
return v < 0 ? 0 : (v > 1 ? 1 : v);
}
/* нормализовать стиль маркера узлов кривой plot: 'dot'|'ring'|'none' (деф. none). */
function _markerStyle(v) {
return (v === 'dot' || v === 'ring') ? v : 'none';
}
/* полупрозрачная версия цвета для заливки под кривой. #RGB/#RRGGBB -> rgba(...,a);
прочие форматы (rgb()/named) оставляем как есть (canvas сам применит globalAlpha). */
function _fillAlpha(color, a) {
if (typeof color !== 'string') return color;
var m = color.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
if (!m) return color;
var h = m[1], r, g, b;
if (h.length === 3) {
r = parseInt(h[0] + h[0], 16); g = parseInt(h[1] + h[1], 16); b = parseInt(h[2] + h[2], 16);
} else {
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
}
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
/* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */
function bind(value, dflt) {
if (value === undefined || value === null) {
@@ -342,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();
}
@@ -482,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(); });
@@ -635,13 +686,52 @@
bp('x', 0); bp('y', 0);
} else if (type === 'plot') {
prep.varName = (typeof o['var'] === 'string' && o['var']) ? o['var'] : 'x';
prep.exprFn = bind(o.expr != null ? o.expr : '0', 0);
var rng = Array.isArray(o.range) ? o.range : null;
prep.rangeA = bind(rng ? rng[0] : null, null);
prep.rangeB = bind(rng ? rng[1] : null, null);
prep.hasRange = !!rng;
prep.samples = Math.max(2, Math.min(2000, num(o.samples, 200) | 0));
prep.trace = !!o.trace;
// ── P3: несколько кривых на одном plot ──
// Источник кривых (приоритет): curves[] -> exprs[] -> одиночный expr (легаси).
// Каждой кривой свой нормализованный стиль; цвет — явный или из палитры по индексу.
var curveDefs = [];
if (Array.isArray(o.curves) && o.curves.length) {
curveDefs = o.curves.map(function (cv) {
return (cv && typeof cv === 'object') ? cv : { expr: cv };
});
} else if (Array.isArray(o.exprs) && o.exprs.length) {
curveDefs = o.exprs.map(function (ex) { return { expr: ex }; });
} else {
curveDefs = [{ expr: o.expr != null ? o.expr : '0' }];
}
var plotMarker = _markerStyle(o.marker);
// plot-уровневые дефолты заливки/маркера наследуются кривыми (если у кривой не задано)
prep.curves = curveDefs.map(function (cv, ci) {
cv = cv || {};
var cFill = (cv.fill !== undefined) ? cv.fill : o.fill;
return {
exprFn: bind(cv.expr != null ? cv.expr : '0', 0),
color: cv.color || o.color || DEFAULT_PALETTE[ci % DEFAULT_PALETTE.length],
label: (cv.label != null) ? String(cv.label) : '',
width: num(cv.width, prep.width),
lineStyle: (cv.lineStyle === 'dashed' || cv.lineStyle === 'dotted') ? cv.lineStyle
: prep.lineStyle,
opacity: (cv.opacity === undefined || cv.opacity === null) ? prep.opacity : _opacity(cv.opacity),
// заливка под кривой: true -> полупрозрачный цвет кривой; строка -> явный цвет
fill: (cFill === true || (typeof cFill === 'string' && cFill)) ? cFill : false,
// маркеры узлов: none|dot|ring (наследует plot-уровень)
marker: (cv.marker !== undefined) ? _markerStyle(cv.marker) : plotMarker,
glow: prep.glow,
glowColor: prep.glowColor,
glowBlur: prep.glowBlur
};
});
// легаси: одиночное выражение для trace-режима (накопление по t)
prep.exprFn = prep.curves[0].exprFn;
// легенда: показывать, если есть хотя бы одна подпись (можно явно legend:false)
var anyLabel = prep.curves.some(function (c) { return !!c.label; });
prep.legend = (o.legend === false) ? false : anyLabel;
} else if (type === 'readout') {
// компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка)
var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0')
@@ -690,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;
@@ -1236,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();
}
};
/* пружины как зигзаг между концами (наглядно для маятника/осциллятора) */
@@ -1471,11 +1774,14 @@
}
};
/* ── plot: график выражения f(var) на отрезке range (мир-координаты) ── */
/* ── plot: график f(var) на отрезке range (мир-координаты) ──
P3: несколько кривых (o.curves[]), заливка под кривой (к оси y=0), маркеры узлов,
легенда (на canvas). Trace-режим (накопление по t) рисуется отдельно через _drawTrail. */
SimEngineInstance.prototype._drawPlot = function (ctx, o, env) {
// trace без явного range — только накапливаемый след (статической кривой нет)
if (o.trace && !o.hasRange) return;
var vp = this._vp();
var W = this._cw, H = this._ch;
var a = o.rangeA.ev(env), b = o.rangeB.ev(env);
if (!o.hasRange || !isFinite(a) || !isFinite(b)) { a = vp.xmin; b = vp.xmax; }
if (a === b) return;
@@ -1485,25 +1791,141 @@
var prev = env[o.varName];
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
ctx.save();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
ctx.beginPath();
var started = false;
for (var i = 0; i < n; i++) {
var xv = a + step * i;
env[o.varName] = xv;
var yv = o.exprFn.ev(env);
if (typeof yv !== 'number' || !isFinite(yv)) { started = false; continue; }
var px = this._toPx(xv, yv);
if (!started) { ctx.moveTo(px[0], px[1]); started = true; }
else ctx.lineTo(px[0], px[1]);
var curves = o.curves || [];
var legendItems = [];
// y=0 в экранных px (для клипа заливки к оси X) + видимая область для клипа
var zeroPy = this._toPx(0, 0)[1];
for (var ci = 0; ci < curves.length; ci++) {
var cv = curves[ci];
// сэмплинг: экранные точки [px,py,worldY], разрывы как null (не-finite y -> пропуск сегмента)
var pts = [];
for (var i = 0; i < n; i++) {
var xv = a + step * i;
env[o.varName] = xv;
var yv = cv.exprFn.ev(env);
if (typeof yv !== 'number' || !isFinite(yv)) { pts.push(null); continue; }
var p = this._toPx(xv, yv);
pts.push([p[0], p[1], yv]);
}
ctx.save();
// заливка под кривой (между кривой и осью y=0), клиппится к видимой высоте
if (cv.fill) {
var fillCol = (cv.fill === true) ? _fillAlpha(cv.color, 0.18) : cv.fill;
ctx.save();
ctx.globalAlpha = cv.opacity;
ctx.fillStyle = fillCol;
ctx.shadowBlur = 0;
this._fillUnderCurve(ctx, pts, _clamp(zeroPy, 0, H));
ctx.restore();
}
// линия кривой (через _applyStroke: dash/opacity/glow/width)
this._applyStroke(ctx, cv);
ctx.strokeStyle = cv.color;
ctx.beginPath();
var started = false;
for (var k = 0; k < pts.length; k++) {
if (!pts[k]) { started = false; continue; }
if (!started) { ctx.moveTo(pts[k][0], pts[k][1]); started = true; }
else ctx.lineTo(pts[k][0], pts[k][1]);
}
ctx.stroke();
// маркеры узлов (с прореживанием: не чаще ~28px по экрану)
if (cv.marker && cv.marker !== 'none') {
this._drawCurveMarkers(ctx, pts, cv);
}
ctx.restore();
if (o.legend && cv.label) legendItems.push({ color: cv.color, label: cv.label });
}
ctx.stroke();
ctx.restore();
// восстановить env
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
// легенда (поверх кривых, в углу области plot, на canvas)
if (legendItems.length) this._drawLegend(ctx, W, H, legendItems);
};
/* Заливка области под полилинией к базовой линии y=baseY (экранные px). Каждый
непрерывный сегмент (между разрывами null) заливается отдельным замкнутым контуром:
curve-up -> вдоль кривой -> curve-down к baseY -> закрыть. baseY клиппится к canvas. */
SimEngineInstance.prototype._fillUnderCurve = function (ctx, pts, baseY) {
var i = 0, n = pts.length;
while (i < n) {
// найти начало непрерывного сегмента
while (i < n && !pts[i]) i++;
var startI = i;
while (i < n && pts[i]) i++;
var endI = i; // [startI, endI)
if (endI - startI < 2) continue; // сегмент из <2 точек — заливать нечего
ctx.beginPath();
ctx.moveTo(pts[startI][0], baseY);
for (var k = startI; k < endI; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.lineTo(pts[endI - 1][0], baseY);
ctx.closePath();
ctx.fill();
}
};
/* Маркеры узлов кривой (dot|ring) с прореживанием по экранному расстоянию (~28px),
чтобы не рисовать сотни точек. Цвет — цвет кривой; opacity наследуется от ctx. */
SimEngineInstance.prototype._drawCurveMarkers = function (ctx, pts, cv) {
var MIN_PX = 28; // минимальный шаг между маркерами по экрану
var r = Math.max(2.5, (cv.width || 2) + 1.5);
var marker = { color: cv.color, fillColor: cv.color, opacity: cv.opacity, glow: false,
pointStyle: (cv.marker === 'ring') ? 'hollow' : 'filled', width: cv.width || 2 };
var lastX = -1e9, lastY = -1e9;
for (var k = 0; k < pts.length; k++) {
var p = pts[k];
if (!p) continue;
var dx = p[0] - lastX, dy = p[1] - lastY;
if (dx * dx + dy * dy < MIN_PX * MIN_PX) continue;
this._drawPoint(ctx, marker, p[0], p[1], r);
lastX = p[0]; lastY = p[1];
}
};
/* Компактная легенда в углу области plot (на canvas, без DOM): цветная метка + текст.
Позиция: верх-право, со смещением вниз, чтобы не наезжать на ось Y/подписи. */
SimEngineInstance.prototype._drawLegend = function (ctx, W, H, items) {
if (!items.length) return;
ctx.save();
ctx.font = '12px Manrope,system-ui,sans-serif';
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
var pad = 8, rowH = 18, swatch = 11, gap = 7;
// ширина по самой длинной подписи
var maxTxt = 0;
for (var i = 0; i < items.length; i++) {
var w = ctx.measureText(items[i].label).width;
if (w > maxTxt) maxTxt = w;
}
var boxW = pad * 2 + swatch + gap + Math.ceil(maxTxt);
var boxH = pad * 2 + items.length * rowH - (rowH - 14);
// верх-право; отступ от края, не наезжает на бар кнопок (right/bottom) и оси
var bx = W - boxW - 12, by = 12;
if (bx < 6) bx = 6;
// фон-плашка (полупрозрачная тёмная, как readout-бейджи)
ctx.globalAlpha = 1;
ctx.fillStyle = 'rgba(13,13,26,0.78)';
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); ctx.stroke(); }
else { ctx.fillRect(bx, by, boxW, boxH); ctx.strokeRect(bx, by, boxW, boxH); }
for (var j = 0; j < items.length; j++) {
var cy = by + pad + 7 + j * rowH;
// цветная метка (линия-свотч)
ctx.strokeStyle = items[j].color;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(bx + pad, cy); ctx.lineTo(bx + pad + swatch, cy);
ctx.stroke();
// текст метки (светлый, без пользовательского цвета в DOM)
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillText(items[j].label, bx + pad + swatch + gap, cy);
}
ctx.restore();
};
/* ── readout: живое значение выражения как бейдж на оверлее ── */
@@ -1767,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);
}
@@ -1796,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;
@@ -1811,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;
@@ -1840,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;
};
@@ -1882,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) {
+1 -3
View File
@@ -39,16 +39,14 @@
if (btn) btn.disabled = true;
try {
let url = o.url;
let thumbUrl = o.thumbUrl || null;
if (o.blob) {
const fd = new FormData();
fd.append('file', o.blob, o.name || 'image.png');
const up = await LS.uploadMaterialFile(fd);
url = up.url;
thumbUrl = up.thumbUrl || null;
}
if (!url) throw new Error('Нет изображения');
await LS.saveMaterial({ kind: 'image', title: o.title || '', url: url, thumbUrl: thumbUrl, sourceTitle: o.sourceTitle || null });
await LS.saveMaterial({ kind: 'image', title: o.title || '', url: url, sourceTitle: o.sourceTitle || null });
ok();
} catch (e) { err(e); } finally { if (btn) btn.disabled = false; }
}
File diff suppressed because it is too large Load Diff
-1
View File
@@ -185,7 +185,6 @@
kind: 'image',
title: input.value.trim() || sectionTitle(),
url: up.url,
thumbUrl: up.thumbUrl || null,
sourceTitle: chapterTitle()
});
toast('Сохранено в «Мои материалы»', 'success');
+31 -226
View File
@@ -70,27 +70,6 @@
.mm-viewer-note { white-space: pre-wrap; word-break: break-word; line-height: 1.6; font-size: 0.9rem; color: var(--text-2); }
.mm-empty { padding: 60px 20px; text-align: center; color: var(--text-3); }
.mm-empty svg { width: 38px; height: 38px; opacity: 0.4; margin-bottom: 12px; }
.mm-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.mm-tag { font-size: .68rem; font-weight: 600; padding: 2px 8px; border-radius: 99px; background: rgba(6,182,212,0.12); color: #0891b2; cursor: pointer; transition: background .12s; }
.mm-tag:hover { background: rgba(6,182,212,0.24); }
.mm-src { color: var(--text-3); text-decoration: none; border-bottom: 1px dotted var(--text-3); }
.mm-src:hover { color: var(--violet); border-bottom-color: var(--violet); }
.mm-tagpill { display: inline-flex; align-items: center; gap: 4px; font-size: .76rem; font-weight: 600; padding: 6px 10px; border-radius: 9px; background: rgba(155,93,229,0.12); color: var(--violet); }
.mm-tagpill-x { display: inline-flex; cursor: pointer; }
.mm-tagpill-x svg { width: 13px; height: 13px; }
.mm-check { position: absolute; top: 10px; left: 10px; z-index: 3; width: 18px; height: 18px; cursor: pointer; accent-color: var(--violet); opacity: 0; transition: opacity .12s; }
.mm-card:hover .mm-check, .mm-check:checked { opacity: 1; }
.mm-card.mm-selected { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.35); }
.mm-bulk { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; padding: 10px 12px; border: 1px solid var(--violet); border-radius: 10px; background: rgba(155,93,229,0.06); }
.mm-bulk-count { font-weight: 700; font-size: .84rem; color: var(--violet); margin-right: auto; }
.mm-swatches { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.mm-swatch { width: 26px; height: 26px; border-radius: 8px; cursor: pointer; border: 2px solid transparent; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); }
.mm-swatch.on { border-color: var(--text); }
.mm-swatch-none { background: repeating-linear-gradient(45deg,#fff,#fff 4px,#e2e8f0 4px,#e2e8f0 8px); }
.mm-preview { min-height: 22px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 8px; font-size: .86rem; color: var(--text-2); background: rgba(148,163,184,0.06); white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
.mm-preview:empty::before { content: 'Превью формул появится здесь…'; color: var(--text-3); }
.mm-more { display: flex; justify-content: center; padding: 8px 0 2px; }
@media (max-width: 768px) { .mm-check { opacity: .85; } }
@media (max-width: 768px) {
.mm-body { flex-direction: column; }
.mm-rail { width: auto; position: static; flex-direction: row; overflow-x: auto; gap: 6px; padding-bottom: 4px; }
@@ -130,15 +109,7 @@
<option value="note">Заметки</option>
<option value="link">Ссылки</option>
</select>
<select class="mm-kind" id="mm-sort" onchange="onSort(this.value)" title="Сортировка">
<option value="new">Сначала новые</option>
<option value="old">Сначала старые</option>
<option value="title">По названию</option>
<option value="kind">По типу</option>
</select>
<span id="mm-tagfilter"></span>
</div>
<div id="mm-bulk" class="mm-bulk" style="display:none"></div>
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
</div>
</div>
@@ -178,9 +149,6 @@
}
return tmp.innerHTML;
}
/* Live formula preview for the note editor (renders $…$ as you type). */
function mmPreview(ta, prevId) { const p = document.getElementById(prevId); if (p) p.innerHTML = mathHtml(ta.value); }
window.mmPreview = mmPreview;
function fmtDate(s) {
if (!s) return '';
try { const d = new Date(s.replace(' ', 'T') + (s.includes('Z') ? '' : 'Z'));
@@ -202,41 +170,9 @@
if (u.startsWith('/lab')) return 'Лаборатория';
return 'Ссылка';
}
function parseTags(s) { return String(s || '').split(',').map(t => t.trim()).filter(Boolean); }
/* Only trust folder colors that look like a hex value (guards inline-style injection). */
function safeColor(c) { return /^#[0-9a-fA-F]{3,8}$/.test(String(c || '')) ? c : ''; }
/* Meta line: source title links back to the originating lesson when known. */
function metaHtml(m) {
const date = fmtDate(m.created_at);
let src = '';
if (m.source_title) {
src = m.source_session_id
? `<a class="mm-src" href="/my-lessons?session=${Number(m.source_session_id)}" title="Открыть исходный урок">${esc(m.source_title)}</a>`
: esc(m.source_title);
src += ' · ';
}
return src + esc(date);
}
/* Tag chips (click → filter). data-t carries the raw value, dodging JS-string injection. */
function tagsHtml(m) {
const tg = parseTags(m.tags);
if (!tg.length) return '';
return `<div class="mm-tags">${tg.map(t => `<span class="mm-tag" data-t="${esc(t)}" onclick="filterTag(this.dataset.t)">${esc(t)}</span>`).join('')}</div>`;
}
/* Lazy-load the full note body — the list endpoint returns only a 1000-char preview. */
async function ensureFullBody(m) {
if (!m || !m.body_trunc) return m;
try { const full = await LS.getMaterial(m.id); if (full && typeof full.body === 'string') { m.body = full.body; m.body_trunc = 0; } } catch (e) {}
return m;
}
let _mats = [];
let _cols = [];
const _filter = { col: 'all', kind: 'all', q: '', sort: 'new', tag: '' };
const _sel = new Set(); // ids selected for bulk actions
const PAGE_SIZE = 60; // cards rendered to the DOM at once ("Показать ещё" adds more)
let _shown = PAGE_SIZE;
const _filter = { col: 'all', kind: 'all', q: '' };
/* ── Move-to-collection select ── */
function moveSelect(m) {
@@ -247,10 +183,7 @@
function card(m) {
const kind = KIND_LABEL[m.kind] || m.kind;
const meta = metaHtml(m);
const tags = tagsHtml(m);
const selCls = _sel.has(m.id) ? ' mm-selected' : '';
const cb = `<input type="checkbox" class="mm-check" ${_sel.has(m.id) ? 'checked' : ''} onclick="toggleSel(event,${m.id})" title="Выбрать" />`;
const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`;
const chip = `<span class="mm-kind-chip"><i data-lucide="${KIND_ICON[m.kind] || 'tag'}"></i>${kind}</span>`;
const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})" title="Удалить"><i data-lucide="trash-2"></i></button>`;
const edit = `<button class="mm-btn" onclick="editMaterial(${m.id})" title="Изменить"><i data-lucide="pencil"></i></button>`;
@@ -263,12 +196,12 @@
const mv = moveSelect(m);
if (m.kind === 'board' || m.kind === 'image') {
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
<a class="mm-card-media" href="${esc(m.url)}" onclick="openViewer(${m.id});return false;"><img src="${esc(m.thumb_url || m.url)}" alt="" loading="lazy" decoding="async" draggable="false"/></a>
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
<a class="mm-card-media" href="${esc(m.url)}" onclick="openViewer(${m.id});return false;"><img src="${esc(m.url)}" alt="" loading="lazy" draggable="false"/></a>
<div class="mm-card-body">
${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>${tags}
<div class="mm-card-meta">${meta}</div>
<div class="mm-card-actions">
${mv}
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
@@ -280,7 +213,7 @@
}
if (m.kind === 'link') {
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
<a class="mm-card-link" href="${esc(m.url)}" target="_blank" rel="noopener" title="${esc(m.url)}">
<span class="mm-card-link-ic"><i data-lucide="link"></i></span>
<span class="mm-card-link-meta">
@@ -291,7 +224,7 @@
<div class="mm-card-body">
${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>${tags}
<div class="mm-card-meta">${meta}</div>
<div class="mm-card-actions">
${mv}
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
@@ -302,31 +235,29 @@
}
// note
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
<div class="mm-card-note">${mathHtml(m.body || '')}</div>
<div class="mm-card-body">
${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>${tags}
<div class="mm-card-meta">${meta}</div>
<div class="mm-card-actions">${mv}${fc}${sh}${edit}${del}</div>
</div>
</div>`;
}
/* ── Folder rail (вертикальный список папок слева) ── */
function railItem(key, label, count, editId, droppable, color) {
function railItem(key, label, count, editId, droppable) {
const active = _filter.col === key ? ' active' : '';
const ed = editId
? `<span class="mm-rail-edit" onclick="event.stopPropagation();editCollection(${editId})" title="Изменить папку">${PENCIL}</span>`
: '';
const ic = key === 'all' ? 'inbox' : (key === 'none' ? 'folder-minus' : 'folder');
const tint = safeColor(color);
const icStyle = (tint && !active) ? ` style="color:${tint}"` : '';
const drop = droppable
? ` ondragover="mmDragOver(event,this)" ondragleave="mmDragLeave(this)" ondrop="mmDrop(event,'${key}')"`
: '';
return `<div class="mm-rail-item${active}" onclick="setCol('${key}')"${drop}>
<i data-lucide="${ic}"${icStyle}></i>
<i data-lucide="${ic}"></i>
<span class="mm-rail-label">${esc(label)}</span>
<span class="mm-rail-count">${count}</span>${ed}
</div>`;
@@ -335,7 +266,7 @@
const bar = document.getElementById('mm-cols');
const noneCount = _mats.filter(m => !m.collection_id).length;
let html = railItem('all', 'Все', _mats.length, null, false);
_cols.forEach(c => { html += railItem(String(c.id), c.name, c.count, c.id, true, c.color); });
_cols.forEach(c => { html += railItem(String(c.id), c.name, c.count, c.id, true); });
html += railItem('none', 'Без папки', noneCount, null, true);
bar.innerHTML = html;
}
@@ -368,28 +299,17 @@
window.mmDragStart = mmDragStart; window.mmDragEnd = mmDragEnd;
window.mmDragOver = mmDragOver; window.mmDragLeave = mmDragLeave; window.mmDrop = mmDrop;
function sortRows(rows) {
const s = _filter.sort || 'new';
if (s === 'new') return rows; // server already returns newest-first
const a = rows.slice();
if (s === 'old') a.reverse();
else if (s === 'title') a.sort((x, y) => (x.title || x.body || '').localeCompare(y.title || y.body || '', 'ru'));
else if (s === 'kind') a.sort((x, y) => (x.kind || '').localeCompare(y.kind || ''));
return a;
}
function filtered() {
const rows = _mats.filter(m => {
return _mats.filter(m => {
if (_filter.col === 'none' && m.collection_id) return false;
if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false;
if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false;
if (_filter.tag && !parseTags(m.tags).includes(_filter.tag)) return false;
if (_filter.q) {
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
if (!hay.includes(_filter.q)) return false;
}
return true;
});
return sortRows(rows);
}
function renderGrid() {
@@ -403,20 +323,11 @@
return;
}
const rows = filtered();
if (!rows.length) {
grid.innerHTML = `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
lucide.createIcons(); renderBulk(); return;
}
let html = rows.slice(0, _shown).map(card).join('');
if (rows.length > _shown) {
html += `<div class="mm-more" style="grid-column:1/-1"><button class="mm-btn" onclick="showMore()"><i data-lucide="chevron-down"></i> Показать ещё (${rows.length - _shown})</button></div>`;
}
grid.innerHTML = html;
grid.innerHTML = rows.length
? rows.map(card).join('')
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
lucide.createIcons();
renderBulk();
}
function showMore() { _shown += PAGE_SIZE; renderGrid(); }
window.showMore = showMore;
async function load() {
try {
@@ -425,73 +336,16 @@
_cols = data.collections || [];
renderCols();
renderGrid();
renderTagFilter();
} catch (e) {
document.getElementById('mm-grid').innerHTML = `<div class="mm-empty" style="grid-column:1/-1">Ошибка загрузки</div>`;
}
}
/* ── Filters ── */
function setCol(key) { _filter.col = key; _shown = PAGE_SIZE; renderCols(); renderGrid(); }
function onKind(v) { _filter.kind = v; _shown = PAGE_SIZE; renderGrid(); }
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); _shown = PAGE_SIZE; renderGrid(); }
function onSort(v) { _filter.sort = v; _shown = PAGE_SIZE; renderGrid(); }
function filterTag(t) { _filter.tag = String(t || ''); _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); }
function clearTag() { _filter.tag = ''; _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); }
function renderTagFilter() {
const el = document.getElementById('mm-tagfilter');
if (!el) return;
el.innerHTML = _filter.tag
? `<span class="mm-tagpill">#${esc(_filter.tag)} <span class="mm-tagpill-x" onclick="clearTag()" title="Сбросить фильтр по тегу"><i data-lucide="x"></i></span></span>`
: '';
if (window.lucide) lucide.createIcons();
}
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); }
function onKind(v) { _filter.kind = v; renderGrid(); }
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); }
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch;
window.onSort = onSort; window.filterTag = filterTag; window.clearTag = clearTag;
/* ── Multi-select + bulk actions (reuse per-item endpoints) ── */
function renderBulk() {
const bar = document.getElementById('mm-bulk');
if (!bar) return;
const n = _sel.size;
if (!n) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
const opts = ['<option value="__none">Без папки</option>']
.concat(_cols.map(c => `<option value="${c.id}">${esc(c.name)}</option>`)).join('');
bar.style.display = 'flex';
bar.innerHTML = `<span class="mm-bulk-count">Выбрано: ${n}</span>
<select class="mm-move" onchange="bulkMove(this.value)" title="Переместить выбранные"><option value="">Переместить в…</option>${opts}</select>
<button class="mm-btn danger" onclick="bulkDelete()"><i data-lucide="trash-2"></i> Удалить</button>
<button class="mm-btn" onclick="clearSel()"><i data-lucide="x"></i> Снять</button>`;
if (window.lucide) lucide.createIcons();
}
function toggleSel(e, id) {
e.stopPropagation();
const cb = e.target;
if (cb.checked) _sel.add(id); else _sel.delete(id);
const cardEl = cb.closest('.mm-card');
if (cardEl) cardEl.classList.toggle('mm-selected', cb.checked);
renderBulk();
}
function clearSel() { _sel.clear(); renderGrid(); }
async function bulkMove(v) {
if (v === '') return;
const cid = v === '__none' ? null : Number(v);
const ids = [..._sel];
try {
for (const id of ids) await LS.updateMaterial(id, { collection_id: cid });
_sel.clear(); load(); LS.toast('Перемещено: ' + ids.length, 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function bulkDelete() {
const ids = [..._sel];
if (!ids.length) return;
if (!await LS.confirm(`Будет удалено материалов: ${ids.length}. Действие необратимо.`, { title: 'Удалить выбранные?', confirmText: 'Удалить' })) return;
try {
for (const id of ids) await LS.deleteMaterial(id);
_sel.clear(); load(); LS.toast('Удалено: ' + ids.length, 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
window.toggleSel = toggleSel; window.clearSel = clearSel; window.bulkMove = bulkMove; window.bulkDelete = bulkDelete;
/* ── Material actions ── */
async function moveMaterial(id, cid) {
@@ -511,56 +365,46 @@
function createNote() {
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки… (поддерживается $формула$)" style="${FLD};resize:vertical" oninput="mmPreview(this,'mm-nt-prev')"></textarea>
<div id="mm-nt-prev" class="mm-preview"></div>
<input id="mm-nt-tags" placeholder="Теги через запятую (необязательно)" style="${FLD}" />
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea>
</div>`;
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Создать', primary: true, onClick: async () => {
const title = m.body.querySelector('#mm-nt-title').value.trim();
const text = m.body.querySelector('#mm-nt-body').value.trim();
const tags = m.body.querySelector('#mm-nt-tags').value.trim() || null;
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null;
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col, tags }); m.close(); load(); }
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
window.createNote = createNote;
async function editMaterial(id) {
function editMaterial(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return;
const isNote = mt.kind === 'note';
if (isNote) await ensureFullBody(mt);
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical" oninput="mmPreview(this,'mm-ed-prev')">${esc(mt.body || '')}</textarea><div id="mm-ed-prev" class="mm-preview"></div>` : ''}
<input id="mm-ed-tags" value="${esc(mt.tags || '')}" placeholder="Теги через запятую" style="${FLD}" />
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical">${esc(mt.body || '')}</textarea>` : ''}
</div>`;
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Сохранить', primary: true, onClick: async () => {
const data = {
title: m.body.querySelector('#mm-ed-title').value.trim(),
tags: m.body.querySelector('#mm-ed-tags').value.trim() || null,
};
const data = { title: m.body.querySelector('#mm-ed-title').value.trim() };
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
try { await LS.updateMaterial(id, data); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
if (isNote) { const ta = m.body.querySelector('#mm-ed-body'); if (ta) mmPreview(ta, 'mm-ed-prev'); }
}
window.editMaterial = editMaterial;
/* ── Просмотр материала в модалке (лайтбокс) ── */
async function openViewer(id) {
function openViewer(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return false;
if (mt.kind === 'note') await ensureFullBody(mt);
const kind = KIND_LABEL[mt.kind] || mt.kind;
let body;
if (mt.kind === 'image' || mt.kind === 'board') {
@@ -587,61 +431,24 @@
window.openViewer = openViewer;
/* ── Collection CRUD ── */
const COL_PALETTE = ['#9b5de5', '#06b6d4', '#f97316', '#10b981', '#ef4444', '#eab308', '#3b82f6', '#ec4899'];
function colorPalette(sel) {
sel = safeColor(sel);
return `<div style="font-size:.78rem;color:var(--text-3)">Цвет</div>
<div class="mm-swatches">
<span class="mm-swatch mm-swatch-none${!sel ? ' on' : ''}" data-c="" onclick="pickSwatch(this)" title="Без цвета"></span>
${COL_PALETTE.map(c => `<span class="mm-swatch${sel === c ? ' on' : ''}" data-c="${c}" style="background:${c}" onclick="pickSwatch(this)"></span>`).join('')}
</div>`;
}
function pickSwatch(el) { el.parentNode.querySelectorAll('.mm-swatch').forEach(s => s.classList.remove('on')); el.classList.add('on'); }
function pickedColor(body) { const on = body.querySelector('.mm-swatch.on'); return on ? (on.dataset.c || null) : null; }
window.pickSwatch = pickSwatch;
function createCollection() {
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-col-name" placeholder="Название папки" style="${FLD}" />
${colorPalette(null)}
</div>`;
const content = `<input id="mm-col-name" placeholder="Название папки" style="${FLD}" />`;
const m = LS.modal({ title: 'Новая папка', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Создать', primary: true, onClick: async () => {
const name = m.body.querySelector('#mm-col-name').value.trim();
if (!name) { LS.toast('Введите название', 'warn'); return; }
try { await LS.createMaterialCollection({ name, color: pickedColor(m.body) }); m.close(); load(); }
try { await LS.createMaterialCollection({ name }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
window.createCollection = createCollection;
/* Reorder a folder up/down by normalizing sort_order to the new index order. */
async function moveCollection(id, dir) {
const arr = _cols.slice();
const i = arr.findIndex(c => c.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= arr.length) return;
[arr[i], arr[j]] = [arr[j], arr[i]];
try {
await Promise.all(arr.map((c, k) => c.sort_order !== k ? LS.updateMaterialCollection(c.id, { sortOrder: k }) : null).filter(Boolean));
load();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
window.moveCollection = moveCollection;
function editCollection(id) {
const col = _cols.find(c => c.id === id);
if (!col) return;
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-col-name" value="${esc(col.name)}" placeholder="Название папки" style="${FLD}" />
${colorPalette(col.color)}
<div style="display:flex;gap:8px;margin-top:2px">
<button class="mm-btn" onclick="moveCollection(${id},-1)"><i data-lucide="arrow-up"></i> Выше</button>
<button class="mm-btn" onclick="moveCollection(${id},1)"><i data-lucide="arrow-down"></i> Ниже</button>
</div>
</div>`;
const content = `<input id="mm-col-name" value="${esc(col.name)}" placeholder="Название папки" style="${FLD}" />`;
const m = LS.modal({ title: 'Папка', content, size: 'sm', actions: [
{ label: 'Удалить', onClick: async () => {
if (!await LS.confirm('Материалы из неё останутся и станут «Без папки».', { title: 'Удалить папку?', confirmText: 'Удалить' })) return;
@@ -652,11 +459,10 @@
{ label: 'Сохранить', primary: true, onClick: async () => {
const name = m.body.querySelector('#mm-col-name').value.trim();
if (!name) { LS.toast('Введите название', 'warn'); return; }
try { await LS.updateMaterialCollection(id, { name, color: pickedColor(m.body) }); m.close(); load(); }
try { await LS.updateMaterialCollection(id, { name }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
if (window.lucide) lucide.createIcons();
}
window.editCollection = editCollection;
@@ -696,10 +502,10 @@
const up = await LS.uploadMaterialFile(fd);
if (o.materialId) {
// Аннотация существующего материала — перезаписываем его, а не плодим копии
await LS.updateMaterial(o.materialId, { url: up.url, thumbUrl: up.thumbUrl || null });
await LS.updateMaterial(o.materialId, { url: up.url });
close(); load(); LS.toast('Изменения сохранены', 'success');
} else {
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: up.url, thumbUrl: up.thumbUrl || null, sourceTitle: o.sourceTitle || null });
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: up.url, sourceTitle: o.sourceTitle || null });
close(); load(); LS.toast('Сохранено в «Мои материалы»', 'success');
}
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; }
@@ -719,7 +525,6 @@
async function toFlashcard(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return;
await ensureFullBody(mt);
let decks = [];
try { const d = await LS.fcListDecks(); decks = d.decks || []; } catch (e) {}
const opts = ['<option value="__new">+ Новая колода «Из материалов»</option>']
+431
View File
@@ -0,0 +1,431 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Квантик — Законы Мира</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/css/ls.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
/* ════════════════════ Игровая страница «Квантик» ════════════════════ */
.qg-wrap { display: flex; flex-direction: column; height: 100vh; min-height: 0; }
/* ── Topbar ── */
.qg-top {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 11px 20px; border-bottom: 1px solid rgba(148,163,184,0.18);
background: #11132A; flex-shrink: 0; position: relative; z-index: 3;
}
.qg-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.02rem; color: #E2E8F0; white-space: nowrap; }
.qg-sub { font-size: .8rem; color: #94A3B8; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.qg-pill { font-size: .66rem; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; padding: 3px 10px; border-radius: 99px; background: rgba(34,211,238,0.16); color: #67E8F9; }
.qg-back {
display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .82rem; font-weight: 600;
color: #CBD5E1; background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.2);
border-radius: 99px; padding: 5px 13px; cursor: pointer; transition: .16s;
}
.qg-back:hover { background: rgba(34,211,238,0.16); color: #67E8F9; border-color: rgba(34,211,238,0.35); }
.qg-back .ic { width: 15px; height: 15px; }
/* ════════════════════ Карта-созвездие ════════════════════ */
.qm-root {
flex: 1; min-height: 0; position: relative; overflow-y: auto; overflow-x: hidden;
background:
radial-gradient(1100px 700px at 78% -10%, rgba(167,139,250,0.16), transparent 60%),
radial-gradient(900px 600px at 12% 8%, rgba(34,211,238,0.13), transparent 55%),
radial-gradient(700px 800px at 50% 120%, rgba(244,114,182,0.10), transparent 60%),
linear-gradient(180deg, #0B0B1A 0%, #0D0D1F 55%, #0A0A16 100%);
}
/* атмосферное зерно поверх фона */
.qm-root::before {
content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .5;
background-image: radial-gradient(rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 3px 3px;
}
/* ── Шапка карты (нарратор + XP + скины) ── */
.qm-header { position: relative; z-index: 2; padding: 22px 26px 8px; }
.qm-header-inner {
display: grid; grid-template-columns: minmax(240px, 1.4fr) minmax(280px, 1.1fr) auto;
gap: 18px; align-items: center; max-width: 1180px; margin: 0 auto;
}
@media (max-width: 920px) { .qm-header-inner { grid-template-columns: 1fr; } }
.qm-narrator { display: flex; align-items: center; gap: 14px; }
.qm-pet { width: 76px; height: 80px; flex-shrink: 0; filter: drop-shadow(0 8px 22px rgba(34,211,238,0.28)); }
.qm-pet svg { width: 100%; height: 100%; }
.qm-bubble {
position: relative; background: rgba(20,22,44,0.78); border: 1px solid rgba(148,163,184,0.18);
border-radius: 14px; padding: 12px 15px; backdrop-filter: blur(6px); box-shadow: 0 10px 30px rgba(0,0,0,0.35);
}
.qm-bubble::before {
content: ''; position: absolute; left: -7px; top: 50%; transform: translateY(-50%) rotate(45deg);
width: 12px; height: 12px; background: rgba(20,22,44,0.78); border-left: 1px solid rgba(148,163,184,0.18); border-bottom: 1px solid rgba(148,163,184,0.18);
}
.qm-bubble-t { color: #DCE3EE; font-size: .86rem; line-height: 1.45; }
.qm-stats { display: flex; align-items: center; gap: 18px; }
.qm-level { display: flex; flex-direction: column; align-items: center; line-height: 1; flex-shrink: 0; }
.qm-level-num {
font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 2rem;
background: linear-gradient(135deg, #67E8F9, #A78BFA); -webkit-background-clip: text; background-clip: text; color: transparent;
}
.qm-level-lbl { font-size: .62rem; text-transform: uppercase; letter-spacing: .06em; color: #8B9AAE; margin-top: 4px; max-width: 84px; text-align: center; }
.qm-xpbox { flex: 1; min-width: 160px; }
.qm-xp-head { display: flex; justify-content: space-between; font-size: .74rem; color: #CBD5E1; margin-bottom: 6px; font-weight: 600; font-variant-numeric: tabular-nums; }
.qm-xp-next { color: #8B9AAE; font-weight: 500; }
.qm-xp-bar { height: 9px; border-radius: 99px; background: rgba(148,163,184,0.18); overflow: hidden; }
.qm-xp-fill {
height: 100%; border-radius: 99px; width: 0;
background: linear-gradient(90deg, #22D3EE, #A78BFA);
box-shadow: 0 0 14px rgba(103,232,249,0.5);
transition: width .9s cubic-bezier(.22,.61,.36,1);
}
.qm-starcount {
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1rem; color: #FBBF24; font-variant-numeric: tabular-nums;
}
/* ── Скины ── */
.qm-skins { display: flex; flex-direction: column; gap: 7px; }
.qm-skins-lbl { font-size: .62rem; text-transform: uppercase; letter-spacing: .06em; color: #8B9AAE; font-weight: 700; }
.qm-skins-row { display: flex; gap: 7px; flex-wrap: wrap; max-width: 220px; }
.qm-skin {
width: 30px; height: 30px; border-radius: 50%; cursor: pointer; padding: 0;
background: radial-gradient(circle at 34% 30%, color-mix(in srgb, var(--sk) 70%, #fff), var(--sk) 70%);
border: 2px solid rgba(255,255,255,0.18); position: relative; transition: transform .14s, box-shadow .14s, border-color .14s;
}
.qm-skin:hover:not(.locked) { transform: scale(1.12); }
.qm-skin.active { border-color: #fff; box-shadow: 0 0 0 2px var(--sk), 0 0 14px var(--sk); }
.qm-skin.locked { filter: grayscale(.7) brightness(.5); cursor: not-allowed; }
.qm-skin-lock { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.qm-skin-lock .ic { width: 12px; height: 12px; }
/* ── Тело: созвездия ── */
.qm-body { position: relative; z-index: 1; max-width: 1180px; margin: 0 auto; padding: 8px 26px 60px; }
.qm-constellation { margin-top: 18px; }
.qm-con-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; padding-left: 4px; }
.qm-con-title {
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.1rem; color: #E8EDF5;
position: relative; padding-left: 16px;
}
.qm-con-title::before {
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent);
}
.qm-con-sub { font-size: .8rem; color: #8B9AAE; flex: 1; }
.qm-con-stars { display: inline-flex; align-items: center; gap: 4px; font-size: .78rem; color: #FBBF24; font-weight: 700; font-variant-numeric: tabular-nums; }
.qm-field { position: relative; height: 220px; margin-top: 2px; }
@media (max-width: 620px) { .qm-field { height: 280px; } }
.qm-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.qm-link { stroke: rgba(148,163,184,0.22); stroke-width: .35; stroke-dasharray: 1.4 1.6; }
.qm-link.on { stroke: var(--accent); stroke-opacity: .55; stroke-dasharray: none; stroke-width: .45; }
.qm-tw { animation: qmTwinkle var(--tw, 2.4s) ease-in-out var(--td, 0s) infinite; transform-box: fill-box; transform-origin: center; }
@keyframes qmTwinkle { 0%,100% { opacity: .25; } 50% { opacity: .9; } }
/* ── Узлы ── */
.qm-node {
position: absolute; transform: translate(-50%, -50%); z-index: 2;
display: flex; flex-direction: column; align-items: center; gap: 6px;
background: none; border: none; cursor: pointer; font: inherit; padding: 0;
transition: transform .2s;
}
.qm-node-core {
width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
position: relative; transition: transform .18s, box-shadow .18s;
}
.qm-node-core .ic { color: #fff; }
.qm-node-label { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: .76rem; color: #CBD5E1; white-space: nowrap; text-shadow: 0 1px 4px rgba(0,0,0,.6); }
.qm-node-stars { display: inline-flex; gap: 1px; }
.qm-node-need { display: inline-flex; align-items: center; gap: 3px; font-size: .68rem; color: #94A3B8; font-weight: 600; }
/* доступный узел */
.qm-available .qm-node-core {
background: radial-gradient(circle at 35% 30%, #34D399, #0E9F6E);
box-shadow: 0 0 0 4px rgba(52,211,153,0.18), 0 8px 24px rgba(16,185,129,0.4);
animation: qmPulse 2.4s ease-in-out infinite;
}
@keyframes qmPulse {
0%,100% { box-shadow: 0 0 0 4px rgba(52,211,153,0.18), 0 8px 24px rgba(16,185,129,0.4); }
50% { box-shadow: 0 0 0 8px rgba(52,211,153,0.10), 0 10px 32px rgba(16,185,129,0.55); }
}
.qm-available:hover { transform: translate(-50%, -50%) scale(1.07); }
.qm-available:hover .qm-node-core { transform: scale(1.05); }
/* пройденный узел */
.qm-completed .qm-node-core {
background: radial-gradient(circle at 35% 30%, #67E8F9, #2563EB);
box-shadow: 0 0 0 3px rgba(34,211,238,0.2), 0 6px 20px rgba(37,99,235,0.4);
}
.qm-completed:hover { transform: translate(-50%, -50%) scale(1.06); }
.qm-node-order { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.15rem; color: #fff; }
/* заблокированный узел */
.qm-locked { cursor: not-allowed; }
.qm-locked .qm-node-core {
background: rgba(30,33,58,0.85); border: 1.5px solid rgba(148,163,184,0.22);
box-shadow: inset 0 2px 10px rgba(0,0,0,0.4);
}
.qm-locked .qm-node-label { color: #6B7A90; }
/* фокус для клавиатуры */
.qm-node:focus-visible { outline: none; }
.qm-node:focus-visible .qm-node-core { box-shadow: 0 0 0 3px #fff, 0 0 0 6px var(--accent, #22D3EE); }
/* поэтапное появление */
.qm-node.qm-pre { opacity: 0; transform: translate(-50%, -40%) scale(.6); }
.qm-node.qm-in { animation: qmNodeIn .5s cubic-bezier(.22,1.2,.4,1) forwards; }
@keyframes qmNodeIn {
from { opacity: 0; transform: translate(-50%, -40%) scale(.6); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
/* ════════════════════ Сцена уровня ════════════════════ */
.qg-stage { flex: 1; min-height: 0; position: relative; background: #0D0D1A; overflow: hidden; }
.qg-stage .sim-spec-root { position: absolute; inset: 0; }
.qg-fallback { padding: 40px; color: #cbd5e1; font-family: 'Manrope', sans-serif; max-width: 520px; }
.qg-view { display: none; flex: 1; min-height: 0; }
.qg-view.show { display: flex; flex-direction: column; }
/* ── Оверлеи (интро / успех) ── */
.qg-overlay {
position: absolute; inset: 0; z-index: 20;
display: flex; align-items: center; justify-content: center;
background: rgba(7, 7, 18, 0.74); backdrop-filter: blur(5px);
}
.qg-card {
background: linear-gradient(180deg, #15173099, #0F1024EE), #14152C;
border: 1px solid rgba(148,163,184,0.18); border-radius: 20px;
padding: 26px 30px 24px; width: min(440px, 92vw); text-align: center;
box-shadow: 0 24px 70px rgba(0,0,0,0.55);
animation: qg-pop .26s cubic-bezier(.22,1.1,.4,1);
}
@keyframes qg-pop { from { transform: scale(.9) translateY(8px); opacity: 0; } to { transform: scale(1) translateY(0); opacity: 1; } }
.qg-card-kicker { font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; color: #67E8F9; margin-bottom: 4px; }
.qg-card-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.32rem; color: #EAF0F8; margin-bottom: 12px; }
.qg-intro-pet, .qg-success-pet { width: 92px; height: 96px; margin: 0 auto 6px; filter: drop-shadow(0 10px 26px rgba(34,211,238,0.3)); }
.qg-intro-pet svg, .qg-success-pet svg { width: 100%; height: 100%; }
.qg-success-pet { animation: qgBob 1.6s ease-in-out infinite; }
@keyframes qgBob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
.qg-intro-goal { font-weight: 700; font-size: .98rem; color: #DCE3EE; margin-bottom: 8px; }
.qg-intro-hint { font-size: .85rem; color: #A8B4C6; line-height: 1.5; margin-bottom: 18px; max-width: 360px; margin-left: auto; margin-right: auto; }
.qg-stars { display: flex; justify-content: center; gap: 6px; margin-bottom: 18px; }
.qg-star { display: inline-flex; }
.qg-star-svg { filter: drop-shadow(0 2px 6px rgba(251,191,36,0.4)); }
.qg-star-on { animation: qgStarPop .45s cubic-bezier(.22,1.3,.4,1) backwards; animation-delay: calc(.12s * var(--si, 0) + .15s); }
@keyframes qgStarPop { 0% { transform: scale(0) rotate(-30deg); opacity: 0; } 70% { transform: scale(1.25) rotate(6deg); } 100% { transform: scale(1) rotate(0); opacity: 1; } }
.qg-stats { display: flex; justify-content: center; gap: 22px; margin-bottom: 20px; }
.qg-stat { display: flex; flex-direction: column; gap: 3px; }
.qg-stat-lbl { font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #8B9AAE; }
.qg-stat-val { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.05rem; color: #EAF0F8; font-variant-numeric: tabular-nums; }
.qg-actions { display: flex; justify-content: center; gap: 10px; }
.qg-btn { min-width: 118px; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="qg-wrap">
<div class="qg-top">
<span class="qg-title" id="qg-title">Квантик — Законы Мира</span>
<span class="qg-sub" id="qg-sub"></span>
<button class="qg-back" id="qg-back" type="button" style="display:none">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18 9 12 15 6"/></svg>
К карте
</button>
<span class="qg-pill">Физика</span>
</div>
<!-- Вид карты -->
<div class="qg-view show" id="qg-map-view">
<div class="qm-root">
<div class="qm-header" id="qg-map-header"></div>
<div class="qm-body" id="qg-map-body"></div>
</div>
</div>
<!-- Вид уровня -->
<div class="qg-view" id="qg-level-view">
<div class="qg-stage" id="qg-stage"></div>
</div>
</div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<!-- модель питомца (нарратор-Квантик) -->
<script src="/js/pet-sprite.js"></script>
<!-- движок спек-симуляций (тот же путь, что lab.html / sim-builder.html) -->
<script src="/js/labs/_sim_expr.js"></script>
<script src="/js/labs/_sim_engine.js"></script>
<!-- KaTeX для подписей сцены -->
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<!-- уровни (данные) + логика прогресса + карта + игра -->
<script src="/js/game/levels.js"></script>
<script src="/js/game/progress-logic.js"></script>
<script src="/js/game/map.js"></script>
<script src="/js/game/quantik-game.js"></script>
<script>
(function () {
// Доступ: любой авторизованный пользователь (играют и ученики).
if (!LS.initPage()) { return; }
var mapView = document.getElementById('qg-map-view');
var lvlView = document.getElementById('qg-level-view');
var stage = document.getElementById('qg-stage');
var backBtn = document.getElementById('qg-back');
var titleEl = document.getElementById('qg-title');
var subEl = document.getElementById('qg-sub');
if (!window.SimEngine || !window.SimExpr || !window.QuantikLevels ||
!window.QuantikGame || !window.QuantikMap || !window.QuantikProgress) {
stage.innerHTML = '<div class="qg-fallback">Движок игры не загрузился. Обновите страницу.</div>';
lvlView.classList.add('show'); mapView.classList.remove('show');
return;
}
var progressMap = {}; // { level_id: row }
var curInst = null; // текущий инстанс движка уровня
var map = null;
function loadProgress() {
if (window.LS && window.LS.gameProgressList) {
return window.LS.gameProgressList()
.then(function (r) { progressMap = window.QuantikProgress.fromProgressList(r && r.progress); })
.catch(function () { progressMap = {}; });
}
return Promise.resolve();
}
function destroyLevel() {
if (curInst) { try { curInst.destroy(); } catch (_e) {} curInst = null; }
stage.innerHTML = '';
}
/* ── Показать карту ── */
function showMap() {
destroyLevel();
lvlView.classList.remove('show');
mapView.classList.add('show');
backBtn.style.display = 'none';
titleEl.textContent = 'Квантик — Законы Мира';
subEl.textContent = 'Карта мира — выбери уровень и почини закон';
history.replaceState(null, '', '/quantik');
// перезагрузить прогресс (мог обновиться после победы) и перерисовать
loadProgress().then(function () { map.render(progressMap); });
}
/* ── Запустить уровень (после интро) ── */
function launchLevel(level) {
destroyLevel();
mapView.classList.remove('show');
lvlView.classList.add('show');
backBtn.style.display = '';
titleEl.textContent = level.title || 'Квантик';
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || level.hint || '';
history.replaceState(null, '', '/quantik?level=' + encodeURIComponent(level.id));
// Pre-win значение (фолбэк, если пересчёт после победы недоступен).
var nextLevel = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
curInst = window.QuantikGame.start({
host: stage,
level: level,
skin: window.QuantikGame.getSkin(),
hasNext: !!nextLevel,
// Победа разблокирует след. уровень → перезагружаем прогресс и пересчитываем
// «следующий доступный» на свежей карте, чтобы экран успеха показал «Дальше».
resolveNext: function () {
return loadProgress().then(function () {
var nx = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
return { hasNext: !!nx, next: nx };
});
},
onNext: function () {
// прогресс уже перезагружен в resolveNext → берём след. доступный из свежей карты
var nx = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
if (nx) openLevel(nx); else showMap();
},
onMap: showMap
});
if (!curInst) {
stage.innerHTML = '<div class="qg-fallback">Не удалось запустить уровень.</div>';
}
}
/* ── Открыть уровень: показать интро-карточку, потом launch ── */
function openLevel(level) {
destroyLevel();
mapView.classList.remove('show');
lvlView.classList.add('show');
backBtn.style.display = '';
titleEl.textContent = level.title || 'Квантик';
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || '';
var intro = window.QuantikGame.buildIntro(level, window.QuantikGame.getSkin());
intro.btnGo.addEventListener('click', function () {
if (intro.overlay.parentNode) intro.overlay.parentNode.removeChild(intro.overlay);
launchLevel(level);
});
intro.btnBack.addEventListener('click', function () {
if (intro.overlay.parentNode) intro.overlay.parentNode.removeChild(intro.overlay);
showMap();
});
stage.appendChild(intro.overlay);
}
/* ── Карта ── */
map = window.QuantikMap.create({
host: document.getElementById('qg-map-body'),
headerHost: document.getElementById('qg-map-header'),
onPlay: function (level) { openLevel(level); },
getSkin: function () { return window.QuantikGame.getSkin(); },
onSkin: function (key) {
window.QuantikGame.setSkin(key);
map.render(progressMap); // перерисовать (нарратор + активный свотч)
}
});
backBtn.addEventListener('click', showMap);
// Старт: если ?level=<id> в URL и уровень доступен — открыть его, иначе карта.
loadProgress().then(function () {
map.render(progressMap);
var params = new URLSearchParams(location.search);
var wantId = params.get('level');
if (wantId) {
var lvl = window.QuantikLevels.get(wantId);
if (lvl && window.QuantikProgress.isUnlocked(lvl, progressMap, window.QuantikLevels.list())) {
openLevel(lvl);
return;
}
}
showMapNoReload();
});
// показать карту без повторной загрузки прогресса (стартовый случай)
function showMapNoReload() {
lvlView.classList.remove('show');
mapView.classList.add('show');
backBtn.style.display = 'none';
titleEl.textContent = 'Квантик — Законы Мира';
subEl.textContent = 'Карта мира — выбери уровень и почини закон';
}
window.__quantik = { map: map, getInst: function () { return curInst; } };
})();
</script>
</body>
</html>
+39 -2
View File
@@ -82,10 +82,42 @@
/* ── объект ── */
.sbu-obj.sel { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.16); }
.sbu-obj-hdr { display: flex; align-items: center; gap: 6px; }
.sbu-obj.is-hidden, .sbu-plot.is-hidden { opacity: .62; }
.sbu-obj.is-hidden .sbu-obj-fields, .sbu-obj.is-hidden .sbu-obj-style { opacity: .7; }
.sbu-obj-hdr { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.sbu-obj-type { font-size: .72rem; font-weight: 800; color: var(--violet); flex-shrink: 0; }
.sbu-in-id { flex: 1; max-width: 120px; }
.sbu-in-id { flex: 1; min-width: 64px; max-width: 110px; }
.sbu-obj-hdr .sbu-icon-btn { width: 26px; height: 26px; }
.sbu-icon-btn:disabled { opacity: .32; cursor: default; pointer-events: none; }
.sbu-icon-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.1); }
.sbu-zord { color: var(--text-3); }
.sbu-obj-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
/* ── блок «Стиль» объекта (P4) ── */
.sbu-obj-style { border-top: 1px dashed var(--border); padding-top: 7px; margin-top: 1px; display: flex; flex-direction: column; gap: 7px; }
.sbu-obj-style .sbu-sub { margin-top: 0; }
.sbu-style-row { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; align-items: end; }
.sbu-style-row > * { min-width: 0; }
.sbu-grad-row { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
/* ── color-picker контрол (нативный пикер + текст + очистка) ── */
.sbu-color-mini { min-width: 0; }
.sbu-color-wrap { display: flex; align-items: center; gap: 5px; }
.sbu-color-pick { width: 30px; height: 30px; flex-shrink: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; background: #fff; cursor: pointer; }
.sbu-color-pick::-webkit-color-swatch-wrapper { padding: 3px; }
.sbu-color-pick::-webkit-color-swatch { border: none; border-radius: 5px; }
.sbu-color-wrap .sbu-in-color { flex: 1; min-width: 0; }
.sbu-color-clr { width: 26px; height: 26px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 7px; background: #fff; color: var(--text-3); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.sbu-color-clr:hover { border-color: #ef4444; color: #ef4444; }
/* ── range (opacity) ── */
.sbu-range-mini { min-width: 0; }
.sbu-range-val { color: var(--violet); font-variant-numeric: tabular-nums; }
.sbu-range { width: 100%; accent-color: var(--violet); height: 30px; box-sizing: border-box; }
/* ── кривые графика ── */
.sbu-curves { display: flex; flex-direction: column; gap: 8px; }
.sbu-curve { border: 1px solid var(--border); border-radius: 9px; padding: 8px; background: #fafbfd; display: flex; flex-direction: column; gap: 7px; }
.sbu-curve-del { width: 24px; height: 24px; }
.sbu-of { display: flex; flex-direction: column; gap: 2px; }
.sbu-of-lbl { font-size: .66rem; color: var(--text-3); display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.sbu-fx { font-size: .62rem; font-weight: 800; font-style: italic; color: var(--violet); background: rgba(155,93,229,0.1); border: none; border-radius: 5px; padding: 1px 6px; cursor: pointer; }
@@ -113,6 +145,11 @@
.sbu-panels { width: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); }
.sbu-preview { min-height: 320px; }
}
@media (max-width: 560px) {
.sbu-obj-fields { grid-template-columns: 1fr; }
.sbu-style-row, .sbu-grad-row { grid-template-columns: 1fr; }
.sbu-row4 { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
+4 -2
View File
@@ -1037,10 +1037,11 @@ window.LS = {
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
crAdminGetAllHistory, crAdminGetTeachersList,
listMaterials, getMaterial, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
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,
@@ -1253,7 +1254,6 @@ async function uploadMaterialFile(formData) {
}
function downloadFileUrl(id) { return `${API}/files/${id}/download`; }
async function listMaterials() { return req('GET', '/materials'); }
async function getMaterial(id) { return req('GET', `/materials/${id}`); }
async function saveMaterial(data) { return req('POST', '/materials', data); }
async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); }
async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); }
@@ -1272,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 }); }
+1
View File
@@ -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', 'Красная книга')}
-63
View File
@@ -1,63 +0,0 @@
# «Мои материалы» — v2: харднинг и доводка
> Составлен Opus 2026-06-13. Базовый план (PLAN.md, Фазы 1–6) **полностью реализован**.
> Его раздел «Сквозные риски» отложил ровно то, что закрывает этот план: учёт/лимиты/чистку
> хранилища и `materials.test.js`. Источник истины по текущему состоянию — код
> (`studentMaterialsController.js`, `materials.js`, `my-materials.html`, `board-clip.js`,
> `material-save.js`) и [[reference_student_materials]].
Готчи проекта: новый `:id`-роут → `// @public-by-design` + проверка владельца; большие HTML — только Edit;
без эмодзи (inline SVG `.ic`); коммит поимённо + push; перезапуск сервера при правке backend; ветка
`feature/sim-builder` в рабочем дереве — НЕ коммитить чужие правки, только свои файлы.
---
## Фаза 1 — Целостность и безопасность (backend, фундамент) ✅ цель этого захода
1. **Ссылочно-подсчётная чистка файлов.** `DELETE /:id` и смена `url` (аннотация) сейчас оставляют
файл в `uploads/materials/` сиротой. `share` копирует `url` дословно → несколько строк ссылаются на
ОДИН файл, поэтому `unlink` только когда на `url` не ссылается ни одна строка. Хелпер
`releaseFileForUrl(url)` вызывается ПОСЛЕ delete/update.
2. **Allowlist схемы URL.** `create`/`update` принимали любой `url``link` со схемой `javascript:`
рендерится как рабочий `<a href>` (раздача делает это вектором учитель→ученики). Хелпер `safeUrl`:
только `http(s)://` или app-relative `/…` (не `//host`); иначе 400.
3. **Квота на пользователя.** Колонка `bytes` (мигр. 073), счёт `SUM(bytes)`/`COUNT(*)`. Лимит по числу
материалов — в `create()`; лимит по байтам — в `uploadPersonalFile` (до приёма файла). Конфигурируемо
через `MATERIALS_MAX_ITEMS` / `MATERIALS_MAX_BYTES` (для тестов — низкий потолок).
4. **`backend/tests/materials.test.js`** — CRUD, владелец (403/404), коллекции, share-копия + роль/owner,
валидация URL, лимит числа, ссылочная чистка (прямой вызов хелпера на временном файле).
## Фаза 2 — Производительность ✅
-`GET /api/materials` отдаёт **обрезанный** `body` (первые 1000 симв.) + флаг `body_trunc`; полный текст —
ленивый `GET /api/materials/:id` (`getOne`, owner-only). Клиент `ensureFullBody()` подгружает перед
просмотром/правкой/флешкартой (иначе правка сохранила бы усечённый текст).
- ✅ Пагинация рендера: клиент держит весь список (поиск/фильтр/сортировка в памяти), но в DOM рисует
`PAGE_SIZE=60` карточек + «Показать ещё»; `_shown` сбрасывается на смену фильтра. Снимает стоимость
рендера тысяч узлов, не ломая клиентский поиск (keyset на сервере не нужен на текущих объёмах).
- ✅ Серверные миниатюры `board/image`: `uploadPersonalFile` (sharp → webp ≤480px) возвращает `{url, thumbUrl}`;
колонка `thumb_url` (мигр. **074**); грид рисует `<img src=thumb_url||url>`, просмотр/скачивание/аннотация —
полный `url`. Чистится по ссылкам (releaseFileForUrl теперь матчит url **и** thumb_url); share копирует thumb;
квота считает файл+миниатюру. Клиентские сейверы (board-clip/material-save/textbook-clip/draw) пробрасывают `thumbUrl`.
## Фаза 3 — Доводка заложенных фич ✅
- ✅ UI тегов: ввод в модалках создания/правки + чипы на карточке (клик → фильтр) + пилюля активного фильтра.
- ✅ Ссылка «открыть исходный урок» на карточке (`/my-lessons?session=<id>`, есть `source_session_id`).
- ✅ Цвет папки (палитра 8 пресетов, тинт иконки в рейле) + сортировка папок «Выше/Ниже» в модалке правки
(нормализует `sort_order` к индексам). `safeColor` гейтит inline-style инъекцию (только hex).
## Фаза 4 — UX ✅
- ✅ Варианты сортировки (новые/старые/имя/тип) — селект в тулбаре.
- ✅ Множественный выбор (чекбокс на карточке) + панель массовых действий (переместить/удалить, reuse per-item API).
- ✅ Живое превью KaTeX в редакторе заметки (oninput → `mmPreview``mathHtml`).
### Статус — ПЛАН V2 ВЫПОЛНЕН
**Ф1–Ф4 ✅.** Backend: 19 тестов `materials.test.js` (CRUD/владелец/коллекции/share/URL-allowlist/квота/
ссылочная чистка url+thumb/round-trip thumb_url). Frontend: headless-смоук `my-materials.html` (синтаксис +
deep-link/теги/чекбокс/bulk/тинт папки + `<img>` на thumb_url + пагинация «Показать ещё»). sharp-пайплайн и
client-сейверы (board-clip/material-save/textbook-clip) проверены. Открытого из плана не осталось.
---
## Порядок
**Ф1 (этот заход) → Ф2 → Ф3 → Ф4.** Ф1 — серверный фундамент (риск-возврат, без него фронт-фичи множат
мусор). Дальше преимущественно фронтенд `my-materials.html` + точечные ручки API.
+92
View File
@@ -0,0 +1,92 @@
# 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.
- **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; миграция применяется чисто.
- **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`.
Движок вычисляет `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`-слайдеры движка (угол/скорость/
гравитация) или собирает `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`).
+91
View File
@@ -0,0 +1,91 @@
# Feature: Квантик — Законы Мира (образовательная 2D-игра)
**Branch:** `feature/quantik-game`
**Base branch:** `feature/sim-builder` (движок P1P3 и фазы 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)
- [x] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.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)
- [ ] 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 | ✅ Done | ✅ | ✅ | ✅ |
| Phase 2: Карта + мир + XP/скины | fullstack | ✅ Done | ✅ (1 🟡 fixed) | ✅ | ✅ |
| 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,88 @@
# Phase 1: Оболочка игры + 1 физ-уровень + прогресс (MVP)
**Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Сквозной играбельный срез: страница `/quantik` грузит уровень-спеку, монтирует движок в
«игровом режиме» (управление = слайдеры закона + кнопка «Запуск»), на победу шлёт результат
на сервер, показывает экран успеха со звёздами/временем. Прогресс сохраняется в БД.
Первый уровень — «Артиллерия Квантика»: угол+скорость, попасть в портал, собрать звезду.
## Tasks
- [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` — таблица прогресса.
- `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
- [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 оверлея — в `<style>` `quantik.html` (`.qg-*`). Ф2 переиспользует `buildSuccessOverlay` (можно расширить параметром «следующий уровень»).
### Гочи для Ф2
- `inst.onGoal` срабатывает 1 раз и делает `pause()`. Перезапуск — `inst.reset()` (это И физика, И attempts++). Не звать `play()` в onGoal-колбэке.
- `res.timeMs` — мировое время (детерминизм), не wallclock. `res.stars.got`/`res.stars.total` — счётчики звёзд.
- Страница не разрушает `inst` явно при навигации; Ф2 при смене уровня без перезагрузки должна вызвать `inst.destroy()` перед монтированием нового (или перезагружать `?level=`).
- Сайдбар-пункт `/quantik` видим ВСЕМ (без `hidden`), в отличие от teacher-only `/sim-builder`.
+113
View File
@@ -0,0 +1,113 @@
# Phase 2: Карта-созвездие + мир физ-уровней + XP/скины (MVP-мир)
**Status:** ✅ Done (reviewed — PASS w/ notes; «Дальше» stale-hasNext 🟡 fixed; committed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Превратить одиночный уровень в **играбельный мир**: карта-созвездие из ~5–6 физ-уровней,
разблокировка по звёздам, XP, выбор скина Квантика, нарратор-Квантик (`PetSprite`) на интро/
победе. После этой фазы игра полноценно отгружаема.
## Tasks
- [x] Task 1: Контент — ~5–6 физ-уровней-спек (данные в `levels.js`), нарастающая сложность:
артиллерия → перелёт через стену → отскок (restitution) → пружина/маятник → орбита/гравитация.
Каждый: `goal` + 1–3 звезды + норматив времени (`par_ms`) для 3-й звезды.
- [x] Task 2: Структура «мир/глава»: метаданные уровня (id, title, chapter, order, par_ms, hint).
Карта группирует по главам (созвездиям).
- [x] Task 3: Карта-созвездие `frontend/js/game/map.js` (+ разметка в quantik.html): узлы-уровни
на SVG/canvas-фоне, линии-связи, статус (заблокирован/доступен/пройден + число звёзд).
Разблокировка: уровень открыт, если набрано ≥ threshold звёзд в предыдущих (правило в данных).
- [x] Task 4: XP/уровень игрока: XP = сумма звёзд × коэффициент (+ бонус за par). Хранить в
прогрессе (расширить `game_progress` агрегацией на клиенте ИЛИ доб. поле/таблицу `game_player`).
Полоса XP + «уровень Квантика» в шапке карты.
- [x] Task 5: Скины Квантика: выбор `colorKey` из палитр `PetSprite` (+ позже паттерны). Скин
влияет на цвет glow-точки героя в уровне (param/проп движка) и на `PetSprite` на карте.
Хранить выбор (localStorage сейчас; серверно — опц.). Разблокировка скинов по XP/звёздам.
- [x] Task 6: Нарратор: `PetSprite.render(...)` в интро уровня (краткая формулировка «почини закон…»)
и на экране победы (реакция по числу звёзд: happy/ecstatic). Реюз mood из pet-sprite.js.
- [x] Task 7: Навигация: карта → уровень → результат → возврат на карту с обновлённым статусом/XP.
- [x] 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
### Архитектура карты (`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`) — `<button class="qm-node qm-{locked|available|completed}">` с ядром
(`.qm-node-core`), подписью, звёздами/порогом. Позиция в % через `layoutNodes(levels)` (зигзаг-дуга).
- Статус узла = `QuantikProgress.nodeStatus`. Клик по available/completed → `onPlay(level)`.
- Звёздное небо — SVG `<circle class="qm-tw">` (мерцание CSS), линии-связи `<line class="qm-link[.on]">`.
- Поэтапное появление: `staggerReveal` снимает `.qm-pre`/ставит `.qm-in` через setTimeout (70 мс шаг).
### Как добавить главу (созвездие)
- В `levels.js`: дать новым уровням `chapter:'<key>'` + добавить запись в `CHAPTERS`
(`{ key, title, subtitle, accent }`). Карта группирует автоматически (`groupByChapter` сохраняет
порядок появления глав). Узлы внутри главы сортируются по `order`. Никаких правок map.js не нужно.
- **Фаза 3 (граф-уровни) = НОВАЯ глава** (напр. `chapter:'functions'`): добавить уровни-спеки с
`objects:[{type:'plot',...}]` + `goal.when` по форме функции; `unlockStars` гейтит её за Динамику.
Узел рисуется тем же `buildNode` (тип спеки карте безразличен — она читает только метаданные).
### Модуль логики прогресса (`frontend/js/game/progress-logic.js`, `window.QuantikProgress`)
Чистые функции (без DOM/сети/eval) — переносимы на сервер, покрыты тестом:
- `fromProgressList(list)` → карта `{level_id: row}` из ответа `/api/game/progress`.
- `starsFor(id, map)` / `isCompleted(id, map)` / `totalStars(levels, map)`.
- `isUnlocked(level, map, levels)` — уровень открыт, если Σ звёзд во ВСЕХ уровнях с меньшим `order`
`level.unlockStars` (порог в данных уровня). `unlockStars:0` (или нет) → всегда открыт.
- `nodeStatus` / `starsToUnlock` — для карты.
- `computeXp(levels, map)` = Σ(звёзды·`STAR_XP`=100 + `COMPLETE_XP`=40 за пройденный).
- `playerLevel(xp)``{ level, xp, xpInto, xpForNext, progress01 }`. Шкала: `xpForLevel(L)=240·(L-1)L/2`.
- `groupByChapter(levels)``[{ chapter, levels:[…sorted by order] }]`.
- `nextPlayable(curId, levels, map)` → след. разблокированный уровень (для кнопки «Дальше») или null.
### Скины
- localStorage ключ **`quantik-skin`** (экспортирован `QuantikGame.SKIN_KEY`). Значение = `colorKey`
из `PetSprite.PALETTES` (валидируется при чтении, иначе fallback `'cyan'`).
- `QuantikGame.getSkin()/setSkin(key)/skinColor(key)`. Тинт героя — `tintHeroSpec(spec, key)`:
глубокая копия спеки (JSON), переписывает `color/glowColor/trailColor` объекта с `id:'ball'`.
Гейты скинов — массив `SKIN_GATES` в map.js (needStars/needXp). 8 скинов.
### Нарратор
- `PetSprite.render(level, mood, [], colorKey, 0, 'none')` (DOM SVG-строка). Вызовы:
- Карта-шапка: `QuantikMap.renderHeader` (mood по уровню игрока: ecstatic≥5 / happy≥2 / neutral).
- Интро уровня: `QuantikGame.buildIntro(level, skin)` (mood `happy`).
- Экран успеха: `QuantikGame.buildSuccessOverlay(state, {skin, hasNext})` — mood `ecstatic`, если все
звёзды (got≥total и total≥2), иначе `happy`.
### Навигация (inline-bootstrap в quantik.html)
- Два вида: `#qg-map-view` (карта) и `#qg-level-view` (`#qg-stage` под движок). Переключение
классом `.show`. `showMap()` перезагружает прогресс (`LS.gameProgressList`) → `map.render`.
`openLevel→интро→launchLevel→onGoal→успех→onNext(nextPlayable)|onMap`. При смене уровня
ВСЕГДА `destroyLevel()` (= `inst.destroy()` + очистка `#qg-stage`) до нового mount (гоча Ф1).
- Deep-link `?level=<id>` открывает уровень, если он разблокирован; иначе карта.
### Решения/гочи (для ревью и Ф3+)
- **XP/прогресс игрока — чисто клиентская агрегация** из `game_progress` (Ф1). Новых таблиц/роутов НЕТ
→ lint:routes baseline 0 не тронут, бэкенд-тесты не изменились (259, 251 pass / 8 baseline fail).
- Уровни 3/5/6 имеют «лёгкий» выигрышный путь, попутно дающий обе звезды; «честная» механика
(отскок/орбита/колодец) присутствует, но не единственно-возможна — НЕ блокер MVP (см. winnability).
- На сервер агрегацию XP перенести легко: те же чистые функции в `progress-logic.js` (без DOM).
@@ -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. -->
+85 -3
View File
@@ -1,6 +1,85 @@
# Feature Context: Конструктор симуляций (SimForge)
## Current State
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) ЗАВЕРШЁН — P5 «Прямое манипулирование + история» РЕАЛИЗОВАН**
(рабочее дерево, не закоммичено; ветка `feature/sim-builder`). Файл: ТОЛЬКО `frontend/js/sim-builder.js`.
`_sim_engine.js` НЕ тронут — `_toWorld`/`_toPx`/`_niceStep` уже публичны на инстансе движка, хука не
потребовалось (в IMPROVEMENTS.md P5 предполагались правки движка — не понадобились).
- **Прямое манипулирование** (`bindPreviewDrag` переписан): «ручки» через `handlesOf(obj)` для ВСЕХ
позиционируемых типов — point/circle/label/readout/rect (одна ручка x,y), segment/vector (origin x1,y1 +
end x2,y2 ИЛИ origin+dx/dy), polyline/path (по ручке на числовую вершину `points`). Хит-тест `pickHandle`
(14px, через `_toPx`); режимы pointerdown: `handle`/`place` (единств. ручка — клик ставит)/`body`
(несколько ручек — относительный сдвиг)/`none`. Поля-выражения `blocked` (не затираются). `refreshObjFields`
расширен на x1/y1/x2/y2/dx/dy/points.
- **Snap-к-сетке**: тумблер в тулбаре (`_snap`, `toggleSnap`, иконка `ICON.grid`, активность — инлайн
`SNAP_ACTIVE_CSS`); округление к `_niceStep(34)` (минорный шаг сетки; fallback 0.5). Выравнивание к чужим
координатам не делалось (бонус; snap достаточно — отмечено как частичное).
- **Undo/Redo**: стек `JSON.stringify(this.st)` (глубина 50), `pushHistory` (до мутации, без дублей, сброс
redo), `snapField` (один снапшот на сессию правки поля через focusin/`_fieldSnapTaken`). Структурные
операции — снапшот сразу; drag — один на сессию (no-op откатывается). Кнопки undo/redo (SVG `.ic`) +
Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts`, игнорит фокус в полях). `loadFromSim` обнуляет
историю; `_restoreSnapshot` → renderPanels + scheduleRemount.
- Верификация: `node --check` OK; эмодзи/eval — 0; vm-смоук 38/38 PASS (drag всех типов + body-move; snap;
защита выражений; undo/redo drag+add; лимит стека; round-trip идемпотентен). buildSpec/валидация не тронуты.
git status: тронут только sim-builder.js (`_sim_engine.js` в статусе — чужой коммит параллельной сессии
«goal/game», мной НЕ редактировался).
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P4 «UI билдера + контролы стиля» РЕАЛИЗОВАН** (рабочее дерево, не
закоммичено; ветка `feature/sim-builder`). Файлы: только `frontend/sim-builder.html` + `frontend/js/sim-builder.js`.
`_sim_engine.js`/`js/api.js`/lab.* НЕ тронуты — билдер лишь генерит спеку, которую движок (P2/P3) уже умеет рисовать.
- **Контролы стиля объекта** (блок «Стиль», `STYLE_FOR[type]`): слайдер непрозр.(`opacity` 0..1),
select `lineStyle`(solid/dashed/dotted), `pointStyle`(только point), тумблер `glow`, тумблер градиент-
заливки(circle/rect → `gradient:[c0,c1]`). Цвета — `colorCtl`: нативный `<input type=color>` + текст
(источник истины, держит rgba/named) + очистка для fill/trailColor. Синхрон — `wireColorControls`,
`toHexColor``#rrggbb`. Per-объект уже были width/color в OBJ_FIELDS — переведены на color-пикеры.
- **Редактор кривых plot** (`plotEditor`/`curveEditor`): UI-модель `{var,range_a/b,samples,trace,legend,
plotFill,plotMarker,curves:[{expr,color,label,width,lineStyle,opacity,fill,fillColor,marker}]}`. Список
кривых (add/del, минимум 1), на кривую все P3-поля + fx-палитра, plot-уровневые fill/marker/легенда.
`loadPlot` (spec→UI: curves[]→exprs[]→legacy expr; легаси plot-level width/lineStyle/opacity → в кривую),
`normalizePlotForSpec`+`stripCurve` (UI→spec). Одиночная простая кривая → легаси `{expr,color}`, иначе
`curves:[...]`. `legend:false` эмитится только при выкл.
- **Список объектов/графиков**: z-order вверх/вниз (порядок массива = порядок отрисовки), видимость
(`hidden:true` — чисто билдерский флаг, фильтруется в `buildSpec`, движок не знает), дублировать
(deep-clone+новый `_uid`, `id+'_copy'`), удалить. Иконки — новые inline SVG `.ic` (up/down/copy/eye/eyeOff/clearX).
- **Минимизация спеки + стабильный round-trip**: `stripObj.isDefaultStyle` выбрасывает дефолты
(glow:false, lineStyle:'solid', pointStyle:'filled', opacity:1, trail/closed:false) и `hidden`. Save→load→
save идемпотентен (loadFromSim восстанавливает дефолты из контролов).
- **Дизайн/мобайл**: новые CSS-классы в ls.css-стиле (`.sbu-obj-style`/`.sbu-style-row`/`.sbu-color-*`/
`.sbu-range`/`.sbu-curve(s)`/`.is-hidden`/`.sbu-grad-row`); заголовок объекта flex-wrap + 26px-кнопки;
медиа ≤920px (раскладка) + новый ≤560px (поля/стили в один столбец). Пустые состояния дополнены.
- **Безопасность**: выражения только через `SimExpr.compile`; цвета попадают лишь в спеку (canvas-стоки
движка), DOM-style с польз.цветом не используется; eval/new Function — нет.
- Верификация: `node --check` sim-builder.js + извлечённого инлайна html — OK; эмодзи нет (скан кодпойнтов
обоих файлов — 0); eval/new Function — 0; headless vm-смоук (DOM/SimExpr-стаб) 27+12 PASS: стили объекта в
спеке, round-trip объектов идемпотентен ×2, plot с 2 кривыми (label/marker/lineStyle/opacity/fill-цвет/
range/samples) + round-trip ×2, легаси-одиночная кривая → легаси-форма + round-trip, hidden исключает из
спеки, z-order=порядок массива, дефолты-стрип; +шаблонные легаси-plot save→load→save стабильны (2 PASS).
Temp удалены. git status: тронуты только sim-builder.html и sim-builder.js.
- **Следующее (P5):** прямое манипулирование на сцене (drag всех типов + snap-к-сетке + выравнивание) и
undo/redo. Потребуются правки `_sim_engine.js` (хит-тесты/ручки) + `sim-builder.js` (стек снапшотов `this.st`).
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P3 «Графики/диаграммы» РЕАЛИЗОВАН** (рабочее дерево, не
закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Расширен
`_drawPlot` + ветка `type==='plot'` в `_prepareObjects`. Оси/сетка/подписи уже из P1 — не дублировались.
- **Несколько кривых**: нормализуются в `prep.curves[]`, приоритет источника `curves:[{...}]`
`exprs:['sin(x)','x^2']` → одиночный `expr` (легаси, обратная совместимость сохранена). Каждой кривой
свой цвет (явный `color` или `DEFAULT_PALETTE[i%8]`). `prep.exprFn` = первой кривой (для trace-режима).
- **Поля кривой** (`curves[i]`): `expr`, `color`, `label`(→легенда), `width`, `lineStyle`, `opacity`,
`fill`(true→полупрозр. цвет / строка), `marker`(none|dot|ring). Не заданные наследуют plot-уровень.
**Plot-уровневые `fill`/`marker`** — дефолт для всех кривых.
- **Заливка под кривой** `_fillUnderCurve` (между кривой и y=0, посегментно — разрывы у не-finite не
сливаются; baseY клиппится к canvas). **Маркеры** `_drawCurveMarkers` (переиспользует `_drawPoint`,
прорежены ~28px). **Легенда** `_drawLegend` на canvas (тёмная плашка + свотч + светлый текст, верх-право,
авто при `label`, `legend:false` отключает). Новые модульные хелперы `_markerStyle`/`_fillAlpha`.
- **Безопасность**: цвета только в canvas-стоки (strokeStyle/fillStyle/fillText фикс-цвет легенды);
DOM-style с пользовательским цветом не используется; eval нет. Каждая кривая в своём save/restore,
легенда на внешнем уровне.
- Верификация: `node --check` OK; headless vm-смоук (canvas-стаб со счётчиком save/restore + РЕАЛЬНЫЕ
`_sim_expr`+`_sim_engine`) 10/10: легаси/exprs[]/curves+fill+marker+legend/наследование/не-finite
(1/x,tan)/legend:false/trace±range/fillUnder+markers с null/регресс point-vector-circle-rect — все PASS;
ctx сбалансирован (depth→0, нет underflow). Эмодзи нет (только пре-существующие → в комментариях); eval=0.
Temp-смоук удалён. git status: тронут только `_sim_engine.js`.
- **Следующее (P4):** UI билдера + контролы стиля (`sim-builder.html`/`sim-builder.js`) — дать новым полям
plot контролы: список кривых (add/del, expr+color+label+width+lineStyle+opacity+fill+marker), plot-fill/
marker, тумблер легенды; плюс per-объект color/opacity/width/dash, z-order, дублирование, мобайл.
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P2 «Качество графики объектов» РЕАЛИЗОВАН** (рабочее дерево, не
закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Один движок →
эффект и в билдере, и в /lab, и на доске.
@@ -25,9 +104,7 @@
масштаб; все поля прочитаны; палитра применена + явный color сохранён; трасса накоплена; destroy чист.
Эмодзи нет (скан кодпойнтов: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только
в комментарии стр.15. git status: тронут только `_sim_engine.js`.
- **Следующее (P3):** графики/диаграммы (`_drawPlot`): оси-деления plot, несколько кривых, заливка под
кривой, маркеры точек (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/
`_drawPoint` готовы к переиспользованию.
- **Следующее (P3):** РЕАЛИЗОВАНО (см. блок P3 выше) — несколько кривых, заливка, маркеры, легенда.
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P1 «Рабочее поле» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено;
ветка `feature/sim-builder`, общая с параллельной сессией materials/quota). Файл: только
`frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался). Один движок → эффект и в билдере, и в /lab, и на доске.
@@ -213,6 +290,11 @@
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
## RESUME STATE
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md):** P1+P2+P3 закоммичены; **P4 «UI билдера + контролы стиля»
РЕАЛИЗОВАН** (рабочее дерево, не закоммичено — ждёт ревьюера/оркестратора). Файлы: только
`frontend/sim-builder.html` + `frontend/js/sim-builder.js`. Дальше — независимый ревью P4, затем P5
(прямое манипулирование на сцене для всех типов + snap/выравнивание + undo/redo; правки `_sim_engine.js`
+ `sim-builder.js`). Контракт стилей/кривых из P2/P3-handoff полностью покрыт контролами билдера.
- Последний коммит фичи: — (Ф0..Ф7 ВСЕ реализованы, ещё не закоммичены — ждут оркестратора)
- Текущая фаза: Phase 7 — Доска онлайн-урока (✅ Implemented, pending commit) — ФИНАЛЬНАЯ.
Дальше: Final Review (final-reviewer + security review) → коммит всех фаз → merge в master.
+92 -9
View File
@@ -68,20 +68,103 @@
кривых). Для P3 расширять `_drawPlot` — оси-делений plot, несколько кривых, заливка под кривой, маркеры
точек (можно переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint`
готовы к переиспользованию.
- [ ] **P3 — Графики/диаграммы (визуал charts).** Для plot: оси с делениями/подписями, несколько кривых,
заливка под кривой, маркеры точек, легенда; аккуратный стиль диаграмм. Файл: `_sim_engine.js` (+ билдер
поля plot).
- [ ] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color-
- [x] **P3 — Графики/диаграммы (визуал charts).** Для plot: несколько кривых, заливка под кривой,
маркеры точек, легенда; аккуратный стиль диаграмм (оси/сетка/подписи — уже из P1). Файл: `_sim_engine.js`.
**Handoff (P3 → P4): новые поля plot-объекта** (контракт для контролов билдера в P4). Все читаются в
`_prepareObjects` (ветка `type==='plot'`), рендерятся ТОЛЬКО на canvas (без DOM-style/eval). Старый
одиночный `expr`/`var`/`range`/`samples`/`trace` работает как раньше (обратная совместимость):
- **Несколько кривых.** Источник (приоритет): `curves:[{...}]``exprs:['sin(x)','x^2']``expr`
(легаси). Нормализуются в `prep.curves[]`. Каждой кривой свой цвет: явный `color` или
`DEFAULT_PALETTE[i%8]`. `prep.exprFn` = первая кривая (для trace-режима).
- **Поля кривой** (`curves[i]`): `expr` (строка), `color`, `label` (строка → легенда), `width`,
`lineStyle` (`solid|dashed|dotted`), `opacity` (0..1), `fill` (`true` → полупрозр. цвет кривой / строка
цвета), `marker` (`none|dot|ring`). Не заданные наследуются от plot-уровня (`width/lineStyle/opacity`)
или дефолтов.
- **Plot-уровневые** `fill` и `marker` — дефолт для всех кривых (если у кривой не задано).
- **Заливка под кривой** — между кривой и осью `y=0`, посегментно (разрывы у не-finite точек не сливаются),
`_fillUnderCurve`. Прозрачность через `_fillAlpha(color, 0.18)` для `fill:true`.
- **Маркеры узлов**`_drawCurveMarkers` (переиспользует `_drawPoint`), прорежены ~28px по экрану
(не рисуем сотни точек). `dot` → filled, `ring` → hollow.
- **Легенда**`_drawLegend` (на canvas: тёмная плашка + цветной свотч + светлый текст), верх-право,
не наезжает на бар кнопок вида. Включается авто при наличии `label`; `legend:false` отключает.
- **Качество кривой** — пропуск не-finite (разрывы), переиспользован существующий equidistant sampling
(`samples`, деф. 200, макс 2000), `_applyStroke` (dash/opacity/glow/lineJoin/cap).
- **На P4 (билдер):** дать этим полям контролы — список кривых (добавить/удалить, expr + color-picker +
label + width + lineStyle + opacity + fill toggle/color + marker select), plot-уровневые fill/marker,
тумблер легенды. Хелпер `_markerStyle`/`_fillAlpha` — модульного уровня, рядом с `_dashFor`/`_opacity`.
- [x] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color-
пикеры + opacity/width/dash/линестиль на объект, z-order/дублирование/видимость объектов, пустые
состояния, мобайл. Файлы: `frontend/sim-builder.html`, `frontend/js/sim-builder.js`.
- [ ] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
snap-к-сетке, выравнивание; undo/redo в билдере. Файлы: `_sim_engine.js`, `frontend/js/sim-builder.js`.
**Handoff (P4 → P5):**
- **Контролы стиля объекта** (блок «Стиль» в каждом редакторе, `STYLE_FOR[type]` решает набор):
`rangeCtl` непрозр. (слайдер 0..1 → `opacity`), `selectCtl` линия (`lineStyle` solid/dashed/dotted),
стиль точки (`pointStyle`, только point), тумблер `glow`, тумблер «Градиент-заливка» (circle/rect →
`gradient:[c0,c1]`, две пары color-инпутов). Цвета — новый `colorCtl`: нативный `<input type=color>`
+ текстовое поле (источник истины, поддерживает rgba/named) + кнопка очистки для fill/trailColor
(«нет заливки»). Синхрон пикер↔текст — `Builder.wireColorControls(row)` (текст диспатчит `input`,
основной `data-of`/`data-cvf` обработчик ловит). `toHexColor` приводит к `#rrggbb` для нативного пикера.
- **Редактор кривых plot** (`plotEditor`/`curveEditor`): UI-модель plot = `{var, range_a/b, samples,
trace, legend, plotFill, plotMarker, curves:[...]}`. Кривая = `{_uid, expr, color, label, width,
lineStyle, opacity, fill(bool), fillColor, marker}`. Список кривых (добавить `[data-curveadd]` /
удалить `[data-curvedel]`, минимум 1), на кривую — expr+fx, color, label, width, lineStyle, marker,
opacity, fill+цвет. Plot-уровневые `plotFill`/`plotMarker`/легенда. `loadPlot` нормализует
spec→UI (curves[]→exprs[]→legacy expr; легаси plot-level width/lineStyle/opacity наследуются кривой),
`normalizePlotForSpec`+`stripCurve` собирают обратно: **одиночная «простая» кривая (только expr+color,
нет plot-fill/marker) → легаси-форма** `{expr,color}`; иначе `curves:[...]`. `legend:false` эмитится
только при выключенной легенде.
- **Список объектов**: в шапке каждого — z-order вверх/вниз (`[data-oup]`/`[data-odown]`, порядок в
массиве = порядок отрисовки; крайние disabled), видимость (`[data-ohide]``o.hidden=true`),
дублировать (`[data-odup]`, deep-clone + новый `_uid`, `id+'_copy'`), удалить. Аналогично у plot.
- **hidden — чисто на стороне билдера** (движок не трогали): `buildSpec` фильтрует объекты/plot с
`hidden`; `stripObj.isDefaultStyle` гарантирует, что `hidden`/дефолты стиля (glow:false, lineStyle:
'solid', pointStyle:'filled', opacity:1, trail/closed:false) НЕ попадают в спеку → спека минимальна,
round-trip save→load→save идемпотентен (проверено vm-смоуком 27+12+2 PASS).
- **На P5 (прямое манипулирование + история):** в билдере сейчас есть только drag x/y point/circle/label/
readout/rect и конца segment/vector (`bindPreviewDrag` через `inst._toWorld`). Расширять до всех типов
+ snap-к-сетке + выравнивание (нужны правки `_sim_engine.js` — хит-тесты/ручки). Undo/redo: состояние
= `this.st` (сериализуемо JSON); снимать снапшот при `onAdd`/удалении/правке (debounce) — стек в
Builder, перерисовка `renderPanels`+`scheduleRemount`. Идентичность спеки между билдами уже гарантирована.
- [x] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
snap-к-сетке; undo/redo в билдере. Файл: `frontend/js/sim-builder.js` (движок НЕ тронут — `_toWorld`/
`_toPx`/`_niceStep` уже публичны на инстансе, хука не потребовалось).
**Итог / Handoff (P5 — финал раунда):**
- **Прямое манипулирование (`bindPreviewDrag`, переписан).** «Ручки» объекта строит `handlesOf(obj)`:
точка/окружность/подпись/показатель/прямоугольник → одна ручка `pos`(x,y); отрезок/вектор → две
ручки `origin`(x1,y1)+`end`(x2,y2 ИЛИ origin+dx/dy — определяется по наличию полей); ломаная/путь →
по ручке на каждую числовую вершину `points`. Каждая ручка несёт `set(x,y)` и флаг `blocked`. Хит-тест
`pickHandle` (допуск 14px через `inst._toPx`) выбирает ближайшую ручку. Режимы pointerdown:
`handle` (попали в ручку — двигаем её), `place` (единственная ручка, клик ставит точку — сохранён
исходный смысл «клик ставит»), `body` (несколько ручек — двигаем всё тело относительным сдвигом от
стартовой мир-точки), `none` (нет двигаемых ручек). Поля-ВЫРАЖЕНИЯ не трогаются: `numField` вернёт
`null` для нечислового значения → ручка `blocked` (не двигается, не сериализуется молча).
- **Snap-к-сетке.** Тумблер в тулбаре (иконка `ICON.grid`, флаг `this._snap`, переключатель `toggleSnap`,
активное состояние — инлайн-стиль `SNAP_ACTIVE_CSS`, без зависимости от CSS-класса). При включённом
drag округляет мир-координаты к шагу `inst._niceStep(34)` (минорный шаг сетки движка; fallback 0.5).
Выключенный — `round2`.
- **Выравнивание** — реализован минимум (snap-к-сетке движка). Прилипание к координатам других объектов
НЕ делалось (бонус; достаточно snap для зачёта). Зафиксировано как частичное.
- **Undo/Redo.** Стек снапшотов `JSON.stringify(this.st)` (глубина `_undoMax=50`). `pushHistory` снимает
снапшот ПЕРЕД мутацией (без дублей верхушки; сбрасывает redo). `snapField` — один снапшот на сессию
правки поля (focusin сбрасывает флаг `_fieldSnapTaken`, первый input/change снимает) → Ctrl+Z откатывает
значение целиком, а не посимвольно. Структурные операции (add/delete/z-order/duplicate/hide/toggle,
включая plot/curve/wall/spring и физ-тумблер) — снапшот сразу. Drag — один снапшот на сессию (пустые
no-op-снапшоты откатываются в `end()`). Кнопки undo/redo в тулбаре (SVG `.ic`), горячие клавиши
Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts`, вешается один раз, игнорит фокус в полях ввода).
`loadFromSim` обнуляет историю. `_restoreSnapshot``renderPanels`+`scheduleRemount`.
- **Совместимость.** `buildSpec`/round-trip/валидация не тронуты; идемпотентность спеки сохранена.
`refreshObjFields` расширен на x1/y1/x2/y2/dx/dy/points. Проверено vm-смоуком: 38/38 PASS
(drag point/circle/segment-оба-конца/vector-dx,dy/polyline-vertex + body-move polyline/segment; snap к
0.5; выражение не затирается; undo/redo drag и add; стек ограничен; round-trip идемпотентен; no-op
drag не плодит историю). `node --check` OK, эмодзи/eval нет.
## Progress
| Phase | Status | Review | Committed |
|-------|--------|--------|-----------|
| P1 Working field | Done | ✅ PASS | ✅ |
| P2 Object graphics | Done | ✅ PASS | ✅ |
| P3 Charts | ⬜ | ⬜ | |
| P4 Builder UI | ⬜ | ⬜ | |
| P5 Direct manip + history | ⬜ | ⬜ | |
| P3 Charts | Done | ✅ PASS | |
| P4 Builder UI | Done | ✅ PASS | |
| P5 Direct manip + history | Done | ✅ PASS | |