9 Commits

Author SHA1 Message Date
Maxim Dolgolyov 69df2f8190 @
chore(quantik-game): полировка по финальному ревью + security-review

Финальное ревью: READY TO MERGE (0 блокеров). Security: SECURE (0 critical).
Применены дешёвые фиксы из ревью:
- validateSpec: блок game{} санитизируется ПОИМЁННО (chapter/subject →
  sanitizeText, order/par_ms/unlockStars → проверка типа, неизвестные ключи
  отбрасываются) — закрыт латентный хранимый XSS (раньше clean.game=spec.game).
- quantik.html: @media (prefers-reduced-motion) делает анимации мгновенными
  (не выключает — иначе forwards-появление узлов оставило бы их скрытыми).
- progress-logic.js: фикс комментария isUnlocked (сумма звёзд по ВСЕМ уровням
  с меньшим глобальным order, а не «той же главы»).
План: Ф6 (лидерборд/гонка) удалена (Amendment 1, решение пользователя);
финальные гейты отмечены; deferred-бэклог зафиксирован.
Затронутые тесты 45/45; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 17:00:13 +03:00
Maxim Dolgolyov c780b6fd96 @
feat(quantik-game): фаза 5 — авторинг игровых уровней в sim-builder + раздача

Учитель собирает игровой уровень без кода: новая (аддитивная, сворачиваемая)
панель в sim-builder задаёт блок goal (when/title/hint/hold/fail) + до 3
звёзд + game-мету (chapter/order/par_ms); выражения проверяются inline через
SimExpr.compile (без eval). buildSpec/loadFromSim — round-trip без потерь
(goal/game пишутся только при включённом слое; обычная sim не меняется).
Кнопка «Играть» монтирует черновик в SimEngine-модалке (HUD цели из Ф0).
QuantikLevels стал async: подмешивает custom_sims cat=game (свои+
published) в реестр (custom:<dbid>), offline-safe, строки без goal
отбрасываются; deep-link /quantik?level=custom:<id> с серверной проверкой
доступа (own|published → иначе 403/404), мимо геймплейного гейта unlockStars.
Раздача классу — реюз share Ф6 (game-aware ссылка + durable pushNotif).
Правки sim-builder строго аддитивны (параллельная сессия). npm test 259/8
baseline; quantik-authoring 6/6; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 16:09:10 +03:00
Maxim Dolgolyov 8db8171b97 @
fix(pet-sprite): уникальные id градиентов спрайта — фикс «пропадающего» тела

uid градиента питомца строился детерминированно (pg+level+mood+colorKey),
поэтому два питомца с одинаковыми параметрами на одной странице получали
совпадающие id. url(#id) заливки тела резолвился в чужой градиент (часто в
display:none-вьюхе) → тело без заливки, видны только контур/усики/аура.
Проявлялось «случайно» — только при совпадении параметров (нарратор на
карте vs на экране победы в /quantik). Теперь uid — глобальный счётчик
(pg1, pg2, …), коллизий нет. Чинит и /pet, и /dashboard, и игру.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 10:59:17 +03:00
Maxim Dolgolyov 6e33be3de1 @
fix(quantik-game): отображать заработанные звёзды на узлах карты и экране победы

Правило .ic в ls.css (fill:none; stroke:currentColor) перебивало
presentation-атрибуты fill/stroke в starSvg → заработанные звёзды
рисовались как пустые (CSS-свойства приоритетнее presentation-атрибутов).
Цвета звёзд теперь задаются inline style (приоритетнее класса) и в map.js,
и в quantik-game.js. Заодно звезда главы становится сплошной золотой.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 10:43:18 +03:00
Maxim Dolgolyov 0b1925fd3b @
feat(quantik-game): фаза 4 — квантовые способности + SR-комнаты

Глава-созвездие quantum (L12–L16) и фирменные механики — всё через
безопасную модель спеки, движок и бэкенд НЕ тронуты (engine touch = 0):
- Суперпозиция: два тела ball+ball2, goal.when требует ОБА (зеркальный
  закон). Туннелирование: forbidden-зона wall + fail wall.hit && tunnel<1;
  способность тратит энергию → setParam(tunnel,1). Коллапс/прицел: пунктир-
  plot предсказанной траектории на паузе.
- Энергия — клиентский ресурс (localStorage quantik-energy, QuantikEnergy).
- SR-комната: мини-сессия повторения флешкарт в модалке (НЕ iframe),
  LS.fcStudySession/fcReview; «Знаю/Легко» дают энергию; текст карт
  экранируется, картинки — по regex-вайтлисту.
Все 5 уровней проверены на реальном движке (2★ достижимы; суперпозиция
требует оба тела; туннель-гейт блокирует без заряда). npm test 253/8
baseline; lint:routes 0; цепочка разблокировки проходима.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 10:29:35 +03:00
Maxim Dolgolyov 978448d99b @
feat(quantik-game): фаза 3 — граф-уровни (движение по f(x)) + зоны

Новый тип уровня: Квантик едет по кривой y=f(x), которую игрок собирает
слайдерами коэффициентов, проходя сквозь зоны-препятствия. Движок
(аддитивно): plot.runner → env-поля curve.runX/runY/runDone (f компилится
1 раз, питает И кривую, И бегунок-героя, без само-ссылки); type zone
(forbidden/target/collect) → булево env-поле zone.hit. Грамматика
выражений ЗАКРЫТА — никаких inzone()-предикатов, только именованные
env-поля (модель t/tries из Ф0), без eval. Глава-созвездие functions из
5 уровней (луч/синус/парабола/модуль/экспонента), разблокировка 9/11/13/
15/17 (цепочка проходима). validateSpec принимает zone+runner. Все 5
уровней независимо проверены на движке (2★ достижимы). npm test 253/8
baseline; custom-sims 26/26; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-13 17:07:33 +03:00
Maxim Dolgolyov 02ab886bee merge: SimForge (конструктор + улучшения + тумблер + руководство) в quantik-game 2026-06-13 16:32:30 +03:00
Maxim Dolgolyov 34afdafcb1 docs(teacher-guide): глава 21 «Конструктор симуляций» — подробное руководство + актуализация навигации 2026-06-13 15:31:03 +03:00
Maxim Dolgolyov 225e252e3c feat(sim-builder): тумблер «Конструктор симуляций» в админке (feature_sim_builder_enabled) — гейт авторинга + скрытие/редирект 2026-06-13 15:22:59 +03:00
25 changed files with 2461 additions and 101 deletions
+35
View File
@@ -238,3 +238,38 @@ git push origin master
- **Навигация (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=` открывает только разблокированный. - **Навигация (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). - **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; в комментариях `★` заменён на «зв.»). - **Верификация 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; в комментариях `★` заменён на «зв.»).
### Phase 3 — Learnings (Граф-уровни: движение по f(x) + зоны)
- **«Бегунок по кривой» — поле `runner` на `plot`, НЕ новый тип объекта.** `plot.runner:{duration?:8, hold?:true}` превращает ПЕРВУЮ кривую plot в дорожку. Движок в `_buildEnv` (ДО формульных центров, после физ-тел) кладёт `<plotId>.runX` (= `a+(ba)·clamp(t/duration,0,1)` по range кривой), `<plotId>.runY` (= f(runX) ТОЙ ЖЕ скомпил. `cv.exprFn`, что рисует кривую → видимая кривая и путь героя идентичны), `<plotId>.runDone` (1 при t≥duration). **Само-ссылку снимает разделение**: герой = ОБЫЧНЫЙ `point` с `x:'curve.runX', y:'curve.runY'` (glow+trail, визуал P2), а f компилируется один раз и питает И кривую, И бегунок — точка НЕ ссылается на собственный x в одном проходе env. `hold:true` оставляет бегунок на конце (иначе зацикливание по `time.loop`). Кинематический проход (без физики) — герой не тело.
- **Зоны — `type:'zone'` + булево env-поле `<zoneId>.hit`, БЕЗ предикатов в грамматике.** `{type:'zone', id, shape:'rect'|'circle', kind:'forbidden'|'target'|'collect', track?:'ball', x,y,w,h|r, color?, label?}`. Движок считает `<zoneId>.hit` (1/0) в `_buildEnv` **последним** (нужна актуальная позиция героя из тела/формулы) через `_zoneHit(z,env)` (геометрия в мире). `goal.when/fail/stars[].when` ссылаются на поле (`when:'gate.hit'`, `fail:'pit.hit'`). ⛔ **Никаких `inzone(...)` в синтаксис SimExpr** — контракт выражений закрыт, добавляются только именованные env-поля (та же модель безопасности, что `t`/`tries` из Ф0). Рисует `_drawZone` (forbidden=красный пунктир, target=зелёный, collect=золотой пунктир) — цвета ТОЛЬКО в canvas-стоки (fillStyle/strokeStyle), XSS нет. Зона НЕ кладёт `<id>.x/.y` как центр (`hasCenter` пропущен для `type==='zone'` — это область, не точка).
- **ГОЧА имён param (повтор Ф4 SimForge, укусила здесь): `t/w/h/pi/e/E/PI/tau` зарезервированы движком.** `_buildEnv` ставит `env.h = ymaxymin` (высота вьюпорта) и `env.w` — поэтому param с именем `h` (планировался под вершину модуля `a·|xh|+1`) затирался: `abs(xh)` видел h=10 (высота), а не значение слайдера → 0 решающих комбинаций. Фикс — переименовать в `m`. **При добавлении граф-уровней проверять имена коэффициентов против этого списка.** (Сетка-смоук solvability ловит такую ошибку как «0 wins» — обязательна.)
- **Контент: глава `functions` (5 уровней) через хелперы-данные.** `road(exprStr,a,b,dur)` (plot+runner, id 'curve'), `graphHero()` (point ball на curve.runX/runY), `rectZone/circZone(id,kind,...)`, `startMarker`. Уровни: луч `a·x+b`, синус `A·sin(k·x)`, парабола `a·(x5)²+k`, модуль `a·|xm|+1`, экспонента `c·e^(r·x)`. `time:{duration,loop:false}` синхронизирован с `runner.duration`. Управление = обычные `params`-слайдеры коэффициентов (крутишь → кривая+путь перестраиваются live); свободный ввод выражения не понадобился. Звёзды: collect-зона + доп. условие формы кривой (sticky через механизм stars Ф0).
- **Карта/запуск без правок map.js** (подтверждён хэндофф Ф2): глава `functions` в `CHAPTERS` (key/title/subtitle/accent) — узлы рисуются по метаданным, тип спеки карте безразличен. `unlockStars` 9/11/13/15/17 ≤ 18 (макс звёзд 6 физ-уровней) → **нет дедлока** (даже только физ-главы дают 18 ≥ 17). `QuantikGame.start``SimEngine.mount` тот же; спец-вайринг управления НЕ нужен (те же слайдеры). `tintHeroSpec` тинтует point-героя на `curve.runX/runY` штатно. quantik.html: бейдж темы стал per-level (`level.subject`→Физика/Алгебра) — аддитивно, id `qg-pill`.
- **Сервер `validateSpec` (customSimController.js): `zone` в OBJECT_TYPES + поля.** `zone.track` санитизируется как id; `plot.runner.duration` — checkExpr (длина). Готовит авторённые граф-уровни Ф5. x/y/w/h/r зон проходят общий expr-loop. Тест custom-sims.test.js +2 (приём zone+runner спеки; отказ unknown type при разрешённой zone) → 26/26.
- **Верификация Ф3**: `node --check` всех изменённых JS + inline-`<script>` quantik.html — OK; headless vm-смоук (РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`+`levels.js`, DOM/canvas-стаб + виртуальные часы): **per-level solvability** (сетка коэффициентов 625 комбо/уровень) — line 59/625, sine 290/625, parab 88/625, abs 231/625, exp 36/625, у КАЖДОГО найден full-star комбо; **logic** — правильная f→победа без forbidden, плоская f→fail (зашёл в forbidden), zone.hit флипается по позиции, runX/runY/runDone корректны, регресс всех типов + физики без throw, ctx сбалансирован → 29/29. E2E `QuantikGame.start`→onGoal на graph-line-7 → won 2/2. Смоуки удалены. `npm test` 261/253 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function — 0 (только пре-существующие →/⛔ в комментариях; зоны/звёзды — canvas/inline SVG).
### Phase 4 — Learnings (Квантовые способности + SR-комнаты)
- **Все три способности — через БЕЗОПАСНУЮ модель спеки, движок НЕ тронут (engine touch = 0).** План допускал поле `tunnelable` у стены в `_sim_engine.js`, но фактически не понадобилось: **туннелирование** = `forbidden`-зона `wall` + `fail:'wall.hit && tunnel<1'`, где `tunnel` — обычный param (не слайдер). По умолчанию `tunnel` отсутствует в env → SimExpr трактует неизвестный идентификатор как 0 → `tunnel<1` истинно → стена сплошная. Способность зовёт `inst.setParam('tunnel',1)``_buildEnv` спредит ВСЕ `this.params` в env (стр.1193) → `fail` видит `tunnel=1` → стена проницаема. **Суперпозиция** = чистый контент (2 тела `ball`+`ball2`, `goal.when` с обоими). **Прицел** = пауза-тоггл (`inst.pause/play`) над пунктир-`plot`. Ни новой грамматики SimExpr, ни новых типов объектов, ни правок движка.
- **`setParam` для НЕ-слайдер-параметра работает штатно**: ставит `this.params[name]`, слайдера нет → на паузе ре-рендерит. Значение переживает кадр (спредится в env). НО reset физики НЕ трогает `tunnel` (он не нач.условие тела) — поэтому `tunnel` надо ставить ПОСЛЕ `reset()` (в харнессе и в `resetAbilities`). `tunnelUsed`-флаг + сброс `tunnel→0` на новую попытку/mount → заряд тратится один раз за попытку.
- **Энергия — клиентский ресурс, чистая логика (`window.QuantikEnergy`).** localStorage ключ **`quantik-energy`** (целое 0..99). `getEnergy/setEnergy/grantEnergy/spendEnergy/canSpend/rewardForQuality/onEnergyChange`. `TUNNEL_COST=3`; награда `rewardForQuality`: q=5(Легко)→2, q=4(Знаю)→1, иначе 0 (та же шкала, что flashcards.html). `spendEnergy` атомарен (не хватило → false, без списания). `onEnergyChange`-подписки обновляют HUD без перезагрузки (панель подписывается в mountBar, отписывается в destroy — без утечки).
- **SR-комната = РЕЮЗ серверного SR, НЕ iframe и НЕ дубль расписания.** `QuantikAbilities.openRestRoom` — своя модалка в стиле игры: `LS.fcListDecks()` → авто-выбор колоды с макс. `due_count` (одна → сразу учить; несколько → пикер) → `LS.fcStudySession(deckId)` (отдаёт `{cards,total_due}`) → лицо→`Показать ответ`→оценки (Снова0/Трудно3/Знаю4/Легко5) → `LS.fcReview(cardId,quality)` (отдаёт `{ok,graduated,...}`; `graduated=false` → re-queue в пределах сессии через RQ_GAP, как flashcards.html). «Знаю/Легко» начисляют энергию ОПТИМИСТИЧНО (до ответа сети). Пусто (нет колод / нет due / SR недоступен) → дружелюбное окно + ссылка `/flashcards`. Картинка карты — только свой `/uploads/flashcards/...` (regex-гейт), текст escape.
- **Клиентские врапера SR в `js/api.js`**: `fcStudySession(deckId)` = GET `/flashcards/decks/${id}/study`, `fcReview(cardId,quality)` = POST `/flashcards/cards/${id}/review` `{quality}` — стиль блока `fcListDecks/fcCreateDeck/fcAddCard`. Контроллер `flashcardController.getStudySession`/`submitReview` уже существовал (Tier-1 SR, мигр.074) — бэкенд не трогался, lint:routes/тесты неизменны.
- **`tintHeroSpec` (quantik-game.js) тинтует `ball` И `ball2`**: ball — цвет скина, ball2 — осветлённый «фантом» (`lighten(color,0.42)`, hex→белый). Авторские id ВНЕ `ball`/`ball2` скином не тинтуются (Phase 5 при желании расширит список). Панель способностей оборачивает `inst.destroy` (снимает бар) — аддитивно, без правки lifecycle движка.
- **Глава `quantum` (L12–L16) появляется на карте без правок map.js** (контракт Ф2 подтверждён 3-й раз): `groupByChapter`+`Levels.chapter` метадата-driven. `CHAPTERS.quantum` (accent `#C4B5FD`). `unlockStars` 19/20/22/24/26 ≤ кумулятив макс-звёзд всех уровней меньшего `order` (по 3 звезды/уровень: 18 физ + 15 граф = 33 до L12 ≥ 19) → **нет дедлока** (проверено цепочкой). `isUnlocked` считает звёзды по ВСЕМ уровням с меньшим глобальным `order`, не по главе.
- **Активация способностей — по СОДЕРЖИМОМУ спеки, не по флагу уровня**: `levelHasTunnel(level)` = слово `tunnel` в `goal.fail/when/stars[].when`; `levelHasAim(level)` = на сцене `plot` с `id:'aim'` ИЛИ `lineStyle:'dashed'`. Кнопка появляется только если уместна. Контракт для авторского UI Ф5.
- **ГОЧА харнесса solvability (физ-уровни): mount планирует ОТЛОЖЕННЫЙ rAF, который делает `_fit`+`reset`(+autoplay).** Если не «слить» его ДО своего `play()`, он выстрелит в середине прогона, вызовет `reset→pause→cancelAnimationFrame` и убьёт кадровый цикл (тело стоит на старте, `t=0`, 0 wins у ЗАВЕДОМО решаемого уровня). Фикс: после mount слить отложенный callback БЕЗ продвижения часов, затем `pause()`, конфиг params, `reset()`, `play()`, гнать кадры с виртуальными часами (8.33мс/кадр, `performance.now` синхронен с таймстампом rAF). Headless-смоук физики обязан гнать РЕАЛЬНУЮ физику (`SimPhysics` экспортится из `_sim_engine.js`).
- **Контент-фикс L16 (поймал sweep)**: монета `(5,6)` r0.7 у параболы `a·(x5)²+k` (вершина в `(5,k)`) собиралась при `5.3<k<6.7`, а 2-я звезда требует `k≥6.8`**взаимоисключающие → full-star недостижим**. Сдвинул монету на `(5,6.9)` r0.85 → пересечение с `k≥6.8` есть → full-star достижим (a-0.25/k7.2). **Урок: проверять full-star reachability sweep'ом, а не только «есть ли победа».**
- **Верификация Ф4**: `node --check` всех изменённых JS + inline quantik.html — OK; headless vm-смоук (РЕАЛЬНЫЕ `_sim_expr`+`_sim_engine`+`levels`+`progress-logic`+`quantik-abilities`, DOM/canvas-стаб + виртуальный rAF-клок): энергия grant/spend/reward/clamp/notify; суперпозиция-`when` требует ОБА тела; tunnel флипает fail (вкл. absent→0); per-level solvability (L12 52 win, L13/L14/L15/L16 ≥3 win + full-star у всех 5; L15/L16 БЕЗ tunnel = 0 win → гейт работает); регресс 11 существующих уровней mount+step без throw → **48/48**, удалён. `npm test` 261/253 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function в UI — 0 (`⛔` U+26D4 — только в комментариях, пре-существующая конвенция всего кодбейза; способности — inline SVG `.ic`).
### Phase 5 — Learnings (Авторинг уровней в sim-builder + раздача классу)
- **Бэкенд почти не понадобился — Ф0/Ф3/Ф6 уже всё дали.** `validateSpec` уже пропускал `goal`/`game` (Ф0), `CATS` уже содержал `'game'`, `share`/`clone`/`links`/per-row-ownership/`GET /:id` (own|published|admin) — Ф6. Единственная серверная правка: в `share()` для `cat==='game'` переключить ссылку на `/quantik?level=custom:<id>` + тип `game_level_shared` (иначе `/lab?sim=…`+`sim_shared`); ответ дополнен `link`. Доступ к чужому draft (deep-link/embed-утечка) закрыт ТЕМ ЖЕ `GET /:id` 403 — отдельной защиты не потребовалось.
- **⚠️ ПАРАЛЛЕЛЬНАЯ СЕССИЯ на ветке правит sim-builder.js/.html → все правки строго АДДИТИВНЫЕ.** В sim-builder.js тронуто минимум существующих строк: по 1 врезке в `blankState`(+блок `game`), `loadFromSim`(+`st.game=loadGame(...)`), `buildSpec`(+материализация при `st.game.enabled`), `renderPanels`(+`sectionGame()`), `validate`(+проверка goal-выражений), `wirePanels`(+блок game-листенеров перед `renderLatexPreviews`), `onAdd`(+ветка `'star'`), `_open`(+`game:false`). НОВЫЕ методы/функции: `sectionGame`, `playGame`, модульные `loadGame`/`buildGoal`/`buildGameMeta`. HTML — только +CSS-блок `.sbu-game-fields/.sbu-star/.sbu-star-hdr/.sbu-stars-list`. **Никаких переформатирований/перестановок** — минимизирует merge-конфликты.
- **Игровой слой ⇄ UI = `st.game = { enabled, when,title,hint,hold,fail, stars:[{when,label}], chapter,order,par_ms }`.** Хранит «как введено» (строки/числа), как plot-range в Ф4. `buildGoal`/`buildGameMeta` материализуют → `spec.goal`/`spec.game` (числа коэрсятся: hold/order/par_ms; пустые поля выкидываются; звёзды clamp ≤3). `loadGame(spec.goal,spec.game)` включает слой, если присутствует goal ИЛИ game. **Выключенный `enabled` → goal/game НЕ эмитятся** → обычная симуляция ведёт себя ровно как раньше. Round-trip `buildSpec→loadFromSim→buildSpec``deepEqual` goal+game (доказано смоуком).
- **«Играть» = монтировать `SimEngine` в модалке, НЕ открывать /quantik.** На странице sim-builder уже загружены `_sim_expr`+`_sim_engine`; HUD/победа/звёзды активируются САМИ наличием блока `goal` (Ф0 движка) — `QuantikGame` не нужен, доп. скрипт-тегов нет. Тестирует ЧЕРНОВИК без сохранения/сети. Инстанс уничтожается на закрытии модалки (кнопка + `m.onClose`, если поддерживается). Если `goal.when` пуст — тост-подсказка, модалку не открываем.
- **`QuantikLevels` стал асинхронным (контракт Ф1 исполнен).** `ensureCustom()` (Promise, кэш `_customPromise`): `LS.customSimsList()` → фильтр `cat==='game'` (список БЕЗ spec) → `LS.customSimGet(id)` каждой → `customToLevel(row)`. `list()=LEVELS.concat(CUSTOM)`, `get(id)` ищет в обоих. **`getAsync(id)`** для deep-link: в кэше → синхронно; иначе `custom:<dbid>``LS.customSimGet(dbid)` (сервер-доступ own|published|admin), резолвнутый уровень подмешивается в `CUSTOM` (повторное открытие/«Дальше» синхронны). Встроенные уровни — offline, как раньше.
- **Запись авторённого уровня (`customToLevel`)**: `{ id:'custom:<dbid>', dbid, title, chapter:(game.chapter||'custom'), order:(game.order|| 1000+dbid), unlockStars:(game.unlockStars||0), par_ms, subject, hint:(goal.hint), spec, _custom:true }`. Без `goal``null` (не уровень). Глава по умолчанию **`custom`** (новая `CHAPTERS.custom`, accent `#F472B6`) — map.js рисует автоматически (метадата-driven, не тронут, контракт Ф2 подтверждён в 4-й раз). `order` дефолт `1000+dbid` ставит custom-уровни ПОСЛЕ встроенных в сортировке.
- **Deep-link `?level=custom:<id>` открывается БЕЗ гейта `unlockStars`** (получатель ссылки/автор заходит прямо в уровень); встроенный `?level=<id>` — через `isUnlocked` как раньше. quantik.html: `Promise.all([loadProgress(), ensureCustom()])` до первого `map.render`, deep-link через `getAsync`. Прогресс по custom-уровням: `gameProgressSubmit('custom:<dbid>',…)``game_progress.level_id` TEXT≤120, двоеточие проходит, бэкенд НЕ менялся.
- **Верификация Ф5**: `node --check` всех изменённых JS + inline обоих HTML — OK; headless vm-смоук (РЕАЛЬНЫЕ `_sim_expr`+`sim-builder`+`levels`, DOM-стаб) 7/7: blank без goal/game; материализация goal+game; round-trip `deepEqual`; non-game sim не включает слой; `validate` ловит пустой/битый `when`; `customToLevel` маппинг + дефолты + null-для-non-game — удалён. Бэкенд-тест `tests/quantik-authoring.test.js` 6/6 (создание game-уровня, чужой draft→403, published виден, share→`game_level_shared`+`/quantik`-ссылка+авто-публикация, >3 звезды→400). `npm test` 267/259 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function — 0 (новый UI — inline SVG `.ic`, выражения — только `SimExpr`).
+1 -1
View File
@@ -525,7 +525,7 @@ function getFeatures(_req, res) {
function updateFeatures(req, res) { function updateFeatures(req, res) {
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection', const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom', 'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
'gamification', 'assistant']; 'gamification', 'assistant', 'sim_builder'];
const updates = req.body; const updates = req.body;
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)"); const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?"); const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
+31 -7
View File
@@ -28,6 +28,7 @@ const MAX_POINTS = 1000; // точек в polyline/path/points
const OBJECT_TYPES = new Set([ const OBJECT_TYPES = new Set([
'point', 'segment', 'vector', 'circle', 'rect', 'point', 'segment', 'vector', 'circle', 'rect',
'polyline', 'path', 'label', 'plot', 'readout', 'polyline', 'path', 'label', 'plot', 'readout',
'zone', // Квантик Ф3: зона-препятствие/цель/сбор (граф-уровни)
]); ]);
const STATUSES = new Set(['draft', 'published']); const STATUSES = new Set(['draft', 'published']);
@@ -189,6 +190,14 @@ function validateSpec(spec) {
out.drag.param = sanitizeText(o.drag.param, 60); out.drag.param = sanitizeText(o.drag.param, 60);
if (o.drag.paramY !== undefined) out.drag.paramY = sanitizeText(o.drag.paramY, 60); if (o.drag.paramY !== undefined) out.drag.paramY = sanitizeText(o.drag.paramY, 60);
} }
// zone{} — track = id отслеживаемой точки (Квантик Ф3): санитизируем как id.
if (type === 'zone' && o.track !== undefined) out.track = sanitizeText(o.track, 60);
// runner{} на plot (Квантик Ф3): duration — число/выражение (длина).
if (o.runner && typeof o.runner === 'object' && !Array.isArray(o.runner)) {
if (o.runner.duration !== undefined) checkExpr(o.runner.duration, `objects[${i}].runner.duration`, errs);
}
return out; return out;
}); });
@@ -266,10 +275,18 @@ function validateSpec(spec) {
clean.goal = cg; clean.goal = cg;
} }
// game{} — зарезервированный блок мета-слоя (Фаза 1/5). Пропускаем как есть // game{} — мета-слой игрового уровня (Фаза 1/5). Санитизируем ПОИМЁННО (как goal):
// (проверен общими лимитами: размер/глубина). Не исполняем. // строки → sanitizeText (escape), числа → проверка типа, неизвестные ключи отбрасываем.
// Иначе произвольная строка в game.* стала бы хранимым XSS у любого, кому раздали уровень.
if (spec.game && typeof spec.game === 'object' && !Array.isArray(spec.game)) { if (spec.game && typeof spec.game === 'object' && !Array.isArray(spec.game)) {
clean.game = spec.game; const gm = spec.game;
const cgm = {};
if (gm.chapter !== undefined) cgm.chapter = sanitizeText(gm.chapter, 60);
if (gm.subject !== undefined) cgm.subject = sanitizeText(gm.subject, 60);
if (typeof gm.order === 'number') cgm.order = gm.order;
if (typeof gm.par_ms === 'number') cgm.par_ms = gm.par_ms;
if (typeof gm.unlockStars === 'number') cgm.unlockStars = gm.unlockStars;
clean.game = cgm;
} }
if (errs.length) return { ok: false, error: errs.slice(0, 8).join('; ') }; if (errs.length) return { ok: false, error: errs.slice(0, 8).join('; ') };
@@ -445,17 +462,24 @@ function share(req, res) {
} }
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель'; const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
const simTitle = row.title || 'симуляция'; const isGame = row.cat === 'game';
const link = '/lab?sim=custom:' + row.id; const simTitle = row.title || (isGame ? 'игровой уровень' : 'симуляция');
// Игровой уровень открывается в «Квантике» (/quantik?level=custom:<id>),
// обычная симуляция — в лаборатории (/lab?sim=custom:<id>). Фаза 5/6.
const link = (isGame ? '/quantik?level=custom:' : '/lab?sim=custom:') + row.id;
const notifType = isGame ? 'game_level_shared' : 'sim_shared';
const notifMsg = isGame
? `Новый игровой уровень от ${teacherName}: «${simTitle}»`
: `Новая симуляция от ${teacherName}: «${simTitle}»`;
const recipients = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId).map(r => r.user_id); const recipients = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId).map(r => r.user_id);
let sent = 0; let sent = 0;
for (const uid of recipients) { for (const uid of recipients) {
if (!uid || uid === req.user.id) continue; if (!uid || uid === req.user.id) continue;
pushNotif(uid, 'sim_shared', `Новая симуляция от ${teacherName}: «${simTitle}»`, link); pushNotif(uid, notifType, notifMsg, link);
sent++; sent++;
} }
res.json({ ok: true, sent, status: 'published' }); res.json({ ok: true, sent, status: 'published', link });
} }
/* POST /api/custom-sims/:id/clone — копия спеки текущему пользователю как draft. /* POST /api/custom-sims/:id/clone — копия спеки текущему пользователю как draft.
+13 -7
View File
@@ -6,29 +6,35 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth'); const { authMiddleware, requireRole } = require('../middleware/auth');
const { requireFeature } = require('../middleware/features');
const c = require('../controllers/customSimController'); const c = require('../controllers/customSimController');
router.use(authMiddleware); router.use(authMiddleware);
// «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
// Чтение/проигрывание уже сохранённых симуляций остаётся доступным; гейтим только
// авторинг — создание/правку/удаление/раздачу/клон/связи.
const gate = requireFeature('sim_builder');
router.get('/', c.list); router.get('/', c.list);
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler // @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
router.get('/:id', c.get); router.get('/:id', c.get);
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler // @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
router.get('/:id/related', c.related); router.get('/:id/related', c.related);
router.post('/', requireRole('teacher', 'admin'), c.create); router.post('/', gate, requireRole('teacher', 'admin'), c.create);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.put('/:id', requireRole('teacher', 'admin'), c.update); router.put('/:id', gate, requireRole('teacher', 'admin'), c.update);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.delete('/:id', requireRole('teacher', 'admin'), c.remove); router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove);
// Фаза 6 — раздача классу / клон / курикулумные связи. Мутации — inline // Фаза 6 — раздача классу / клон / курикулумные связи. Мутации — inline
// requireRole(teacher,admin) + per-row ownership в хендлере. // requireRole(teacher,admin) + per-row ownership в хендлере.
router.post('/:id/share', requireRole('teacher', 'admin'), c.share); router.post('/:id/share', gate, requireRole('teacher', 'admin'), c.share);
router.post('/:id/clone', requireRole('teacher', 'admin'), c.clone); router.post('/:id/clone', gate, requireRole('teacher', 'admin'), c.clone);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.post('/:id/links', requireRole('teacher', 'admin'), c.addLink); router.post('/:id/links', gate, requireRole('teacher', 'admin'), c.addLink);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.delete('/:id/links/:linkId', requireRole('teacher', 'admin'), c.removeLink); router.delete('/:id/links/:linkId', gate, requireRole('teacher', 'admin'), c.removeLink);
module.exports = router; module.exports = router;
+28
View File
@@ -203,6 +203,34 @@ describe('/api/custom-sims', () => {
assert.ok(txt.includes('&lt;img'), 'escaped form present'); assert.ok(txt.includes('&lt;img'), 'escaped form present');
}); });
it('accepts graph-level spec with zone + runner (Квантик Ф3)', async () => {
const spec = {
specVersion: 1,
meta: { title: 'Граф-уровень' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8 },
params: [{ name: 'a', min: -1, max: 2, step: 0.05, value: 0.5 }],
objects: [
{ id: 'curve', type: 'plot', expr: 'a*x', var: 'x', range: [0, 10], runner: { duration: 5 } },
{ id: 'ball', type: 'point', x: 'curve.runX', y: 'curve.runY', r: 7 },
{ type: 'zone', id: 'pit', kind: 'forbidden', shape: 'rect', x: 5, y: 0, w: 4, h: 2, track: 'ball', label: 'яма' },
{ type: 'zone', id: 'gate', kind: 'target', shape: 'circle', x: 10, y: 5, r: 1, track: 'ball' },
],
goal: { when: 'gate.hit', fail: 'pit.hit', stars: [{ when: 'gate.hit' }] },
};
const res = await inject('POST', '/api/custom-sims', { spec }, teacherToken);
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, teacherToken);
const objs = get.body.sim.spec.objects;
assert.ok(objs.find(o => o.type === 'zone' && o.id === 'pit'), 'zone object preserved');
assert.ok(objs.find(o => o.type === 'plot' && o.runner), 'runner block preserved');
});
it('rejects unknown object type even with zone allowed (400)', async () => {
const bad = { ...VALID_SPEC, objects: [{ type: 'zoney_fake', x: 0, y: 0 }] };
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('owner can DELETE own sim (then 404)', async () => { it('owner can DELETE own sim (then 404)', async () => {
const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, teacherToken); const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, teacherToken);
assert.equal(del.status, 200, `got ${del.status}`); assert.equal(del.status, 200, `got ${del.status}`);
+136
View File
@@ -0,0 +1,136 @@
'use strict';
/**
* Integration tests: Квантик Фаза 5 — авторинг/раздача игровых уровней.
* Уровень = custom_sims с cat='game' + блок goal/game в спеке. Покрываем:
* - создание игрового уровня (goal+game принимаются validateSpec'ом);
* - доступ: чужой DRAFT игровой уровень → 403 (deep-link/embed не утечёт),
* свой draft / чужой published → виден;
* - раздача классу игрового уровня шлёт ДОЛГОВЕЧНОЕ уведомление со ссылкой
* /quantik?level=custom:<id> (тип game_level_shared), авто-публикация.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Mount /api/custom-sims on the shared test app (setup.js его не монтирует).
app.use('/api/custom-sims', require('../src/routes/customSims'));
after(() => cleanup());
/* Минимальная валидная спека ИГРОВОГО уровня: goal + game-метаданные. */
const GAME_SPEC = {
specVersion: 1,
meta: { title: 'Мой уровень' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8 },
params: [{ name: 'theta', label: 'Угол', min: 10, max: 80, step: 1, value: 45 }],
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
objects: [
{ id: 'ball', type: 'point', x: 0, y: 0, r: 7, body: { mass: 1, vx: 'cos(theta*pi/180)*10', vy: 'sin(theta*pi/180)*10' } },
{ type: 'circle', id: 'gate', x: 8, y: 1, r: 0.8, color: '#A78BFA' },
],
goal: {
title: 'Попади в портал',
hint: 'Подбери угол',
when: 'hypot(ball.x - 8, ball.y - 1) < 0.8',
fail: 'ball.y < -1 || t > 8',
stars: [{ when: 't*1000 <= 1500', label: 'Быстро' }],
},
game: { chapter: 'custom', order: 3, par_ms: 1500 },
};
function seedClass(teacherId, studentIds) {
const code = 'C' + Math.random().toString(36).slice(2, 10).toUpperCase();
const r = db.prepare(
'INSERT INTO classes (name, teacher_id, invite_code) VALUES (?, ?, ?)'
).run('Класс ' + code, teacherId, code);
const classId = Number(r.lastInsertRowid);
const ins = db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)');
for (const uid of studentIds) ins.run(classId, uid);
return classId;
}
async function createGameLevel(token, overrides) {
return inject('POST', '/api/custom-sims',
Object.assign({ title: 'Мой уровень', cat: 'game', spec: GAME_SPEC }, overrides || {}), token);
}
describe('Квантик Ф5 — авторинг игровых уровней', () => {
let teacher, otherTeacher, student, studentB, admin;
before(async () => {
teacher = await getToken('teacher');
otherTeacher = await getToken('teacher');
student = await getToken('student');
studentB = await getToken('student');
admin = await getToken('admin');
});
it('teacher creates a GAME level (cat=game, goal+game preserved)', async () => {
const res = await createGameLevel(teacher.token);
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, teacher.token);
const s = get.body.sim;
assert.equal(s.cat, 'game', 'cat=game accepted');
assert.ok(s.spec.goal && s.spec.goal.when, 'goal.when preserved');
assert.equal(s.spec.goal.stars.length, 1, 'star preserved');
assert.ok(s.spec.game && s.spec.game.chapter === 'custom', 'game.chapter preserved');
assert.equal(s.spec.game.order, 3, 'game.order preserved');
assert.equal(s.spec.game.par_ms, 1500, 'game.par_ms preserved');
});
it("another user's DRAFT game level → 403 (deep-link / ensureSpec cannot leak)", async () => {
const c = await createGameLevel(teacher.token); // draft
const get = await inject('GET', `/api/custom-sims/${c.body.id}`, null, otherTeacher.token);
assert.equal(get.status, 403, `got ${get.status}`);
// student also cannot open another user's draft level
const getS = await inject('GET', `/api/custom-sims/${c.body.id}`, null, student.token);
assert.equal(getS.status, 403, `student got ${getS.status}`);
});
it('published game level is visible to any user (deep-link works)', async () => {
const c = await createGameLevel(teacher.token, { status: 'published' });
assert.equal(c.status, 201);
const get = await inject('GET', `/api/custom-sims/${c.body.id}`, null, student.token);
assert.equal(get.status, 200, 'student can read published game level');
assert.ok(get.body.sim.spec.goal, 'goal present');
// и присутствует в общем списке для другого учителя
const list = await inject('GET', '/api/custom-sims', null, otherTeacher.token);
assert.ok(list.body.sims.find(s => s.id === c.body.id && s.cat === 'game'), 'published game in list');
});
it('owner sees own draft game level (for editing)', async () => {
const c = await createGameLevel(teacher.token);
const get = await inject('GET', `/api/custom-sims/${c.body.id}`, null, teacher.token);
assert.equal(get.status, 200, 'owner reads own draft');
});
it('share game level → game_level_shared notification with /quantik link + auto-publish', async () => {
const classId = seedClass(teacher.userId, [student.userId, studentB.userId]);
const c = await createGameLevel(teacher.token); // draft
const simId = c.body.id;
const res = await inject('POST', `/api/custom-sims/${simId}/share`, { classId }, teacher.token);
assert.equal(res.status, 200, `got ${res.status}: ${JSON.stringify(res.body)}`);
assert.equal(res.body.sent, 2, 'two students notified');
assert.equal(res.body.status, 'published', 'auto-published');
assert.equal(res.body.link, '/quantik?level=custom:' + simId, 'reports game link');
const after = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(simId);
assert.equal(after.status, 'published', 'sim auto-published in DB');
const notif = db.prepare(
"SELECT type, link FROM notifications WHERE user_id = ? AND type = 'game_level_shared' ORDER BY id DESC"
).get(student.userId);
assert.ok(notif, 'student has game_level_shared notification');
assert.equal(notif.link, '/quantik?level=custom:' + simId, 'notification links to /quantik');
});
it('rejects game level with too many stars (>3) (400)', async () => {
const bad = {
...GAME_SPEC,
goal: { ...GAME_SPEC.goal, stars: [{ when: 'a' }, { when: 'b' }, { when: 'c' }, { when: 'd' }] },
};
const res = await createGameLevel(teacher.token, { spec: bad });
assert.equal(res.status, 400, `got ${res.status}`);
});
});
+1
View File
@@ -17,6 +17,7 @@
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' }, { key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' }, { key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' }, { key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
]; ];
const FS_FEATURES = [ const FS_FEATURES = [
+663 -6
View File
@@ -378,24 +378,681 @@
} }
}; };
var LEVELS = [artillery1, arc2, bounce3, pendulum4, orbit5, slingshot6]; /* ─────────────────────────────────────────────────────────────────────────
Глава III — «Функции» (созвездие): Квантик едет по кривой y=f(x), которую
СОБИРАЕТ игрок (слайдеры коэффициентов). Препятствия — зоны (Ф3 движка):
forbidden — задел → проигрыш (fail);
target — добрался → победа (goal);
collect — задел → звезда-бонус (sticky).
Реюз: plot+runner рисует кривую и ведёт по ней героя (одна скомпил. функция),
зоны дают булевы env-поля <id>.hit. Без eval — только SimExpr-выражения.
──────────────────────────────────────────────────────────────────────── */
var FORB = '#F87171'; // запретная зона (красный)
var TARG = '#34D399'; // целевая зона (зелёный)
var COIN = '#FBBF24'; // зона-сбор (золото)
var CURVE = '#67E8F9'; // цвет кривой-«дороги»
/* helper: герой-точка, едет по бегунку кривой (id 'curve'): x=curve.runX, y=curve.runY.
glow+trail (визуал P2). НЕ тело (кинематический проход) → не само-ссылка. */
function graphHero() {
return {
id: 'ball', type: 'point', r: 7, color: HERO,
x: 'curve.runX', y: 'curve.runY',
glow: true, glowColor: HERO, trail: true, trailColor: HERO, trailFade: true
};
}
/* helper: plot-«дорога» = кривая f(x) с бегунком. exprStr — выражение кривой. */
function road(exprStr, a, b, dur) {
return {
id: 'curve', type: 'plot', expr: exprStr, var: 'x',
range: [a, b], samples: 220, color: CURVE, width: 3, glow: true, glowColor: CURVE,
runner: { duration: dur, hold: true }
};
}
function rectZone(id, kind, x, y, w, h, label) {
var col = kind === 'target' ? TARG : kind === 'collect' ? COIN : FORB;
return { type: 'zone', id: id, kind: kind, shape: 'rect', x: x, y: y, w: w, h: h,
color: col, label: label || '' };
}
function circZone(id, kind, x, y, r, label) {
var col = kind === 'target' ? TARG : kind === 'collect' ? COIN : FORB;
return { type: 'zone', id: id, kind: kind, shape: 'circle', x: x, y: y, r: r,
color: col, label: label || '' };
}
function startMarker(x, y) {
return { type: 'circle', x: x, y: y, r: 0.16, color: '#94A3B8', width: 0, fill: '#94A3B8' };
}
/* Уровень 7: «Луч через ворота» — прямая f(x)=a·x+b. Сверху и снизу в центре —
запретные брусья; проведи луч между ними в зелёный портал справа. Монета по центру. */
var graphLine7 = {
id: 'graph-line-7',
title: 'Луч через ворота',
chapter: 'functions',
order: 7,
unlockStars: 9,
par_ms: 5200,
subject: 'algebra',
hint: 'Квантик поедет по прямой y = a·x + b. Подбери наклон a и сдвиг b, чтобы луч прошёл между брусьями в воротах и достиг зелёного портала. Монета — точно в центре прохода.',
spec: {
specVersion: 1,
meta: { title: 'Луч через ворота', desc: 'Линейная функция: y = a·x + b.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8, grid: true, axes: true, bg: BG },
time: { duration: 5, loop: false },
params: [
{ name: 'a', label: 'Наклон a', min: -0.5, max: 1.5, step: 0.05, value: 0.2 },
{ name: 'b', label: 'Сдвиг b', min: 0, max: 5, step: 0.1, value: 1 }
],
objects: [
// ворота в центре: верхний и нижний брус (проход 1..6 по y при x≈5)
rectZone('topbar', 'forbidden', 5, 7.0, 4, 2.4, 'стена'),
rectZone('botbar', 'forbidden', 5, -0.2, 4, 2.4, 'стена'),
// монета в центре прохода
circZone('coin', 'collect', 5, 3, 0.7, 'монета'),
// целевой портал справа
circZone('gate', 'target', 10, 5, 0.95, 'портал'),
startMarker(0, 1),
road('a*x + b', 0, 10, 5),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'b', expr: 'b', precision: 1 }
],
goal: {
title: 'Проведи луч в портал',
hint: 'Достигни зелёного портала, не задев красные брусья. Бонус: собери монету.',
when: 'gate.hit',
fail: 'topbar.hit || botbar.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && b < 1.6', label: 'Низкий старт (b < 1.6)' }
]
}
}
};
/* Уровень 8: «Синус сквозь врата» — f(x)=A·sin(k·x). Сверху/снизу — зубцы-стены
с просветами; подбери амплитуду и частоту, чтобы волна прошла сквозь все просветы. */
var graphSine8 = {
id: 'graph-sine-8',
title: 'Синус сквозь врата',
chapter: 'functions',
order: 8,
unlockStars: 11,
par_ms: 6000,
subject: 'algebra',
hint: 'Квантик едет по волне y = A·sin(k·x). Подбери амплитуду A и частоту k, чтобы волна прошла сквозь просветы между зубцами. Монеты — в гребне и впадине волны.',
spec: {
specVersion: 1,
meta: { title: 'Синус сквозь врата', desc: 'Синусоида: y = A·sin(k·x).' },
viewport: { xmin: -0.5, xmax: 12.5, ymin: -5, ymax: 5, grid: true, axes: true, bg: BG },
time: { duration: 6, loop: false },
params: [
{ name: 'A', label: 'Амплитуда A', min: 1, max: 4, step: 0.1, value: 2 },
{ name: 'k', label: 'Частота k', min: 0.3, max: 1.6, step: 0.05, value: 0.6 }
],
objects: [
// зубцы: верхний брус над гребнем (x≈π/2k) и нижний под впадиной (x≈3π/2k)
// при целевом k≈0.79 (π/4): гребень x≈2, впадина x≈6, гребень x≈10
rectZone('spikeTop', 'forbidden', 2, 4.4, 1.2, 2.4, ''),
rectZone('spikeBot', 'forbidden', 6, -4.4, 1.2, 2.4, ''),
rectZone('spikeTop2', 'forbidden', 10, 4.4, 1.2, 2.4, ''),
// монеты в гребне/впадине
circZone('coinTop', 'collect', 2, 3, 0.6, ''),
circZone('coinBot', 'collect', 6, -3, 0.6, ''),
// портал на третьем гребне
circZone('gate', 'target', 11.2, 0, 1.0, 'портал'),
startMarker(0, 0),
road('A*sin(k*x)', 0, 11.5, 6),
graphHero(),
{ type: 'readout', label: 'A', expr: 'A', precision: 1 },
{ type: 'readout', label: 'k', expr: 'k', precision: 2 }
],
goal: {
title: 'Проведи волну в портал',
hint: 'Подбери волну так, чтобы пройти все просветы и достичь портала. Бонус: собери монеты в гребне и впадине.',
when: 'gate.hit',
fail: 'spikeTop.hit || spikeBot.hit || spikeTop2.hit',
stars: [
{ when: 'coinTop.hit', label: 'Монета-гребень' },
{ when: 'coinBot.hit', label: 'Монета-впадина' }
]
}
}
};
/* Уровень 9: «Парабола над ямой» — f(x)=a·(x-3)^2+k (вершина в (3,k)... меняем).
В центре — запретная яма; перебрось параболу-дугу над ней в портал. */
var graphParab9 = {
id: 'graph-parab-9',
title: 'Парабола над ямой',
chapter: 'functions',
order: 9,
unlockStars: 13,
par_ms: 6000,
subject: 'algebra',
hint: 'Квантик едет по параболе y = a·(x − 5)² + k. Подбери раскрытие a (вниз — отрицательное) и высоту вершины k, чтобы дуга прошла над ямой в портал. Монета — на вершине дуги.',
spec: {
specVersion: 1,
meta: { title: 'Парабола над ямой', desc: 'Квадратичная: y = a·(x 5)² + k.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 9, grid: true, axes: true, bg: BG },
time: { duration: 6, loop: false },
params: [
{ name: 'a', label: 'Раскрытие a', min: -0.6, max: -0.1, step: 0.02, value: -0.3 },
{ name: 'k', label: 'Высота вершины k', min: 4, max: 8, step: 0.1, value: 6 }
],
objects: [
// яма в центре (запретная) — занимает низ по центру
rectZone('pit', 'forbidden', 5, 1.4, 5.0, 3.0, 'яма'),
// монета на вершине
circZone('coin', 'collect', 5, 6, 0.7, 'монета'),
// портал у правого края, низко (нужно спуститься на нисходящей ветви)
circZone('gate', 'target', 9.5, 4.4, 0.95, 'портал'),
startMarker(0.5, 0), // ориентир старта (фактический старт = f(0))
road('a*(x-5)^2 + k', 0, 9.5, 6),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'k', expr: 'k', precision: 1 }
],
goal: {
title: 'Дугой над ямой в портал',
hint: 'Перебрось дугу над ямой к порталу. Бонус: задень монету на вершине.',
when: 'gate.hit',
fail: 'pit.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && k >= 6.4', label: 'Высокая дуга (k ≥ 6.4)' }
]
}
}
};
/* Уровень 10: «Угол модуля» — f(x)=a·abs(x-h)+1. V-образная кривая: подбери
раскрытие a и положение вершины h, чтобы «галочка» обошла два бруса и попала в портал. */
var graphAbs10 = {
id: 'graph-abs-10',
title: 'Угол модуля',
chapter: 'functions',
order: 10,
unlockStars: 15,
par_ms: 6000,
subject: 'algebra',
hint: 'Квантик едет по «галочке» y = a·|x − m| + 1. Подбери крутизну a и положение вершины m, чтобы остриё прошло в проход и луч попал в портал. Монета — у самого острия.',
spec: {
specVersion: 1,
meta: { title: 'Угол модуля', desc: 'Модуль: y = a·|x m| + 1.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 9, grid: true, axes: true, bg: BG },
time: { duration: 6, loop: false },
params: [
// m = положение вершины (имя 'h' зарезервировано движком под высоту вьюпорта!)
{ name: 'a', label: 'Крутизна a', min: 0.6, max: 2.2, step: 0.05, value: 1.2 },
{ name: 'm', label: 'Вершина m', min: 3, max: 7, step: 0.1, value: 5 }
],
objects: [
// нижние брусья слева и справа от прохода-острия (вершина должна попасть в проход)
rectZone('floorL', 'forbidden', 2.4, -0.2, 2.4, 1.4, ''),
rectZone('floorR', 'forbidden', 7.6, -0.2, 2.4, 1.4, ''),
// монета у острия (низко, по центру)
circZone('coin', 'collect', 5, 1, 0.7, 'монета'),
// портал справа-вверху (на восходящей ветви)
circZone('gate', 'target', 9.6, 6.4, 1.0, 'портал'),
startMarker(0, 7),
road('a*abs(x-m) + 1', 0, 9.6, 6),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'm', expr: 'm', precision: 1 }
],
goal: {
title: 'Галочкой в портал',
hint: 'Проведи остриё галочки в проход между брусьями и попади в портал. Бонус: задень монету у острия.',
when: 'gate.hit',
fail: 'floorL.hit || floorR.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && m >= 4.6 && m <= 5.4', label: 'Вершина по центру' }
]
}
}
};
/* Уровень 11 (капстоун): «Экспонента-подъём» — f(x)=c·exp(r·x). Барьер-«потолок»
слева заставляет держать низкий старт; затем экспонента взмывает в высокий портал. */
var graphExp11 = {
id: 'graph-exp-11',
title: 'Экспонента-подъём',
chapter: 'functions',
order: 11,
unlockStars: 17,
par_ms: 6500,
subject: 'algebra',
hint: 'Квантик едет по экспоненте y = c·e^(r·x). Подбери начальную высоту c и скорость роста r, чтобы пройти под потолком слева, но взмыть в высокий портал справа. Монета — на разгоне.',
spec: {
specVersion: 1,
meta: { title: 'Экспонента-подъём', desc: 'Экспонента: y = c·e^(r·x).' },
viewport: { xmin: -0.5, xmax: 8.5, ymin: -0.5, ymax: 9, grid: true, axes: true, bg: BG },
time: { duration: 6.5, loop: false },
params: [
{ name: 'c', label: 'Старт c', min: 0.2, max: 1.5, step: 0.05, value: 0.6 },
{ name: 'r', label: 'Рост r', min: 0.2, max: 0.7, step: 0.02, value: 0.4 }
],
objects: [
// потолок слева (низкий старт): запретный брус над началом x∈[1,4], y от 2.5 вверх
rectZone('ceil', 'forbidden', 2.5, 4.0, 3.2, 3.0, 'потолок'),
// пол-яма в начале (нельзя слишком низко/нулём): брус снизу x∈[0,5]
rectZone('floor', 'forbidden', 2.4, -0.5, 5.0, 1.0, ''),
circZone('coin', 'collect', 5, 3.3, 0.7, 'монета'),
circZone('gate', 'target', 7.6, 7.2, 1.05, 'портал'),
startMarker(0, 0.6),
road('c*exp(r*x)', 0, 7.7, 6.5),
graphHero(),
{ type: 'readout', label: 'c', expr: 'c', precision: 2 },
{ type: 'readout', label: 'r', expr: 'r', precision: 2 }
],
goal: {
title: 'Взмой в портал',
hint: 'Пройди под потолком слева и взмой экспонентой в высокий портал. Бонус: задень монету на разгоне.',
when: 'gate.hit',
fail: 'ceil.hit || floor.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && r >= 0.42', label: 'Крутой рост (r ≥ 0.42)' }
]
}
}
};
/* ─────────────────────────────────────────────────────────────────────────
Глава IV — «Квантовые законы» (созвездие): фирменные способности Квантика.
Всё выражено через БЕЗОПАСНУЮ модель спеки (params/зоны/plot), без новых
механик движка и без eval.
Суперпозиция — у уровня ДВА тела-копии Квантика (ball + ball2), один общий
«закон» (params) рулит обеими; победа — когда ОБЕ копии у своих порталов
(goal.when ссылается на ball.* и ball2.*). Реюз мульти-body физики.
Коллапс/прицел — на сцене всегда есть пунктирная «предсказанная траектория»
(plot выражения пути от текущего закона). Способность «Прицел» в игре
ставит паузу — игрок целится по предсказанной кривой до запуска.
Туннелирование — на пути стоит запретная стена-зона (tunnelable). Проигрыш
только когда стена задета И заряд не потрачен: fail:'wall.hit && tunnel < 1'.
Игровой слой тратит ЭНЕРГИЮ (заработанную в SR-комнате) и делает
inst.setParam('tunnel', 1) → стена временно проницаема. tunnel — НЕ слайдер
(управляется только способностью); по умолчанию отсутствует в env → 0
(неизвестная переменная SimExpr = 0) → стена сплошная.
──────────────────────────────────────────────────────────────────────── */
var PHANTOM = '#C4B5FD'; // полупрозрачный «фантом» — вторая копия (ball2)
var AIM = '#38BDF8'; // предсказанная траектория (пунктир)
var TUNNEL_WALL = '#F472B6'; // tunnelable-стена (магента — «квантовый барьер»)
/* helper: вторая копия-герой (ball2) — стартует из (sx,sy), полупрозрачный фантом. */
function hero2(sx, sy, vxExpr, vyExpr) {
return {
id: 'ball2', type: 'point', r: 7, color: PHANTOM, opacity: 0.78,
x: sx, y: sy,
glow: true, glowColor: PHANTOM, trail: true, trailColor: PHANTOM, trailFade: true,
body: { mass: 1, vx: vxExpr, vy: vyExpr }
};
}
/* helper: портал-кольцо «фантомного» цвета (цель второй копии). */
function portal2Objs(px, py, r) {
return [
{ type: 'circle', x: px, y: py, r: r, color: PHANTOM, width: 3, glow: true, glowColor: PHANTOM },
{ type: 'circle', x: px, y: py, r: r * 0.45, color: PHANTOM, width: 2, opacity: 0.7 },
{ type: 'label', x: px, y: py + r + 0.9, text: 'портал-2', color: PHANTOM, size: 12 }
];
}
/* helper: пунктирная предсказанная траектория (plot) — для «прицела». */
function aimPath(exprStr, a, b) {
return {
id: 'aim', type: 'plot', expr: exprStr, var: 'x',
range: [a, b], samples: 120, color: AIM, width: 1.6, lineStyle: 'dashed', opacity: 0.6
};
}
/* Уровень 12 (обучение суперпозиции): «Раздвоение» — две копии Квантика летят
симметрично (вправо и влево) под ОДНИМ законом. Один (θ,v) обязан попасть в ОБА
зеркальных портала. Симметрия гарантирует решение. */
var L12_PX = 6.5, L12_PY = 0.7, L12_PR = 0.95; // правый портал (для ball)
var L12_PX2 = -6.5; // левый портал (для ball2, зеркало)
var superpos12 = {
id: 'quantum-superpos-12',
title: 'Раздвоение',
chapter: 'quantum',
order: 12,
unlockStars: 19,
par_ms: 1700,
subject: 'physics',
hint: 'Квантик в суперпозиции — он сразу в двух местах! Обе копии летят под ОДНИМ законом, зеркально. Подбери угол и скорость так, чтобы каждая копия попала в свой портал.',
spec: {
specVersion: 1,
meta: { title: 'Суперпозиция: раздвоение', desc: 'Два тела, один закон. Симметричный бросок.' },
viewport: { xmin: -9, xmax: 9, ymin: -1.2, ymax: 7, grid: true, axes: true, bg: BG },
params: [
{ name: 'theta', label: 'Угол', min: 20, max: 80, step: 1, value: 50, unit: '°' },
{ name: 'v', label: 'Скорость', min: 5, max: 18, step: 0.5, value: 9, unit: 'м/с' }
],
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
objects: [
{ type: 'segment', x1: -9, y1: 0, x2: 9, y2: 0, color: GROUND, width: 2 }
].concat(portalObjs(L12_PX, L12_PY, L12_PR), portal2Objs(L12_PX2, L12_PY, L12_PR), [
// ball летит вправо, ball2 — зеркально влево (тот же закон)
hero(0, 0, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
hero2(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 - ' + L12_PX + ', ball.y - ' + L12_PY + ') < ' + L12_PR +
' && hypot(ball2.x - (' + L12_PX2 + '), ball2.y - ' + L12_PY + ') < ' + L12_PR,
fail: 't > 6',
stars: [
{ when: 't*1000 <= 1700', label: 'Быстро (≤1.7 с)' },
{ when: 'theta >= 55', label: 'Высокая дуга (θ ≥ 55°)' }
]
}
}
};
/* Уровень 13 (применение суперпозиции): «Две двери» — копии стартуют из РАЗНЫХ
точек, но скорости связаны одним законом (ball вправо-вверх, ball2 влево-вверх
одним законом). Обе зеркальные дуги должны перелететь центральную стену и
одновременно войти в свои порталы на вершинах дуг — нужен точный общий закон. */
var L13_PX = 5.0, L13_PY = 3.2, L13_PR = 0.85; // правый портал (вершина дуги ball)
var L13_PX2 = -5.0; // левый портал (зеркало, ball2)
var L13_WALLH = 2.0; // центральная стена — дуги обязаны её перелететь
var superpos13 = {
id: 'quantum-superpos-13',
title: 'Две двери',
chapter: 'quantum',
order: 13,
unlockStars: 20,
par_ms: 2000,
subject: 'physics',
hint: 'Две копии летят зеркально из центра — вправо и влево, под одним законом. Перебрось обе дуги через центральную стену так, чтобы вершина каждой дуги попала в свой портал.',
spec: {
specVersion: 1,
meta: { title: 'Суперпозиция: две двери', desc: 'Один закон ведёт обе зеркальные копии в свои двери.' },
viewport: { xmin: -7.5, xmax: 7.5, ymin: -1.2, ymax: 6, grid: true, axes: true, bg: BG },
params: [
{ name: 'theta', label: 'Угол', min: 25, max: 80, step: 1, value: 50, unit: '°' },
{ name: 'v', label: 'Скорость', min: 6, max: 16, step: 0.5, value: 10, unit: 'м/с' }
],
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
objects: [
{ type: 'segment', x1: -7.5, y1: 0, x2: 7.5, y2: 0, color: GROUND, width: 2 },
// центральная стена-препятствие (только визуал + ориентир; дуги должны быть выше)
{ type: 'segment', x1: 0, y1: 0, x2: 0, y2: L13_WALLH, color: '#475569', width: 6 },
{ type: 'label', x: 0, y: L13_WALLH + 0.5, text: 'стена', color: '#94A3B8', size: 11 }
].concat(portalObjs(L13_PX, L13_PY, L13_PR), portal2Objs(L13_PX2, L13_PY, L13_PR), [
// обе копии стартуют из центра (0, 0.2): ball вправо, ball2 зеркально влево
hero(0, 0.2, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
hero2(0, 0.2, '-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 - ' + L13_PX + ', ball.y - ' + L13_PY + ') < ' + L13_PR +
' && hypot(ball2.x - (' + L13_PX2 + '), ball2.y - ' + L13_PY + ') < ' + L13_PR,
fail: 't > 6',
stars: [
{ when: 't*1000 <= 2000', label: 'Быстро (≤2.0 с)' },
{ when: 'v <= 11', label: 'Экономный бросок (v ≤ 11)' }
]
}
}
};
/* Уровень 14 (обучение прицела/коллапса): «Прицел» — бросок под углом к порталу,
на сцене всегда видна предсказанная траектория (пунктир). Способность «Прицел»
(в игре) ставит паузу: целься по кривой до запуска. */
var L14_PX = 8.6, L14_PY = 4.4, L14_PR = 0.8;
var L14_CX = 5.0, L14_CY = 5.2, L14_CR = 0.7;
// предсказанная траектория: парабола броска y = tan(θ)·x g·x² / (2·v²·cos²θ)
var L14_AIM = 'tan(theta*pi/180)*x - 9.8*x^2 / (2*v^2*cos(theta*pi/180)^2)';
var aimer14 = {
id: 'quantum-aim-14',
title: 'Прицел',
chapter: 'quantum',
order: 14,
unlockStars: 22,
par_ms: 1600,
subject: 'physics',
hint: 'Пунктирная линия — предсказанная траектория Квантика для текущего закона. Способность «Прицел» ставит паузу: целься по кривой, затем коллапсируй (запусти) в портал.',
spec: {
specVersion: 1,
meta: { title: 'Коллапс: прицел', desc: 'Предсказанная траектория параболы броска.' },
viewport: { xmin: -1, xmax: 11, ymin: -1.2, 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: 6, max: 18, step: 0.5, value: 11, unit: 'м/с' }
],
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
objects: [
{ type: 'segment', x1: -1, y1: 0, x2: 11, y2: 0, color: GROUND, width: 2 },
aimPath(L14_AIM, 0, 10.5)
].concat(crystalObjs(L14_CX, L14_CY, L14_CR), portalObjs(L14_PX, L14_PY, L14_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 - ' + L14_PX + ', ball.y - ' + L14_PY + ') < ' + L14_PR,
fail: 'ball.x > 10.6 || ball.y < -1.0',
stars: [
{ when: 'hypot(ball.x - ' + L14_CX + ', ball.y - ' + L14_CY + ') < ' + L14_CR, label: 'Собрал кристалл' },
{ when: 't*1000 <= 1600', label: 'Быстро (≤1.6 с)' }
]
}
}
};
/* Уровень 15 (обучение туннелирования): «Квантовый барьер» — Квантик едет по
прямой к порталу, но на пути СТЕНА (tunnelable-зона). Без заряда стена сплошная
(задел → проигрыш). Способность «Туннель» (за энергию) делает стену проницаемой:
fail:'wall.hit && tunnel < 1'. */
var tunnel15 = {
id: 'quantum-tunnel-15',
title: 'Квантовый барьер',
chapter: 'quantum',
order: 15,
unlockStars: 24,
par_ms: 5200,
subject: 'physics',
hint: 'Стена-барьер преграждает путь напрямую, обойти её нельзя. Накопи энергию в комнате повторения и потрать заряд туннелирования — Квантик пройдёт СКВОЗЬ барьер.',
spec: {
specVersion: 1,
meta: { title: 'Туннелирование: барьер', desc: 'Прохождение сквозь барьер за квантовый заряд.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8, grid: true, axes: true, bg: BG },
time: { duration: 5, loop: false },
params: [
{ name: 'a', label: 'Наклон a', min: -0.3, max: 0.5, step: 0.02, value: 0.1 },
{ name: 'b', label: 'Высота b', min: 2.5, max: 4.5, step: 0.1, value: 3.5 }
],
objects: [
// барьер ровно поперёк пути (вертикальный брус в центре, перекрывает весь коридор по y)
{ type: 'zone', id: 'wall', kind: 'forbidden', shape: 'rect', x: 5, y: 3.5, w: 0.8, h: 7.2,
color: TUNNEL_WALL, label: 'барьер' },
// целевой портал справа
circZone('gate', 'target', 9.5, 3.5, 0.95, 'портал'),
// бонус-монета сразу за барьером
circZone('coin', 'collect', 6.5, 3.5, 0.6, 'монета'),
startMarker(0, 3.5),
road('a*x + b', 0, 9.5, 5),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'b', expr: 'b', precision: 1 }
],
goal: {
title: 'Пройди сквозь барьер',
hint: 'Доберись до портала. Барьер задержит Квантика, пока не потрачен заряд туннелирования. Бонус: собери монету за барьером.',
when: 'gate.hit',
// проигрыш только если задел стену БЕЗ заряда туннеля (tunnel<1 ⇒ заряд не потрачен)
fail: 'wall.hit && tunnel < 1',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && abs(b - 3.5) < 0.4', label: 'Ровный путь (b ≈ 3.5)' }
]
}
}
};
/* Уровень 16 (капстоун главы): «Сквозь стену в дверь» — комбо: бегунок-парабола,
барьер-зона поперёк дуги (нужен туннель) и целевой портал за ним; монета на дуге. */
var tunnel16 = {
id: 'quantum-tunnel-16',
title: 'Сквозь стену',
chapter: 'quantum',
order: 16,
unlockStars: 26,
par_ms: 6000,
subject: 'physics',
hint: 'Дуга-парабола ведёт к высокому порталу, но её пересекает квантовый барьер. Туннелируй сквозь него (за энергию) и подгони дугу так, чтобы попасть в дверь.',
spec: {
specVersion: 1,
meta: { title: 'Туннель сквозь стену', desc: 'Парабола + барьер: туннель в портал.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 9, grid: true, axes: true, bg: BG },
time: { duration: 6, loop: false },
params: [
{ name: 'a', label: 'Раскрытие a', min: -0.5, max: -0.15, step: 0.02, value: -0.3 },
{ name: 'k', label: 'Высота вершины k', min: 5, max: 8, step: 0.1, value: 6.5 }
],
objects: [
// барьер поперёк восходящей дуги (вертикальный брус)
{ type: 'zone', id: 'wall', kind: 'forbidden', shape: 'rect', x: 3.2, y: 4.0, w: 0.7, h: 8.4,
color: TUNNEL_WALL, label: 'барьер' },
// монета у самой вершины высокой дуги (k≈7) — совместима со 2-й звездой k≥6.8
circZone('coin', 'collect', 5, 6.9, 0.85, 'монета'),
circZone('gate', 'target', 9.3, 4.6, 0.95, 'портал'),
startMarker(0, 0),
road('a*(x-5)^2 + k', 0, 9.3, 6),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'k', expr: 'k', precision: 1 }
],
goal: {
title: 'Туннелем в дальний портал',
hint: 'Туннелируй сквозь барьер и проведи дугу в портал. Бонус: задень монету на вершине.',
when: 'gate.hit',
fail: 'wall.hit && tunnel < 1',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && k >= 6.8', label: 'Высокая дуга (k ≥ 6.8)' }
]
}
}
};
var LEVELS = [
artillery1, arc2, bounce3, pendulum4, orbit5, slingshot6,
graphLine7, graphSine8, graphParab9, graphAbs10, graphExp11,
superpos12, superpos13, aimer14, tunnel15, tunnel16
];
/* Метаданные глав (созвездий) — для заголовков/оформления карты. */ /* Метаданные глав (созвездий) — для заголовков/оформления карты. */
var CHAPTERS = { var CHAPTERS = {
kinematics: { key: 'kinematics', title: 'Кинематика', subtitle: 'Полёт и гравитация', accent: '#22D3EE' }, kinematics: { key: 'kinematics', title: 'Кинематика', subtitle: 'Полёт и гравитация', accent: '#22D3EE' },
dynamics: { key: 'dynamics', title: 'Динамика', subtitle: 'Силы, пружины, орбиты', accent: '#A78BFA' } dynamics: { key: 'dynamics', title: 'Динамика', subtitle: 'Силы, пружины, орбиты', accent: '#A78BFA' },
functions: { key: 'functions', title: 'Функции', subtitle: 'Едем по кривой y = f(x)', accent: '#67E8F9' },
quantum: { key: 'quantum', title: 'Квантовые законы', subtitle: 'Суперпозиция · прицел · туннель', accent: '#C4B5FD' },
// Авторённые учителями уровни (custom_sims cat='game') без явной главы — сюда.
custom: { key: 'custom', title: 'Уровни учителей', subtitle: 'Авторённые уровни сообщества', accent: '#F472B6' }
}; };
function list() { return LEVELS.slice(); } /* ── Авторённые уровни (Фаза 5): custom_sims с cat='game' ───────────────────
Реестр становится «асинхронным»: встроенные уровни доступны сразу (offline),
а опубликованные/свои игровые спеки подмешиваются после ensureCustom().
Запись уровня из строки custom_sims: id='custom:<dbid>', метаданные — из
spec.game (chapter/order/par_ms/unlockStars), spec — как есть. */
var CUSTOM = []; // смёрженные записи авторённых уровней
var _customPromise = null; // кэш промиса загрузки (грузим один раз)
/* Строка из LS.customSimsList/Get -> запись реестра уровня (или null). */
function customToLevel(row) {
if (!row || !row.spec || typeof row.spec !== 'object') return null;
var spec = row.spec;
if (!spec.goal) return null; // не игровой уровень — пропускаем
var gm = (spec.game && typeof spec.game === 'object') ? spec.game : {};
var dbid = row.id;
return {
id: 'custom:' + dbid,
dbid: dbid,
title: row.title || (spec.meta && spec.meta.title) || 'Уровень',
chapter: gm.chapter || 'custom',
order: (typeof gm.order === 'number') ? gm.order : (1000 + Number(dbid)),
unlockStars: (typeof gm.unlockStars === 'number') ? gm.unlockStars : 0,
par_ms: (typeof gm.par_ms === 'number') ? gm.par_ms : undefined,
subject: row.subject || (spec.goal && spec.goal.subject) || undefined,
hint: (spec.goal && spec.goal.hint) || '',
spec: spec,
_custom: true
};
}
/* Загрузить опубликованные + свои игровые custom_sims и смёржить.
Возвращает Promise (кэшируется). Тихо игнорирует ошибки/отсутствие LS. */
function ensureCustom() {
if (_customPromise) return _customPromise;
var LS = global.LS;
if (!LS || !LS.customSimsList || !LS.customSimGet) {
_customPromise = Promise.resolve([]);
return _customPromise;
}
_customPromise = LS.customSimsList().then(function (r) {
var sims = (r && r.sims) || [];
// только игровая категория (список не содержит spec — его берём отдельно)
var games = sims.filter(function (s) { return s && s.cat === 'game'; });
return Promise.all(games.map(function (s) {
return LS.customSimGet(s.id).then(function (g) {
return customToLevel(g && g.sim);
}).catch(function () { return null; });
}));
}).then(function (records) {
CUSTOM = records.filter(Boolean);
return CUSTOM.slice();
}).catch(function () { CUSTOM = []; return []; });
return _customPromise;
}
function list() { return LEVELS.concat(CUSTOM); }
function get(id) { function get(id) {
for (var i = 0; i < LEVELS.length; i++) if (LEVELS[i].id === id) return LEVELS[i]; var all = list();
for (var i = 0; i < all.length; i++) if (all[i].id === id) return all[i];
return null; return null;
} }
/* Достать уровень по id асинхронно — для deep-link `custom:<dbid>`, когда он
может ещё не быть в смёрженном списке (напр. свой draft). Резолвит через
LS.customSimGet с проверкой доступа на сервере (own|published|admin). */
function getAsync(id) {
var found = get(id);
if (found) return Promise.resolve(found);
var m = /^custom:(\d+)$/.exec(String(id || ''));
var LS = global.LS;
if (!m || !LS || !LS.customSimGet) return Promise.resolve(null);
return LS.customSimGet(m[1]).then(function (g) {
var lvl = customToLevel(g && g.sim);
if (lvl) {
// подмешать в кэш, чтобы повторное открытие/«Дальше» нашло его синхронно
if (!CUSTOM.some(function (c) { return c.id === lvl.id; })) CUSTOM.push(lvl);
}
return lvl;
}).catch(function () { return null; });
}
function chapter(key) { return CHAPTERS[key] || { key: key, title: key, subtitle: '', accent: '#22D3EE' }; } function chapter(key) { return CHAPTERS[key] || { key: key, title: key, subtitle: '', accent: '#22D3EE' }; }
global.QuantikLevels = { global.QuantikLevels = {
list: list, get: get, chapter: chapter, list: list, get: get, getAsync: getAsync, ensureCustom: ensureCustom, chapter: chapter,
LEVELS: LEVELS, CHAPTERS: CHAPTERS LEVELS: LEVELS, CHAPTERS: CHAPTERS,
customToLevel: customToLevel
}; };
})(typeof window !== 'undefined' ? window : this); })(typeof window !== 'undefined' ? window : this);
+5 -2
View File
@@ -42,8 +42,11 @@
var s = size || 16; var s = size || 16;
var fill = filled ? '#FBBF24' : 'none'; var fill = filled ? '#FBBF24' : 'none';
var stroke = filled ? '#FBBF24' : 'rgba(148,163,184,0.55)'; 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 + // Цвета — через inline style, а НЕ presentation-атрибуты: правило .ic в ls.css
'" stroke="' + stroke + '" stroke-width="1.5" stroke-linejoin="round"><path d="' + starPath() + '"/></svg>'; // (fill:none; stroke:currentColor) перебивает атрибуты fill/stroke, из-за чего
// заработанные звёзды узлов не закрашивались. Inline style приоритетнее класса.
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s +
'" style="fill:' + fill + ';stroke:' + stroke + '" stroke-width="1.5" stroke-linejoin="round"><path d="' + starPath() + '"/></svg>';
} }
function lockSvg(size) { function lockSvg(size) {
var s = size || 18; var s = size || 18;
+4 -5
View File
@@ -46,11 +46,10 @@
} }
/* ── Разблокировка ──────────────────────────────────────────────────────── /* ── Разблокировка ────────────────────────────────────────────────────────
Уровень открыт, если СУММА звёзд во ВСЕХ предыдущих уровнях той же главы Уровень открыт, если СУММА звёзд во ВСЕХ уровнях с меньшим ГЛОБАЛЬНЫМ order
(по полю order) ≥ level.unlockStars. Первый уровень главы (минимальный order (по всем главам, не только текущей) ≥ level.unlockStars. Уровень с
или unlockStars==0) открыт всегда. Глава открывается, если открыт её первый unlockStars==0 (или без него) открыт всегда. Так первый уровень главы
уровень — он гейтится суммой звёзд предыдущих глав через unlockStars==0 гейтится суммой звёзд всех предыдущих глав через свой порог unlockStars.
первого уровня (по умолчанию) ИЛИ явным порогом.
Чистая функция: вход — уровень + карта прогресса + ВЕСЬ список (для подсчёта Чистая функция: вход — уровень + карта прогресса + ВЕСЬ список (для подсчёта
«предыдущих» по order). Возвращает bool. */ «предыдущих» по order). Возвращает bool. */
+518
View File
@@ -0,0 +1,518 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · Квантовые способности + энергия + SR-комната (Фаза 4).
Всё АДДИТИВНО и через БЕЗОПАСНУЮ модель (без eval/Function, без правок движка):
- ЭНЕРГИЯ — клиентский ресурс в localStorage (ключ 'quantik-energy'). Чистая
логика чтения/траты/начисления (window.QuantikEnergy) — тестируется headless.
- СПОСОБНОСТИ на сцене уровня (window.QuantikAbilities.mountBar):
«Туннель» — тратит заряд → inst.setParam('tunnel', 1) (стена-барьер
уровня становится проницаемой; fail:'wall.hit && tunnel<1').
«Прицел» — ставит/снимает паузу: целься по предсказанной траектории
(пунктир-plot уже на сцене) до запуска.
- SR-КОМНАТА (window.QuantikAbilities.openRestRoom) — мини-сессия повторения
флешкарт прямо в игре: список колод → due-карты → лицо/оборот → оценка
(шкала как в flashcards.html: Снова/Трудно/Знаю/Легко) → каждый «Знаю/Легко»
начисляет энергию. Реюз серверного SR (LS.fcListDecks/fcStudySession/fcReview),
НЕ iframe страницы флешкарт. Пусто (нет колод / нет due) — дружелюбное окно
со ссылкой на /flashcards.
⛔ Без эмодзи (только inline SVG .ic). Без eval/Function.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var doc = global.document;
/* ── Энергия: чистая логика над localStorage ─────────────────────────────
Заряд — целое ≥0. Один «заряд туннеля» = TUNNEL_COST. За правильный ответ
в SR-комнате — REWARD_GOOD (Знаю) / REWARD_EASY (Легко). */
var ENERGY_KEY = 'quantik-energy';
var ENERGY_MAX = 99; // потолок (защита от переполнения хранилища)
var TUNNEL_COST = 3; // зарядов на одно туннелирование
var REWARD_GOOD = 1; // энергия за «Знаю»
var REWARD_EASY = 2; // энергия за «Легко»
function _clampEnergy(n) {
n = Math.floor(Number(n));
if (!isFinite(n) || n < 0) n = 0;
if (n > ENERGY_MAX) n = ENERGY_MAX;
return n;
}
function getEnergy() {
try {
var v = global.localStorage && global.localStorage.getItem(ENERGY_KEY);
return _clampEnergy(v == null ? 0 : v);
} catch (_e) { return 0; }
}
function setEnergy(n) {
var v = _clampEnergy(n);
try { if (global.localStorage) global.localStorage.setItem(ENERGY_KEY, String(v)); } catch (_e) {}
_notify(v);
return v;
}
function grantEnergy(n) { return setEnergy(getEnergy() + _clampEnergy(n)); }
function canSpend(n) { return getEnergy() >= _clampEnergy(n); }
/* Потратить n зарядов. Возвращает true при успехе (хватило), иначе false (без списания). */
function spendEnergy(n) {
n = _clampEnergy(n);
var cur = getEnergy();
if (cur < n) return false;
setEnergy(cur - n);
return true;
}
/* Награда за оценку флешкарты (quality по шкале SR): Знаю(4)→GOOD, Легко(5)→EASY,
остальные (Снова/Трудно) — 0. Чистая функция (для тестов). */
function rewardForQuality(q) {
if (q === 5) return REWARD_EASY;
if (q === 4) return REWARD_GOOD;
return 0;
}
/* подписчики на изменение энергии (HUD обновляется без перезагрузки) */
var _subs = [];
function onEnergyChange(cb) { if (typeof cb === 'function') _subs.push(cb); }
function _notify(v) { for (var i = 0; i < _subs.length; i++) { try { _subs[i](v); } catch (_e) {} } }
global.QuantikEnergy = {
getEnergy: getEnergy, setEnergy: setEnergy, grantEnergy: grantEnergy,
spendEnergy: spendEnergy, canSpend: canSpend, rewardForQuality: rewardForQuality,
onEnergyChange: onEnergyChange,
ENERGY_KEY: ENERGY_KEY, TUNNEL_COST: TUNNEL_COST,
REWARD_GOOD: REWARD_GOOD, REWARD_EASY: REWARD_EASY, ENERGY_MAX: ENERGY_MAX
};
/* ════════════════════════════════════════════════════════════════════════
DOM-хелперы (только если есть document — модуль грузится и в headless vm,
где document может быть стабом без полноценного DOM).
════════════════════════════════════════════════════════════════════════ */
function el(tag, cls, html) {
var n = doc.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function escapeText(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* ── inline SVG иконки (без эмодзи) ── */
function boltIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="currentColor" stroke="none">' +
'<path d="M13 2 4 14h6l-1 8 9-12h-6z"/></svg>';
}
function tunnelIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V11a8 8 0 0 1 16 0v9"/>' +
'<path d="M9 20v-6a3 3 0 0 1 6 0v6"/></svg>';
}
function aimIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/>' +
'<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>' +
'<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>' +
'<circle cx="12" cy="12" r="1.6" fill="currentColor"/></svg>';
}
function cardsIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="13" height="15" rx="2"/>' +
'<path d="M8 5V4a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-1"/></svg>';
}
/* ── tunnel-флаг уровня: спека ссылается на param 'tunnel' в fail? ──────────
Если ни goal.fail, ни stars, ни goal.when не упоминают 'tunnel', способность
«Туннель» бессмысленна для уровня → кнопка скрыта. */
function levelHasTunnel(level) {
var g = level && level.spec && level.spec.goal;
if (!g) return false;
var blob = String(g.fail || '') + ' ' + String(g.when || '');
if (Array.isArray(g.stars)) for (var i = 0; i < g.stars.length; i++) blob += ' ' + String(g.stars[i] && g.stars[i].when || '');
return /\btunnel\b/.test(blob);
}
/* ── aim-флаг уровня: на сцене есть объект-предсказание (id 'aim' или plot
с lineStyle 'dashed')? Тогда способность «Прицел» осмысленна. */
function levelHasAim(level) {
var objs = level && level.spec && level.spec.objects;
if (!Array.isArray(objs)) return false;
for (var i = 0; i < objs.length; i++) {
var o = objs[i];
if (o && o.type === 'plot' && (o.id === 'aim' || o.lineStyle === 'dashed')) return true;
}
return false;
}
/* ════════════════════════════════════════════════════════════════════════
Панель способностей + HUD энергии на сцене уровня.
mountBar({ host, inst, level, onOpenRest }) -> { el, destroy, refresh }
host — контейнер сцены (qg-stage). Кнопки появляются только если уместны
для уровня. tunnel сбрасывается в 0 при каждом mount (новый уровень/попытка).
════════════════════════════════════════════════════════════════════════ */
function mountBar(opts) {
opts = opts || {};
var host = opts.host, inst = opts.inst, level = opts.level;
if (!host || !inst) return null;
var onOpenRest = typeof opts.onOpenRest === 'function' ? opts.onOpenRest : function () {};
var hasTunnel = levelHasTunnel(level);
var hasAim = levelHasAim(level);
var tunnelUsed = false; // потрачен ли заряд в этой попытке
// tunnel стартует выключенным каждую попытку (стена сплошная)
try { inst.setParam('tunnel', 0); } catch (_e) {}
var bar = el('div', 'qa-bar');
// ── HUD энергии ──
var meter = el('div', 'qa-energy', boltIcon() + '<span class="qa-energy-n">' + getEnergy() + '</span>');
meter.title = 'Квантовая энергия — копится в комнате повторения';
bar.appendChild(meter);
// ── Кнопка «Комната повторения» (всегда — заработать энергию) ──
var btnRest = el('button', 'qa-btn qa-rest', cardsIcon() + '<span>Повторение</span>');
btnRest.type = 'button';
btnRest.title = 'Повтори флешкарты — заработай квантовую энергию';
btnRest.addEventListener('click', function () { onOpenRest(); });
bar.appendChild(btnRest);
// ── Способность «Туннель» ──
var btnTunnel = null;
if (hasTunnel) {
btnTunnel = el('button', 'qa-btn qa-ability qa-tunnel',
tunnelIcon() + '<span>Туннель</span><span class="qa-cost">' + boltIcon() + TUNNEL_COST + '</span>');
btnTunnel.type = 'button';
btnTunnel.addEventListener('click', function () {
if (tunnelUsed) return;
if (!canSpend(TUNNEL_COST)) { _flashHint(host, 'Не хватает энергии — повтори флешкарты'); refresh(); return; }
if (!spendEnergy(TUNNEL_COST)) { refresh(); return; }
tunnelUsed = true;
try { inst.setParam('tunnel', 1); } catch (_e) {}
_flashHint(host, 'Туннелирование активно — барьер проницаем');
refresh();
});
bar.appendChild(btnTunnel);
}
// ── Способность «Прицел» (пауза-тоггл) ──
var btnAim = null;
if (hasAim) {
btnAim = el('button', 'qa-btn qa-ability qa-aim', aimIcon() + '<span>Прицел</span>');
btnAim.type = 'button';
btnAim.title = 'Поставить паузу и прицелиться по предсказанной траектории';
btnAim.addEventListener('click', function () {
try {
if (inst.isRunning()) { inst.pause(); }
else { inst.play(); }
} catch (_e) {}
refresh();
});
bar.appendChild(btnAim);
}
function refresh() {
var e = getEnergy();
var n = meter.querySelector('.qa-energy-n');
if (n) n.textContent = String(e);
if (btnTunnel) {
var dis = tunnelUsed || !canSpend(TUNNEL_COST);
btnTunnel.disabled = dis;
btnTunnel.classList.toggle('qa-on', tunnelUsed);
btnTunnel.title = tunnelUsed ? 'Туннель уже активен в этой попытке'
: (canSpend(TUNNEL_COST) ? 'Пройти сквозь барьер (−' + TUNNEL_COST + ' энергии)'
: 'Нужно ' + TUNNEL_COST + ' энергии — повтори флешкарты');
}
if (btnAim) {
var running = false; try { running = inst.isRunning(); } catch (_e) {}
btnAim.classList.toggle('qa-on', !running);
var lbl = btnAim.querySelector('span');
if (lbl) lbl.textContent = running ? 'Прицел' : 'Цельтесь';
}
}
// следить за изменением энергии (после SR-комнаты)
var unsub = function () {};
var sub = function (v) { refresh(); };
onEnergyChange(sub);
unsub = function () {
var i = _subs.indexOf(sub);
if (i >= 0) _subs.splice(i, 1);
};
host.appendChild(bar);
refresh();
return {
el: bar,
refresh: refresh,
// сбросить tunnel-состояние (новая попытка того же уровня)
resetAbilities: function () {
tunnelUsed = false;
try { inst.setParam('tunnel', 0); } catch (_e) {}
refresh();
},
destroy: function () {
unsub();
if (bar.parentNode) bar.parentNode.removeChild(bar);
}
};
}
/* всплывающая подсказка над сценой (без эмодзи) */
function _flashHint(host, text) {
var t = el('div', 'qa-toast', escapeText(text));
host.appendChild(t);
requestAnimationFrame(function () { t.classList.add('show'); });
setTimeout(function () {
t.classList.remove('show');
setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 320);
}, 1900);
}
/* ════════════════════════════════════════════════════════════════════════
SR-комната — модальная мини-сессия повторения флешкарт.
openRestRoom({ host, onClose }) — асинхронно тянет колоды и due-карты.
════════════════════════════════════════════════════════════════════════ */
function openRestRoom(opts) {
opts = opts || {};
var host = opts.host || doc.body;
var onClose = typeof opts.onClose === 'function' ? opts.onClose : function () {};
var LS = global.LS;
var overlay = el('div', 'qa-overlay');
var modal = el('div', 'qa-modal');
overlay.appendChild(modal);
host.appendChild(overlay);
var earned = 0; // энергия, начисленная за сессию
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
onClose(earned);
}
overlay.addEventListener('click', function (ev) { if (ev.target === overlay) close(); });
function header(title) {
var h = el('div', 'qa-modal-head');
h.appendChild(el('div', 'qa-modal-title', cardsIcon() + '<span>' + escapeText(title) + '</span>'));
var meter = el('div', 'qa-modal-energy', boltIcon() + '<span class="qa-modal-energy-n">' + getEnergy() + '</span>');
h.appendChild(meter);
var x = el('button', 'qa-modal-x', '&times;');
x.type = 'button'; x.setAttribute('aria-label', 'Закрыть');
x.addEventListener('click', close);
h.appendChild(x);
return h;
}
function setEnergyChip() {
var n = modal.querySelector('.qa-modal-energy-n');
if (n) n.textContent = String(getEnergy());
}
function renderMessage(title, msg, withFlashcardsLink) {
modal.innerHTML = '';
modal.appendChild(header('Комната повторения'));
var body = el('div', 'qa-modal-body');
body.appendChild(el('div', 'qa-empty-title', escapeText(title)));
body.appendChild(el('div', 'qa-empty-msg', escapeText(msg)));
var actions = el('div', 'qa-modal-actions');
if (withFlashcardsLink) {
var open = el('a', 'btn-primary qa-modal-btn', 'Открыть флешкарты');
open.href = '/flashcards';
actions.appendChild(open);
}
var done = el('button', 'btn-ghost qa-modal-btn', 'Закрыть');
done.type = 'button';
done.addEventListener('click', close);
actions.appendChild(done);
body.appendChild(actions);
modal.appendChild(body);
}
function renderLoading() {
modal.innerHTML = '';
modal.appendChild(header('Комната повторения'));
var body = el('div', 'qa-modal-body');
body.appendChild(el('div', 'qa-loading', 'Загрузка колод…'));
modal.appendChild(body);
}
if (!LS || !LS.fcListDecks || !LS.fcStudySession || !LS.fcReview) {
renderMessage('Повторение недоступно', 'Не удалось подключиться к флешкартам. Попробуй позже.', false);
return { el: overlay, close: close };
}
renderLoading();
LS.fcListDecks().then(function (r) {
var decks = (r && r.decks) || [];
if (!decks.length) {
renderMessage('Нет колод', 'Создай колоду флешкарт, чтобы повторять и зарабатывать энергию.', true);
return;
}
// авто-выбор колоды с наибольшим числом due-карт
var withDue = decks.filter(function (d) { return (d.due_count || 0) > 0; });
if (!withDue.length) {
renderMessage('Всё повторено', 'Сейчас нет карточек к повторению. Возвращайся позже — энергия копится повторением.', true);
return;
}
withDue.sort(function (a, b) { return (b.due_count || 0) - (a.due_count || 0); });
// если несколько колод с due — дать выбрать; иначе сразу учить
if (withDue.length === 1) startStudy(withDue[0]);
else renderDeckPicker(withDue);
}).catch(function () {
renderMessage('Ошибка', 'Не удалось загрузить колоды. Проверь соединение.', false);
});
function renderDeckPicker(decks) {
modal.innerHTML = '';
modal.appendChild(header('Выбери колоду'));
var body = el('div', 'qa-modal-body');
var list = el('div', 'qa-deck-list');
decks.forEach(function (d) {
var b = el('button', 'qa-deck', '');
b.type = 'button';
b.style.setProperty('--dk', d.color || '#9B5DE5');
b.innerHTML = '<span class="qa-deck-dot"></span>' +
'<span class="qa-deck-title">' + escapeText(d.title || 'Колода') + '</span>' +
'<span class="qa-deck-due">' + (d.due_count || 0) + ' к повтору</span>';
b.addEventListener('click', function () { startStudy(d); });
list.appendChild(b);
});
body.appendChild(list);
modal.appendChild(body);
}
/* ── Сессия изучения одной колоды ── */
function startStudy(deck) {
renderLoading();
LS.fcStudySession(deck.id).then(function (r) {
var cards = (r && r.cards) || [];
if (!cards.length) {
renderMessage('Всё повторено', 'В этой колоде нет карточек к повторению. Возвращайся позже.', false);
return;
}
runSession(deck, cards.slice());
}).catch(function () {
renderMessage('Ошибка', 'Не удалось загрузить карточки колоды.', false);
});
}
var RQ_GAP = 3; // через сколько карт вернуть недоученную (как FC_RQ_GAP в flashcards.html)
function runSession(deck, queue) {
var idx = 0, done = 0, flipped = false;
var seenCount = 0;
function finish() {
renderMessage('Готово!', 'Повторено ' + seenCount + ' карточек. Заработано энергии: ' + earned + '.', false);
}
function show() {
if (idx >= queue.length) { finish(); return; }
flipped = false;
var card = queue[idx];
modal.innerHTML = '';
modal.appendChild(header(escapeText(deck.title || 'Повторение')));
var body = el('div', 'qa-modal-body qa-study');
// прогресс
var total = done + (queue.length - idx);
var prog = el('div', 'qa-prog');
var fill = el('div', 'qa-prog-fill');
fill.style.width = (total ? (done / total * 100) : 0) + '%';
prog.appendChild(fill);
body.appendChild(prog);
body.appendChild(el('div', 'qa-prog-count', Math.min(done + 1, total) + ' / ' + total));
// карточка
var cardEl = el('div', 'qa-card');
var front = el('div', 'qa-card-side qa-card-front');
front.innerHTML = _cardHtml(card.front, card.front_image);
cardEl.appendChild(front);
var back = el('div', 'qa-card-side qa-card-back');
back.innerHTML = _cardHtml(card.back, card.back_image);
back.style.display = 'none';
cardEl.appendChild(back);
body.appendChild(cardEl);
// кнопка «показать ответ» / оценки
var flipBtn = el('button', 'btn-primary qa-flip', 'Показать ответ');
flipBtn.type = 'button';
body.appendChild(flipBtn);
var grades = el('div', 'qa-grades');
grades.style.display = 'none';
// шкала как в flashcards.html: Снова(0) Трудно(3) Знаю(4) Легко(5)
[
{ q: 0, l: 'Снова', cls: 'qa-g-again' },
{ q: 3, l: 'Трудно', cls: 'qa-g-hard' },
{ q: 4, l: 'Знаю', cls: 'qa-g-good' },
{ q: 5, l: 'Легко', cls: 'qa-g-easy' }
].forEach(function (g) {
var gb = el('button', 'qa-grade ' + g.cls, g.l);
gb.type = 'button';
gb.addEventListener('click', function () { answer(card, g.q); });
grades.appendChild(gb);
});
body.appendChild(grades);
flipBtn.addEventListener('click', function () {
if (flipped) return;
flipped = true;
back.style.display = '';
flipBtn.style.display = 'none';
grades.style.display = '';
});
modal.appendChild(body);
}
function answer(card, quality) {
// награда сразу (оптимистично) — энергия за «Знаю/Легко»
var rw = rewardForQuality(quality);
if (rw > 0) { grantEnergy(rw); earned += rw; setEnergyChip(); }
seenCount++;
// отправляем отзыв; re-queue недоученных в пределах сессии
var requeue = (quality < 3); // фолбэк-эвристика, уточняется ответом
LS.fcReview(card.id, quality).then(function (resp) {
requeue = resp ? !resp.graduated : (quality < 3);
advance(card, requeue);
}).catch(function () {
advance(card, requeue);
});
}
function advance(card, requeue) {
queue.splice(idx, 1);
if (requeue) {
var pos = Math.min(idx + RQ_GAP, queue.length);
queue.splice(pos, 0, card);
} else {
done++;
}
if (idx >= queue.length) finish();
else show();
}
show();
}
return { el: overlay, close: close };
}
/* безопасный рендер стороны карточки (текст escape, картинка — только свой /uploads) */
function _cardHtml(text, image) {
var html = '';
if (image && /^\/uploads\/flashcards\/[A-Za-z0-9._-]+$/.test(image)) {
html += '<img class="qa-card-img" src="' + image + '" alt=""/>';
}
if (text) html += '<div class="qa-card-text">' + escapeText(text) + '</div>';
return html || '<div class="qa-card-text qa-card-empty">—</div>';
}
global.QuantikAbilities = {
mountBar: mountBar,
openRestRoom: openRestRoom,
levelHasTunnel: levelHasTunnel,
levelHasAim: levelHasAim
};
})(typeof window !== 'undefined' ? window : this);
+61 -4
View File
@@ -46,30 +46,54 @@
} }
/* Тинтуем героя уровня (объект с id 'ball') цветом скина — БЕЗ исполнения, /* Тинтуем героя уровня (объект с id 'ball') цветом скина — БЕЗ исполнения,
просто переписываем цветовые поля спеки-копии перед монтированием. */ просто переписываем цветовые поля спеки-копии перед монтированием.
Фаза 4: вторую копию суперпозиции (id 'ball2') тоже тинтуем, но осветлённым
«фантомным» оттенком (полупрозрачность задаётся самой спекой). */
function tintHeroSpec(spec, skinKey) { function tintHeroSpec(spec, skinKey) {
var color = skinColor(skinKey); var color = skinColor(skinKey);
var phantom = lighten(color, 0.42);
// глубокая копия (спека — данные, без функций) чтобы не мутировать реестр // глубокая копия (спека — данные, без функций) чтобы не мутировать реестр
var copy = JSON.parse(JSON.stringify(spec)); var copy = JSON.parse(JSON.stringify(spec));
if (Array.isArray(copy.objects)) { if (Array.isArray(copy.objects)) {
for (var i = 0; i < copy.objects.length; i++) { for (var i = 0; i < copy.objects.length; i++) {
var o = copy.objects[i]; var o = copy.objects[i];
if (o && o.id === 'ball') { if (!o) continue;
if (o.id === 'ball') {
o.color = color; o.color = color;
if (o.glow) o.glowColor = color; if (o.glow) o.glowColor = color;
if (o.trail) o.trailColor = color; if (o.trail) o.trailColor = color;
} else if (o.id === 'ball2') {
o.color = phantom;
if (o.glow) o.glowColor = phantom;
if (o.trail) o.trailColor = phantom;
} }
} }
} }
return copy; return copy;
} }
/* Осветлить hex-цвет к белому на долю t (0..1). Для «фантома» суперпозиции.
Принимает #RGB/#RRGGBB; прочее возвращает как есть. */
function lighten(hex, t) {
if (typeof hex !== 'string') return hex;
var m = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return hex;
var h = m[1];
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
r = Math.round(r + (255 - r) * t); g = Math.round(g + (255 - g) * t); b = Math.round(b + (255 - b) * t);
function hx(n) { var s = n.toString(16); return s.length === 1 ? '0' + s : s; }
return '#' + hx(r) + hx(g) + hx(b);
}
/* ── Inline SVG звезды ── */ /* ── Inline SVG звезды ── */
function starSvg(filled) { function starSvg(filled) {
var fill = filled ? '#FBBF24' : 'none'; var fill = filled ? '#FBBF24' : 'none';
var stroke = filled ? '#FBBF24' : '#64748B'; var stroke = filled ? '#FBBF24' : '#64748B';
return '<svg class="ic qg-star-svg" viewBox="0 0 24 24" width="34" height="34" fill="' + fill + // Цвета через inline style: .ic в ls.css (fill:none; stroke:currentColor) иначе
'" stroke="' + stroke + '" stroke-width="1.6" stroke-linejoin="round">' + // перебивает атрибуты fill/stroke и заработанные звёзды не закрашиваются.
return '<svg class="ic qg-star-svg" viewBox="0 0 24 24" width="34" height="34" style="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>'; '<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>';
} }
@@ -164,6 +188,17 @@
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
/* ── Открыть SR-комнату (повторение флешкарт → энергия) ────────────────────
Делегирует в QuantikAbilities.openRestRoom; после закрытия обновляет HUD
панели способностей (энергия могла измениться). */
function openRest(host, abilities) {
if (!global.QuantikAbilities || !global.QuantikAbilities.openRestRoom) return;
global.QuantikAbilities.openRestRoom({
host: host,
onClose: function () { if (abilities) try { abilities.refresh(); } catch (_e) {} }
});
}
/* ── Старт уровня ─────────────────────────────────────────────────────── /* ── Старт уровня ───────────────────────────────────────────────────────
opts: { host, level, skin?, onNext?(level), onMap?(), hasNext?, resolveNext? } opts: { host, level, skin?, onNext?(level), onMap?(), hasNext?, resolveNext? }
resolveNext?() -> Promise<{ hasNext, next }>: пересчитать следующий уровень resolveNext?() -> Promise<{ hasNext, next }>: пересчитать следующий уровень
@@ -180,6 +215,27 @@
var spec = tintHeroSpec(level.spec, skin); var spec = tintHeroSpec(level.spec, skin);
var inst = global.SimEngine.mount(host, spec); var inst = global.SimEngine.mount(host, spec);
// ── Панель квантовых способностей + HUD энергии (Фаза 4) ──
// Аддитивно: монтируется только если доступен модуль; кнопки сами решают,
// уместны ли они для уровня (tunnel/aim-флаги). SR-комната открывается отсюда.
var abilities = null;
if (global.QuantikAbilities && global.QuantikAbilities.mountBar) {
abilities = global.QuantikAbilities.mountBar({
host: host,
inst: inst,
level: level,
onOpenRest: function () { openRest(host, abilities); }
});
// Убираем панель при destroy инстанса (оборачиваем существующий destroy).
if (abilities) {
var _origDestroy = inst.destroy.bind(inst);
inst.destroy = function () {
try { abilities.destroy(); } catch (_e) {}
return _origDestroy();
};
}
}
var overlayRef = null; var overlayRef = null;
function clearOverlay() { function clearOverlay() {
if (overlayRef && overlayRef.overlay && overlayRef.overlay.parentNode) { if (overlayRef && overlayRef.overlay && overlayRef.overlay.parentNode) {
@@ -203,6 +259,7 @@
overlayRef.btnAgain.addEventListener('click', function () { overlayRef.btnAgain.addEventListener('click', function () {
clearOverlay(); clearOverlay();
try { inst.reset(); } catch (_e) {} try { inst.reset(); } catch (_e) {}
if (abilities) try { abilities.resetAbilities(); } catch (_e) {}
}); });
overlayRef.btnNext.addEventListener('click', function () { overlayRef.btnNext.addEventListener('click', function () {
clearOverlay(); clearOverlay();
+150 -4
View File
@@ -44,7 +44,9 @@
range:[a,b], // отрезок построения (деф. xmin..xmax) range:[a,b], // отрезок построения (деф. xmin..xmax)
samples?:200, // число точек (деф. 200, клампится) samples?:200, // число точек (деф. 200, клампится)
trace?:false, // true -> точка (varValue=t) пишется в след по времени trace?:false, // true -> точка (varValue=t) пишется в след по времени
color?, width? }, color?, width?,
// ── Квантик Ф3: «бегунок по кривой» (граф-уровни) ──
runner?:{ duration?:8, hold?:false } }, // см. блок «БЕГУНОК ПО КРИВОЙ» ниже
{ type:'vector', origin:[ox,oy], dx, dy, // стрелка из origin на (dx,dy) { type:'vector', origin:[ox,oy], dx, dy, // стрелка из origin на (dx,dy)
color?, width? }, // (x1/y1/x2/y2 тоже поддерживаются) color?, width? }, // (x1/y1/x2/y2 тоже поддерживаются)
{ type:'readout', // живой числовой бейдж { type:'readout', // живой числовой бейдж
@@ -94,6 +96,35 @@
] ]
} }
// game?: {...} — зарезервированный блок мета-слоя (Фаза 1/5); сервер его пропускает. // game?: {...} — зарезервированный блок мета-слоя (Фаза 1/5); сервер его пропускает.
// ── ГРАФ-УРОВНИ (Квантик, Фаза 3) ── «бегунок по кривой» + зоны-препятствия.
// Аддитивно: спека без runner/zone ведёт себя как раньше.
//
// БЕГУНОК ПО КРИВОЙ: на объекте plot поле runner:{ duration?, hold? } делает
// из ПЕРВОЙ кривой plot «дорожку»: за время t от 0 до duration (деф. 8 с)
// свободная переменная (x) линейно проходит range[a..b], а герой едет по
// точке (x, f(x)) ТОЙ ЖЕ скомпилированной функции, что рисует кривую — видимая
// кривая и путь героя идентичны (нет рассинхрона). Движок кладёт в env поля
// <plotId>.runX — текущий x бегунка (a + (b-a)·clamp(t/duration,0,1));
// <plotId>.runY — f(runX) первой кривой (тот же exprFn, что у кривой);
// <plotId>.runDone — 1, когда бегунок дошёл до конца (t>=duration), иначе 0.
// Герой = ОБЫЧНЫЙ point с x:'curve.runX', y:'curve.runY', glow+trail (визуал P2).
// Так нет само-ссылки (точка не ссылается на собственный x в одном проходе env):
// f компилируется один раз и питает И кривую, И бегунок. hold:true оставляет
// бегунок на последней точке после конца (иначе t зацикливается по time.loop).
// ⛔ Никакого eval: f — это SimExpr-выражение кривой (компилируется как обычно).
//
// ЗОНЫ-ПРЕПЯТСТВИЯ: объект type:'zone' — прямоугольная/круговая область в мире.
// { type:'zone', id:'pit', shape:'rect'|'circle',
// kind:'forbidden'|'target'|'collect', // цвет/семантика (деф. forbidden)
// // rect: x,y (центр), w, h ; circle: x,y (центр), r — числа ИЛИ выражения
// track?:'ball', // чью позицию проверять (деф. 'ball')
// color?, fill?, label? }
// Движок кладёт в env булево поле <zoneId>.hit = 1, если точка track сейчас
// ВНУТРИ зоны, иначе 0. goal.when/fail/stars[].when ссылаются на него
// (напр. fail:'pit.hit', goal:'gate.hit', stars:[{when:'coin.hit'}]).
// ⛔ В синтаксис выражений предикаты НЕ добавляются (безопасность контракта) —
// только именованные булевы env-поля, как `t`/`tries` (Фаза 0).
} }
Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также
<objId>.x / <objId>.y для объектов, у которых заданы числовые/выраж. x,y. <objId>.x / <objId>.y для объектов, у которых заданы числовые/выраж. x,y.
@@ -101,7 +132,10 @@
интегратора (а не из выражения) — это снимает проблему forward-ref однопроходного интегратора (а не из выражения) — это снимает проблему forward-ref однопроходного
env для тел: их позиция/скорость не пересчитываются формулой каждый кадр. env для тел: их позиция/скорость не пересчитываются формулой каждый кадр.
Выражения цели (goal.when/fail/stars[].when) видят ВЕСЬ env кадра ПЛЮС `tries` Выражения цели (goal.when/fail/stars[].when) видят ВЕСЬ env кадра ПЛЮС `tries`
(число пользовательских reset с начала). Новых небезопасных идентификаторов не вводится. (число пользовательских reset с начала). Граф-уровни (Ф3) добавляют ИМЕНОВАННЫЕ
булевы/числовые env-поля: <plotId>.runX/.runY/.runDone (бегунок) и <zoneId>.hit
(попадание в зону). Это данные env, а не функции синтаксиса — контракт выражений
остаётся закрытым (никаких inzone()/предикатов). Новых небезопасных идентификаторов нет.
── ИНТЕРАКЦИИ (Фаза 1) ────────────────────────────────────────────────── ── ИНТЕРАКЦИИ (Фаза 1) ──────────────────────────────────────────────────
Объект с полем drag:{param, axis, min?, max?, paramY?} становится ручкой: Объект с полем drag:{param, axis, min?, max?, paramY?} становится ручкой:
@@ -732,6 +766,25 @@
// легенда: показывать, если есть хотя бы одна подпись (можно явно legend:false) // легенда: показывать, если есть хотя бы одна подпись (можно явно legend:false)
var anyLabel = prep.curves.some(function (c) { return !!c.label; }); var anyLabel = prep.curves.some(function (c) { return !!c.label; });
prep.legend = (o.legend === false) ? false : anyLabel; prep.legend = (o.legend === false) ? false : anyLabel;
// ── Квантик Ф3: «бегунок по кривой» ──
// runner делает из ПЕРВОЙ кривой дорожку: x проходит range[a..b] за duration
// секунд (мирового t), y = f(x) той же кривой. Кладём в env <id>.runX/.runY/.runDone.
if (o.runner && typeof o.runner === 'object') {
prep.runner = {
duration: (typeof o.runner.duration === 'number' && o.runner.duration > 0) ? o.runner.duration : 8,
hold: o.runner.hold !== false // деф. true: остаётся на конце (не зацикливается)
};
}
} else if (type === 'zone') {
// ── Квантик Ф3: зона-препятствие/цель/сбор (прямоугольник или круг) ──
prep.shape = (o.shape === 'circle') ? 'circle' : 'rect';
prep.kind = (o.kind === 'target' || o.kind === 'collect') ? o.kind : 'forbidden';
prep.track = (typeof o.track === 'string' && o.track) ? o.track : 'ball';
prep.label = o.label != null ? String(o.label) : '';
bp('x', 0); bp('y', 0);
if (prep.shape === 'circle') { B.r = bind(o.r, 1); }
else { bp('w', 1); bp('h', 1); }
// зона НЕ участвует в obj.x/obj.y центрах (это область, не точка) — hasCenter не ставим
} else if (type === 'readout') { } else if (type === 'readout') {
// компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка) // компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка)
var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0') var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0')
@@ -773,7 +826,8 @@
} }
// привязки для центра объекта (для obj.x/obj.y в env): point/circle/rect/label // привязки для центра объекта (для obj.x/obj.y в env): point/circle/rect/label
if (B.x && B.y) { prep.hasCenter = true; } // (zone — область, не точка: его x/y не кладём в env как центр объекта)
if (B.x && B.y && type !== 'zone') { prep.hasCenter = true; }
out.push(prep); out.push(prep);
} }
@@ -1157,7 +1211,32 @@
} }
} }
// 2) центры формульных объектов (одношагово; тела пропускаем — их x/y уже в env). // 2) бегунок по кривой (Ф3): <plotId>.runX/.runY/.runDone — ДО формульных центров,
// чтобы герой-точка (x:'curve.runX') увидела актуальную позицию в том же кадре.
// runX линейно проходит range за runner.duration сек (по мировому t); runY = f(runX)
// ТОЙ ЖЕ скомпилированной функции, что рисует кривую (нет рассинхрона, нет само-ссылки).
for (var ri = 0; ri < this._objs.length; ri++) {
var pr = this._objs[ri];
if (pr.type !== 'plot' || !pr.runner) continue;
var aR = pr.rangeA.ev(env), bR = pr.rangeB.ev(env);
if (!pr.hasRange || !isFinite(aR) || !isFinite(bR)) { aR = vp.xmin; bR = vp.xmax; }
var frac = pr.runner.duration > 0 ? (env.t / pr.runner.duration) : 1;
var done = frac >= 1;
if (frac < 0) frac = 0; if (frac > 1) frac = 1;
var rx = aR + (bR - aR) * frac;
// y = f(runX): подставляем runX во временную копию свободной переменной
var hadV = Object.prototype.hasOwnProperty.call(env, pr.varName);
var prevV = env[pr.varName];
env[pr.varName] = rx;
var ry = pr.exprFn.ev(env);
if (hadV) env[pr.varName] = prevV; else delete env[pr.varName];
if (typeof ry !== 'number' || !isFinite(ry)) ry = 0;
env[pr.id + '.runX'] = rx;
env[pr.id + '.runY'] = ry;
env[pr.id + '.runDone'] = done ? 1 : 0;
}
// 3) центры формульных объектов (одношагово; тела пропускаем — их x/y уже в env).
for (var i = 0; i < this._objs.length; i++) { for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i]; var o = this._objs[i];
if (o.hasCenter && !o.body) { if (o.hasCenter && !o.body) {
@@ -1167,9 +1246,32 @@
env[o.id + '.y'] = y; env[o.id + '.y'] = y;
} }
} }
// 4) зоны (Ф3): <zoneId>.hit = 1/0 по позиции отслеживаемой точки (track).
// Считаем ПОСЛЕДНИМ — нужна актуальная позиция героя (из тела/формулы выше).
for (var zi = 0; zi < this._objs.length; zi++) {
var z = this._objs[zi];
if (z.type !== 'zone') continue;
env[z.id + '.hit'] = this._zoneHit(z, env) ? 1 : 0;
}
return env; return env;
}; };
/* Внутри ли зоны z отслеживаемая точка (env[track.x], env[track.y])? Геометрия в
мир-координатах. Точка отсутствует (нет такого track) -> не внутри (0). */
SimEngineInstance.prototype._zoneHit = function (z, env) {
var tx = env[z.track + '.x'], ty = env[z.track + '.y'];
if (typeof tx !== 'number' || typeof ty !== 'number' || !isFinite(tx) || !isFinite(ty)) return false;
var cx = z.b.x.ev(env), cy = z.b.y.ev(env);
if (z.shape === 'circle') {
var r = Math.abs(z.b.r.ev(env));
var dx = tx - cx, dy = ty - cy;
return (dx * dx + dy * dy) <= r * r;
}
var hw = Math.abs(z.b.w.ev(env)) / 2, hh = Math.abs(z.b.h.ev(env)) / 2;
return tx >= cx - hw && tx <= cx + hw && ty >= cy - hh && ty <= cy + hh;
};
/* ── трансформация мир→экран (ось Y вверх) с сохранением пропорций ── /* ── трансформация мир→экран (ось Y вверх) с сохранением пропорций ──
Эффективный transform (_scale/_offX/_offY) = базовый fit (_baseScale/...) с Эффективный transform (_scale/_offX/_offY) = базовый fit (_baseScale/...) с
наложенным пользовательским зумом/паном. _fit пересчитывает DPR/размер и базу; наложенным пользовательским зумом/паном. _fit пересчитывает DPR/размер и базу;
@@ -1771,6 +1873,50 @@
this._drawReadout(o, env); this._drawReadout(o, env);
break; break;
} }
case 'zone': {
this._drawZone(ctx, o, env);
break;
}
}
};
/* ── zone: область-препятствие/цель/сбор (Ф3) ──
Цвет по kind (forbidden=danger, target=goal, collect=bonus) ИЛИ явный o.color.
⛔ Цвета только в canvas-стоки (fillStyle/strokeStyle) — XSS-безопасно. */
var ZONE_STYLE = {
forbidden: { stroke: '#F87171', fill: 'rgba(248,113,113,0.16)', dash: true },
target: { stroke: '#34D399', fill: 'rgba(52,211,153,0.16)', dash: false },
collect: { stroke: '#FBBF24', fill: 'rgba(251,191,36,0.16)', dash: true }
};
SimEngineInstance.prototype._drawZone = function (ctx, o, env) {
var st = ZONE_STYLE[o.kind] || ZONE_STYLE.forbidden;
var stroke = o.color || st.stroke;
var fill = o.fillColor || st.fill;
var cx = o.b.x.ev(env), cy = o.b.y.ev(env);
ctx.save();
ctx.globalAlpha = o.opacity;
ctx.lineJoin = 'round';
ctx.lineWidth = 2;
ctx.strokeStyle = stroke;
ctx.fillStyle = fill;
if (st.dash) ctx.setLineDash([7, 5]); else ctx.setLineDash([]);
if (o.shape === 'circle') {
var r = Math.abs(o.b.r.ev(env)) * this._scale;
var c0 = this._toPx(cx, cy);
ctx.beginPath(); ctx.arc(c0[0], c0[1], r, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
} else {
var rw = Math.abs(o.b.w.ev(env)), rh = Math.abs(o.b.h.ev(env));
var tl = this._toPx(cx - rw / 2, cy + rh / 2); // верх-лево (Y вверх)
var pw = rw * this._scale, ph = rh * this._scale;
ctx.fillRect(tl[0], tl[1], pw, ph);
ctx.strokeRect(tl[0], tl[1], pw, ph);
}
ctx.restore();
// подпись зоны (на оверлее, через _drawLabel — KaTeX/текст; цвет = stroke зоны)
if (o.label) {
var lp = this._toPx(cx, cy);
this._drawLabel({ text: o.label, color: stroke, size: o.size || 12, latex: false }, lp[0], lp[1]);
} }
}; };
+5 -1
View File
@@ -5,6 +5,10 @@
* dashboard.html используют window.PetSprite.render(...) — без дублей. * dashboard.html используют window.PetSprite.render(...) — без дублей.
*/ */
(function () { (function () {
// Счётчик для УНИКАЛЬНЫХ id градиентов/клипов спрайта. Иначе два питомца с
// одинаковыми level/mood/colorKey дают совпадающие id, и url(#id) заливки тела
// резолвится в чужой (возможно display:none) градиент → тело без заливки.
let _petUidSeq = 0;
const PET_PALETTES = { const PET_PALETTES = {
purple:'#9B5DE5', cyan:'#06D6E0', gold:'#F9C74F', purple:'#9B5DE5', cyan:'#06D6E0', gold:'#F9C74F',
red:'#F94144', green:'#38D95A', blue:'#4A90D9', red:'#F94144', green:'#38D95A', blue:'#4A90D9',
@@ -24,7 +28,7 @@
const col = PET_PALETTES[colorKey] || '#9B5DE5'; const col = PET_PALETTES[colorKey] || '#9B5DE5';
const dark = shadeColor(col, -45); const dark = shadeColor(col, -45);
const light = shadeColor(col, 52); const light = shadeColor(col, 52);
const uid = `pg${level}${mood[0]}${colorKey[0]}`; const uid = `pg${(++_petUidSeq).toString(36)}`;
const bodyPath = 'M55,22 C70,22 86,37 87,56 C89,75 78,94 55,97 C32,94 21,75 23,56 C24,37 40,22 55,22 Z'; const bodyPath = 'M55,22 C70,22 86,37 87,56 C89,75 78,94 55,97 C32,94 21,75 23,56 C24,37 40,22 55,22 Z';
const eyeY = 52, eyeX1 = 40, eyeX2 = 70; const eyeY = 52, eyeX1 = 40, eyeX2 = 70;
+261 -3
View File
@@ -74,7 +74,15 @@
params: [], params: [],
objects: [], objects: [],
plots: [], // храним plot-объекты отдельно для удобства UI, при сборке мерджим в objects plots: [], // храним plot-объекты отдельно для удобства UI, при сборке мерджим в objects
physics: { enabled: false, gx: 0, gy: -9.8, friction: 0, restitution: 0.9, walls: [], springs: [] } physics: { enabled: false, gx: 0, gy: -9.8, friction: 0, restitution: 0.9, walls: [], springs: [] },
// P5-Квантик: игровой слой (goal + игровые метаданные). enabled=false → goal/game
// не попадают в спеку (обычная симуляция ведёт себя ровно как раньше).
game: {
enabled: false,
when: '', title: '', hint: '', hold: '', fail: '',
stars: [], // [{ when, label }], макс 3
chapter: '', order: '', par_ms: ''
}
}; };
} }
@@ -91,7 +99,7 @@
this._remountTimer = null; this._remountTimer = null;
this._selObjId = null; // выбранный для drag-on-preview объект this._selObjId = null; // выбранный для drag-on-preview объект
this._placing = false; // режим «поставить объект кликом» this._placing = false; // режим «поставить объект кликом»
this._open = { meta: true, params: true, objects: true, plots: true }; this._open = { meta: true, params: true, objects: true, plots: true, game: false };
this._lastSpec = null; this._lastSpec = null;
// P5: прямое манипулирование + история // P5: прямое манипулирование + история
this._snap = false; // привязка к сетке при drag this._snap = false; // привязка к сетке при drag
@@ -226,6 +234,8 @@
walls: (Array.isArray(ph.walls) ? ph.walls : []).map(function (w) { return Object.assign({ _uid: uid('w') }, w); }), walls: (Array.isArray(ph.walls) ? ph.walls : []).map(function (w) { return Object.assign({ _uid: uid('w') }, w); }),
springs: (Array.isArray(ph.springs) ? ph.springs : []).map(function (s) { return Object.assign({ _uid: uid('s') }, s); }) springs: (Array.isArray(ph.springs) ? ph.springs : []).map(function (s) { return Object.assign({ _uid: uid('s') }, s); })
}; };
// game/goal (P5-Квантик): раскладываем spec.goal + spec.game обратно в st.game.
st.game = loadGame(spec.goal, spec.game);
this.st = st; this.st = st;
// свежая загрузка (открытие симуляции / шаблон) — история начинается заново // свежая загрузка (открытие симуляции / шаблон) — история начинается заново
this._undo.length = 0; this._redo.length = 0; this._fieldSnapTaken = false; this._undo.length = 0; this._redo.length = 0; this._fieldSnapTaken = false;
@@ -272,6 +282,15 @@
}; };
spec.physics = ph; spec.physics = ph;
} }
// game/goal (P5-Квантик): материализуем игровой слой, если он включён.
// goal{when,title,hint,hold,fail,stars[]} и game{chapter,order,par_ms}.
if (st.game && st.game.enabled) {
var goal = buildGoal(st.game);
if (goal) spec.goal = goal;
var game = buildGameMeta(st.game);
if (game) spec.game = game;
}
return spec; return spec;
}; };
@@ -321,6 +340,71 @@
return s; return s;
} }
/* ── Игровой слой (P5-Квантик): goal/game ⇄ UI-состояние st.game ───────────
st.game = { enabled, when, title, hint, hold, fail, stars:[{when,label}],
chapter, order, par_ms }. Хранит «как введено» (строки/числа) —
материализация в spec.goal/spec.game на сборке, разбор обратно на загрузке. */
/* spec.goal + spec.game -> st.game (для loadFromSim). Включаем игровой режим,
если в спеке присутствует goal ИЛИ game. */
function loadGame(goal, game) {
var g = {
enabled: false,
when: '', title: '', hint: '', hold: '', fail: '',
stars: [], chapter: '', order: '', par_ms: ''
};
if (goal && typeof goal === 'object') {
g.enabled = true;
g.when = goal.when == null ? '' : String(goal.when);
g.title = goal.title == null ? '' : String(goal.title);
g.hint = goal.hint == null ? '' : String(goal.hint);
g.fail = goal.fail == null ? '' : String(goal.fail);
g.hold = (goal.hold == null || goal.hold === '') ? '' : goal.hold;
g.stars = (Array.isArray(goal.stars) ? goal.stars : []).map(function (s) {
s = s || {};
return {
_uid: uid('star'),
when: s.when == null ? '' : String(s.when),
label: s.label == null ? '' : String(s.label)
};
});
}
if (game && typeof game === 'object') {
g.enabled = true;
g.chapter = game.chapter == null ? '' : String(game.chapter);
g.order = (game.order == null || game.order === '') ? '' : game.order;
g.par_ms = (game.par_ms == null || game.par_ms === '') ? '' : game.par_ms;
}
return g;
}
/* st.game -> spec.goal (или null, если нет ни одного содержательного поля). */
function buildGoal(gm) {
var out = {};
if (trimStr(gm.when)) out.when = trimStr(gm.when);
if (trimStr(gm.title)) out.title = trimStr(gm.title);
if (trimStr(gm.hint)) out.hint = trimStr(gm.hint);
if (trimStr(gm.fail)) out.fail = trimStr(gm.fail);
if (gm.hold !== '' && gm.hold != null && isFinite(parseFloat(gm.hold))) out.hold = parseFloat(gm.hold);
var stars = (Array.isArray(gm.stars) ? gm.stars : []).map(function (s) {
var os = {};
if (trimStr(s.when)) os.when = trimStr(s.when);
if (trimStr(s.label)) os.label = trimStr(s.label);
return os;
}).filter(function (s) { return s.when || s.label; }).slice(0, 3);
if (stars.length) out.stars = stars;
return Object.keys(out).length ? out : null;
}
/* st.game -> spec.game (метаданные уровня; null, если все пусты). */
function buildGameMeta(gm) {
var out = {};
if (trimStr(gm.chapter)) out.chapter = trimStr(gm.chapter);
if (gm.order !== '' && gm.order != null && isFinite(parseFloat(gm.order))) out.order = parseFloat(gm.order);
if (gm.par_ms !== '' && gm.par_ms != null && isFinite(parseFloat(gm.par_ms))) out.par_ms = parseFloat(gm.par_ms);
return Object.keys(out).length ? out : null;
}
/* ════════════════════════ ЖИВОЕ ПРЕВЬЮ ════════════════════════ */ /* ════════════════════════ ЖИВОЕ ПРЕВЬЮ ════════════════════════ */
Builder.prototype.scheduleRemount = function (immediate) { Builder.prototype.scheduleRemount = function (immediate) {
@@ -609,6 +693,41 @@
if (action === 'snap') { this.toggleSnap(); return; } if (action === 'snap') { this.toggleSnap(); return; }
}; };
/* «Играть»: открыть текущую (в работе) спеку в игровом режиме для теста уровня.
Монтируем тот же SimEngine в модалке — слой цели (HUD/победа/звёзды) активируется
САМ наличием блока goal (Фаза 0 движка), как и в /quantik. Без сохранения/сети —
тестируем прямо черновик. Если goal не задан, подсказываем включить игровой слой. */
Builder.prototype.playGame = function () {
var self = this;
var spec = this.buildSpec();
if (!spec.goal || !spec.goal.when) {
global.LS.toast('Задайте цель (поле «победа») и включите игровой уровень', 'warn', 2600);
return;
}
if (!global.SimEngine) { global.LS.toast('Движок не загружен', 'error'); return; }
// Модалка с хост-узлом сцены; SimEngine монтируется после открытия. Инстанс
// уничтожается в onClose — он срабатывает на ЛЮБОЕ закрытие (X / оверлей / Escape /
// кнопка «Закрыть»), поэтому отдельный destroy в onClick кнопки не нужен.
var host = global.document.createElement('div');
host.style.cssText = 'position:relative;width:100%;height:min(70vh,560px);background:#0D0D1A;border-radius:10px;overflow:hidden';
var inst = null;
var m = global.LS.modal({
title: 'Тест уровня', size: 'lg', content: '',
onClose: function () { if (inst) { try { inst.destroy(); } catch (e) {} inst = null; } },
actions: [
{ label: 'Сброс', onClick: function () { if (inst && inst.reset) { try { inst.reset(); } catch (e) {} } } },
{ label: 'Закрыть', primary: true, onClick: function () { m.close(); } }
]
});
m.body.appendChild(host);
try {
inst = global.SimEngine.mount(host, spec);
if (inst && inst.play) inst.play();
} catch (e) {
host.innerHTML = '<div style="padding:30px;color:#ef4444">Ошибка запуска: ' + esc(e.message || e) + '</div>';
}
};
/* Переключить привязку к сетке (drag будет округлять к шагу сетки). */ /* Переключить привязку к сетке (drag будет округлять к шагу сетки). */
Builder.prototype.toggleSnap = function () { Builder.prototype.toggleSnap = function () {
this._snap = !this._snap; this._snap = !this._snap;
@@ -764,6 +883,18 @@
if (r < 0 || r > 1) errs.push('Упругость (restitution) должна быть в диапазоне 0..1.'); if (r < 0 || r > 1) errs.push('Упругость (restitution) должна быть в диапазоне 0..1.');
} }
// game/goal (P5-Квантик): проверяем выражения цели/проигрыша/звёзд
if (st.game && st.game.enabled) {
checkExpr(typeof st.game.when === 'string' ? st.game.when : '', 'Цель: условие победы (when)');
checkExpr(typeof st.game.fail === 'string' ? st.game.fail : '', 'Цель: условие проигрыша (fail)');
if (!trimStr(st.game.when)) errs.push('Игровой уровень: укажите условие победы (when).');
var starList = Array.isArray(st.game.stars) ? st.game.stars : [];
if (starList.length > 3) errs.push('Максимум 3 звезды.');
starList.forEach(function (s, i) {
checkExpr(typeof s.when === 'string' ? s.when : '', 'Звезда #' + (i + 1) + ', условие');
});
}
// размер JSON // размер JSON
try { try {
var bytes = new global.Blob([JSON.stringify(this.buildSpec())]).size; var bytes = new global.Blob([JSON.stringify(this.buildSpec())]).size;
@@ -833,7 +964,8 @@
this.sectionMeta() + this.sectionMeta() +
this.sectionParams() + this.sectionParams() +
this.sectionObjects() + this.sectionObjects() +
this.sectionPlotsPhysics(); this.sectionPlotsPhysics() +
this.sectionGame();
this.wirePanels(); this.wirePanels();
}; };
@@ -1134,6 +1266,72 @@
section('physics', 'Физика', physBody, !!ph.enabled); section('physics', 'Физика', physBody, !!ph.enabled);
}; };
/* ── Игровой уровень (P5-Квантик) ─────────────────────────────────────────
Панель «Цель» собирает блок goal (when/title/hint/hold/fail) + список звёзд
(макс 3) + игровые метаданные (chapter/order/par_ms). Тумблер «Это игровой
уровень» включает слой; выключенный — goal/game НЕ попадают в спеку.
Выражения (when/fail/звёзды) проверяются inline через SimExpr.compile. */
Builder.prototype.sectionGame = function () {
var gm = this.st.game || {};
var on = !!gm.enabled;
// строка-выражение цели/проигрыша с inline-ошибкой
function exprRow(key, label, val, ph) {
var err = exprError(val);
return '<div class="sbu-of' + (err ? ' has-err' : '') + '">' +
'<label class="sbu-of-lbl">' + esc(label) +
'<button class="sbu-fx" data-gfx="' + key + '" title="Палитра функций/параметров">fx</button>' +
'</label>' +
'<input class="sbu-in sbu-in-expr" data-gf="' + key + '" value="' + esc(val == null ? '' : val) + '" placeholder="' + esc(ph || 'условие (выражение)') + '" />' +
(err ? '<span class="sbu-of-err">' + esc(err) + '</span>' : '') +
'</div>';
}
var stars = Array.isArray(gm.stars) ? gm.stars : [];
var starRows = stars.map(function (s, i) {
var err = exprError(s.when);
return '<div class="sbu-star" data-si="' + i + '">' +
'<div class="sbu-star-hdr">' +
'<span class="sbu-obj-type">Звезда ' + (i + 1) + '</span>' +
'<span style="flex:1"></span>' +
'<button class="sbu-icon-btn sbu-del" data-stardel="' + i + '" title="Удалить звезду">' + ICON.trash + '</button>' +
'</div>' +
'<div class="sbu-of' + (err ? ' has-err' : '') + '">' +
'<label class="sbu-of-lbl">условие' +
'<button class="sbu-fx" data-sfx="' + i + '" title="Палитра">fx</button>' +
'</label>' +
'<input class="sbu-in sbu-in-expr" data-sf="when" value="' + esc(s.when == null ? '' : s.when) + '" placeholder="напр. coin.hit" />' +
(err ? '<span class="sbu-of-err">' + esc(err) + '</span>' : '') +
'</div>' +
miniField('подпись', '<input class="sbu-in" data-sf="label" value="' + esc(s.label == null ? '' : s.label) + '" placeholder="Собрал кристалл" />') +
'</div>';
}).join('');
var inner =
'<label class="sbu-of-check sbu-phys-toggle"><input type="checkbox" data-game="enabled"' + (on ? ' checked' : '') + '/> Это игровой уровень (Квантик)</label>' +
'<div class="sbu-game-fields"' + (on ? '' : ' style="opacity:.45;pointer-events:none"') + '>' +
'<div class="sbu-sub">Цель</div>' +
exprRow('when', 'победа (when)', gm.when, 'напр. gate.hit или hypot(ball.x-8,ball.y-1)<0.8') +
exprRow('fail', 'проигрыш (fail) — опц.', gm.fail, 'напр. ball.y < -1 || t > 8') +
'<div class="sbu-row2">' +
field('Заголовок цели', '<input class="sbu-in" data-gf="title" value="' + esc(gm.title || '') + '" placeholder="Попади в портал" />') +
miniField('удержать, с (hold)', '<input class="sbu-in" type="number" step="0.1" min="0" data-gf="hold" value="' + esc(gm.hold == null ? '' : gm.hold) + '" placeholder="0" />') +
'</div>' +
field('Подсказка', '<textarea class="sbu-in" data-gf="hint" rows="2" placeholder="Краткая подсказка игроку">' + esc(gm.hint || '') + '</textarea>') +
'<div class="sbu-sub">Звёзды (макс 3)</div>' +
'<div class="sbu-stars-list">' + (starRows || '<div class="sbu-empty-sm">Нет звёзд-бонусов. Победа = 1-я звезда автоматически.</div>') + '</div>' +
(stars.length < 3 ? '<button class="sbu-add sbu-add-sm" data-add="star">' + ICON.plus + ' Звезда</button>' : '') +
'<div class="sbu-divider"></div>' +
'<div class="sbu-sub">Метаданные уровня</div>' +
'<div class="sbu-row4">' +
miniField('глава', '<input class="sbu-in" data-gf="chapter" value="' + esc(gm.chapter || '') + '" placeholder="kinematics" />') +
miniField('порядок', '<input class="sbu-in" type="number" data-gf="order" value="' + esc(gm.order == null ? '' : gm.order) + '" placeholder="1" />') +
miniField('норматив, мс', '<input class="sbu-in" type="number" data-gf="par_ms" value="' + esc(gm.par_ms == null ? '' : gm.par_ms) + '" placeholder="1500" />') +
'<span></span>' +
'</div>' +
'<button class="sbu-add sbu-add-sm" data-a2="play-game">' + ICON.play + ' Играть (тест уровня)</button>' +
'</div>';
return section('game', 'Игровой уровень (цель/звёзды)', inner, this._open.game);
};
/* ════════════════════════ ПРИВЯЗКА СОБЫТИЙ ПАНЕЛЕЙ ════════════════════════ */ /* ════════════════════════ ПРИВЯЗКА СОБЫТИЙ ПАНЕЛЕЙ ════════════════════════ */
Builder.prototype.wirePanels = function () { Builder.prototype.wirePanels = function () {
@@ -1468,6 +1666,61 @@
}); });
}); });
// ── Игровой слой (P5-Квантик) ──
var gameOn = p.querySelector('[data-game="enabled"]');
if (gameOn) gameOn.addEventListener('change', function () {
self.pushHistory();
self.st.game.enabled = gameOn.checked;
self.renderPanels(); self.scheduleRemount(false);
});
// goal/game поля (when/fail/title/hint/hold/chapter/order/par_ms)
p.querySelectorAll('[data-gf]').forEach(function (el) {
el.addEventListener('input', function () {
self.snapField();
var k = el.getAttribute('data-gf');
self.st.game[k] = el.value;
self.updateFieldFeedback(el, null); // inline-ошибка выражения (when/fail)
self.scheduleRemount(false);
});
});
// звёзды: поля when/label
p.querySelectorAll('.sbu-star').forEach(function (row) {
var i = parseInt(row.getAttribute('data-si'), 10);
row.querySelectorAll('[data-sf]').forEach(function (el) {
el.addEventListener('input', function () {
self.snapField();
var k = el.getAttribute('data-sf');
if (self.st.game.stars[i]) self.st.game.stars[i][k] = el.value;
self.updateFieldFeedback(el, null);
self.scheduleRemount(false);
});
});
});
p.querySelectorAll('[data-stardel]').forEach(function (b) {
b.addEventListener('click', function () {
self.pushHistory();
self.st.game.stars.splice(parseInt(b.getAttribute('data-stardel'), 10), 1);
self.renderPanels(); self.scheduleRemount(false);
});
});
// fx-палитра для goal-выражений и условий звёзд
p.querySelectorAll('[data-gfx]').forEach(function (b) {
b.addEventListener('click', function () {
var key = b.getAttribute('data-gfx');
self.openPalette(p.querySelector('[data-gf="' + key + '"]'));
});
});
p.querySelectorAll('[data-sfx]').forEach(function (b) {
b.addEventListener('click', function () {
var row = b.closest('.sbu-star');
self.openPalette(row && row.querySelector('[data-sf="when"]'));
});
});
// «Играть (тест уровня)» внутри панели
p.querySelectorAll('[data-a2="play-game"]').forEach(function (b) {
b.addEventListener('click', function () { self.playGame(); });
});
// add buttons // add buttons
p.querySelectorAll('[data-add]').forEach(function (b) { p.querySelectorAll('[data-add]').forEach(function (b) {
b.addEventListener('click', function () { self.onAdd(b.getAttribute('data-add')); }); b.addEventListener('click', function () { self.onAdd(b.getAttribute('data-add')); });
@@ -1533,6 +1786,11 @@
if (this.st.physics.springs.length >= LIMITS.springs) { global.LS.toast('Достигнут лимит пружин', 'warn'); return; } if (this.st.physics.springs.length >= LIMITS.springs) { global.LS.toast('Достигнут лимит пружин', 'warn'); return; }
this.pushHistory(); this.pushHistory();
this.st.physics.springs.push({ _uid: uid('s'), a: '', b: '', k: 40, length: 2, damping: 0.5 }); this.st.physics.springs.push({ _uid: uid('s'), a: '', b: '', k: 40, length: 2, damping: 0.5 });
} else if (what === 'star') {
this.st.game.stars = Array.isArray(this.st.game.stars) ? this.st.game.stars : [];
if (this.st.game.stars.length >= 3) { global.LS.toast('Максимум 3 звезды', 'warn'); return; }
this.pushHistory();
this.st.game.stars.push({ _uid: uid('star'), when: '', label: '' });
} }
this.renderPanels(); this.renderPanels();
this.scheduleRemount(false); this.scheduleRemount(false);
+159 -6
View File
@@ -220,12 +220,133 @@
.qg-star-svg { filter: drop-shadow(0 2px 6px rgba(251,191,36,0.4)); } .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); } .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; } } @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; } }
/* Доступность: уважаем prefers-reduced-motion. Делаем анимации мгновенными
(а не выключаем) — иначе forwards-анимация появления узлов (.qm-in/qmNodeIn)
не применит конечное состояние и узлы останутся скрытыми. Циклы (пульс/мерцание/
покачивание) при этом фактически останавливаются (1 итерация мгновенно). */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
.qg-stats { display: flex; justify-content: center; gap: 22px; margin-bottom: 20px; } .qg-stats { display: flex; justify-content: center; gap: 22px; margin-bottom: 20px; }
.qg-stat { display: flex; flex-direction: column; gap: 3px; } .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-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-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-actions { display: flex; justify-content: center; gap: 10px; }
.qg-btn { min-width: 118px; } .qg-btn { min-width: 118px; }
/* ════════════════════ Квантовые способности (Фаза 4) ════════════════════ */
/* Панель способностей + HUD энергии — оверлеем поверх сцены уровня. */
.qa-bar {
position: absolute; right: 12px; bottom: 12px; z-index: 12;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;
pointer-events: auto; max-width: calc(100% - 24px);
}
.qa-energy {
display: inline-flex; align-items: center; gap: 5px;
background: rgba(17,19,42,0.86); border: 1px solid rgba(251,191,36,0.4);
border-radius: 99px; padding: 6px 12px 6px 10px; color: #FBBF24;
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: .9rem; font-variant-numeric: tabular-nums;
box-shadow: 0 6px 18px rgba(0,0,0,0.4);
}
.qa-energy .ic { width: 16px; height: 16px; color: #FBBF24; }
.qa-btn {
display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .82rem; font-weight: 600;
color: #E2E8F0; background: rgba(17,19,42,0.86); border: 1px solid rgba(148,163,184,0.28);
border-radius: 99px; padding: 7px 13px; cursor: pointer; transition: .16s;
box-shadow: 0 6px 18px rgba(0,0,0,0.4);
}
.qa-btn .ic { width: 16px; height: 16px; }
.qa-btn:hover:not(:disabled) { border-color: rgba(196,181,253,0.6); color: #fff; background: rgba(30,33,66,0.94); }
.qa-btn:disabled { opacity: .42; cursor: not-allowed; }
.qa-rest { color: #67E8F9; border-color: rgba(34,211,238,0.32); }
.qa-rest:hover:not(:disabled) { border-color: rgba(34,211,238,0.6); }
.qa-tunnel { color: #F0ABFC; border-color: rgba(244,114,182,0.34); }
.qa-aim { color: #7DD3FC; border-color: rgba(56,189,248,0.34); }
.qa-cost { display: inline-flex; align-items: center; gap: 2px; font-size: .74rem; color: #FBBF24; font-weight: 800; }
.qa-cost .ic { width: 12px; height: 12px; color: #FBBF24; }
.qa-ability.qa-on {
color: #fff; border-color: rgba(196,181,253,0.85);
box-shadow: 0 0 0 2px rgba(196,181,253,0.35), 0 6px 18px rgba(167,139,250,0.4);
}
/* всплывающая подсказка способности */
.qa-toast {
position: absolute; left: 50%; bottom: 70px; transform: translateX(-50%) translateY(8px); z-index: 14;
background: rgba(13,13,26,0.94); border: 1px solid rgba(196,181,253,0.5); color: #E8EDF5;
padding: 9px 16px; border-radius: 12px; font-size: .85rem; font-weight: 600;
box-shadow: 0 12px 34px rgba(0,0,0,0.5); opacity: 0; transition: opacity .28s, transform .28s; pointer-events: none;
max-width: 84%; text-align: center;
}
.qa-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ── SR-комната (модалка повторения) ── */
.qa-overlay {
position: fixed; inset: 0; z-index: 60;
display: flex; align-items: center; justify-content: center;
background: rgba(7,7,18,0.78); backdrop-filter: blur(6px); padding: 16px;
}
.qa-modal {
background: linear-gradient(180deg, #15173099, #0F1024EE), #14152C;
border: 1px solid rgba(148,163,184,0.2); border-radius: 18px;
width: min(460px, 96vw); max-height: 92vh; overflow: hidden;
display: flex; flex-direction: column; box-shadow: 0 24px 70px rgba(0,0,0,0.6);
animation: qg-pop .24s cubic-bezier(.22,1.1,.4,1);
}
.qa-modal-head {
display: flex; align-items: center; gap: 10px; padding: 14px 16px;
border-bottom: 1px solid rgba(148,163,184,0.16); flex-shrink: 0;
}
.qa-modal-title { display: inline-flex; align-items: center; gap: 8px; font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1rem; color: #EAF0F8; flex: 1; min-width: 0; }
.qa-modal-title .ic { width: 18px; height: 18px; color: #67E8F9; flex-shrink: 0; }
.qa-modal-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.qa-modal-energy { display: inline-flex; align-items: center; gap: 4px; color: #FBBF24; font-weight: 800; font-variant-numeric: tabular-nums; }
.qa-modal-energy .ic { width: 15px; height: 15px; color: #FBBF24; }
.qa-modal-x { background: none; border: none; color: #94A3B8; font-size: 1.5rem; line-height: 1; cursor: pointer; padding: 0 4px; }
.qa-modal-x:hover { color: #fff; }
.qa-modal-body { padding: 18px 18px 20px; overflow-y: auto; }
.qa-loading { text-align: center; color: #94A3B8; padding: 30px 0; }
.qa-empty-title { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.1rem; color: #EAF0F8; text-align: center; margin-bottom: 8px; }
.qa-empty-msg { color: #A8B4C6; text-align: center; line-height: 1.5; margin-bottom: 18px; }
.qa-modal-actions { display: flex; justify-content: center; gap: 10px; flex-wrap: wrap; }
.qa-modal-btn { min-width: 130px; text-align: center; text-decoration: none; }
.qa-deck-list { display: flex; flex-direction: column; gap: 8px; }
.qa-deck {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: rgba(30,33,58,0.6); border: 1px solid rgba(148,163,184,0.18); border-radius: 12px;
padding: 11px 13px; cursor: pointer; font: inherit; transition: .15s;
}
.qa-deck:hover { border-color: var(--dk, #9B5DE5); background: rgba(40,44,78,0.7); }
.qa-deck-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--dk, #9B5DE5); flex-shrink: 0; }
.qa-deck-title { color: #E2E8F0; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.qa-deck-due { color: #67E8F9; font-size: .76rem; font-weight: 700; flex-shrink: 0; }
/* сессия повторения */
.qa-prog { height: 6px; border-radius: 99px; background: rgba(148,163,184,0.2); overflow: hidden; margin-bottom: 6px; }
.qa-prog-fill { height: 100%; background: linear-gradient(90deg, #22D3EE, #A78BFA); border-radius: 99px; transition: width .3s; }
.qa-prog-count { text-align: right; font-size: .72rem; color: #8B9AAE; margin-bottom: 12px; font-variant-numeric: tabular-nums; }
.qa-card {
min-height: 130px; background: rgba(20,22,44,0.72); border: 1px solid rgba(148,163,184,0.18);
border-radius: 14px; padding: 20px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 12px;
}
.qa-card-side { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.qa-card-back { border-top: 1px dashed rgba(148,163,184,0.3); padding-top: 12px; }
.qa-card-text { color: #E8EDF5; font-size: 1.02rem; line-height: 1.45; text-align: center; word-break: break-word; }
.qa-card-empty { color: #64748B; }
.qa-card-img { max-width: 100%; max-height: 160px; border-radius: 10px; }
.qa-flip { width: 100%; }
.qa-grades { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.qa-grade { font: inherit; font-size: .82rem; font-weight: 700; color: #fff; border: none; border-radius: 10px; padding: 10px 4px; cursor: pointer; transition: filter .14s; }
.qa-grade:hover { filter: brightness(1.12); }
.qa-g-again { background: #DC2626; }
.qa-g-hard { background: #D97706; }
.qa-g-good { background: #2563EB; }
.qa-g-easy { background: #16A34A; }
</style> </style>
</head> </head>
<body> <body>
@@ -240,7 +361,7 @@
<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> <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> </button>
<span class="qg-pill">Физика</span> <span class="qg-pill" id="qg-pill">Физика</span>
</div> </div>
<!-- Вид карты --> <!-- Вид карты -->
@@ -271,10 +392,11 @@
<!-- KaTeX для подписей сцены --> <!-- 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/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.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/levels.js"></script>
<script src="/js/game/progress-logic.js"></script> <script src="/js/game/progress-logic.js"></script>
<script src="/js/game/map.js"></script> <script src="/js/game/map.js"></script>
<script src="/js/game/quantik-abilities.js"></script>
<script src="/js/game/quantik-game.js"></script> <script src="/js/game/quantik-game.js"></script>
<script> <script>
(function () { (function () {
@@ -287,6 +409,14 @@
var backBtn = document.getElementById('qg-back'); var backBtn = document.getElementById('qg-back');
var titleEl = document.getElementById('qg-title'); var titleEl = document.getElementById('qg-title');
var subEl = document.getElementById('qg-sub'); var subEl = document.getElementById('qg-sub');
var pillEl = document.getElementById('qg-pill');
// Бейдж темы по предмету уровня (аддитивно; граф-уровни — «Алгебра»).
var SUBJECT_LABEL = { physics: 'Физика', algebra: 'Алгебра', math: 'Математика' };
function setPill(level) {
if (!pillEl) return;
pillEl.textContent = SUBJECT_LABEL[level && level.subject] || 'Физика';
}
if (!window.SimEngine || !window.SimExpr || !window.QuantikLevels || if (!window.SimEngine || !window.SimExpr || !window.QuantikLevels ||
!window.QuantikGame || !window.QuantikMap || !window.QuantikProgress) { !window.QuantikGame || !window.QuantikMap || !window.QuantikProgress) {
@@ -321,6 +451,7 @@
backBtn.style.display = 'none'; backBtn.style.display = 'none';
titleEl.textContent = 'Квантик — Законы Мира'; titleEl.textContent = 'Квантик — Законы Мира';
subEl.textContent = 'Карта мира — выбери уровень и почини закон'; subEl.textContent = 'Карта мира — выбери уровень и почини закон';
if (pillEl) pillEl.textContent = 'Физика';
history.replaceState(null, '', '/quantik'); history.replaceState(null, '', '/quantik');
// перезагрузить прогресс (мог обновиться после победы) и перерисовать // перезагрузить прогресс (мог обновиться после победы) и перерисовать
loadProgress().then(function () { map.render(progressMap); }); loadProgress().then(function () { map.render(progressMap); });
@@ -334,6 +465,7 @@
backBtn.style.display = ''; backBtn.style.display = '';
titleEl.textContent = level.title || 'Квантик'; titleEl.textContent = level.title || 'Квантик';
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || level.hint || ''; subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || level.hint || '';
setPill(level);
history.replaceState(null, '', '/quantik?level=' + encodeURIComponent(level.id)); history.replaceState(null, '', '/quantik?level=' + encodeURIComponent(level.id));
// Pre-win значение (фолбэк, если пересчёт после победы недоступен). // Pre-win значение (фолбэк, если пересчёт после победы недоступен).
@@ -373,6 +505,7 @@
backBtn.style.display = ''; backBtn.style.display = '';
titleEl.textContent = level.title || 'Квантик'; titleEl.textContent = level.title || 'Квантик';
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || ''; subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || '';
setPill(level);
var intro = window.QuantikGame.buildIntro(level, window.QuantikGame.getSkin()); var intro = window.QuantikGame.buildIntro(level, window.QuantikGame.getSkin());
intro.btnGo.addEventListener('click', function () { intro.btnGo.addEventListener('click', function () {
@@ -400,17 +533,37 @@
backBtn.addEventListener('click', showMap); backBtn.addEventListener('click', showMap);
// Подмешать авторённые уровни (custom_sims cat='game') до рендера карты (Ф5).
function ensureCustomLevels() {
if (window.QuantikLevels.ensureCustom) {
return window.QuantikLevels.ensureCustom().catch(function () {});
}
return Promise.resolve();
}
// Старт: если ?level=<id> в URL и уровень доступен — открыть его, иначе карта. // Старт: если ?level=<id> в URL и уровень доступен — открыть его, иначе карта.
loadProgress().then(function () { // Сначала грузим прогресс И авторённые уровни (параллельно), затем deep-link.
Promise.all([loadProgress(), ensureCustomLevels()]).then(function () {
map.render(progressMap); map.render(progressMap);
var params = new URLSearchParams(location.search); var params = new URLSearchParams(location.search);
var wantId = params.get('level'); var wantId = params.get('level');
if (wantId) { if (wantId) {
var lvl = window.QuantikLevels.get(wantId); // custom:<id> может быть свой draft (нет в списке) — резолвим асинхронно с
if (lvl && window.QuantikProgress.isUnlocked(lvl, progressMap, window.QuantikLevels.list())) { // проверкой доступа на сервере (own|published|admin → иначе 404/403 → карта).
var resolve = window.QuantikLevels.getAsync
? window.QuantikLevels.getAsync(wantId)
: Promise.resolve(window.QuantikLevels.get(wantId));
resolve.then(function (lvl) {
// Авторённый уровень (deep-link) — открываем без гейта unlockStars
// (учитель/получатель ссылки заходит прямо в него). Встроенный — как раньше.
var isCustom = /^custom:/.test(wantId);
if (lvl && (isCustom || window.QuantikProgress.isUnlocked(lvl, progressMap, window.QuantikLevels.list()))) {
openLevel(lvl); openLevel(lvl);
return; } else {
showMapNoReload();
} }
});
return;
} }
showMapNoReload(); showMapNoReload();
}); });
+13
View File
@@ -133,6 +133,12 @@
.sbu-phys-fields { display: flex; flex-direction: column; gap: 8px; } .sbu-phys-fields { display: flex; flex-direction: column; gap: 8px; }
.sbu-wall { display: flex; flex-direction: column; gap: 6px; } .sbu-wall { display: flex; flex-direction: column; gap: 6px; }
/* ── игровой уровень (P5-Квантик): цель + звёзды ── */
.sbu-game-fields { display: flex; flex-direction: column; gap: 8px; }
.sbu-stars-list { display: flex; flex-direction: column; gap: 8px; }
.sbu-star { border: 1px solid var(--border); border-radius: 10px; padding: 9px; background: #fafbfd; display: flex; flex-direction: column; gap: 7px; }
.sbu-star-hdr { display: flex; align-items: center; gap: 5px; }
/* ── палитра ── */ /* ── палитра ── */
.sbu-pal { display: flex; flex-direction: column; gap: 12px; max-height: 60vh; overflow-y: auto; } .sbu-pal { display: flex; flex-direction: column; gap: 12px; max-height: 60vh; overflow-y: auto; }
.sbu-pal-title { font-size: .72rem; font-weight: 700; color: var(--text-3); margin-bottom: 5px; } .sbu-pal-title { font-size: .72rem; font-weight: 700; color: var(--text-3); margin-bottom: 5px; }
@@ -189,6 +195,13 @@
var ip = LS.initPage() || {}; var ip = LS.initPage() || {};
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; } if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
if (LS.loadFeatures) {
LS.loadFeatures().then(function (feats) {
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
}).catch(function () {});
}
if (!window.SimEngine || !window.SimExpr || !window.SimBuilder) { if (!window.SimEngine || !window.SimExpr || !window.SimBuilder) {
document.getElementById('sbu-preview').innerHTML = document.getElementById('sbu-preview').innerHTML =
'<div style="padding:40px;color:#fff">Движок симуляций не загрузился. Обновите страницу.</div>'; '<div style="padding:40px;color:#fff">Движок симуляций не загрузился. Обновите страницу.</div>';
+131 -2
View File
@@ -416,7 +416,7 @@
<div class="tg-nav-title">Содержание</div> <div class="tg-nav-title">Содержание</div>
<div class="tg-progress-wrap"> <div class="tg-progress-wrap">
<div class="tg-progress-bar-outer"><div class="tg-progress-bar-inner" id="tg-prog-bar"></div></div> <div class="tg-progress-bar-outer"><div class="tg-progress-bar-inner" id="tg-prog-bar"></div></div>
<div class="tg-progress-text" id="tg-prog-text">0 из 13 глав прочитано</div> <div class="tg-progress-text" id="tg-prog-text">0 из 21 глав прочитано</div>
</div> </div>
</div> </div>
<div class="tg-nav-search"> <div class="tg-nav-search">
@@ -1587,7 +1587,7 @@
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Магазин наград</b> — за монеты (начисляются вместе с XP) ученик покупает предметы и награды.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Магазин наград</b> — за монеты (начисляются вместе с XP) ученик покупает предметы и награды.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Родительские аккаунты</b> (<a href="/parent">/parent</a>) — родитель привязывается к ученику и видит его прогресс и уведомления.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Родительские аккаунты</b> (<a href="/parent">/parent</a>) — родитель привязывается к ученику и видит его прогресс и уведомления.</div></div>
</div> </div>
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Это вся учительская часть</div>Дальше — главы для администраторов (видны только под ролью admin).</div></div> <div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Почти всё</div>Дальше — мощный «Конструктор симуляций» (создание своих интерактивных сцен), а за ним — главы для администраторов (видны только под ролью admin).</div></div>
</div> </div>
<div class="tg-chapter-nav"> <div class="tg-chapter-nav">
@@ -1595,6 +1595,134 @@
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div> <div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Флэшкарты</div></div> <div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Флэшкарты</div></div>
</div> </div>
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-21')" style="text-align:right">
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Конструктор симуляций</div></div>
</div>
</div>
</div>
<!-- ═══ CHAPTER 21 — КОНСТРУКТОР СИМУЛЯЦИЙ ═══ -->
<div class="tg-chapter" id="ch-21">
<div class="tg-chapter-header">
<div class="tg-chapter-icon"><i data-lucide="pencil-ruler"></i></div>
<div class="tg-chapter-meta">
<div class="tg-chapter-num">Глава 21</div>
<div class="tg-chapter-title">Конструктор симуляций</div>
</div>
<a href="/sim-builder" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Открыть конструктор</a>
</div>
<div class="tg-section" id="s-21-1">
<div class="tg-section-title">21.1 Что это и где</div>
<p><b>Конструктор симуляций</b> — инструмент, в котором вы сами, без программирования, собираете интерактивную 2D-сцену: параметры-ползунки, объекты (точки, отрезки, векторы, фигуры, подписи), привязанные <b>формулами</b> к параметрам и времени, настоящую физику и графики. Готовую симуляцию можно сохранить, опубликовать в лабораторию, раздать классу и открыть на доске онлайн-урока.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Открыть: в боковом меню пункт <b>«Конструктор симуляций»</b> или адрес <a href="/sim-builder">/sim-builder</a>. Доступно учителю и администратору.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Симуляция — это <b>данные</b>, а не код. Формулы вычисляются безопасным движком (доступны только математические функции), поэтому готовыми сценами безопасно делиться.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="lightbulb"></i></div><div class="tg-box-body"><div class="tg-box-label">Что реально собрать</div>Кинематику (брошенное тело, равноускоренное движение), колебания и волны, графики функций, геометрические чертежи, а с включённой физикой — маятники, пружины и упругие столкновения.</div></div>
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="shield"></i></div><div class="tg-box-body"><div class="tg-box-label">Если пункта меню нет</div>Администратор мог отключить конструктор: <b>Админка → Функции → «Конструктор симуляций»</b>. При выключенном тумблере страница недоступна, но ранее опубликованные симуляции в лаборатории продолжают работать.</div></div>
</div>
<div class="tg-section" id="s-21-2">
<div class="tg-section-title">21.2 Рабочее поле</div>
<p>В центре — <b>живое превью</b>: всё, что вы добавляете и меняете, сразу видно. Слева — панели настроек, сверху — панель инструментов (Тест, Сброс, Сохранить, Опубликовать, Шаблон, Раздать, отмена/повтор).</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Масштаб</b> — колесо мыши (приближает к курсору).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Перемещение вида (панорама)</b> — перетаскивание мышью по пустому месту сцены.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Кнопки в углу сцены</b> — «Вписать» (показать всю область) и «Сбросить вид».</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Сетка и оси</b> с числовыми делениями и точкой (0,0) рисуются автоматически; границы области задаются в настройках сцены (xmin/xmax/ymin/ymax).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Контролы запущенной симуляции (ползунки, плей/пауза) — плавающая панель в углу, не закрывает сцену; её можно свернуть.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="play"></i></div><div class="tg-box-body"><div class="tg-box-label">Кнопка «Тест»</div>Запускает анимацию (время <code>t</code> идёт), «Сброс» — возвращает в начало. Пока симуляция на паузе, перетаскивание объектов и ползунки служат для настройки сцены.</div></div>
</div>
<div class="tg-section" id="s-21-3">
<div class="tg-section-title">21.3 Параметры (ползунки)</div>
<p>Параметр — это переменная со ползунком, которой можно управлять прямо в симуляции. На параметры потом ссылаются формулы объектов.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Откройте панель <b>«Параметры»</b> → «Добавить параметр».</div></div>
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">Задайте: <b>имя</b> (латиницей, например <code>v</code>, <code>theta</code>), минимум, максимум, шаг, начальное значение и (необязательно) единицу.</div></div>
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">В превью появится ползунок — двигайте его, чтобы видеть, как меняется сцена.</div></div>
</div>
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="alert-triangle"></i></div><div class="tg-box-body"><div class="tg-box-label">Зарезервированные имена</div>Нельзя называть параметр <code>t</code> (время), <code>e</code>, <code>pi</code>, <code>w</code>, <code>h</code> — это служебные имена в формулах. Конструктор предупредит и не даст сохранить.</div></div>
</div>
<div class="tg-section" id="s-21-4">
<div class="tg-section-title">21.4 Объекты и формулы</div>
<p>Объекты — это то, что рисуется на сцене. Любое числовое поле объекта (координата, радиус, размер) можно задать <b>числом или формулой</b> от параметров и времени <code>t</code>.</p>
<div class="tg-tools-grid">
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="dot"></i></div><div><div class="tg-tool-name">Точка / Отрезок / Вектор</div><div class="tg-tool-desc">Базовая геометрия со стрелками</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="circle"></i></div><div><div class="tg-tool-name">Круг / Прямоугольник</div><div class="tg-tool-desc">Фигуры с заливкой</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="spline"></i></div><div><div class="tg-tool-name">Ломаная / Кривая</div><div class="tg-tool-desc">Набор точек</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="type"></i></div><div><div class="tg-tool-name">Подпись (LaTeX)</div><div class="tg-tool-desc">Текст и формулы KaTeX</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="line-chart"></i></div><div><div class="tg-tool-name">График</div><div class="tg-tool-desc">Кривая y=f(x), см. 21.7</div></div></div>
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="gauge"></i></div><div><div class="tg-tool-name">Индикатор (readout)</div><div class="tg-tool-desc">Живое числовое значение</div></div></div>
</div>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Панель <b>«Объекты»</b> → выберите тип → «Добавить».</div></div>
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">В полях координат пишите формулы: например для броска тела <code>x = v*cos(theta)*t</code>, <code>y = v*sin(theta)*t - 5*t^2</code>.</div></div>
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">Кнопка <b>fx</b> у поля открывает палитру: параметры, время <code>t</code>, функции (sin, cos, sqrt, abs, exp, ln…), константы (pi, e). Клик вставляет имя в формулу.</div></div>
<div class="tg-step"><div class="tg-step-num">4</div><div class="tg-step-body">Объект можно поставить мышью: нажмите значок <b>«прицел»</b> у объекта и кликните по сцене — координаты подставятся (см. 21.5).</div></div>
<div class="tg-step"><div class="tg-step-num">5</div><div class="tg-step-body">У объекта можно задать <b>id</b> и затем ссылаться на его координаты в других формулах: <code>id.x</code>, <code>id.y</code>.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="function-square"></i></div><div class="tg-box-body"><div class="tg-box-label">Ошибки в формуле</div>Если выражение записано неверно, поле подсветится и покажет ошибку — симуляция при этом не ломается. Деление на ноль и неопределённости дают 0.</div></div>
</div>
<div class="tg-section" id="s-21-5">
<div class="tg-section-title">21.5 Стиль, порядок и прямое редактирование</div>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Стиль объекта</b>: выбор цвета (палитра), прозрачность, толщина и тип линии (сплошная / штрих / пунктир), стиль точки, свечение, градиентная заливка.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Порядок и операции</b>: кнопки «вверх/вниз» меняют порядок отрисовки (что поверх чего), есть дублирование и тумблер видимости (глаз), удаление.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Перетаскивание на сцене</b>: значок «прицел» включает ручки — тяните точку, концы отрезка/вектора, вершины ломаной прямо на превью (на паузе). Поля с формулами при этом не затираются.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Привязка к сетке</b> (тумблер в панели инструментов) — координаты при перетаскивании округляются к узлам сетки.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Отмена/повтор</b>: кнопки в панели инструментов и горячие клавиши <code>Ctrl+Z</code> / <code>Ctrl+Y</code> (или <code>Ctrl+Shift+Z</code>).</div></div>
</div>
</div>
<div class="tg-section" id="s-21-6">
<div class="tg-section-title">21.6 Физика</div>
<p>Включите тумблер <b>«Физика»</b> — и часть объектов будет двигаться по законам механики, а не по формуле. Движок сам интегрирует движение.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Общие силы</b>: гравитация (g по X и Y), трение, упругость столкновений (0…1).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Тело</b>: у точки/круга включите «тело» и задайте массу и начальную скорость (vx, vy). Тело падает, сталкивается, отскакивает.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Пружины</b> — между двумя телами или телом и точкой-якорем (жёсткость, длина покоя, демпфирование).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Стены</b> — границы области (низ/верх/лево/право) или произвольный отрезок: от них тела отражаются.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Тело можно <b>тянуть мышью</b> на паузе; при отпускании в запущенной сцене оно полетит.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="info"></i></div><div class="tg-box-body"><div class="tg-box-label">Формулы и физика вместе</div>Физические тела и формульные объекты сосуществуют на одной сцене: например, подпись или вектор скорости можно привязать к координатам тела через <code>id.x</code>, <code>id.y</code>.</div></div>
</div>
<div class="tg-section" id="s-21-7">
<div class="tg-section-title">21.7 Графики и диаграммы</div>
<p>Объект <b>«График»</b> рисует кривую функции прямо на сцене в мировых координатах.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Задайте выражение (например <code>sin(x)</code>), переменную (по умолчанию <code>x</code>), диапазон и число точек.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Несколько кривых</b> на одном графике — у каждой свои цвет, подпись, толщина и стиль линии.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Заливка под кривой</b>, <b>маркеры точек</b> и <b>легенда</b> (по подписям кривых) включаются переключателями.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body">Режим <b>«след» (trace)</b> — кривая накапливается по времени <code>t</code> (удобно строить график величины в ходе анимации).</div></div>
</div>
</div>
<div class="tg-section" id="s-21-8">
<div class="tg-section-title">21.8 Сохранение, публикация и раздача</div>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Сохранить</b> — симуляция сохраняется как черновик (видна только вам).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Опубликовать</b> — симуляция появляется в лаборатории для всех; «Снять с публикации» возвращает в черновик.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Шаблон</b> — начать не с нуля, а с заготовки (пустая, маятник, график, бросок).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Раздать классу</b> — ученики класса получают уведомление со ссылкой на симуляцию (она автоматически публикуется).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Клонировать</b> — сделать свою копию чужой опубликованной симуляции и доработать её.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="flask-conical"></i></div><div class="tg-box-body"><div class="tg-box-label">Где появляются ваши симуляции</div>В <a href="/lab">лаборатории</a> есть раздел <b>«Мои симуляции»</b> — там ваши черновики и опубликованные, с кнопками «Редактировать» и «Удалить». Прямая ссылка вида <code>/lab?sim=custom:НОМЕР</code> открывает конкретную симуляцию.</div></div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="presentation"></i></div><div class="tg-box-body"><div class="tg-box-label">На онлайн-уроке</div>Свою симуляцию можно открыть на доске урока — значения ползунков синхронизируются у учеников, а поверх можно рисовать аннотации.</div></div>
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Готово</div>Это вся учительская часть руководства. Дальше — главы для администраторов (видны только под ролью admin).</div></div>
</div>
<div class="tg-chapter-nav">
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-20')">
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Ещё модули платформы</div></div>
</div>
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-1')" style="text-align:right"> <div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-1')" style="text-align:right">
<div class="tg-ch-nav-icon"><i data-lucide="rotate-ccw"></i></div> <div class="tg-ch-nav-icon"><i data-lucide="rotate-ccw"></i></div>
<div><div class="tg-ch-nav-label">Вернуться к началу</div><div class="tg-ch-nav-title">Быстрый старт</div></div> <div><div class="tg-ch-nav-label">Вернуться к началу</div><div class="tg-ch-nav-title">Быстрый старт</div></div>
@@ -1928,6 +2056,7 @@
{ id:'ch-18', label:'Квантик-ассистент', icon:'sparkles', sections:['s-18-1','s-18-2','s-18-3'], sLabels:['Что умеет','Спроси Квантика','Подсказки на экзамене'] }, { id:'ch-18', label:'Квантик-ассистент', icon:'sparkles', sections:['s-18-1','s-18-2','s-18-3'], sLabels:['Что умеет','Спроси Квантика','Подсказки на экзамене'] },
{ id:'ch-19', label:'Флэшкарты', icon:'copy', sections:['s-19-1','s-19-2','s-19-3'], sLabels:['Колоды и карточки','Картинки и формулы','Импорт и генерация ИИ'] }, { id:'ch-19', label:'Флэшкарты', icon:'copy', sections:['s-19-1','s-19-2','s-19-3'], sLabels:['Колоды и карточки','Картинки и формулы','Импорт и генерация ИИ'] },
{ id:'ch-20', label:'Ещё модули платформы', icon:'grid-3x3', sections:['s-20-1','s-20-2','s-20-3','s-20-4'], sLabels:['Карта знаний и Теория','Игры: Кроссворд, Виселица','Красная книга и Коллекция','Материалы, Магазин, Родители'] }, { id:'ch-20', label:'Ещё модули платформы', icon:'grid-3x3', sections:['s-20-1','s-20-2','s-20-3','s-20-4'], sLabels:['Карта знаний и Теория','Игры: Кроссворд, Виселица','Красная книга и Коллекция','Материалы, Магазин, Родители'] },
{ id:'ch-21', label:'Конструктор симуляций', icon:'pencil-ruler', sections:['s-21-1','s-21-2','s-21-3','s-21-4','s-21-5','s-21-6','s-21-7','s-21-8'], sLabels:['Что это и где','Рабочее поле','Параметры','Объекты и формулы','Стиль и порядок','Физика','Графики','Сохранение и раздача'] },
]; ];
const ADMIN_CHAPTERS = [ const ADMIN_CHAPTERS = [
+4 -1
View File
@@ -860,6 +860,7 @@ async function hideDisabledFeatures() {
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'], biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
live_quiz: ['/live-quiz'], live_quiz: ['/live-quiz'],
classroom: ['/classroom'], classroom: ['/classroom'],
sim_builder: ['/sim-builder', '/sim-builder.html'],
exam9: ['/exam9', '/exam9.html'], exam9: ['/exam9', '/exam9.html'],
textbooks: ['/textbooks', '/textbooks.html', '/textbook'], textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
}; };
@@ -1045,7 +1046,7 @@ window.LS = {
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
fcListDecks, fcCreateDeck, fcAddCard, fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview,
escapeHtml, esc, escapeHtml, esc,
parseDate, fmtRelTime, safeHref, parseDate, fmtRelTime, safeHref,
initPage, initPage,
@@ -1296,6 +1297,8 @@ async function adminAssistantModels(params) { const q = new URLSearchParams(para
async function fcListDecks() { return req('GET', '/flashcards/decks'); } async function fcListDecks() { return req('GET', '/flashcards/decks'); }
async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); } async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); }
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); } async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
async function fcStudySession(deckId){ return req('GET', `/flashcards/decks/${deckId}/study`); }
async function fcReview(cardId, quality) { return req('POST', `/flashcards/cards/${cardId}/review`, { quality }); }
async function deleteFile(id) { return req('DELETE', `/files/${id}`); } async function deleteFile(id) { return req('DELETE', `/files/${id}`); }
async function getFileAccess(id) { return req('GET', `/files/${id}/access`); } async function getFileAccess(id) { return req('GET', `/files/${id}/access`); }
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); } async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }
+49
View File
@@ -38,6 +38,55 @@
`js/game/quantik-game.js`. `node --check` все OK; смоуки (логика 16/16, рендер 7/7, winnability 6/6 `js/game/quantik-game.js`. `node --check` все OK; смоуки (логика 16/16, рендер 7/7, winnability 6/6
на реальном движке) зелёные и удалены; `npm test` 259/251 pass / 8 baseline fail (без изменений); на реальном движке) зелёные и удалены; `npm test` 259/251 pass / 8 baseline fail (без изменений);
lint:routes 0. lint:routes 0.
- **Phase 3 реализован** (pending review): новый ТИП уровня — Квантик едет по кривой `y=f(x)`,
которую СОБИРАЕТ игрок (слайдеры коэффициентов). Движок (`_sim_engine.js`, аддитивно):
(1) «бегунок по кривой» — на `plot` поле `runner:{duration,hold}` кладёт в env `<id>.runX/.runY/.runDone`;
герой = обычный point на `curve.runX/runY` (f компилируется 1 раз, питает И кривую, И бегунок — нет само-ссылки);
(2) `type:'zone'` (rect/circle, kind forbidden/target/collect, track) → булево env-поле `<zoneId>.hit` (1/0);
goal/fail/stars ссылаются на него. ⛔ Предикаты в грамматику SimExpr НЕ добавлялись. Новая глава-созвездие
`functions` в `levels.js` (5 уровней: луч/синус/парабола/модуль/экспонента, `unlockStars` 9..17 ≤ 18 макс
физ-звёзд → нет дедлока); map.js НЕ тронут (рисует по метаданным). Сервер `validateSpec` принимает
`zone`+`runner` (OBJECT_TYPES + поля). Изменены: `_sim_engine.js`, `levels.js`, `customSimController.js`,
`quantik.html` (per-level бейдж темы). Новые тесты: custom-sims.test.js +2 (приём zone+runner, отказ
unknown type) — 26/26. Headless vm-смоук (per-level solvability + logic 29/29) зелёный и удалён.
`npm test` 261 / 253 pass / 8 baseline fail (без новых); lint:routes 0; все `node --check` OK.
- **Phase 4 реализован** (pending review): фирменные квантовые способности + SR-связка, ВСЁ через
безопасную модель (движок `_sim_engine.js` НЕ тронут). Новый `frontend/js/game/quantik-abilities.js`:
`window.QuantikEnergy` (клиентский ресурс энергии, localStorage `quantik-energy`, 0..99;
grant/spend/canSpend/rewardForQuality; TUNNEL_COST=3, GOOD=1/EASY=2) + `window.QuantikAbilities`
(`mountBar` — HUD энергии + кнопки «Повторение/Туннель/Прицел» оверлеем на сцене; `openRestRoom`
мини-сессия повторения флешкарт в модалке, реюз `LS.fcListDecks/fcStudySession/fcReview`, НЕ iframe).
**Туннель** = тратит энергию → `inst.setParam('tunnel',1)`; барьер = `forbidden`-зона `wall`,
`fail:'wall.hit && tunnel<1'` (tunnel — не слайдер, отсутствует в env → 0 → стена сплошная).
**Прицел** = пауза-тоггл над пунктир-plot предсказанной траектории. **Суперпозиция** = чистый
контент: 2 тела `ball`+`ball2`, `goal.when` с обоими. Глава `quantum` (L12L16) + `CHAPTERS.quantum`
в `levels.js`; карта рисует автоматически (map.js не тронут). `js/api.js` +2 врапера
(`fcStudySession`, `fcReview`). `quantik.html` +script-тег +CSS `.qa-*`. **Backend НЕ тронут.**
Все `node --check` OK (вкл. инлайн quantik.html); headless vm-смоук (РЕАЛЬНЫЕ движки):
энергия + суперпозиция-оба-тела + tunnel-flips-fail + per-level solvability sweep (5/5 выигрываемы,
full-star достижим, L15/L16 без tunnel = 0 win) + регресс 11 существующих уровней — 48/48, удалён.
Контент-фикс: монета L16 (5,6)r0.7 → (5,6.9)r0.85 (была несовместима со 2-й звездой k≥6.8).
`npm test` 261 / 253 pass / 8 baseline fail (без новых); lint:routes 0.
- **Phase 5 реализован** (pending review): авторинг игровых уровней в sim-builder + раздача классу.
⚠️ **ПАРАЛЛЕЛЬНАЯ СЕССИЯ активна** на ветке (правит sim-builder + admin «games»), поэтому все правки
sim-builder.js/.html — строго АДДИТИВНЫЕ (новые методы/панель/CSS-блок, существующие строки почти не
тронуты). sim-builder: панель «Игровой уровень (цель/звёзды)» (`sectionGame` + wiring + `playGame` +
helpers `loadGame`/`buildGoal`/`buildGameMeta`) — тумблер «Это игровой уровень» включает слой goal
(`when/title/hint/hold/fail`) + до 3 звёзд (`when`+`label`) + метаданные (`chapter/order/par_ms`);
выражения проверяются inline через `SimExpr.compile`. `blankState`/`loadFromSim`/`buildSpec`/`validate`
расширены аддитивно (по 1 врезке каждый). Кнопка «Играть» монтирует SimEngine в модалке (HUD/победа
активируются сами наличием `goal` — Ф0). Round-trip goal/game без потерь.
Игра: `QuantikLevels` стал асинхронным — `ensureCustom()` грузит `custom_sims` cat='game' (свои+
published) и мёржит как записи `custom:<dbid>`; `getAsync(id)` резолвит deep-link (own draft через
`LS.customSimGet`). Новая глава `custom` в `CHAPTERS`. quantik.html: `Promise.all([loadProgress,
ensureCustom])` до карты + deep-link `?level=custom:<id>` (без гейта unlockStars). Backend:
`share()` для cat='game' шлёт `game_level_shared` со ссылкой `/quantik?level=custom:<id>` (иначе
`/lab?sim=…`), ответ +`link`. `CATS` уже содержал 'game' (Ф0/Ф3); goal/game уже в validateSpec.
Изменены: `frontend/js/sim-builder.js`, `frontend/sim-builder.html`, `frontend/js/game/levels.js`,
`frontend/quantik.html`, `backend/src/controllers/customSimController.js`. Новый тест:
`tests/quantik-authoring.test.js` (6/6). Headless round-trip-смоук (vm + реальные _sim_expr+sim-builder
+levels) 7/7 — удалён. Все `node --check` OK (вкл. инлайн обоих HTML). `npm test` 267 / 259 pass /
8 baseline fail (без новых); lint:routes 0.
## Key Architecture Decisions ## Key Architecture Decisions
- **«Атом» = блок `goal` в спеке** (булево SimExpr). Любой уровень = спека SimForge + `goal`. - **«Атом» = блок `goal` в спеке** (булево SimExpr). Любой уровень = спека SimForge + `goal`.
+29 -13
View File
@@ -61,10 +61,10 @@
- [x] Phase 0: Слой целей в движке (goal/HUD/result) [domain: frontend] → [subplan](./phase-0-objective-layer.md) - [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 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md)
- [x] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md) - [x] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md)
- [ ] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md) - [x] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md)
- [ ] Phase 4: Квантовые способности + SR-комнаты [domain: fullstack] → [subplan](./phase-4-quantum-abilities-sr.md) - [x] Phase 4: Квантовые способности + SR-комнаты [domain: fullstack] → [subplan](./phase-4-quantum-abilities-sr.md)
- [ ] Phase 5: Авторинг уровней в sim-builder + раздача классу [domain: fullstack] → [subplan](./phase-5-authoring-sharing.md) - [x] 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 6: Класс-лидерборд / живая гонка (classroom SSE)~~ — **REMOVED** (см. Amendment 1) → [subplan](./phase-6-leaderboard-live.md)
## Phase Progress Log ## Phase Progress Log
@@ -73,19 +73,35 @@
| Phase 0: Слой целей в движке | frontend | ✅ Done | ✅ | ✅ | ✅ | | Phase 0: Слой целей в движке | frontend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ✅ Done | ✅ | ✅ | ✅ | | Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ✅ Done | ✅ | ✅ | ✅ |
| Phase 2: Карта + мир + XP/скины | fullstack | ✅ Done | ✅ (1 🟡 fixed) | ✅ | ✅ | | Phase 2: Карта + мир + XP/скины | fullstack | ✅ Done | ✅ (1 🟡 fixed) | ✅ | ✅ |
| Phase 3: Граф-уровни + зоны | fullstack | ⬜ Not Started | | | | | Phase 3: Граф-уровни + зоны | fullstack | ✅ Done | | | |
| Phase 4: Квантовые способности + SR | fullstack | ⬜ Not Started | | | | | Phase 4: Квантовые способности + SR | fullstack | ✅ Done | | | |
| Phase 5: Авторинг + раздача | fullstack | ⬜ Not Started | | | | | Phase 5: Авторинг + раздача | fullstack | ✅ Done | | | |
| Phase 6: Лидерборд / живая гонка | fullstack | ⬜ Not Started | | | | | ~~Phase 6: Лидерборд / живая гонка~~ | fullstack | ❌ Removed (Amendment 1) | | | |
## MVP boundary ## MVP boundary
После **Phase 2** игра играбельна и отгружаема: один полный мир физ-уровней с картой, После **Phase 2** игра играбельна и отгружаема: один полный мир физ-уровней с картой,
прогрессом, XP и скинами. Фазы 3–6 — расширение (новые типы уровней, способности, прогрессом, XP и скинами. Фазы 3–6 — расширение (новые типы уровней, способности,
авторинг, мультиплеер). авторинг, мультиплеер).
## Amendment Log
### Amendment 1 — 2026-06-14
**Type:** Removed phase
**What changed:** Phase 6 (Класс-лидерборд / живая гонка через classroom SSE) убрана из объёма по решению пользователя.
**Why:** Пользователь решил не реализовывать соревновательный слой; переходим к полировке и финальному ревью после Ф5.
**Impact on existing phases:** Нет. Фазы 0–5 самодостаточны и отгружаемы. `game_progress.level_id` (TEXT) уже готов под будущий лидерборд, если фичу вернут. Subplan `phase-6-leaderboard-live.md` сохранён как архив с пометкой REMOVED.
## Final Review ## Final Review
- [ ] Comprehensive code review (final-reviewer) - [x] Comprehensive code review (final-reviewer) — ✅ READY TO MERGE, 0 блокеров (2026-06-14)
- [ ] Security review (новые API: прогресс/лидерборд, user-input) - [x] Security review (новые API/ввод) — ✅ SECURE, 0 critical (2026-06-14)
- [ ] `npm test` без новых регрессий (поверх baseline) - [x] Polish-фиксы по ревью применены: game-блок санитизируется (был латентный XSS), prefers-reduced-motion guard, фикс комментария isUnlocked. Тесты 45/45 затронутых, lint 0.
- [ ] `npm run lint:routes` baseline 0 - [x] `npm test` без новых регрессий (8 = baseline: 3 auth + 5 jsdom)
- [ ] Merged to `feature/sim-builder` - [x] `npm run lint:routes` baseline 0
- [ ] Merged to `feature/sim-builder` (ожидает одобрения пользователя)
### Deferred / Backlog (не блокеры — из финального ревью)
- `QuantikLevels.ensureCustom` — N+1 `customSimGet` на загрузку /quantik; при росте числа авторённых уровней заменить на bulk-эндпоинт «список game-спек».
- Уровни 3/5/6 (отскок/орбита/манёвр): `fail` — только таймаут, «честная» механика не форсится. Точечно ужесточить `fail`-предикаты (контент-тюнинг).
- `graph-exp-11` капстоун узковат (~36–42/625) — при жалобах на сложность чуть расширить gate.
- Три похожих `starSvg`/`_starIcon` в разных модулях — консолидация не стоит связывания движка с игрой (оставлено).
- Клиентские очки прогресса фальсифицируемы (ожидаемо для single-player). ⚠️ Если когда-нибудь вернут Ф6-лидерборд — валидировать на сервере (replay/подписанные токены).
+57 -10
View File
@@ -1,6 +1,6 @@
# Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия # Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия
**Status:** ⬜ Not Started **Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md) **Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack **Domain:** fullstack
@@ -10,21 +10,34 @@
Реюз `plot` + `SimExpr`. Сид граф-главы. Реюз `plot` + `SimExpr`. Сид граф-главы.
## Tasks ## Tasks
- [ ] Task 1: «Бегунок по кривой»: герой-точка с `x` = функция t (напр. линейный проход xmin→xmax), - [x] Task 1: «Бегунок по кривой»: герой-точка с `x` = функция t (напр. линейный проход xmin→xmax),
`y = f(x)` через ту же скомпилированную функцию, что у `plot`. Кривая рисуется (P3 plot), `y = f(x)` через ту же скомпилированную функцию, что у `plot`. Кривая рисуется (P3 plot),
герой едет по ней с glow/trail. Без физики (кинематический проход), либо мягкая физика — на выбор уровня. герой едет по ней с glow/trail. Без физики (кинематический проход), либо мягкая физика — на выбор уровня.
- [ ] Task 2: Тип объекта/поле «зона» (forbidden/target): прямоугольник/круг в мире + удобные `plot.runner:{duration,hold}` кладёт в env `<plotId>.runX/.runY/.runDone`; герой = обычный point
с `x:'curve.runX', y:'curve.runY'`, glow+trail. f компилируется 1 раз и питает И кривую, И бегунок.
- [x] Task 2: Тип объекта/поле «зона» (forbidden/target): прямоугольник/круг в мире + удобные
env-предикаты (или документированный паттерн: `fail:'inzone(...)'`). Реализовать helper-предикаты env-предикаты (или документированный паттерн: `fail:'inzone(...)'`). Реализовать helper-предикаты
БЕЗ расширения небезопасного синтаксиса — предпочесть готовить булевы поля зон в env БЕЗ расширения небезопасного синтаксиса — предпочесть готовить булевы поля зон в env
(напр. `zone1.hit`) на основе позиции героя, чтобы `goal`/`fail` ссылались на них. (напр. `zone1.hit`) на основе позиции героя, чтобы `goal`/`fail` ссылались на них.
- [ ] Task 3: Цель = добраться до конца/в целевую зону, не задев запретные (`fail`). Звёзды: пройти `type:'zone'` (shape rect/circle, kind forbidden/target/collect, track). Движок кладёт `<zoneId>.hit`
(1/0) в env. ⛔ Никаких inzone()-предикатов в грамматике — только именованные булевы env-поля.
- [x] Task 3: Цель = добраться до конца/в целевую зону, не задев запретные (`fail`). Звёзды: пройти
под нормативом, собрать бонус-точки (зоны-сборы). под нормативом, собрать бонус-точки (зоны-сборы).
- [ ] Task 4: Управление: слайдеры коэффициентов `f(x)` (a·sin(b·x+c)+d и т.п.) ИЛИ выбор/набор → goal.when=`'gate.hit'`, fail=`'pit.hit'`, stars=[collect-zone hit, доп. условие формы кривой].
- [x] Task 4: Управление: слайдеры коэффициентов `f(x)` (a·sin(b·x+c)+d и т.п.) ИЛИ выбор/набор
выражения с inline-проверкой `SimExpr.compile(...).error` (как в sim-builder). Безопасно. выражения с inline-проверкой `SimExpr.compile(...).error` (как в sim-builder). Безопасно.
- [ ] Task 5: Контент: сид граф-главы (~4–5 уровней): синус под мостом, парабола над ямой, → коэффициенты = обычные `params`-слайдеры движка; крутишь → кривая+путь героя перестраиваются.
Свободный ввод выражения не понадобился (слайдеры коэффициентов достаточны для MVP-главы).
- [x] Task 5: Контент: сид граф-главы (~4–5 уровней): синус под мостом, парабола над ямой,
кусочная подгонка, экспонента/логарифм — растущая сложность, привязка к темам алгебры. кусочная подгонка, экспонента/логарифм — растущая сложность, привязка к темам алгебры.
- [ ] Task 6: Интеграция в карту (Ф2): новая глава-созвездие; общий конвейер результата/XP. → 5 уровней в `functions`: луч (a·x+b), синус (A·sin(k·x)), парабола (a·(x5)²+k),
- [ ] Task 7: Тесты: проход по кривой достигает цели; задевание зоны → fail; смоук рендера кривой+героя. модуль (a·|x−m|+1), экспонента (c·e^(r·x)). Все solvable (см. Concerns).
- [x] Task 6: Интеграция в карту (Ф2): новая глава-созвездие; общий конвейер результата/XP.
→ глава `functions` в `CHAPTERS`; map.js НЕ тронут (рисует по метаданным). Бейдж темы в quantik.html
стал per-level (`subject` → Физика/Алгебра) — аддитивно.
- [x] Task 7: Тесты: проход по кривой достигает цели; задевание зоны → fail; смоук рендера кривой+героя.
→ headless vm-смоук (логика+per-level solvability, 29/29, удалён); серверный тест приёма
zone+runner спеки (custom-sims.test.js, +2 теста, остаётся).
## Files to Modify/Create ## Files to Modify/Create
- `frontend/js/labs/_sim_engine.js` — поддержка «бегунка по кривой» (если не выразимо текущими полями) - `frontend/js/labs/_sim_engine.js` — поддержка «бегунка по кривой» (если не выразимо текущими полями)
@@ -43,7 +56,41 @@
- Переиспользовать P3 plot (несколько кривых, заливка, маркеры) для визуала «земли»/препятствий. - Переиспользовать P3 plot (несколько кривых, заливка, маркеры) для визуала «земли»/препятствий.
## Review Checklist ## Review Checklist
- [ ] Все задачи; аддитивность движка; без эмодзи/eval; тесты зелёные; lint baseline 0 - [x] Все задачи; аддитивность движка; без эмодзи/eval; тесты зелёные; lint baseline 0
## Handoff to Next Phase ## Handoff to Next Phase
<!-- Заполняет агент-имплементер. -->
### Контракт «бегунка по кривой» (движок, `_sim_engine.js`)
- На объекте `plot`: `runner:{ duration?:8, hold?:true }`. Делает из ПЕРВОЙ кривой plot дорожку.
- Движок кладёт в env (в `_buildEnv`, ДО формульных центров): `<plotId>.runX` (= `a + (ba)·clamp(t/duration,0,1)`),
`<plotId>.runY` (= f(runX) ТОЙ ЖЕ скомпил. функции, что рисует кривую), `<plotId>.runDone` (1 при t≥duration).
- Герой = обычный `point` с `x:'curve.runX', y:'curve.runY'` + glow + trail. НЕ тело → нет само-ссылки
(f компилируется один раз, питает И кривую, И бегунок). `hold:true` — остаётся на конце; иначе зацикливание по `time.loop`.
- ⛔ Никакого eval: f — обычное SimExpr-выражение кривой.
### Контракт зон (движок)
- `type:'zone'`, `id`, `shape:'rect'|'circle'`, `kind:'forbidden'|'target'|'collect'` (цвет/семантика),
геометрия (rect: x,y центр + w,h; circle: x,y + r — числа ИЛИ выражения), `track?:'ball'` (чью позицию тестить), `label?`, `color?`.
- Движок кладёт `<zoneId>.hit` (1/0) в env (последним — нужна актуальная позиция героя). `goal.when/fail/stars[].when` ссылаются на него.
- ⛔ Предикаты в синтаксис выражений НЕ добавлялись — только именованные булевы env-поля (модель безопасности `t`/`tries` из Ф0).
- Рисуется в `_drawObject`/`_drawZone`: forbidden=красный пунктир, target=зелёный, collect=золотой пунктир. Цвета — только canvas-стоки.
- Зона НЕ кладёт `<zoneId>.x/.y` как центр объекта (`hasCenter` пропущен для type==='zone').
### Как определяется граф-уровень (данные, `levels.js`)
- Хелперы: `road(exprStr,a,b,dur)` (plot+runner, id 'curve'), `graphHero()` (point ball на curve.runX/runY),
`rectZone/circZone(id,kind,...)`, `startMarker`. Уровень = спека с этими объектами + `goal{when:'gate.hit',fail:'<forb>.hit',stars}`.
- ⚠️ ГОЧА: имена param `t/w/h/pi/e/E/PI/tau` зарезервированы движком (`h`=высота вьюпорта!). abs-уровень
использует `m` (вершина), НЕ `h`. При добавлении уровней проверять имена коэффициентов.
- `time:{duration,loop:false}` синхронизирован с `runner.duration` — герой доезжает до конца за один проход.
### Карта / запуск
- Глава `functions` добавлена в `CHAPTERS` (key/title/subtitle/accent). map.js НЕ тронут — узлы рисуются по метаданным,
тип спеки карте безразличен. Разблокировка: `unlockStars` 9/11/13/15/17 (≤ 18 макс. звёзд физ-глав → нет дедлока).
- Запуск тот же (`QuantikGame.start``SimEngine.mount`); граф-уровни используют те же слайдеры params, спец-вайринг
НЕ нужен. Бейдж темы в quantik.html — per-level по `level.subject` (аддитивно).
### Для Ф4 (квантовые способности)
- `runDone`/`runX`/`.hit` — готовые env-поля для условий способностей (напр. «туннель» = временно игнорить forbidden.hit
в `fail`). Способность может менять `params` (коэффициенты) или подменять выражение кривой — всё через тот же SimExpr-конвейер.
- Зоны kind:'collect' уже «залипают» через механизм stars (Ф0). Новая способность = новый env-флаг + условие, БЕЗ eval.
- Сервер уже принимает `zone`+`runner` (validateSpec, OBJECT_TYPES) — авторённые граф-уровни (Ф5) пройдут гейт.
@@ -1,6 +1,6 @@
# Phase 4: Квантовые способности + SR-комнаты # Phase 4: Квантовые способности + SR-комнаты
**Status:** ⬜ Not Started **Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md) **Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack **Domain:** fullstack
@@ -10,19 +10,21 @@
(проход сквозь тонкую стену за «энергию», которую даёт быстрое SR-повторение). (проход сквозь тонкую стену за «энергию», которую даёт быстрое SR-повторение).
## Tasks ## Tasks
- [ ] Task 1: Суперпозиция: уровень с двумя телами-копиями Квантика; общий «закон» (params) рулит - [x] Task 1: Суперпозиция: уровни L12/L13 с двумя телами `ball`+`ball2`, общий закон (theta,v)
обеими; цель — обе достигают порталов/условий (`goal.when` ссылается на оба `<id>.x/.y`). рулит обеими зеркально; `goal.when` ссылается на `ball.*` И `ball2.*` (победа — только обе).
Реюз существующей мульти-body физики. Визуал — две glow-точки (полупрозрачные «фантомы»). Реюз мульти-body физики. ball2 — полупрозрачный «фантом» (tintHeroSpec в quantik-game.js).
- [ ] Task 2: Коллапс/пауза-прицел: на паузе показать предсказанную траекторию (`plot trace`/ - [x] Task 2: Коллапс/пауза-прицел: уровень L14 несёт пунктир-`plot` (id 'aim') с предсказанной
пунктир) текущего закона до запуска — «прицеливание». Реюз предпросмотра старта (P1/P2). параболой текущего закона; способность «Прицел» = пауза-тоггл (`inst.pause/play`) для прицела.
- [ ] Task 3: Туннелирование: «энергетический заряд» расходуется, чтобы пройти сквозь помеченную - [x] Task 3: Туннелирование: барьер = `forbidden`-зона `wall`; `fail:'wall.hit && tunnel<1'`.
`tunnelable:true` стену (стена временно проницаема). Энергия в HUD. Способность «Туннель» тратит TUNNEL_COST энергии → `inst.setParam('tunnel',1)` (стена
- [ ] Task 4: SR-комната: перед/в уровне — мини-сессия повторения флешкарт (реюз Tier-1 SR API, проницаема). `tunnel` — НЕ слайдер (только способность); отсутствует в env → 0 → стена сплошная.
мигр.074). Правильные ответы дают «энергию туннелирования». Открыть существующий движок - [x] Task 4: SR-комната: `QuantikAbilities.openRestRoom` — мини-сессия повторения в модалке
повторения в модалке/панели игры; начислять заряды по результату. (НЕ iframe). `LS.fcListDecks → fcStudySession → fcReview`; «Знаю/Легко» начисляют энергию.
- [ ] Task 5: Контент: 2–3 уровня под каждую способность (обучающий + применение). Пусто (нет колод/нет due) → дружелюбное окно + ссылка `/flashcards`.
- [ ] Task 6: Тесты: суперпозиция (оба тела в `goal`), расход/начисление энергии (чистая логика), - [x] Task 5: Контент — глава `quantum` (5 уровней): L12/L13 суперпозиция, L14 прицел, L15/L16 туннель.
проницаемость стены при заряде; смоук. - [x] Task 6: Тесты (headless vm + РЕАЛЬНЫЕ движки): суперпозиция (оба тела), энергия (grant/spend/
reward чистая логика), tunnel flips fail, per-level solvability sweep (5/5 выигрываемы,
full-star достижим), регресс существующих 11 уровней без throw. 48/48; harness удалён.
## Files to Modify/Create ## Files to Modify/Create
- `frontend/js/labs/_sim_engine.js` — поле `tunnelable` у стены + расход энергии (аддитивно, документировать). - `frontend/js/labs/_sim_engine.js` — поле `tunnelable` у стены + расход энергии (аддитивно, документировать).
@@ -42,7 +44,45 @@
- SR-движок повторения уже существует — переиспользовать, не дублировать расписание. - SR-движок повторения уже существует — переиспользовать, не дублировать расписание.
## Review Checklist ## Review Checklist
- [ ] Все задачи; аддитивность; без эмодзи/eval; тесты зелёные; lint baseline 0 - [x] Все задачи; аддитивность; без эмодзи/eval; тесты зелёные; lint baseline 0
## Handoff to Next Phase ## Handoff to Next Phase
<!-- Заполняет агент-имплементер. -->
### Что добавлено (файлы)
- **Новый** `frontend/js/game/quantik-abilities.js` (`window.QuantikEnergy` + `window.QuantikAbilities`).
- `frontend/js/game/levels.js` — глава `quantum` (L12L16) + `CHAPTERS.quantum`.
- `frontend/js/game/quantik-game.js``tintHeroSpec` тинтует `ball2` (фантом); `start()` монтирует
`QuantikAbilities.mountBar` (оборачивает `inst.destroy` для снятия панели); `openRest()` + сброс
`abilities.resetAbilities()` на «Ещё раз».
- `frontend/quantik.html``<script src="/js/game/quantik-abilities.js">` (после map.js, до game.js)
+ CSS `.qa-*` (панель/HUD/тост/SR-модалка/карточка/оценки).
- `js/api.js``fcStudySession(deckId)` (GET `/flashcards/decks/:id/study`) и
`fcReview(cardId, quality)` (POST `/flashcards/cards/:id/review` body `{quality}`).
- ⛔ Движок `_sim_engine.js` НЕ тронут — туннель/прицел/суперпозиция выражены через безопасную
модель спеки (зона+undefined-param / pause-toggle / два тела). Engine touch = 0.
### Энергия (клиентский ресурс)
- localStorage ключ **`quantik-energy`** (целое 0..`ENERGY_MAX`=99). `window.QuantikEnergy`:
`getEnergy/setEnergy/grantEnergy/spendEnergy/canSpend/rewardForQuality/onEnergyChange`.
`TUNNEL_COST=3`, `REWARD_GOOD=1` (q=4 «Знаю»), `REWARD_EASY=2` (q=5 «Легко»).
- Туннель тратит энергию ОДИН раз за попытку (`tunnelUsed`); «Ещё раз»/новый mount сбрасывают
`tunnel→0` (стена снова сплошная).
### Контракты для Phase 5 (авторинг)
- **Способность «Туннель»** активируется, если `goal.fail/when/stars` упоминают слово `tunnel`
(`QuantikAbilities.levelHasTunnel`). Авторский UI должен уметь добавить `forbidden`-зону `wall`
и `fail:'wall.hit && tunnel<1'`. `tunnel` — служебный param (не слайдер).
- **Способность «Прицел»** появляется, если на сцене есть `plot` с `id:'aim'` ИЛИ `lineStyle:'dashed'`
(`levelHasAim`). Авторский UI может предложить «предсказанную траекторию» как пунктир-plot.
- **Суперпозиция** = чистый контент: второе тело `id:'ball2'` (point/circle с `body`), `goal.when`
с обоими `ball.*`+`ball2.*`. `tintHeroSpec` уже тинтует `ball`/`ball2`; авторские id вне этих
двух тинтоваться скином не будут (Phase 5 при желании расширит).
- **Глава = метаданные**: `quantum` появилась на карте без правок map.js (`groupByChapter` +
`Levels.chapter`). Новая глава = новый `chapter`-ключ + `CHAPTERS`-запись (контракт Phase 2).
### Solvability (проверено на реальном движке, harness удалён)
- L12 Раздвоение: 52 win-комбо (есть выигрышная v почти для любой θ), full-star напр. θ70/v10.
- L13 Две двери: full-star θ45/v11. L14 Прицел: full-star θ60/v12 (2/2 звезды).
- L15 барьер: с tunnel=1 — 4 win, full-star a0/b3.7; БЕЗ tunnel — 0 win (гейт работает).
- L16 капстоун: с tunnel=1 — full-star a-0.25/k7.2 (монета сдвинута на (5,6.9) r0.85 — была (5,6)
r0.7, конфликтовала со 2-й звездой k≥6.8); БЕЗ tunnel — 0 win.
+45 -10
View File
@@ -1,6 +1,6 @@
# Phase 5: Авторинг уровней в sim-builder + раздача классу # Phase 5: Авторинг уровней в sim-builder + раздача классу
**Status:** ⬜ Not Started **Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md) **Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack **Domain:** fullstack
@@ -13,18 +13,18 @@
быть смержен. При необходимости влить base в `feature/quantik-game` и разрешить конфликты. быть смержен. При необходимости влить base в `feature/quantik-game` и разрешить конфликты.
## Tasks ## Tasks
- [ ] Task 1: Режим «Игровой уровень» в `sim-builder.js`/`.html`: панель цели (`goal.when`, - [x] Task 1: Режим «Игровой уровень» в `sim-builder.js`/`.html`: панель цели (`goal.when`,
`title`, `hint`, `hold`, `fail`), список звёзд (add/del: `when`+`label`), глава/порядок/`par_ms`. `title`, `hint`, `hold`, `fail`), список звёзд (add/del: `when`+`label`), глава/порядок/`par_ms`.
Inline-проверка выражений через `SimExpr.compile().error` (как остальные поля билдера). Inline-проверка выражений через `SimExpr.compile().error` (как остальные поля билдера).
- [ ] Task 2: `buildSpec()` материализует блок `goal`/`game`; `loadFromSim()` раскладывает обратно - [x] Task 2: `buildSpec()` материализует блок `goal`/`game`; `loadFromSim()` раскладывает обратно
(round-trip), как сделано с plot-range в Ф4 билдера. (round-trip), как сделано с plot-range в Ф4 билдера.
- [ ] Task 3: Кнопка «Играть» в билдере — открыть текущую спеку в игровом режиме (тест уровня автором). - [x] Task 3: Кнопка «Играть» в билдере — открыть текущую спеку в игровом режиме (тест уровня автором).
- [ ] Task 4: Каталог уровней: игра грузит `custom_sims` c `cat='game'` (свои+published) — реюз - [x] Task 4: Каталог уровней: игра грузит `custom_sims` c `cat='game'` (свои+published) — реюз
`LS.customSimsList`/`Get`. Категория `game` в списке `CATS` (customSimController) + фильтр. `LS.customSimsList`/`Get`. Категория `game` в списке `CATS` (customSimController) + фильтр.
- [ ] Task 5: Раздача классу: реюз паттерна Ф6 sim-builder (авто-публикация + `pushNotif` ученикам, - [x] Task 5: Раздача классу: реюз паттерна Ф6 sim-builder (авто-публикация + `pushNotif` ученикам,
ссылка `/quantik?level=custom:<id>`); привязка к программе через `lab_sim_links` (`sim_id='custom:<id>'`). ссылка `/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). - [x] Task 6: Deep-link `/quantik?level=custom:<id>` (паттерн Ф5/Ф7 sim-builder, доступ own|published|admin).
- [ ] Task 7: Тесты: round-trip goal в билдере (headless как Ф4 sim-builder); доступ к чужому - [x] Task 7: Тесты: round-trip goal в билдере (headless как Ф4 sim-builder); доступ к чужому
draft запрещён; published-уровень виден; раздача шлёт уведомление. draft запрещён; published-уровень виден; раздача шлёт уведомление.
## Files to Modify/Create ## Files to Modify/Create
@@ -43,7 +43,42 @@
- Санитизация goal-полей — уже на сервере (Ф0). Клиентская валидация зеркалит её (как в Ф4 билдера). - Санитизация goal-полей — уже на сервере (Ф0). Клиентская валидация зеркалит её (как в Ф4 билдера).
## Review Checklist ## Review Checklist
- [ ] Все задачи; аддитивность билдера; ownership/доступ корректны; без эмодзи/eval; тесты зелёные - [x] Все задачи; аддитивность билдера; ownership/доступ корректны; без эмодзи/eval; тесты зелёные
## Handoff to Next Phase ## Handoff to Next Phase
<!-- Заполняет агент-имплементер. -->
### Как авторённый уровень попадает в реестр игры
- Хранилище: `custom_sims` с `cat='game'`. Спека = обычная SimForge-спека + блок
`goal{when,title,hint,hold,fail,stars[]}` + блок `game{chapter,order,par_ms,unlockStars?}`.
- `window.QuantikLevels` стал «асинхронным»: встроенные `LEVELS` доступны сразу (offline),
а опубликованные/свои игровые спеки подмешиваются через **`QuantikLevels.ensureCustom()`**
(Promise, кэш): `LS.customSimsList()` → фильтр `cat==='game'``LS.customSimGet(id)` каждой →
`customToLevel(row)` → запись реестра. `list()` = `LEVELS.concat(CUSTOM)`; `get(id)` ищет в обоих.
- **Форма записи авторённого уровня** (`customToLevel`): `{ id:'custom:<dbid>', dbid, title,
chapter:(game.chapter||'custom'), order:(game.order||1000+dbid), unlockStars:(game.unlockStars||0),
par_ms, subject, hint:(goal.hint), spec, _custom:true }`. Запись БЕЗ `goal` отбрасывается (не уровень).
- Новая глава-созвездие **`custom`** в `CHAPTERS` (levels.js) — авторённые уровни без явной главы
группируются в неё; map.js рисует автоматически (по метаданным, не тронут). Если автор задал
`game.chapter='kinematics'` и т.п. — уровень встанет в соответствующее созвездие.
### Deep-link контракт
- `/quantik?level=custom:<dbid>` → `QuantikLevels.getAsync('custom:<dbid>')`: если уже в кэше —
синхронно; иначе `LS.customSimGet(dbid)` (сервер: доступ own|published|admin → иначе 404/403 → карта).
Авторённый уровень по deep-link открывается БЕЗ гейта `unlockStars` (получатель ссылки заходит прямо).
Встроенный `?level=<id>` — как раньше (через `isUnlocked`).
- Прогресс игрока по авторённым уровням пишется так же: `LS.gameProgressSubmit('custom:<dbid>', ...)`
(`game_progress.level_id` — TEXT ≤120, двоеточие проходит; бэкенд НЕ менялся).
### Share-flow
- Реюз контроллера `customSimController.share` (Ф6). Для `cat==='game'` ссылка/тип уведомления
переключены: link `/quantik?level=custom:<id>`, тип `game_level_shared` (обычная sim — `/lab?sim=…`,
`sim_shared`). Авто-публикация + durable `pushNotif` ученикам класса. Ответ теперь содержит `link`.
- Раздача игрового уровня из билдера — той же кнопкой «Раздать» (`openShareModal` → `LS.customSimShare`),
отдельный UI не нужен. Курикулумная привязка — `lab_sim_links` `sim_id='custom:<id>'` (Ф6, не трогалось).
### Для Phase 6 (лидерборд / живая гонка)
- Лидерборд может агрегировать `game_progress` по `level_id` (включая `custom:<dbid>`). Уровень-метаданные
(title/chapter) для custom доступны через `QuantikLevels.getAsync` или прямой `LS.customSimGet`.
- Живая гонка (мост `sim_state`) — он на base-ветке sim-builder Ф7; авторённый игровой уровень уже
монтируется тем же `SimEngine`, что и встроенные, поэтому мост применим без изменений в этой фазе.
- Авторинг-панель пишет `goal`/`game` только при `st.game.enabled` — обычные симуляции не затронуты.
@@ -1,6 +1,9 @@
# Phase 6: Класс-лидерборд / живая гонка (classroom SSE) # Phase 6: Класс-лидерборд / живая гонка (classroom SSE)
**Status:** ⬜ Not Started > **REMOVED (Amendment 1, 2026-06-14)** — фаза не реализуется по решению пользователя.
> Архивный subplan. `game_progress.level_id` (TEXT) уже готов под лидерборд, если фичу вернут.
**Status:** ❌ Removed
**Parent plan:** [PLAN.md](./PLAN.md) **Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack **Domain:** fullstack