@
merge: SimForge + Квантик — Законы Мира → master Вливает конструктор симуляций (SimForge) и игру «Квантик: Законы Мира» (фазы 0–5) в master. master был прямым предком feature/sim-builder — мерж чистый, без конфликтов. @
This commit is contained in:
@@ -185,3 +185,91 @@ git push origin master
|
||||
- **Новые ICON** (inline SVG `.ic`, ⛔ без эмодзи): up/down/copy/eye/eyeOff/clearX. Новые CSS-классы в ls.css-стиле; заголовок объекта `flex-wrap` + 26px-кнопки; медиа ≤920px (была) + новый ≤560px (поля/стили в один столбец).
|
||||
- **Верификация P4**: `node --check` sim-builder.js + извлечённого инлайна html — OK; эмодзи/eval/new Function — 0 (скан кодпойнтов обоих файлов); headless vm-смоук (DOM/SimExpr-стаб) 27+12+2 PASS: стили объекта в спеке, round-trip объектов ×2 идемпотентен, plot с 2 кривыми (все поля) + round-trip ×2, легаси-одиночная→легаси-форма, hidden исключён, z-order=порядок, дефолты-стрип, шаблонные легаси-plot save→load→save стабильны. Temp удалены. git status: только sim-builder.html и sim-builder.js.
|
||||
- **На P5 (прямое манипулирование + история)**: drag сейчас только x/y point/circle/label/readout/rect + конец segment/vector (`bindPreviewDrag` через `inst._toWorld`). Расширять до всех типов + snap-к-сетке + выравнивание (нужны хит-тесты/ручки в `_sim_engine.js`). Undo/redo: `this.st` сериализуем JSON → стек снапшотов в Builder, restore + `renderPanels`/`scheduleRemount`.
|
||||
|
||||
### SimForge improvements — P5 (Прямое манипулирование + история) — ФИНАЛ раунда — Learnings
|
||||
|
||||
Всё в `frontend/js/sim-builder.js`. **`_sim_engine.js` НЕ тронут** — вопреки прогнозу IMPROVEMENTS, хук в движке не понадобился: `_toWorld`/`_toPx`/`_niceStep(targetPx)` уже публичны на инстансе, их хватает для хит-теста/перевода координат/шага сетки прямо из билдера.
|
||||
|
||||
- **Ручки вместо «drag только x/y» (`bindPreviewDrag` переписан).** `handlesOf(obj)` строит список ручек `{label, blocked, wx, wy, set(x,y)}` по типу: point/circle/label/readout/rect → одна ручка (x,y); segment/vector → `origin`(x1,y1) + `end` (x2,y2 ИЛИ, если у объекта `dx`/`dy` без `x2`/`y2` — origin+dx/dy: ручка пишет `dx=x-x1`, `dy=y-y1`); polyline/path → по ручке на каждую числовую вершину `points` (её `set` ре-парсит JSON-строку и пишет свой индекс). `pickHandle` — ближайшая незаблокированная ручка в 14px (через `_toPx`). pointerdown-режимы: `handle` (драг ручки), `place` (единств. ручка — клик СТАВИТ точку, сохранён исходный смысл), `body` (несколько ручек — относительный сдвиг всех от стартовой мир-точки), `none`.
|
||||
- **Выражения не затираются.** `numField(obj,key)` → число, либо `null` если значение — строка-выражение (не парсится как число) → ручка `blocked` (не двигается; молча в спеку не пишется). `refreshObjFields` расширен на x1/y1/x2/y2/dx/dy/points.
|
||||
- **Snap-к-сетке = шаг движка.** Тумблер в тулбаре (`_snap`, `toggleSnap`, `ICON.grid`; активность — инлайн `SNAP_ACTIVE_CSS`, без зависимости от CSS-класса). При вкл координаты округляются к `inst._niceStep(34)` (минорный шаг видимой сетки; fallback 0.5), при выкл — `round2`. Выравнивание к чужим координатам/осям не делалось (бонус; snap достаточно — частично).
|
||||
- **Undo/Redo без библиотек.** Снапшот = `JSON.stringify(this.st)` (`this.st` уже сериализуемо). `pushHistory` снимает ДО мутации (без дублей верхушки; чистит redo; глубина `_undoMax=50`). **Гранулярность правки поля**: `snapField` снимает ОДИН снапшот на сессию (флаг `_fieldSnapTaken` сбрасывается на `focusin` поля; первый input/change снимает) → Ctrl+Z откатывает значение целиком, не посимвольно. Структурные операции (add/del/z-order/dup/hide/тумблеры — объекты/plot/curve/wall/spring/физика) — снапшот сразу. Drag — один на сессию (pushHistory в pointerdown; no-op-снапшот без изменений откатывается в `end()`). Кнопки undo/redo (SVG `.ic`) + клавиши Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts` на `document`, вешается один раз, игнорит фокус в INPUT/TEXTAREA/SELECT). `loadFromSim` обнуляет историю; `_restoreSnapshot` → `renderPanels`+`scheduleRemount` (гочи: захватить `this._selObjId` в локальную переменную — иначе `this` теряется в колбэке `.some()`).
|
||||
- **Верификация P5**: `node --check` OK; эмодзи/eval/new Function — 0 (скан кодпойнтов); headless vm-смоук (DOM/SimExpr/SimEngine-стаб с линейным `_toPx`/`_toWorld`) **38/38 PASS**: drag point/circle, оба конца segment, vector origin+dx/dy, вершина polyline, body-move polyline и segment, snap к 0.5, выражение-поле не затирается, undo/redo drag и onAdd, лимит стека, round-trip buildSpec идемпотентен ×2, no-op-drag не плодит историю. Temp удалён. git status: тронут только sim-builder.js (`_sim_engine.js` в статусе — чужой коммит «goal/game» параллельной сессии, мной не редактировался).
|
||||
|
||||
## Feature: Квантик — Законы Мира (игра)
|
||||
|
||||
2D физика-головоломка поверх SimForge. План: `plans/quantik-game/`. Уровень = спека SimForge + блок `goal`.
|
||||
|
||||
### Phase 0 — Learnings (Слой целей в движке)
|
||||
|
||||
- **«Атом» игры = верхнеуровневый блок `goal` в спеке** (формат — в шапке `_sim_engine.js`): `goal:{ when, title?, hint?, hold?:0, fail?, stars?:[{when,label?}] }` (звёзд ≤3). Аддитивно: нет `goal` → `_goal=null`, HUD не создаётся, в rAF ветка `if(self._goal)` пропускается → **поведение спеки без goal не меняется** (нет накладных вычислений побед, нет DOM-узлов).
|
||||
- **Компиляция один раз** через `SimExpr.compile(src).fn` (как все выражения движка; кривое выражение → fn возвращает 0, не бросает). Истинность булева = `_truthy` (модульный хелпер): конечное ненулевое число. Без `eval`/`Function`.
|
||||
- **Env цели = весь env кадра + ЕДИНСТВЕННЫЙ доп.идентификатор `tries`** (= `attempts`). Не вводить других новых идентификаторов — контракт безопасности шаренных выражений. `env.tries` ставится и в `_evalGoal` (rAF), и в `_renderFrame` (star-accumulation на паузе/предпросмотре) для консистентности.
|
||||
- **Оценка в rAF-кадре**: `_evalGoal(self._buildEnv(), dt)` ПОСЛЕ `_stepPhysics`, ДО `_renderFrame`. Порядок: накопить звёзды (залипают до reset) → `fail` (мягкий проигрыш, приоритет, НЕ победа) → `when` с учётом `hold` (таймер `_goalHoldT` копит мировые секунды; условие пропало → сброс таймера). Победа → `timeMs = max(1, round(t*1000))` (мировое `t`, детерминизм), `won=true`, `pause()`, `_fireGoal()` (onGoal один раз).
|
||||
- **onGoal не задваивается**: победа делает `pause()` внутри кадра; уже-заквигованный следующий rAF выходит по `if(!self._running) return`. Повторный `play()` после победы не перезапускает (уже won, paused).
|
||||
- **attempts**: инкрементится только на пользовательском `reset()` (флаг `_goalInited` — первый авто-reset при mount НЕ считается). `resetResult()` сбрасывает результат, но attempts сохраняет (НЕ попытка).
|
||||
- **HUD = DOM-оверлей** (НЕ canvas), стиль `_readoutBadgeCss` (тёмная плашка). Контейнеры `pointer-events:none` (не крадёт pan/drag), кнопка «Ещё раз» — `pointer-events:auto` → `inst.reset()`. Звёзды — inline SVG (`_starIcon`: заполненная #FBBF24 / контур), без эмодзи. `destroy()` снимает click-слушатель кнопки + removeChild HUD-узлов (баланс add/remove; узлы и так внутри `inst.el`, который удаляется — belt-and-suspenders).
|
||||
- **Публичное API инстанса**: `onGoal(cb)` (chainable), `getResult()`→`{won,failed,timeMs,attempts,stars:{got,total}}` (без goal → `null`), `resetResult()`. Полный перезапуск уровня = `reset()` (физика+время+attempts++).
|
||||
- **Сервер** `customSimController.validateSpec`: `goal` (объект) + `game` (резерв Ф1/5) разрешены на верхнем уровне. `when`/`fail`/`stars[].when` → `checkExpr` (длина ≤500, НЕ исполняются); `title`/`hint`/`stars[].label` → `sanitizeText` (escape `& < >` + обрезка); `stars`>3 → 400; `hold` не-число → 400. `cat='game'` уже в `CATS`. Санитизированный `goal`/`game` пишется в `clean`.
|
||||
- **Верификация P0**: `node --check` обоих файлов OK; headless vm-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`, rAF-очередь степается вручную, `performance.now()` = виртуальные часы) **40/40 PASS**: when→win+timeMs>0, звёзды копятся+залипают+сброс на reset, fail без won, hold требует удержания + сброс при лапсе, спека без goal без HUD/без throw, onGoal ровно 1 раз, destroy баланс add/remove, серверный validateSpec (escape/>3 звезды/длина/hold/без-goal). `npm test` 238 pass / 8 baseline fail; lint:routes 0. Temp удалён. Эмодзи/eval/new Function — 0 (new Function только в пре-существующем комментарии стр.15).
|
||||
- **На Phase 1**: использовать `onGoal`/`getResult`/`resetResult`; HUD включается сам наличием `goal`. Уровни хранятся в `custom_sims` (cat='game'). `game{}`-блок зарезервирован под мета (узел карты/мир/XP).
|
||||
|
||||
### Phase 1 — Learnings (Оболочка игры + 1 уровень + прогресс)
|
||||
|
||||
- **Сквозной MVP-срез играбелен.** Страница `/quantik` (`frontend/quantik.html` + `frontend/js/game/quantik-game.js`): `QuantikGame.start({host, level})` → `SimEngine.mount(host, level.spec)` → `inst`. «Игровой режим» НЕ требует флага — HUD из Ф0 появляется сам по наличию `goal` в спеке; управление = собственные слайдеры params движка + play/reset (внутри `inst.el`). Победа: `inst.onGoal(res => { LS.gameProgressSubmit(level.id, {time_ms:res.timeMs, stars:res.stars.got}); showSuccess(res); })`.
|
||||
- **Уровни = ДАННЫЕ, встроенные (MVP).** `frontend/js/game/levels.js` → `window.QuantikLevels.{list,get,LEVELS}`. Запись `{ id, title, subject?, hint?, spec }`, `id`==`level_id`. Один уровень `phys-artillery-1`: physics-гравитация + body-запуск (`point` с `body.vx='v*cos(theta*pi/180)'`, `vy='v*sin(...)'`), портал-цель (`goal.when:'hypot(ball.x-PX,ball.y-PY)<R'`), бонус-звезда (`stars[].when`), `fail` при промахе за поле. Подобран ПРОХОДИМЫМ в пределах слайдеров (θ 10..80°, v 5..20 м/с; портал x=8, дальность v²·sin2θ/g ≈ 6..10 м). custom_sims cat='game' остаётся для авторённых уровней (Ф5) — реестр тогда станет асинхронным со слиянием.
|
||||
- **API прогресса**: таблица `game_progress` (мигр.**076**, UNIQUE(user_id,level_id), user_id ON DELETE CASCADE), контроллер `gameController.js` + роутер `routes/game.js` (`router.use(authMiddleware)` → lint:routes 0), смонтировано в `server.js` после `/api/custom-sims`. `GET /api/game/progress` → `{progress:[…]}`; `POST` `{level_id,time_ms,stars}` → upsert best (min time / max stars) + attempts++. Валидация: level_id строка ≤120, time_ms/stars неотрицательные ЦЕЛЫЕ (`Number.isInteger`, отвергает дробь/NaN/∞), stars 0..3. Прогресс всегда `req.user` — нет межпользовательских роутов, ownership-проверка не нужна. Клиент `LS.gameProgressList()`/`LS.gameProgressSubmit(levelId,{time_ms,stars})` (стиль customSim*-врапперов в js/api.js).
|
||||
- **Маршрутизация без правок server.js**: `/quantik` → `quantik.html` через `express.static(frontendDir,{extensions:['html']})` (как все clean URL). `/js/game/*` и `/js/labs/*` отдаются тем же static (гоча `/js`→корневой `js/` касается только api.js/sidebar.js, не подпапок). Подключение движка — копия sim-builder.html: `/js/labs/_sim_expr.js` + `/js/labs/_sim_engine.js`.
|
||||
- **Экран успеха** = DOM-оверлей страницы `.qg-overlay` (НЕ HUD движка), `QuantikGame.buildSuccessOverlay(state)` строит карточку: звёзды inline SVG (заполн./контур, без эмодзи), время/звёзды/попытки, «Ещё раз» (убрать оверлей + `inst.reset()`) / «Дальше» (disabled-заглушка MVP — Ф2 активирует). CSS `.qg-*` в `<style>` quantik.html. Кнопки — классы `btn-primary`/`btn-ghost` (НЕ `ls-btn-*` — таких в ls.css нет).
|
||||
- **Сайдбар**: `/quantik` (icon `rocket`) в группе practice ПЕРЕД `/sim-builder`, БЕЗ `hidden` (видно ученикам — это игра, в отличие от teacher-only sim-builder). `isActive('/quantik')` подсвечивает на clean URL.
|
||||
- **Доступ страницы**: `LS.initPage()` (без `{requireLogin:false}`) сам редиректит на `/login` если не авторизован и возвращает null → бутстрап выходит. Любой авторизованный играет.
|
||||
- **Верификация P1**: `node --check` всех новых/изменённых JS — OK; `npm run migrate` 076 применяется чисто; `npm test` 251 pass / 8 baseline fail (3 auth + 5 jsdom page-тестов — пре-существующие; **game.test.js 13/13 PASS**); `lint:routes` 247 :id-роутов, 0 unprotected (baseline 0). Эмодзи в коде нет (флагуются только `→`/`⛔` в комментариях — конвенция проекта); eval/new Function — 0. Спека без goal по-прежнему работает (Ф0 не задет).
|
||||
- **На Phase 2 (карта/мир/XP)**: реестр уровней расширяемый (добавить запись в `LEVELS`); `game_progress`-API готов; экран успеха `buildSuccessOverlay` переиспользуем (расширить «следующим уровнем», активировать «Дальше»); при смене уровня без перезагрузки — `inst.destroy()` перед новым mount.
|
||||
|
||||
### Phase 2 — Learnings (Карта-созвездие + мир физ-уровней + XP/скины)
|
||||
|
||||
- **Phase 2 = FRONTEND-ONLY** (осознанное решение): XP/уровень игрока агрегируются на КЛИЕНТЕ из `game_progress` (Ф1), скин — localStorage. Без новых таблиц/роутов/миграций → `lint:routes` baseline 0 не тронут, `npm test` ровно как в Ф1 (259 tests / 251 pass / 8 baseline fail). Перенос XP на сервер позже тривиален — те же чистые функции `progress-logic.js`.
|
||||
- **Чистая логика в отдельном модуле `frontend/js/game/progress-logic.js`** (`window.QuantikProgress`, без DOM/сети/eval — тестируемо в изоляции): `isUnlocked(level,map,levels)` (Σ звёзд во всех уровнях с меньшим `order` ≥ `level.unlockStars`; порог в ДАННЫХ уровня), `computeXp`(звёзды·100+40/пройден), `playerLevel(xp)` (квадратичная шкала `xpForLevel(L)=240·(L-1)L/2`), `groupByChapter`, `nextPlayable`, `fromProgressList`, `starsFor/starsToUnlock/nodeStatus`. Гоча тестов: `assert.deepEqual` через `vm`-границу сравнивает массивы РАЗНЫХ реалмов (прототипы ≠) → ложный fail; сравнивать через `JSON.stringify`.
|
||||
- **Карта `frontend/js/game/map.js`** (`window.QuantikMap.create({host,headerHost,onPlay,getSkin,onSkin})->{render(progressMap),destroy()}`): созвездия по главам (`groupByChapter`), узлы — `<button class="qm-node qm-{locked|available|completed}">`, позиция в % через `layoutNodes` (зигзаг-дуга), статус из `nodeStatus`. Звёздное небо — SVG `<circle class="qm-tw">` (CSS-мерцание, seeded `mulberry32`), линии-связи `<line>`. Поэтапное появление — `staggerReveal` (`.qm-pre`→`.qm-in`, setTimeout 70 мс). Тип спеки уровня карте безразличен — читает только метаданные → Ф3 граф-уровни = НОВАЯ глава без правок map.js.
|
||||
- **Метаданные уровня (Ф2)**: `{ id, title, chapter, order, unlockStars?, par_ms?, hint, spec }`. Главы — `QuantikLevels.CHAPTERS` (`{key,title,subtitle,accent}`). 6 уровней: Кинематика (артиллерия/перелёт-через-стену/отскок-от-стены) + Динамика (маятник-на-пружине/орбита-в-колодце/гравиманёвр). По 2 звезды: кристалл (`stars[0]`) + норматив времени `t*1000<=par_ms` (`stars[1]` — par-звезда выражается через мировое `t`, идентификатор `tries` для неё НЕ нужен).
|
||||
- **Физика «силовых» уровней через ПРУЖИНУ** (движок не имеет central-gravity): маятник — пружина к якорю-точке с короткой `length` (растянута → сильный возврат) + горизонтальный толчок; орбита — пружина к центру с `length:0` (== гармонический осциллятор `F=-k·r` == эллиптические орбиты); гравиманёвр — гравитация вниз + пружина-«колодец» к центру. k/толчок/сила = params-слайдеры.
|
||||
- **Скин: тинт без исполнения.** `tintHeroSpec(spec,key)` — глубокая JSON-копия спеки (данные!), переписывает `color/glowColor/trailColor` объекта `id:'ball'` цветом из `PetSprite.PALETTES[key]`. localStorage ключ **`quantik-skin`** (валидируется при чтении). Скин тинтует и героя, и нарратора (`PetSprite.render(...,colorKey,...)`). Гейты — массив `SKIN_GATES` (needStars/needXp).
|
||||
- **Нарратор = `PetSprite.render(level,mood,[],skin,0,'none')`** на карте-шапке (mood по уровню игрока), интро (`buildIntro`, happy) и успехе (`buildSuccessOverlay`, ecstatic при всех звёздах≥2 / happy при ≥1). `quantik.html` грузит `/js/pet-sprite.js` (как dashboard/pet).
|
||||
- **Навигация (inline-bootstrap quantik.html)**: 2 вида `#qg-map-view`/`#qg-level-view` (класс `.show`). `showMap` перезагружает прогресс (`LS.gameProgressList`) → `map.render`. `openLevel→интро→launchLevel→onGoal→успех→onNext(nextPlayable)|onMap`. **Смена уровня ВСЕГДА через `destroyLevel()` (=`inst.destroy()`)** до нового mount (гоча Ф1). Deep-link `?level=` открывает только разблокированный.
|
||||
- **Per-level winnability обязательна** (как Ф1): harness грузит РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js` в `vm`, свипует слайдеры через движок, проверяет `getResult().won`. Гоча OOM: **переиспользовать ОДИН `inst` через `reset()` по сотням комбо ТЕЧЁТ** (накопление через goal-state/bodyById-замыкания) → mount+`destroy()` СВЕЖИЙ inst на каждое комбо (leak-proof). Headless `_renderFrame` рано выходит при `_cw/_ch==0` (рендер не нужен, физика/`_evalGoal` идут в `play`-кадре независимо); для point-радиуса в физике выставить `inst._scale`. Виртуальные часы синхронны с `performance.now()`/rAF-timestamp. Результат: ВСЕ 6 winnable, у всех достижимы обе звезды (combos: artillery 28/196, arc 5/196, bounce 92/343, pendulum 189/196, orbit 94/196, gravimanёvr 170/343).
|
||||
- **Верификация P2**: `node --check` всех новых/изменённых JS + inline-`<script>` quantik.html — OK; смоуки (логика 16/16, рендер карты/оверлеев 7/7, winnability 6/6) зелёные и удалены; `npm test` 259/251 pass/8 baseline fail (без новых падений); `lint:routes` baseline 0. Эмодзи/`★`/eval/new Function — 0 (звёзды UI — inline SVG; в комментариях `★` заменён на «зв.»).
|
||||
|
||||
### Phase 3 — Learnings (Граф-уровни: движение по f(x) + зоны)
|
||||
|
||||
- **«Бегунок по кривой» — поле `runner` на `plot`, НЕ новый тип объекта.** `plot.runner:{duration?:8, hold?:true}` превращает ПЕРВУЮ кривую plot в дорожку. Движок в `_buildEnv` (ДО формульных центров, после физ-тел) кладёт `<plotId>.runX` (= `a+(b−a)·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 = ymax−ymin` (высота вьюпорта) и `env.w` — поэтому param с именем `h` (планировался под вершину модуля `a·|x−h|+1`) затирался: `abs(x−h)` видел 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·(x−5)²+k`, модуль `a·|x−m|+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·(x−5)²+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`).
|
||||
|
||||
@@ -28,6 +28,7 @@ const MAX_POINTS = 1000; // точек в polyline/path/points
|
||||
const OBJECT_TYPES = new Set([
|
||||
'point', 'segment', 'vector', 'circle', 'rect',
|
||||
'polyline', 'path', 'label', 'plot', 'readout',
|
||||
'zone', // Квантик Ф3: зона-препятствие/цель/сбор (граф-уровни)
|
||||
]);
|
||||
|
||||
const STATUSES = new Set(['draft', 'published']);
|
||||
@@ -189,6 +190,14 @@ function validateSpec(spec) {
|
||||
out.drag.param = sanitizeText(o.drag.param, 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;
|
||||
});
|
||||
|
||||
@@ -235,6 +244,51 @@ function validateSpec(spec) {
|
||||
clean.physics = cph;
|
||||
}
|
||||
|
||||
// goal{} — слой цели/победы (Квантик, Фаза 0). Выражения НЕ исполняем (длина),
|
||||
// текст — sanitizeText (escape + обрезка), не более 3 звёзд, hold — число.
|
||||
if (spec.goal && typeof spec.goal === 'object' && !Array.isArray(spec.goal)) {
|
||||
const g = spec.goal;
|
||||
const cg = {};
|
||||
if (g.when !== undefined) { checkExpr(g.when, 'goal.when', errs); cg.when = g.when; }
|
||||
if (g.fail !== undefined) { checkExpr(g.fail, 'goal.fail', errs); cg.fail = g.fail; }
|
||||
if (g.title !== undefined) cg.title = sanitizeText(g.title, 120);
|
||||
if (g.hint !== undefined) cg.hint = sanitizeText(g.hint, 300);
|
||||
if (g.hold !== undefined) {
|
||||
if (typeof g.hold !== 'number') errs.push('goal.hold должно быть числом');
|
||||
else cg.hold = g.hold;
|
||||
}
|
||||
if (g.stars !== undefined) {
|
||||
if (!Array.isArray(g.stars)) {
|
||||
errs.push('goal.stars должно быть массивом');
|
||||
} else if (g.stars.length > 3) {
|
||||
return { ok: false, error: 'goal.stars > 3' };
|
||||
} else {
|
||||
cg.stars = g.stars.map((s, i) => {
|
||||
if (!s || typeof s !== 'object') { errs.push(`goal.stars[${i}]: не объект`); return {}; }
|
||||
const os = {};
|
||||
if (s.when !== undefined) { checkExpr(s.when, `goal.stars[${i}].when`, errs); os.when = s.when; }
|
||||
if (s.label !== undefined) os.label = sanitizeText(s.label, 120);
|
||||
return os;
|
||||
});
|
||||
}
|
||||
}
|
||||
clean.goal = cg;
|
||||
}
|
||||
|
||||
// game{} — мета-слой игрового уровня (Фаза 1/5). Санитизируем ПОИМЁННО (как goal):
|
||||
// строки → sanitizeText (escape), числа → проверка типа, неизвестные ключи отбрасываем.
|
||||
// Иначе произвольная строка в game.* стала бы хранимым XSS у любого, кому раздали уровень.
|
||||
if (spec.game && typeof spec.game === 'object' && !Array.isArray(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('; ') };
|
||||
return { ok: true, clean };
|
||||
}
|
||||
@@ -408,17 +462,24 @@ function share(req, res) {
|
||||
}
|
||||
|
||||
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
|
||||
const simTitle = row.title || 'симуляция';
|
||||
const link = '/lab?sim=custom:' + row.id;
|
||||
const isGame = row.cat === 'game';
|
||||
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);
|
||||
|
||||
let sent = 0;
|
||||
for (const uid of recipients) {
|
||||
if (!uid || uid === req.user.id) continue;
|
||||
pushNotif(uid, 'sim_shared', `Новая симуляция от ${teacherName}: «${simTitle}»`, link);
|
||||
pushNotif(uid, notifType, notifMsg, link);
|
||||
sent++;
|
||||
}
|
||||
res.json({ ok: true, sent, status: 'published' });
|
||||
res.json({ ok: true, sent, status: 'published', link });
|
||||
}
|
||||
|
||||
/* POST /api/custom-sims/:id/clone — копия спеки текущему пользователю как draft.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
/* Game progress ("Квантик — Законы Мира", Фаза 1).
|
||||
*
|
||||
* Прогресс игрока по уровням. Уровень = спека SimForge с блоком goal;
|
||||
* идентифицируется строковым level_id. На победу клиент шлёт результат
|
||||
* (time_ms, stars); сервер делает upsert, сохраняя ЛУЧШИЙ результат
|
||||
* (минимальное время, максимум звёзд) и инкрементируя attempts.
|
||||
*
|
||||
* Стиль следует customSimController / studentMaterialsController:
|
||||
* node:sqlite db.prepare, auth-only (роутер ставит authMiddleware),
|
||||
* валидация входа без исполнения, статусы 400.
|
||||
*/
|
||||
const db = require('../db/db');
|
||||
|
||||
const MAX_LEVEL_ID = 120; // длина level_id (TEXT)
|
||||
const MAX_TIME_MS = 24 * 60 * 60 * 1000; // санитарный потолок: сутки в мс
|
||||
|
||||
/* Целое неотрицательное число (отвергаем NaN/Infinity/дробь/отрицательное). */
|
||||
function isNonNegInt(v) {
|
||||
return typeof v === 'number' && Number.isInteger(v) && v >= 0;
|
||||
}
|
||||
|
||||
/* GET /api/game/progress — прогресс текущего пользователя по всем уровням. */
|
||||
function listProgress(req, res) {
|
||||
const uid = req.user.id;
|
||||
const rows = db.prepare(`
|
||||
SELECT level_id, best_time_ms, best_stars, attempts, completed_at
|
||||
FROM game_progress
|
||||
WHERE user_id = ?
|
||||
ORDER BY completed_at DESC, id DESC
|
||||
`).all(uid);
|
||||
res.json({ progress: rows });
|
||||
}
|
||||
|
||||
/* POST /api/game/progress body: { level_id, time_ms, stars }
|
||||
* Upsert: сохраняем ЛУЧШИЙ результат (min time_ms, max stars); attempts++.
|
||||
* Валидация: level_id строка ≤120; time_ms/stars — неотрицательные целые;
|
||||
* stars 0..3. БЕЗ исполнения чего-либо. */
|
||||
function submitProgress(req, res) {
|
||||
const uid = req.user.id;
|
||||
const b = req.body || {};
|
||||
|
||||
const levelId = typeof b.level_id === 'string' ? b.level_id.trim() : '';
|
||||
if (!levelId) return res.status(400).json({ error: 'level_id обязателен' });
|
||||
if (levelId.length > MAX_LEVEL_ID) {
|
||||
return res.status(400).json({ error: `level_id длиннее ${MAX_LEVEL_ID} символов` });
|
||||
}
|
||||
|
||||
const timeMs = b.time_ms;
|
||||
const stars = b.stars;
|
||||
if (!isNonNegInt(timeMs)) return res.status(400).json({ error: 'time_ms должно быть неотрицательным целым' });
|
||||
if (timeMs > MAX_TIME_MS) return res.status(400).json({ error: 'time_ms вне допустимого диапазона' });
|
||||
if (!isNonNegInt(stars)) return res.status(400).json({ error: 'stars должно быть неотрицательным целым' });
|
||||
if (stars > 3) return res.status(400).json({ error: 'stars вне диапазона 0..3' });
|
||||
|
||||
const existing = db.prepare(
|
||||
'SELECT id, best_time_ms, best_stars FROM game_progress WHERE user_id = ? AND level_id = ?'
|
||||
).get(uid, levelId);
|
||||
|
||||
if (!existing) {
|
||||
db.prepare(`
|
||||
INSERT INTO game_progress (user_id, level_id, best_time_ms, best_stars, attempts)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
`).run(uid, levelId, timeMs, stars);
|
||||
} else {
|
||||
// Лучшее время = минимум (null трактуем как «нет результата»); лучшие звёзды = максимум.
|
||||
const bestTime = (existing.best_time_ms == null)
|
||||
? timeMs
|
||||
: Math.min(existing.best_time_ms, timeMs);
|
||||
const bestStars = Math.max(existing.best_stars || 0, stars);
|
||||
db.prepare(`
|
||||
UPDATE game_progress
|
||||
SET best_time_ms = ?, best_stars = ?, attempts = attempts + 1
|
||||
WHERE id = ?
|
||||
`).run(bestTime, bestStars, existing.id);
|
||||
}
|
||||
|
||||
const row = db.prepare(
|
||||
'SELECT level_id, best_time_ms, best_stars, attempts, completed_at FROM game_progress WHERE user_id = ? AND level_id = ?'
|
||||
).get(uid, levelId);
|
||||
res.json({ ok: true, progress: row });
|
||||
}
|
||||
|
||||
module.exports = { listProgress, submitProgress };
|
||||
@@ -0,0 +1,25 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 076: Game progress (Квантик — Законы Мира, Фаза 1).
|
||||
--
|
||||
-- Прогресс игрока по уровням игры «Квантик». Уровень идентифицируется
|
||||
-- строковым level_id (напр. 'phys-artillery-1'); сами уровни — это спеки
|
||||
-- SimForge (встроенные данные сейчас, custom_sims cat='game' в Ф5).
|
||||
--
|
||||
-- Upsert хранит ЛУЧШИЙ результат: best_time_ms (минимальное время прохождения),
|
||||
-- best_stars (максимум собранных звёзд 0..3). attempts растёт на каждый submit.
|
||||
-- UNIQUE(user_id, level_id) — одна строка прогресса на пару игрок-уровень.
|
||||
-- user_id ON DELETE CASCADE — прогресс удаляется вместе с игроком.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS game_progress (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
level_id TEXT NOT NULL, -- идентификатор уровня (спека)
|
||||
best_time_ms INTEGER, -- лучшее (минимальное) время, мс
|
||||
best_stars INTEGER NOT NULL DEFAULT 0, -- лучшее число звёзд 0..3
|
||||
attempts INTEGER NOT NULL DEFAULT 0, -- число попыток (++ на submit)
|
||||
completed_at TEXT DEFAULT (datetime('now')), -- время первого прохождения
|
||||
UNIQUE (user_id, level_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_game_progress_user ON game_progress (user_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
/* /api/game — прогресс игрока в игре «Квантик — Законы Мира» (Фаза 1).
|
||||
* Все роуты — auth-only (играют и ученики). router.use(authMiddleware)
|
||||
* → lint:routes baseline 0. Прогресс всегда принадлежит req.user — нет
|
||||
* межпользовательских роутов, проверка владения не требуется. */
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const c = require('../controllers/gameController');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/progress', c.listProgress);
|
||||
router.post('/progress', c.submitProgress);
|
||||
|
||||
module.exports = router;
|
||||
@@ -197,6 +197,7 @@ app.use('/api/teacher-students', teacherStudentsRoutes);
|
||||
app.use('/api/lab', labRoutes);
|
||||
app.use('/api/materials', require('./routes/materials'));
|
||||
app.use('/api/custom-sims', require('./routes/customSims'));
|
||||
app.use('/api/game', require('./routes/game'));
|
||||
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||
|
||||
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
||||
|
||||
@@ -203,6 +203,34 @@ describe('/api/custom-sims', () => {
|
||||
assert.ok(txt.includes('<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 () => {
|
||||
const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, teacherToken);
|
||||
assert.equal(del.status, 200, `got ${del.status}`);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: /api/game — прогресс игрока «Квантик» (Фаза 1).
|
||||
* Covers: submit создаёт строку; лучший результат перезаписывает, худший — нет;
|
||||
* attempts++; auth-only (401 без токена); валидация входа (400).
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
// Mount /api/game on the shared test app (setup.js не монтирует новые роуты).
|
||||
app.use('/api/game', require('../src/routes/game'));
|
||||
|
||||
after(() => cleanup());
|
||||
|
||||
const LVL = 'phys-artillery-1';
|
||||
|
||||
describe('/api/game progress', () => {
|
||||
let token;
|
||||
|
||||
before(async () => {
|
||||
token = (await getToken('student')).token;
|
||||
});
|
||||
|
||||
it('GET /progress requires auth (401)', async () => {
|
||||
const res = await inject('GET', '/api/game/progress', null, null);
|
||||
assert.equal(res.status, 401, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('POST /progress requires auth (401)', async () => {
|
||||
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: 1 }, null);
|
||||
assert.equal(res.status, 401, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('submit creates a progress row', async () => {
|
||||
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 5000, stars: 1 }, token);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
assert.equal(res.body.ok, true);
|
||||
assert.equal(res.body.progress.level_id, LVL);
|
||||
assert.equal(res.body.progress.best_time_ms, 5000);
|
||||
assert.equal(res.body.progress.best_stars, 1);
|
||||
assert.equal(res.body.progress.attempts, 1);
|
||||
});
|
||||
|
||||
it('GET /progress lists the row', async () => {
|
||||
const res = await inject('GET', '/api/game/progress', null, token);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
assert.ok(Array.isArray(res.body.progress), 'progress is array');
|
||||
const row = res.body.progress.find(r => r.level_id === LVL);
|
||||
assert.ok(row, 'level row present');
|
||||
assert.equal(row.best_time_ms, 5000);
|
||||
});
|
||||
|
||||
it('better result (less time, more stars) overwrites best; attempts++', async () => {
|
||||
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 3200, stars: 2 }, token);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
assert.equal(res.body.progress.best_time_ms, 3200, 'time improved');
|
||||
assert.equal(res.body.progress.best_stars, 2, 'stars improved');
|
||||
assert.equal(res.body.progress.attempts, 2, 'attempts incremented');
|
||||
});
|
||||
|
||||
it('worse result does NOT overwrite best, but still counts an attempt', async () => {
|
||||
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 9999, stars: 0 }, token);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
assert.equal(res.body.progress.best_time_ms, 3200, 'best time kept');
|
||||
assert.equal(res.body.progress.best_stars, 2, 'best stars kept');
|
||||
assert.equal(res.body.progress.attempts, 3, 'attempts still incremented');
|
||||
});
|
||||
|
||||
it('progress is per-user (другой игрок начинает с нуля)', async () => {
|
||||
const other = (await getToken('student')).token;
|
||||
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 7000, stars: 1 }, other);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
assert.equal(res.body.progress.attempts, 1, 'fresh user has attempts=1');
|
||||
assert.equal(res.body.progress.best_time_ms, 7000);
|
||||
});
|
||||
|
||||
it('validation: missing level_id → 400', async () => {
|
||||
const res = await inject('POST', '/api/game/progress', { time_ms: 1000, stars: 1 }, token);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('validation: negative time_ms → 400', async () => {
|
||||
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: -5, stars: 1 }, token);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('validation: non-integer time_ms → 400', async () => {
|
||||
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 12.5, stars: 1 }, token);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('validation: stars out of range (>3) → 400', async () => {
|
||||
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: 4 }, token);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('validation: negative stars → 400', async () => {
|
||||
const res = await inject('POST', '/api/game/progress', { level_id: LVL, time_ms: 1000, stars: -1 }, token);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('validation: level_id too long → 400', async () => {
|
||||
const res = await inject('POST', '/api/game/progress',
|
||||
{ level_id: 'x'.repeat(200), time_ms: 1000, stars: 1 }, token);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Квантик — Законы Мира · Карта-созвездие (Фаза 2).
|
||||
|
||||
Рисует мир как звёздную карту: каждая глава (chapter) — отдельное созвездие,
|
||||
уровни — узлы-«звёзды», соединённые линиями по порядку. Узел показывает статус
|
||||
(заблокирован / доступен / пройден + число звёзд). По клику на доступный узел —
|
||||
колбэк onPlay(level).
|
||||
|
||||
Зависит от:
|
||||
window.QuantikLevels — реестр уровней (Ф1/Ф2)
|
||||
window.QuantikProgress — чистая логика прогресса/разблокировки/XP (Ф2)
|
||||
window.PetSprite — нарратор-Квантик (SVG)
|
||||
|
||||
window.QuantikMap.create({ host, headerHost, onPlay, getSkin, onSkin }) -> {
|
||||
render(progressMap), // перерисовать карту + шапку под новый прогресс
|
||||
destroy()
|
||||
}
|
||||
|
||||
⛔ Без эмодзи — звёзды/замки/иконки только inline SVG. Без eval/Function.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
|
||||
var doc = global.document;
|
||||
var NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function el(tag, cls, html) {
|
||||
var n = doc.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (html != null) n.innerHTML = html;
|
||||
return n;
|
||||
}
|
||||
function svgEl(tag, attrs) {
|
||||
var n = doc.createElementNS(NS, tag);
|
||||
if (attrs) for (var k in attrs) if (attrs.hasOwnProperty(k)) n.setAttribute(k, attrs[k]);
|
||||
return n;
|
||||
}
|
||||
|
||||
/* ── inline SVG иконки (без эмодзи) ── */
|
||||
function starPath() { return 'M12 2 15.1 8.6 22 9.3 17 14.1 18.2 21 12 17.6 5.8 21 7 14.1 2 9.3 8.9 8.6 Z'; }
|
||||
function starSvg(filled, size) {
|
||||
var s = size || 16;
|
||||
var fill = filled ? '#FBBF24' : 'none';
|
||||
var stroke = filled ? '#FBBF24' : 'rgba(148,163,184,0.55)';
|
||||
// Цвета — через inline style, а НЕ presentation-атрибуты: правило .ic в ls.css
|
||||
// (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) {
|
||||
var s = size || 18;
|
||||
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" ' +
|
||||
'stroke="rgba(226,232,240,0.85)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>';
|
||||
}
|
||||
function playSvg(size) {
|
||||
var s = size || 18;
|
||||
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="currentColor" ' +
|
||||
'stroke="none"><path d="M8 5.5 19 12 8 18.5 Z"/></svg>';
|
||||
}
|
||||
function checkSvg(size) {
|
||||
var s = size || 18;
|
||||
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" ' +
|
||||
'stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<path d="M4 12.5 10 18.5 20 6"/></svg>';
|
||||
}
|
||||
|
||||
/* ── Раскладка узлов созвездия ──────────────────────────────────────────
|
||||
Для каждой главы раскладываем её уровни по «созвездию»: лёгкая зигзаг-дуга
|
||||
внутри своего вертикального пояса. Координаты в % ширины ленты главы. */
|
||||
function layoutNodes(levels) {
|
||||
var n = levels.length;
|
||||
var pts = [];
|
||||
for (var i = 0; i < n; i++) {
|
||||
// x идёт слева-направо, y — мягкий зигзаг (созвездие, не прямая)
|
||||
var x = n === 1 ? 50 : (12 + (76 * i / (n - 1)));
|
||||
var y = 50 + (i % 2 === 0 ? -16 : 16) + (i % 3 === 0 ? 6 : -4);
|
||||
pts.push({ x: x, y: y });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/* ── Звёздное небо (статичные точки на canvas-фоне через SVG) ──────────── */
|
||||
function buildStarfield(seedCount) {
|
||||
var g = svgEl('g', { class: 'qm-stars' });
|
||||
var rnd = mulberry32(0x51ec7 + seedCount);
|
||||
for (var i = 0; i < seedCount; i++) {
|
||||
var cx = rnd() * 100, cy = rnd() * 100;
|
||||
var r = 0.08 + rnd() * 0.22;
|
||||
var op = 0.25 + rnd() * 0.55;
|
||||
var c = svgEl('circle', { cx: cx, cy: cy, r: r, fill: '#E2E8F0', opacity: op.toFixed(2) });
|
||||
c.style.setProperty('--tw', (1.6 + rnd() * 3).toFixed(2) + 's');
|
||||
c.style.setProperty('--td', (rnd() * 3).toFixed(2) + 's');
|
||||
c.classList.add('qm-tw');
|
||||
g.appendChild(c);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function mulberry32(a) {
|
||||
return function () {
|
||||
a |= 0; a = a + 0x6D2B79F5 | 0;
|
||||
var t = Math.imul(a ^ a >>> 15, 1 | a);
|
||||
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/* ════════════════════════ Создание карты ════════════════════════ */
|
||||
function create(opts) {
|
||||
opts = opts || {};
|
||||
var host = opts.host;
|
||||
var headerHost = opts.headerHost;
|
||||
var onPlay = typeof opts.onPlay === 'function' ? opts.onPlay : function () {};
|
||||
var getSkin = typeof opts.getSkin === 'function' ? opts.getSkin : function () { return 'cyan'; };
|
||||
var onSkin = typeof opts.onSkin === 'function' ? opts.onSkin : function () {};
|
||||
if (!host) return null;
|
||||
|
||||
var Levels = global.QuantikLevels;
|
||||
var Prog = global.QuantikProgress;
|
||||
if (!Levels || !Prog) return null;
|
||||
|
||||
var revealTimer = null;
|
||||
|
||||
function clearReveal() { if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; } }
|
||||
|
||||
/* ── Шапка: нарратор + XP-бар + всего звёзд + скины ── */
|
||||
function renderHeader(progressMap) {
|
||||
if (!headerHost) return;
|
||||
headerHost.innerHTML = '';
|
||||
|
||||
var levels = Levels.list();
|
||||
var xp = Prog.computeXp(levels, progressMap);
|
||||
var pl = Prog.playerLevel(xp);
|
||||
var tStars = Prog.totalStars(levels, progressMap);
|
||||
var maxStars = levels.reduce(function (s, L) { return s + (L.spec && L.spec.goal && L.spec.goal.stars ? L.spec.goal.stars.length : 0); }, 0);
|
||||
|
||||
var wrap = el('div', 'qm-header-inner');
|
||||
|
||||
// Нарратор-Квантик (mood по уровню игрока)
|
||||
var mood = pl.level >= 5 ? 'ecstatic' : (pl.level >= 2 ? 'happy' : 'neutral');
|
||||
var narr = el('div', 'qm-narrator');
|
||||
if (global.PetSprite) {
|
||||
var petLvl = Math.min(8, Math.max(1, pl.level));
|
||||
narr.innerHTML = '<div class="qm-pet">' + global.PetSprite.render(petLvl, mood, [], getSkin(), 0, 'none') + '</div>';
|
||||
}
|
||||
var bubble = el('div', 'qm-bubble');
|
||||
bubble.appendChild(el('div', 'qm-bubble-t', narrLine(pl, tStars, maxStars)));
|
||||
narr.appendChild(bubble);
|
||||
wrap.appendChild(narr);
|
||||
|
||||
// XP / уровень игрока
|
||||
var stats = el('div', 'qm-stats');
|
||||
var lvlBox = el('div', 'qm-level');
|
||||
lvlBox.innerHTML = '<span class="qm-level-num">' + pl.level + '</span><span class="qm-level-lbl">уровень Квантика</span>';
|
||||
stats.appendChild(lvlBox);
|
||||
|
||||
var xpBox = el('div', 'qm-xpbox');
|
||||
var xpHead = el('div', 'qm-xp-head');
|
||||
xpHead.innerHTML = '<span>' + xp + ' XP</span><span class="qm-xp-next">' +
|
||||
(pl.xpForNext > 0 ? ('до ур. ' + (pl.level + 1) + ': ' + Math.max(0, pl.xpForNext - pl.xpInto) + ' XP') : 'максимум') + '</span>';
|
||||
xpBox.appendChild(xpHead);
|
||||
var bar = el('div', 'qm-xp-bar');
|
||||
var fill = el('div', 'qm-xp-fill');
|
||||
fill.style.width = '0%';
|
||||
bar.appendChild(fill);
|
||||
xpBox.appendChild(bar);
|
||||
stats.appendChild(xpBox);
|
||||
|
||||
// всего звёзд
|
||||
var starBox = el('div', 'qm-starcount');
|
||||
starBox.innerHTML = starSvg(true, 18) + '<span>' + tStars + ' / ' + maxStars + '</span>';
|
||||
stats.appendChild(starBox);
|
||||
|
||||
wrap.appendChild(stats);
|
||||
|
||||
// Скины
|
||||
wrap.appendChild(buildSkinPicker(xp, tStars));
|
||||
|
||||
headerHost.appendChild(wrap);
|
||||
|
||||
// анимация XP-бара (после вставки в DOM)
|
||||
requestAnimationFrame(function () {
|
||||
fill.style.width = (pl.progress01 * 100).toFixed(1) + '%';
|
||||
});
|
||||
}
|
||||
|
||||
function narrLine(pl, tStars, maxStars) {
|
||||
if (tStars === 0) return 'Привет! Я — Квантик. Помоги мне починить законы мира — выбери уровень и подкрути формулы.';
|
||||
if (tStars >= maxStars) return 'Все звёзды собраны! Ты настоящий мастер законов мира.';
|
||||
if (pl.level >= 5) return 'Невероятно! Уровень ' + pl.level + '. Осталось всего ' + (maxStars - tStars) + ' звёзд.';
|
||||
if (pl.level >= 3) return 'Отлично идём — уровень ' + pl.level + '. Звёзды открывают новые созвездия.';
|
||||
return 'Уже ' + tStars + ' звёзд! Собирай больше, чтобы открыть новые уровни.';
|
||||
}
|
||||
|
||||
/* Палитра скинов: первые открыты, остальные — за XP/звёзды. */
|
||||
var SKIN_GATES = [
|
||||
{ key: 'cyan', name: 'Циан', need: 0 },
|
||||
{ key: 'purple', name: 'Аметист', need: 0 },
|
||||
{ key: 'green', name: 'Изумруд', needStars: 2 },
|
||||
{ key: 'pink', name: 'Магента', needStars: 4 },
|
||||
{ key: 'gold', name: 'Золото', needStars: 7 },
|
||||
{ key: 'blue', name: 'Сапфир', needXp: 600 },
|
||||
{ key: 'orange', name: 'Янтарь', needXp: 1000 },
|
||||
{ key: 'indigo', name: 'Индиго', needStars: 11 }
|
||||
];
|
||||
function skinUnlocked(g, xp, stars) {
|
||||
if (g.needStars && stars < g.needStars) return false;
|
||||
if (g.needXp && xp < g.needXp) return false;
|
||||
if (g.need && stars < g.need) return false;
|
||||
return true;
|
||||
}
|
||||
function buildSkinPicker(xp, stars) {
|
||||
var box = el('div', 'qm-skins');
|
||||
box.appendChild(el('div', 'qm-skins-lbl', 'Скин'));
|
||||
var row = el('div', 'qm-skins-row');
|
||||
var cur = getSkin();
|
||||
var pal = (global.PetSprite && global.PetSprite.PALETTES) || {};
|
||||
SKIN_GATES.forEach(function (g) {
|
||||
var unlocked = skinUnlocked(g, xp, stars);
|
||||
var sw = el('button', 'qm-skin' + (cur === g.key ? ' active' : '') + (unlocked ? '' : ' locked'));
|
||||
sw.type = 'button';
|
||||
sw.style.setProperty('--sk', pal[g.key] || '#06D6E0');
|
||||
sw.title = unlocked ? g.name : (g.name + ' — ' + skinReq(g));
|
||||
sw.setAttribute('aria-label', g.name + (unlocked ? '' : ' (заблокирован)'));
|
||||
if (!unlocked) sw.innerHTML = '<span class="qm-skin-lock">' + lockSvg(12) + '</span>';
|
||||
if (unlocked) {
|
||||
sw.addEventListener('click', function () { onSkin(g.key); });
|
||||
} else {
|
||||
sw.disabled = true;
|
||||
}
|
||||
row.appendChild(sw);
|
||||
});
|
||||
box.appendChild(row);
|
||||
return box;
|
||||
}
|
||||
function skinReq(g) {
|
||||
if (g.needStars) return 'нужно ' + g.needStars + ' звёзд';
|
||||
if (g.needXp) return 'нужно ' + g.needXp + ' XP';
|
||||
return 'заблокирован';
|
||||
}
|
||||
|
||||
/* ── Тело карты: созвездия по главам ── */
|
||||
function renderMap(progressMap) {
|
||||
clearReveal();
|
||||
host.innerHTML = '';
|
||||
|
||||
var groups = Prog.groupByChapter(Levels.list());
|
||||
var allLevels = Levels.list();
|
||||
var revealOrder = []; // узлы для поэтапного появления
|
||||
|
||||
groups.forEach(function (grp, gi) {
|
||||
var meta = Levels.chapter(grp.chapter);
|
||||
var section = el('section', 'qm-constellation');
|
||||
section.style.setProperty('--accent', meta.accent || '#22D3EE');
|
||||
|
||||
// заголовок главы
|
||||
var head = el('div', 'qm-con-head');
|
||||
head.innerHTML = '<span class="qm-con-title">' + escapeHtml(meta.title) + '</span>' +
|
||||
'<span class="qm-con-sub">' + escapeHtml(meta.subtitle || '') + '</span>';
|
||||
// прогресс главы
|
||||
var cStars = 0, cMax = 0;
|
||||
grp.levels.forEach(function (L) {
|
||||
cStars += Prog.starsFor(L.id, progressMap);
|
||||
cMax += (L.spec && L.spec.goal && L.spec.goal.stars) ? L.spec.goal.stars.length : 0;
|
||||
});
|
||||
var cbadge = el('span', 'qm-con-stars', starSvg(true, 14) + ' ' + cStars + '/' + cMax);
|
||||
head.appendChild(cbadge);
|
||||
section.appendChild(head);
|
||||
|
||||
// поле созвездия
|
||||
var field = el('div', 'qm-field');
|
||||
var pts = layoutNodes(grp.levels);
|
||||
|
||||
// SVG-слой: звёздное небо + линии-связи
|
||||
var svg = svgEl('svg', { class: 'qm-svg', viewBox: '0 0 100 100', preserveAspectRatio: 'none' });
|
||||
svg.appendChild(buildStarfield(46 + gi * 7));
|
||||
// линии между последовательными узлами
|
||||
for (var li = 0; li < pts.length - 1; li++) {
|
||||
var a = pts[li], b = pts[li + 1];
|
||||
var nextUnlocked = Prog.isUnlocked(grp.levels[li + 1], progressMap, allLevels);
|
||||
var line = svgEl('line', {
|
||||
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
|
||||
class: 'qm-link' + (nextUnlocked ? ' on' : '')
|
||||
});
|
||||
svg.appendChild(line);
|
||||
}
|
||||
field.appendChild(svg);
|
||||
|
||||
// узлы-уровни
|
||||
grp.levels.forEach(function (L, idx) {
|
||||
var status = Prog.nodeStatus(L, progressMap, allLevels);
|
||||
var node = buildNode(L, status, progressMap, allLevels, pts[idx]);
|
||||
field.appendChild(node);
|
||||
revealOrder.push(node);
|
||||
});
|
||||
|
||||
section.appendChild(field);
|
||||
host.appendChild(section);
|
||||
});
|
||||
|
||||
// поэтапное появление узлов
|
||||
staggerReveal(revealOrder);
|
||||
}
|
||||
|
||||
function buildNode(level, status, progressMap, allLevels, pt) {
|
||||
var stars = Prog.starsFor(level.id, progressMap);
|
||||
var total = (level.spec && level.spec.goal && level.spec.goal.stars) ? level.spec.goal.stars.length : 0;
|
||||
|
||||
var node = el('button', 'qm-node qm-' + status);
|
||||
node.type = 'button';
|
||||
node.style.left = pt.x + '%';
|
||||
node.style.top = pt.y + '%';
|
||||
node.setAttribute('data-level', level.id);
|
||||
|
||||
// ядро узла
|
||||
var core = el('span', 'qm-node-core');
|
||||
var icon = status === 'locked' ? lockSvg(20)
|
||||
: (status === 'completed' ? '<span class="qm-node-order">' + level.order + '</span>' : playSvg(18));
|
||||
core.innerHTML = icon;
|
||||
node.appendChild(core);
|
||||
|
||||
// подпись
|
||||
var label = el('span', 'qm-node-label', escapeHtml(level.title));
|
||||
node.appendChild(label);
|
||||
|
||||
// звёзды узла (для пройденных) или порог (для заблокированных)
|
||||
if (status === 'completed' && total > 0) {
|
||||
var sb = el('span', 'qm-node-stars');
|
||||
var html = '';
|
||||
for (var i = 0; i < total; i++) html += starSvg(i < stars, 13);
|
||||
sb.innerHTML = html;
|
||||
node.appendChild(sb);
|
||||
} else if (status === 'locked') {
|
||||
var need = Prog.starsToUnlock(level, progressMap, allLevels);
|
||||
var hint = el('span', 'qm-node-need', starSvg(true, 11) + ' ещё ' + need);
|
||||
node.appendChild(hint);
|
||||
}
|
||||
|
||||
if (status === 'locked') {
|
||||
node.disabled = true;
|
||||
node.setAttribute('aria-disabled', 'true');
|
||||
node.title = 'Заблокировано — собери больше звёзд в предыдущих уровнях';
|
||||
} else {
|
||||
node.title = level.title + (status === 'completed' ? ' — пройдено' : ' — играть');
|
||||
node.addEventListener('click', function () { onPlay(level); });
|
||||
}
|
||||
node.setAttribute('aria-label', level.title + ' (' +
|
||||
(status === 'locked' ? 'заблокировано' : status === 'completed' ? ('пройдено, ' + stars + ' из ' + total + ' звёзд') : 'доступно') + ')');
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function staggerReveal(nodes) {
|
||||
nodes.forEach(function (n) { n.classList.add('qm-pre'); });
|
||||
var i = 0;
|
||||
function step() {
|
||||
if (i >= nodes.length) { revealTimer = null; return; }
|
||||
nodes[i].classList.remove('qm-pre');
|
||||
nodes[i].classList.add('qm-in');
|
||||
i++;
|
||||
revealTimer = setTimeout(step, 70);
|
||||
}
|
||||
revealTimer = setTimeout(step, 120);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function render(progressMap) {
|
||||
progressMap = progressMap || {};
|
||||
renderHeader(progressMap);
|
||||
renderMap(progressMap);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
clearReveal();
|
||||
if (host) host.innerHTML = '';
|
||||
if (headerHost) headerHost.innerHTML = '';
|
||||
}
|
||||
|
||||
return { render: render, destroy: destroy };
|
||||
}
|
||||
|
||||
global.QuantikMap = { create: create };
|
||||
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -0,0 +1,194 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Квантик — Законы Мира · ЧИСТАЯ логика прогресса (Фаза 2).
|
||||
|
||||
Никакого DOM/сети/движка — только функции над данными. Это делает их
|
||||
тривиально тестируемыми (headless vm) и переносимыми на сервер позже.
|
||||
|
||||
ВХОД везде:
|
||||
levels — массив записей уровней (форма QuantikLevels): { id, chapter,
|
||||
order, par_ms?, unlockStars?, ... }.
|
||||
progressMap — объект { [level_id]: { best_stars, best_time_ms, attempts } },
|
||||
агрегируется из LS.gameProgressList() (см. fromProgressList).
|
||||
|
||||
⛔ Без eval/Function. Без побочных эффектов.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
|
||||
/* Превратить ответ /api/game/progress ([{level_id, best_stars, ...}]) в карту. */
|
||||
function fromProgressList(list) {
|
||||
var map = {};
|
||||
if (!Array.isArray(list)) return map;
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var row = list[i];
|
||||
if (row && row.level_id != null) map[row.level_id] = row;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/* Лучшее число звёзд по уровню (0, если не пройден). */
|
||||
function starsFor(levelId, progressMap) {
|
||||
var p = progressMap && progressMap[levelId];
|
||||
var s = p ? p.best_stars : 0;
|
||||
return (typeof s === 'number' && s > 0) ? s : 0;
|
||||
}
|
||||
|
||||
/* Пройден ли уровень (есть хотя бы одна звезда == достигнута цель). */
|
||||
function isCompleted(levelId, progressMap) {
|
||||
return starsFor(levelId, progressMap) > 0;
|
||||
}
|
||||
|
||||
/* Сумма лучших звёзд по всем уровням. */
|
||||
function totalStars(levels, progressMap) {
|
||||
var sum = 0;
|
||||
for (var i = 0; i < levels.length; i++) sum += starsFor(levels[i].id, progressMap);
|
||||
return sum;
|
||||
}
|
||||
|
||||
/* ── Разблокировка ────────────────────────────────────────────────────────
|
||||
Уровень открыт, если СУММА звёзд во ВСЕХ уровнях с меньшим ГЛОБАЛЬНЫМ order
|
||||
(по всем главам, не только текущей) ≥ level.unlockStars. Уровень с
|
||||
unlockStars==0 (или без него) открыт всегда. Так первый уровень главы
|
||||
гейтится суммой звёзд всех предыдущих глав через свой порог unlockStars.
|
||||
|
||||
Чистая функция: вход — уровень + карта прогресса + ВЕСЬ список (для подсчёта
|
||||
«предыдущих» по order). Возвращает bool. */
|
||||
function isUnlocked(level, progressMap, levels) {
|
||||
if (!level) return false;
|
||||
var need = (typeof level.unlockStars === 'number') ? level.unlockStars : 0;
|
||||
if (need <= 0) return true; // нет порога — всегда доступен
|
||||
// звёзды, набранные во всех уровнях с меньшим глобальным order
|
||||
var have = 0;
|
||||
for (var i = 0; i < levels.length; i++) {
|
||||
var L = levels[i];
|
||||
if (L.id === level.id) continue;
|
||||
if ((L.order || 0) < (level.order || 0)) {
|
||||
have += starsFor(L.id, progressMap);
|
||||
}
|
||||
}
|
||||
return have >= need;
|
||||
}
|
||||
|
||||
/* Статус узла для карты: 'completed' | 'available' | 'locked'. */
|
||||
function nodeStatus(level, progressMap, levels) {
|
||||
if (isCompleted(level.id, progressMap)) return 'completed';
|
||||
if (isUnlocked(level, progressMap, levels)) return 'available';
|
||||
return 'locked';
|
||||
}
|
||||
|
||||
/* ── XP ────────────────────────────────────────────────────────────────────
|
||||
XP = сумма (звёзды × STAR_XP) + бонус за каждый пройденный уровень
|
||||
(COMPLETE_XP) + бонус за «par» (3-я звезда == уложился в норматив времени,
|
||||
она и так считается звездой; дополнительный PAR_BONUS за первое прохождение
|
||||
уровня в принципе). Детерминированная функция от карты прогресса. */
|
||||
var STAR_XP = 100; // за каждую звезду
|
||||
var COMPLETE_XP = 40; // за факт прохождения уровня (≥1 звезда)
|
||||
|
||||
function computeXp(levels, progressMap) {
|
||||
var xp = 0;
|
||||
for (var i = 0; i < levels.length; i++) {
|
||||
var id = levels[i].id;
|
||||
var s = starsFor(id, progressMap);
|
||||
if (s > 0) {
|
||||
xp += s * STAR_XP + COMPLETE_XP;
|
||||
}
|
||||
}
|
||||
return xp;
|
||||
}
|
||||
|
||||
/* ── Уровень игрока ──────────────────────────────────────────────────────
|
||||
Порог уровня растёт линейно-нарастающе: уровень N требует кумулятивно
|
||||
XP_PER_LEVEL_BASE·N·(N+1)/2 … упрощаем до квадратичной обратной формулы.
|
||||
playerLevel(xp) -> { level, xpInto, xpForNext, progress01, totalForLevel }.
|
||||
level начинается с 1. */
|
||||
var XP_STEP = 240; // базовый шаг XP (level n требует n*XP_STEP суммарно для перехода)
|
||||
|
||||
// Кумулятивный XP, нужный чтобы ДОСТИЧЬ уровня L (L>=1). level 1 = 0 XP.
|
||||
function xpForLevel(L) {
|
||||
if (L <= 1) return 0;
|
||||
// сумма k=1..L-1 of k*XP_STEP = XP_STEP * (L-1)*L/2
|
||||
return XP_STEP * (L - 1) * L / 2;
|
||||
}
|
||||
|
||||
function playerLevel(xp) {
|
||||
if (!(xp > 0)) xp = 0;
|
||||
var L = 1;
|
||||
// найти максимальный L, чей порог <= xp
|
||||
while (xpForLevel(L + 1) <= xp) L++;
|
||||
var base = xpForLevel(L);
|
||||
var next = xpForLevel(L + 1);
|
||||
var span = next - base;
|
||||
var into = xp - base;
|
||||
return {
|
||||
level: L,
|
||||
xp: xp,
|
||||
xpInto: into,
|
||||
xpForNext: span,
|
||||
totalForNext: next,
|
||||
progress01: span > 0 ? Math.min(1, into / span) : 1
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Группировка по главам ────────────────────────────────────────────────
|
||||
Возвращает массив { chapter, levels:[...] } в порядке появления глав;
|
||||
уровни внутри главы сортируются по order. */
|
||||
function groupByChapter(levels) {
|
||||
var order = [];
|
||||
var byKey = {};
|
||||
for (var i = 0; i < levels.length; i++) {
|
||||
var L = levels[i];
|
||||
var key = L.chapter || 'misc';
|
||||
if (!byKey[key]) { byKey[key] = { chapter: key, levels: [] }; order.push(key); }
|
||||
byKey[key].levels.push(L);
|
||||
}
|
||||
return order.map(function (k) {
|
||||
var g = byKey[k];
|
||||
g.levels = g.levels.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
|
||||
return g;
|
||||
});
|
||||
}
|
||||
|
||||
/* Следующий разблокированный непройденный уровень после данного (по глоб. order),
|
||||
или null. Используется кнопкой «Дальше». */
|
||||
function nextPlayable(currentId, levels, progressMap) {
|
||||
var sorted = levels.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
|
||||
var idx = -1;
|
||||
for (var i = 0; i < sorted.length; i++) if (sorted[i].id === currentId) { idx = i; break; }
|
||||
// сначала ищем следующий по порядку доступный (предпочтительно непройденный)
|
||||
for (var j = idx + 1; j < sorted.length; j++) {
|
||||
if (isUnlocked(sorted[j], progressMap, levels)) return sorted[j];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Сколько ещё звёзд нужно, чтобы открыть уровень (для подсказки на замке). */
|
||||
function starsToUnlock(level, progressMap, levels) {
|
||||
var need = (typeof level.unlockStars === 'number') ? level.unlockStars : 0;
|
||||
if (need <= 0) return 0;
|
||||
var have = 0;
|
||||
for (var i = 0; i < levels.length; i++) {
|
||||
var L = levels[i];
|
||||
if (L.id === level.id) continue;
|
||||
if ((L.order || 0) < (level.order || 0)) have += starsFor(L.id, progressMap);
|
||||
}
|
||||
return Math.max(0, need - have);
|
||||
}
|
||||
|
||||
global.QuantikProgress = {
|
||||
fromProgressList: fromProgressList,
|
||||
starsFor: starsFor,
|
||||
isCompleted: isCompleted,
|
||||
totalStars: totalStars,
|
||||
isUnlocked: isUnlocked,
|
||||
nodeStatus: nodeStatus,
|
||||
computeXp: computeXp,
|
||||
playerLevel: playerLevel,
|
||||
xpForLevel: xpForLevel,
|
||||
groupByChapter: groupByChapter,
|
||||
nextPlayable: nextPlayable,
|
||||
starsToUnlock: starsToUnlock,
|
||||
// константы (для отображения/тестов)
|
||||
STAR_XP: STAR_XP, COMPLETE_XP: COMPLETE_XP, XP_STEP: XP_STEP
|
||||
};
|
||||
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/* ── 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', '×');
|
||||
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);
|
||||
@@ -0,0 +1,316 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Квантик — Законы Мира · логика игрового уровня (Фаза 2).
|
||||
|
||||
Монтирует уровень-спеку через SimEngine.mount (тот же движок, что lab.html
|
||||
и sim-builder.html). «Игровой режим» включается САМ наличием блока goal в
|
||||
спеке (Фаза 0). На победу (inst.onGoal) шлём результат на сервер и показываем
|
||||
экран успеха с нарратором-Квантиком; реакция нарратора зависит от числа звёзд.
|
||||
|
||||
Фаза 2:
|
||||
- Скин Квантика (colorKey из палитр PetSprite, localStorage 'quantik-skin')
|
||||
тинтует glow-точку героя в уровне и нарратора.
|
||||
- Экран успеха активирует «Дальше» (переход к следующему уровню) через колбэк.
|
||||
- Интро-карточка с нарратором перед стартом уровня.
|
||||
|
||||
window.QuantikGame.start({ host, level, skin?, onNext?, onMap?, hasNext?, resolveNext? }) -> инстанс.
|
||||
⛔ Без eval/Function. Уровни — данные из window.QuantikLevels.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
|
||||
var doc = global.document;
|
||||
var SKIN_KEY = 'quantik-skin';
|
||||
var DEFAULT_SKIN = 'cyan';
|
||||
|
||||
function el(tag, cls, html) {
|
||||
var n = doc.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (html != null) n.innerHTML = html;
|
||||
return n;
|
||||
}
|
||||
|
||||
/* ── Скин ──────────────────────────────────────────────────────────────── */
|
||||
function getSkin() {
|
||||
try {
|
||||
var v = global.localStorage && global.localStorage.getItem(SKIN_KEY);
|
||||
if (v && global.PetSprite && global.PetSprite.PALETTES && global.PetSprite.PALETTES[v]) return v;
|
||||
} catch (_e) {}
|
||||
return DEFAULT_SKIN;
|
||||
}
|
||||
function setSkin(key) {
|
||||
try { if (global.localStorage) global.localStorage.setItem(SKIN_KEY, key); } catch (_e) {}
|
||||
}
|
||||
function skinColor(key) {
|
||||
var pal = (global.PetSprite && global.PetSprite.PALETTES) || {};
|
||||
return pal[key || getSkin()] || '#06D6E0';
|
||||
}
|
||||
|
||||
/* Тинтуем героя уровня (объект с id 'ball') цветом скина — БЕЗ исполнения,
|
||||
просто переписываем цветовые поля спеки-копии перед монтированием.
|
||||
Фаза 4: вторую копию суперпозиции (id 'ball2') тоже тинтуем, но осветлённым
|
||||
«фантомным» оттенком (полупрозрачность задаётся самой спекой). */
|
||||
function tintHeroSpec(spec, skinKey) {
|
||||
var color = skinColor(skinKey);
|
||||
var phantom = lighten(color, 0.42);
|
||||
// глубокая копия (спека — данные, без функций) чтобы не мутировать реестр
|
||||
var copy = JSON.parse(JSON.stringify(spec));
|
||||
if (Array.isArray(copy.objects)) {
|
||||
for (var i = 0; i < copy.objects.length; i++) {
|
||||
var o = copy.objects[i];
|
||||
if (!o) continue;
|
||||
if (o.id === 'ball') {
|
||||
o.color = color;
|
||||
if (o.glow) o.glowColor = 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;
|
||||
}
|
||||
|
||||
/* Осветлить 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 звезды ── */
|
||||
function starSvg(filled) {
|
||||
var fill = filled ? '#FBBF24' : 'none';
|
||||
var stroke = filled ? '#FBBF24' : '#64748B';
|
||||
// Цвета через inline style: .ic в ls.css (fill:none; stroke:currentColor) иначе
|
||||
// перебивает атрибуты 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>';
|
||||
}
|
||||
|
||||
function fmtTime(ms) {
|
||||
if (!ms && ms !== 0) return '—';
|
||||
return (ms / 1000).toFixed(2) + ' с';
|
||||
}
|
||||
|
||||
function petSvg(mood, skinKey) {
|
||||
if (!global.PetSprite) return '';
|
||||
return global.PetSprite.render(4, mood, [], skinKey || getSkin(), 0, 'none');
|
||||
}
|
||||
|
||||
/* ── Интро-карточка уровня (нарратор «почини закон…») ───────────────────── */
|
||||
function buildIntro(level, skinKey) {
|
||||
var overlay = el('div', 'qg-overlay qg-intro');
|
||||
var card = el('div', 'qg-card qg-card-intro');
|
||||
|
||||
var pet = el('div', 'qg-intro-pet', petSvg('happy', skinKey));
|
||||
card.appendChild(pet);
|
||||
|
||||
card.appendChild(el('div', 'qg-card-kicker', 'Почини закон'));
|
||||
card.appendChild(el('div', 'qg-card-title', escapeText(level.title)));
|
||||
var goalT = (level.spec && level.spec.goal && level.spec.goal.title) || '';
|
||||
if (goalT) card.appendChild(el('div', 'qg-intro-goal', escapeText(goalT)));
|
||||
if (level.hint) card.appendChild(el('div', 'qg-intro-hint', escapeText(level.hint)));
|
||||
|
||||
var actions = el('div', 'qg-actions');
|
||||
var btnGo = el('button', 'btn-primary qg-btn', 'Начать');
|
||||
btnGo.type = 'button';
|
||||
var btnBack = el('button', 'btn-ghost qg-btn', 'К карте');
|
||||
btnBack.type = 'button';
|
||||
actions.appendChild(btnGo);
|
||||
actions.appendChild(btnBack);
|
||||
card.appendChild(actions);
|
||||
|
||||
overlay.appendChild(card);
|
||||
return { overlay: overlay, btnGo: btnGo, btnBack: btnBack };
|
||||
}
|
||||
|
||||
/* ── Экран успеха ───────────────────────────────────────────────────────── */
|
||||
function buildSuccessOverlay(state, ctx) {
|
||||
ctx = ctx || {};
|
||||
var got = (state && state.stars && state.stars.got) || 0;
|
||||
var total = (state && state.stars && state.stars.total) || 0;
|
||||
|
||||
var overlay = el('div', 'qg-overlay');
|
||||
var card = el('div', 'qg-card');
|
||||
|
||||
// нарратор: все звёзды (>=2) -> ecstatic, иначе happy
|
||||
var mood = (total > 0 && got >= total && total >= 2) ? 'ecstatic' : (got >= 1 ? 'happy' : 'neutral');
|
||||
if (global.PetSprite) {
|
||||
var pet = el('div', 'qg-success-pet', petSvg(mood, ctx.skin));
|
||||
card.appendChild(pet);
|
||||
}
|
||||
|
||||
card.appendChild(el('div', 'qg-card-title', 'Уровень пройден!'));
|
||||
|
||||
var starsBox = el('div', 'qg-stars');
|
||||
var slots = Math.max(total, got, 1);
|
||||
for (var i = 0; i < slots; i++) {
|
||||
var w = el('span', 'qg-star' + (i < got ? ' qg-star-on' : ''));
|
||||
w.style.setProperty('--si', i);
|
||||
w.innerHTML = starSvg(i < got);
|
||||
starsBox.appendChild(w);
|
||||
}
|
||||
card.appendChild(starsBox);
|
||||
|
||||
var stats = el('div', 'qg-stats');
|
||||
stats.appendChild(el('div', 'qg-stat',
|
||||
'<span class="qg-stat-lbl">Время</span><span class="qg-stat-val">' + fmtTime(state && state.timeMs) + '</span>'));
|
||||
stats.appendChild(el('div', 'qg-stat',
|
||||
'<span class="qg-stat-lbl">Звёзды</span><span class="qg-stat-val">' + got + ' / ' + (total || slots) + '</span>'));
|
||||
stats.appendChild(el('div', 'qg-stat',
|
||||
'<span class="qg-stat-lbl">Попытки</span><span class="qg-stat-val">' + ((state && state.attempts) || 0) + '</span>'));
|
||||
card.appendChild(stats);
|
||||
|
||||
var actions = el('div', 'qg-actions');
|
||||
var btnAgain = el('button', 'btn-ghost qg-btn', 'Ещё раз');
|
||||
btnAgain.type = 'button';
|
||||
var btnNext = el('button', 'btn-primary qg-btn', ctx.hasNext ? 'Дальше' : 'К карте');
|
||||
btnNext.type = 'button';
|
||||
actions.appendChild(btnAgain);
|
||||
actions.appendChild(btnNext);
|
||||
card.appendChild(actions);
|
||||
|
||||
overlay.appendChild(card);
|
||||
return { overlay: overlay, btnAgain: btnAgain, btnNext: btnNext };
|
||||
}
|
||||
|
||||
function escapeText(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/* ── Открыть 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? }
|
||||
resolveNext?() -> Promise<{ hasNext, next }>: пересчитать следующий уровень
|
||||
ПОСЛЕ перезагрузки прогресса (победа разблокирует след. уровень). Если не
|
||||
задан / упал — откатываемся к pre-win opts.hasNext (ровно прежнее поведение). */
|
||||
function start(opts) {
|
||||
opts = opts || {};
|
||||
var host = opts.host;
|
||||
var level = opts.level;
|
||||
if (!host || !level || !level.spec) return null;
|
||||
if (!global.SimEngine || !global.SimExpr) return null;
|
||||
|
||||
var skin = opts.skin || getSkin();
|
||||
var spec = tintHeroSpec(level.spec, skin);
|
||||
var inst = global.SimEngine.mount(host, spec);
|
||||
|
||||
// ── Панель квантовых способностей + 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;
|
||||
function clearOverlay() {
|
||||
if (overlayRef && overlayRef.overlay && overlayRef.overlay.parentNode) {
|
||||
overlayRef.overlay.parentNode.removeChild(overlayRef.overlay);
|
||||
}
|
||||
overlayRef = null;
|
||||
}
|
||||
|
||||
// submitDone — promise сабмита прогресса (или null, если сабмита нет).
|
||||
// Экран успеха показываем СРАЗУ (без ожидания сети) с pre-win hasNext, затем
|
||||
// ОБНОВЛЯЕМ кнопку «Дальше/К карте», когда пересчёт после победы (resolveNext)
|
||||
// увидит свежеразблокированный уровень. Это чинит «мёртвую Дальше» на первом
|
||||
// прохождении (0 звёзд → доступен только L1 → pre-win nextPlayable == null).
|
||||
function showSuccess(state, submitDone) {
|
||||
clearOverlay();
|
||||
// Текущее решение кнопки. Замыкания ниже читают его «живьём» (мутируем var),
|
||||
// поэтому если игрок успеет нажать раньше пересчёта — отработает фолбэк,
|
||||
// а после пересчёта та же кнопка уже ведёт «Дальше».
|
||||
var canNext = typeof opts.onNext === 'function' && !!opts.hasNext;
|
||||
overlayRef = buildSuccessOverlay(state, { skin: skin, hasNext: canNext });
|
||||
overlayRef.btnAgain.addEventListener('click', function () {
|
||||
clearOverlay();
|
||||
try { inst.reset(); } catch (_e) {}
|
||||
if (abilities) try { abilities.resetAbilities(); } catch (_e) {}
|
||||
});
|
||||
overlayRef.btnNext.addEventListener('click', function () {
|
||||
clearOverlay();
|
||||
if (canNext) opts.onNext(level);
|
||||
else if (typeof opts.onMap === 'function') opts.onMap();
|
||||
});
|
||||
host.appendChild(overlayRef.overlay);
|
||||
|
||||
if (typeof opts.resolveNext !== 'function') return;
|
||||
var btn = overlayRef.btnNext;
|
||||
// Пересчёт идёт ПОСЛЕ сабмита: победа сначала сохраняется на сервере, и только
|
||||
// затем перезагрузка прогресса увидит разблокированный уровень.
|
||||
Promise.resolve(submitDone)
|
||||
.catch(function () {}) // сабмит best-effort: даже при ошибке пробуем пересчёт
|
||||
.then(function () { return opts.resolveNext(); })
|
||||
.then(function (r) {
|
||||
// overlayRef мог смениться/закрыться, пока шла сеть — обновляем только «свою» кнопку.
|
||||
if (!r || !overlayRef || overlayRef.btnNext !== btn) return;
|
||||
var next = typeof opts.onNext === 'function' && !!r.hasNext;
|
||||
if (next === canNext) return; // ничего не изменилось
|
||||
canNext = next;
|
||||
btn.textContent = next ? 'Дальше' : 'К карте';
|
||||
})
|
||||
.catch(function () {}); // пересчёт упал → остаёмся на pre-win решении
|
||||
}
|
||||
|
||||
inst.onGoal(function (res) {
|
||||
if (!res || !res.won) return;
|
||||
var got = (res.stars && res.stars.got) || 0;
|
||||
var payload = { time_ms: res.timeMs, stars: got };
|
||||
var submitDone = null;
|
||||
try {
|
||||
if (global.LS && global.LS.gameProgressSubmit) {
|
||||
submitDone = global.LS.gameProgressSubmit(level.id, payload);
|
||||
if (submitDone && typeof submitDone.catch === 'function') submitDone.catch(function () {});
|
||||
}
|
||||
} catch (_e) {}
|
||||
showSuccess(res, submitDone);
|
||||
});
|
||||
|
||||
return inst;
|
||||
}
|
||||
|
||||
global.QuantikGame = {
|
||||
start: start,
|
||||
buildSuccessOverlay: buildSuccessOverlay,
|
||||
buildIntro: buildIntro,
|
||||
getSkin: getSkin,
|
||||
setSkin: setSkin,
|
||||
skinColor: skinColor,
|
||||
SKIN_KEY: SKIN_KEY
|
||||
};
|
||||
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -44,7 +44,9 @@
|
||||
range:[a,b], // отрезок построения (деф. xmin..xmax)
|
||||
samples?:200, // число точек (деф. 200, клампится)
|
||||
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)
|
||||
color?, width? }, // (x1/y1/x2/y2 тоже поддерживаются)
|
||||
{ type:'readout', // живой числовой бейдж
|
||||
@@ -79,13 +81,61 @@
|
||||
{ a:'ballId'|[x,y], b:'ballId'|[x,y], // концы: id тела ИЛИ якорь-точка
|
||||
k:40, length:2, damping?:0.5 }
|
||||
]
|
||||
},
|
||||
|
||||
// ── ЦЕЛЬ / ИГРА (Квантик, Фаза 0) ── декларативный слой победы.
|
||||
// Аддитивно: спека БЕЗ goal ведёт себя как раньше (нет HUD, нет вычислений побед).
|
||||
goal: {
|
||||
when: '<bool expr>', // SimExpr: победа, когда станет истинным (≠0)
|
||||
title?: 'Цель уровня', // краткая формулировка цели для HUD (escape на сервере)
|
||||
hint?: 'текст подсказки', // показывается в HUD (escape на сервере)
|
||||
hold?: 0, // сек: сколько when должно держаться непрерывно (деф. 0)
|
||||
fail?: '<bool expr>', // опц.: мягкий проигрыш (вышел за поле/задел шип)
|
||||
stars?: [ // 0..3 доп.условий-«звёзд» (бонусы, «залипают» до reset)
|
||||
{ when:'<bool expr>', label?:'...' }
|
||||
]
|
||||
}
|
||||
// game?: {...} — зарезервированный блок мета-слоя (Фаза 1/5); сервер его пропускает.
|
||||
|
||||
// ── ГРАФ-УРОВНИ (Квантик, Фаза 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 (мир-размер вьюпорта), а также
|
||||
<objId>.x / <objId>.y для объектов, у которых заданы числовые/выраж. x,y.
|
||||
Для физических тел (body) в env кладутся <objId>.x/.y/.vx/.vy ИЗ СОСТОЯНИЯ
|
||||
интегратора (а не из выражения) — это снимает проблему forward-ref однопроходного
|
||||
env для тел: их позиция/скорость не пересчитываются формулой каждый кадр.
|
||||
Выражения цели (goal.when/fail/stars[].when) видят ВЕСЬ env кадра ПЛЮС `tries`
|
||||
(число пользовательских reset с начала). Граф-уровни (Ф3) добавляют ИМЕНОВАННЫЕ
|
||||
булевы/числовые env-поля: <plotId>.runX/.runY/.runDone (бегунок) и <zoneId>.hit
|
||||
(попадание в зону). Это данные env, а не функции синтаксиса — контракт выражений
|
||||
остаётся закрытым (никаких inzone()/предикатов). Новых небезопасных идентификаторов нет.
|
||||
|
||||
── ИНТЕРАКЦИИ (Фаза 1) ──────────────────────────────────────────────────
|
||||
Объект с полем drag:{param, axis, min?, max?, paramY?} становится ручкой:
|
||||
@@ -103,6 +153,10 @@
|
||||
inst.isRunning() -> bool
|
||||
inst.destroy()
|
||||
inst.el -> корневой DOM-узел (для скрытия/показа адаптером)
|
||||
// ── цель/игра (Фаза 0) ──
|
||||
inst.onGoal(cb) -> подписка: cb(getResult()) при первой победе
|
||||
inst.getResult() -> { won, failed, timeMs, attempts, stars:{got,total} }
|
||||
inst.resetResult() -> сбросить состояние результата (как новый уровень)
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
|
||||
@@ -362,6 +416,12 @@
|
||||
this._phys = null; // состояние интегратора { bodies, springs, walls, opts, dt, acc }
|
||||
this._bodyById = {}; // objId -> body (для drag/env/пружин)
|
||||
this._dragBody = null; // активный захват физ-тела { body, lastW, lastT, vx, vy }
|
||||
// ── цель/игра (Фаза 0 «Квантик») ──
|
||||
this._goal = null; // скомпилированный блок цели { whenFn, failFn, hold, stars:[{fn,label}], title, hint } | null
|
||||
this._goalState = null; // { won, failed, timeMs, attempts, starsGot:[], firstWinT } | null (только при наличии goal)
|
||||
this._goalHoldT = 0; // сколько секунд (мирового t) условие when держится непрерывно
|
||||
this._goalCbs = []; // подписчики onGoal
|
||||
this._hud = null; // DOM-узлы HUD-оверлея (только при наличии goal)
|
||||
this._build();
|
||||
}
|
||||
|
||||
@@ -502,6 +562,11 @@
|
||||
// подготовить объекты (компиляция привязок один раз)
|
||||
this._prepareObjects();
|
||||
|
||||
// подготовить цель/игру (компиляция when/fail/stars один раз) + HUD-оверлей.
|
||||
// Аддитивно: при отсутствии goal в спеке _goal остаётся null и HUD не создаётся.
|
||||
this._prepareGoal();
|
||||
if (this._goal) this._buildHud(stage);
|
||||
|
||||
// resize
|
||||
if (global.ResizeObserver) {
|
||||
this._ro = new ResizeObserver(function () { self._fit(); self._renderFrame(); });
|
||||
@@ -701,6 +766,25 @@
|
||||
// легенда: показывать, если есть хотя бы одна подпись (можно явно legend:false)
|
||||
var anyLabel = prep.curves.some(function (c) { return !!c.label; });
|
||||
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') {
|
||||
// компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка)
|
||||
var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0')
|
||||
@@ -742,13 +826,216 @@
|
||||
}
|
||||
|
||||
// привязки для центра объекта (для 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);
|
||||
}
|
||||
this._objs = out;
|
||||
};
|
||||
|
||||
/* ════════════════════ Цель / игра (Фаза 0 «Квантик») ════════════════════
|
||||
Декларативный слой победы: булевы SimExpr-выражения, компилируемые ОДИН РАЗ
|
||||
(как все выражения движка). В rAF после построения env — оценка. Безопасно:
|
||||
никакого eval, выражения исполняет SimExpr (кривое выражение -> 0, не бросает). */
|
||||
|
||||
/* Скомпилировать блок goal (when/fail/каждое stars[].when) один раз при mount.
|
||||
Спека без goal -> _goal остаётся null (полная аддитивность). */
|
||||
SimEngineInstance.prototype._prepareGoal = function () {
|
||||
var g = this.spec.goal;
|
||||
if (!g || typeof g !== 'object' || Array.isArray(g)) { this._goal = null; this._goalState = null; return; }
|
||||
var compile = (global.SimExpr && global.SimExpr.compile)
|
||||
? global.SimExpr.compile
|
||||
: function () { return { fn: function () { return 0; }, ast: null, error: null }; };
|
||||
|
||||
var whenC = compile(g.when != null ? g.when : '0');
|
||||
var failC = (g.fail != null) ? compile(g.fail) : null;
|
||||
var rawStars = Array.isArray(g.stars) ? g.stars.slice(0, 3) : []; // не более 3 звёзд
|
||||
var stars = rawStars.map(function (s) {
|
||||
s = (s && typeof s === 'object') ? s : { when: s };
|
||||
var c = compile(s.when != null ? s.when : '0');
|
||||
return { fn: c.fn, label: (s.label != null) ? String(s.label) : '' };
|
||||
});
|
||||
|
||||
this._goal = {
|
||||
whenFn: whenC.fn,
|
||||
failFn: failC ? failC.fn : null,
|
||||
hold: (typeof g.hold === 'number' && isFinite(g.hold) && g.hold > 0) ? g.hold : 0,
|
||||
stars: stars,
|
||||
title: (g.title != null) ? String(g.title) : '',
|
||||
hint: (g.hint != null) ? String(g.hint) : ''
|
||||
};
|
||||
// первичное состояние результата (attempts=0; первый mount/авто-reset попыткой не считается)
|
||||
this._goalState = {
|
||||
won: false, failed: false, timeMs: 0,
|
||||
attempts: 0, starsGot: stars.map(function () { return false; }), firstWinT: null
|
||||
};
|
||||
this._goalHoldT = 0;
|
||||
};
|
||||
|
||||
/* Оценить цель за кадр (после построения env и шага физики). Накапливает звёзды,
|
||||
проверяет fail (мягкий проигрыш), then when с учётом hold (удержание). При победе
|
||||
фиксирует timeMs (мировое t, детерминизм), ставит won, ставит на паузу, дёргает onGoal. */
|
||||
SimEngineInstance.prototype._evalGoal = function (env, dt) {
|
||||
var g = this._goal, st = this._goalState;
|
||||
if (!g || !st) return;
|
||||
// tries — число пользовательских reset; добавляем ТОЛЬКО его (безопасность контракта).
|
||||
env.tries = st.attempts;
|
||||
|
||||
// звёзды «залипают»: однажды истинное условие остаётся засчитанным до reset.
|
||||
for (var i = 0; i < g.stars.length; i++) {
|
||||
if (!st.starsGot[i] && _truthy(g.stars[i].fn(env))) st.starsGot[i] = true;
|
||||
}
|
||||
|
||||
if (st.won || st.failed) return; // итог зафиксирован — больше не пересчитываем
|
||||
|
||||
// мягкий проигрыш: fail имеет приоритет над when (НЕ победа)
|
||||
if (g.failFn && _truthy(g.failFn(env))) {
|
||||
st.failed = true;
|
||||
this._goalHoldT = 0;
|
||||
this.pause();
|
||||
this._renderHud();
|
||||
return;
|
||||
}
|
||||
|
||||
// победа: when (с учётом hold — условие должно держаться hold секунд)
|
||||
if (_truthy(g.whenFn(env))) {
|
||||
this._goalHoldT += (typeof dt === 'number' && dt > 0) ? dt : 0;
|
||||
if (this._goalHoldT >= g.hold) {
|
||||
st.won = true;
|
||||
st.firstWinT = this._t;
|
||||
// время победы: мировое t от старта уровня (детерминизм, headless-тест)
|
||||
st.timeMs = Math.max(1, Math.round(this._t * 1000));
|
||||
this.pause();
|
||||
this._fireGoal();
|
||||
this._renderHud();
|
||||
}
|
||||
} else {
|
||||
this._goalHoldT = 0; // условие пропало до удержания — сброс таймера
|
||||
}
|
||||
};
|
||||
|
||||
/* Вызвать onGoal-подписчиков один раз (после первой победы). */
|
||||
SimEngineInstance.prototype._fireGoal = function () {
|
||||
var res = this.getResult();
|
||||
var cbs = this._goalCbs.slice();
|
||||
for (var i = 0; i < cbs.length; i++) {
|
||||
try { cbs[i](res); } catch (e) { /* подписчик не должен ронять цикл */ }
|
||||
}
|
||||
};
|
||||
|
||||
/* ════════════════════ HUD цели (DOM-оверлей) ════════════════════
|
||||
Появляется ТОЛЬКО при наличии goal. Контейнер — pointer-events:none (не крадёт
|
||||
pan/drag сцены), интерактивные кнопки — pointer-events:auto. Стиль — тёмная
|
||||
плашка как у readout-бейджей. Без эмодзи: звёзды/иконки — inline SVG. */
|
||||
SimEngineInstance.prototype._buildHud = function (stage) {
|
||||
var self = this;
|
||||
var hud = {};
|
||||
|
||||
// ── верхняя плашка: цель + звёзды (по центру сверху) ──
|
||||
var top = document.createElement('div');
|
||||
top.style.cssText = 'position:absolute;left:50%;top:10px;transform:translateX(-50%);z-index:6;' +
|
||||
'pointer-events:none;display:flex;flex-direction:column;gap:5px;align-items:center;max-width:80%';
|
||||
|
||||
var objLine = document.createElement('div');
|
||||
objLine.style.cssText = 'display:flex;align-items:center;gap:8px;' + _readoutBadgeCss('#fff') +
|
||||
';font-size:.82rem;font-weight:600;pointer-events:none';
|
||||
var titleSpan = document.createElement('span');
|
||||
var starsWrap = document.createElement('span');
|
||||
starsWrap.style.cssText = 'display:inline-flex;gap:3px;align-items:center';
|
||||
objLine.appendChild(titleSpan);
|
||||
objLine.appendChild(starsWrap);
|
||||
top.appendChild(objLine);
|
||||
hud.titleSpan = titleSpan;
|
||||
hud.starsWrap = starsWrap;
|
||||
|
||||
var hintEl = document.createElement('div');
|
||||
hintEl.style.cssText = _readoutBadgeCss('rgba(255,255,255,0.72)') +
|
||||
';font-size:.74rem;pointer-events:none;max-width:100%;white-space:normal;text-align:center';
|
||||
top.appendChild(hintEl);
|
||||
hud.hintEl = hintEl;
|
||||
|
||||
stage.appendChild(top);
|
||||
hud.top = top;
|
||||
|
||||
// ── центральный баннер «Победа» / «Ещё раз» (скрыт по умолчанию) ──
|
||||
var banner = document.createElement('div');
|
||||
banner.style.cssText = 'position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:7;' +
|
||||
'display:none;flex-direction:column;align-items:center;gap:10px;pointer-events:none;' +
|
||||
'background:rgba(13,13,26,0.92);border:1px solid rgba(255,255,255,0.16);border-radius:16px;' +
|
||||
'padding:18px 24px;box-shadow:0 12px 40px rgba(0,0,0,0.5);text-align:center';
|
||||
var bannerTitle = document.createElement('div');
|
||||
bannerTitle.style.cssText = 'font-size:1.1rem;font-weight:800;letter-spacing:.3px';
|
||||
var bannerStars = document.createElement('div');
|
||||
bannerStars.style.cssText = 'display:flex;gap:4px;align-items:center';
|
||||
var btnRetry = this._btn(this._resetIcon(), 'Ещё раз');
|
||||
btnRetry.style.pointerEvents = 'auto';
|
||||
btnRetry.style.minWidth = '120px';
|
||||
btnRetry.innerHTML = this._resetIcon() + '<span style="margin-left:7px;font-weight:700">Ещё раз</span>';
|
||||
this._onHudRetry = function () { self.reset(); };
|
||||
btnRetry.addEventListener('click', this._onHudRetry);
|
||||
banner.appendChild(bannerTitle);
|
||||
banner.appendChild(bannerStars);
|
||||
banner.appendChild(btnRetry);
|
||||
stage.appendChild(banner);
|
||||
hud.banner = banner;
|
||||
hud.bannerTitle = bannerTitle;
|
||||
hud.bannerStars = bannerStars;
|
||||
hud.btnRetry = btnRetry;
|
||||
|
||||
this._hud = hud;
|
||||
this._renderHud();
|
||||
};
|
||||
|
||||
/* SVG-звезда: заполненная (got) или контурная (ещё не получена). Без эмодзи. */
|
||||
SimEngineInstance.prototype._starIcon = function (got, size) {
|
||||
var s = size || 15;
|
||||
var fill = got ? '#FBBF24' : 'none';
|
||||
var stroke = got ? '#FBBF24' : 'rgba(255,255,255,0.42)';
|
||||
return '<svg viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="' + fill +
|
||||
'" stroke="' + stroke + '" stroke-width="1.6" stroke-linejoin="round">' +
|
||||
'<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
|
||||
};
|
||||
|
||||
/* Перерисовать HUD по текущему состоянию цели (вызывается каждый кадр + при reset). */
|
||||
SimEngineInstance.prototype._renderHud = function () {
|
||||
var hud = this._hud, g = this._goal, st = this._goalState;
|
||||
if (!hud || !g || !st) return;
|
||||
|
||||
// строка цели
|
||||
hud.titleSpan.textContent = g.title || 'Цель';
|
||||
// индикаторы звёзд (только если есть звёзды)
|
||||
var starsHtml = '';
|
||||
for (var i = 0; i < g.stars.length; i++) starsHtml += this._starIcon(st.starsGot[i], 15);
|
||||
hud.starsWrap.innerHTML = starsHtml;
|
||||
|
||||
// подсказка
|
||||
if (g.hint) { hud.hintEl.style.display = ''; hud.hintEl.textContent = g.hint; }
|
||||
else hud.hintEl.style.display = 'none';
|
||||
|
||||
// баннер итога
|
||||
if (st.won || st.failed) {
|
||||
hud.banner.style.display = 'flex';
|
||||
if (st.won) {
|
||||
var got = 0;
|
||||
for (var k = 0; k < st.starsGot.length; k++) if (st.starsGot[k]) got++;
|
||||
hud.bannerTitle.textContent = 'Победа!';
|
||||
hud.bannerTitle.style.color = '#34D399';
|
||||
var bs = '';
|
||||
for (var j = 0; j < g.stars.length; j++) bs += this._starIcon(st.starsGot[j], 22);
|
||||
hud.bannerStars.innerHTML = bs;
|
||||
hud.bannerStars.style.display = g.stars.length ? 'flex' : 'none';
|
||||
} else {
|
||||
hud.bannerTitle.textContent = 'Не вышло';
|
||||
hud.bannerTitle.style.color = '#FB7185';
|
||||
hud.bannerStars.innerHTML = '';
|
||||
hud.bannerStars.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
hud.banner.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
/* ── физика: есть ли в спеке тела/включён ли интегратор ── */
|
||||
SimEngineInstance.prototype._physEnabled = function () {
|
||||
var ph = this.spec.physics;
|
||||
@@ -924,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++) {
|
||||
var o = this._objs[i];
|
||||
if (o.hasCenter && !o.body) {
|
||||
@@ -934,9 +1246,32 @@
|
||||
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;
|
||||
};
|
||||
|
||||
/* Внутри ли зоны 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 вверх) с сохранением пропорций ──
|
||||
Эффективный transform (_scale/_offX/_offY) = базовый fit (_baseScale/...) с
|
||||
наложенным пользовательским зумом/паном. _fit пересчитывает DPR/размер и базу;
|
||||
@@ -1295,6 +1630,17 @@
|
||||
for (var j = 0; j < this._objs.length; j++) {
|
||||
this._drawObject(ctx, this._objs[j], env);
|
||||
}
|
||||
|
||||
// HUD цели (звёзды могут засчитываться и на паузе/предпросмотре по текущему env)
|
||||
if (this._goal && this._goalState) {
|
||||
env.tries = this._goalState.attempts; // тот же доп. идентификатор, что в _evalGoal
|
||||
for (var gi = 0; gi < this._goal.stars.length; gi++) {
|
||||
if (!this._goalState.starsGot[gi] && _truthy(this._goal.stars[gi].fn(env))) {
|
||||
this._goalState.starsGot[gi] = true;
|
||||
}
|
||||
}
|
||||
this._renderHud();
|
||||
}
|
||||
};
|
||||
|
||||
/* пружины как зигзаг между концами (наглядно для маятника/осциллятора) */
|
||||
@@ -1527,6 +1873,50 @@
|
||||
this._drawReadout(o, env);
|
||||
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]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1945,6 +2335,8 @@
|
||||
}
|
||||
// продвинуть физику фиксированными подшагами (если есть)
|
||||
if (self._phys) self._stepPhysics(dt);
|
||||
// оценить цель после шага (env строится из актуального состояния); победа -> pause
|
||||
if (self._goal) self._evalGoal(self._buildEnv(), dt);
|
||||
self._renderFrame();
|
||||
self._raf = global.requestAnimationFrame(frame);
|
||||
}
|
||||
@@ -1974,9 +2366,31 @@
|
||||
this._trails = {};
|
||||
this._dragBody = null;
|
||||
this._preparePhysics(); // пересобрать тела/пружины с нач. условиями из params
|
||||
// сбросить состояние цели: attempts++ только на ПОЛЬЗОВАТЕЛЬСКОМ reset
|
||||
// (первый авто-reset при mount попыткой не считается).
|
||||
if (this._goalState) {
|
||||
var userReset = this._goalInited === true;
|
||||
this._goalInited = true;
|
||||
this._resetGoalState(userReset);
|
||||
} else {
|
||||
this._goalInited = true;
|
||||
}
|
||||
this._renderFrame();
|
||||
};
|
||||
|
||||
/* Сбросить состояние результата к началу уровня. bumpAttempt=true -> attempts++. */
|
||||
SimEngineInstance.prototype._resetGoalState = function (bumpAttempt) {
|
||||
if (!this._goal) return;
|
||||
var prevAttempts = this._goalState ? this._goalState.attempts : 0;
|
||||
this._goalState = {
|
||||
won: false, failed: false, timeMs: 0,
|
||||
attempts: prevAttempts + (bumpAttempt ? 1 : 0),
|
||||
starsGot: this._goal.stars.map(function () { return false; }),
|
||||
firstWinT: null
|
||||
};
|
||||
this._goalHoldT = 0;
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype.setParam = function (name, value) {
|
||||
var v = parseFloat(value);
|
||||
if (!isFinite(v)) return;
|
||||
@@ -1989,6 +2403,33 @@
|
||||
SimEngineInstance.prototype.getParam = function (name) { return this.params[name]; };
|
||||
SimEngineInstance.prototype.isRunning = function () { return this._running; };
|
||||
|
||||
/* ════════════════════ Цель / игра: публичное API ════════════════════ */
|
||||
/* Подписаться на победу: cb(getResult()) вызывается один раз при первой победе. */
|
||||
SimEngineInstance.prototype.onGoal = function (cb) {
|
||||
if (typeof cb === 'function') this._goalCbs.push(cb);
|
||||
return this;
|
||||
};
|
||||
/* Текущий результат уровня. Для спеки без goal -> null. */
|
||||
SimEngineInstance.prototype.getResult = function () {
|
||||
var st = this._goalState;
|
||||
if (!st) return null;
|
||||
var total = this._goal ? this._goal.stars.length : 0;
|
||||
var got = 0;
|
||||
for (var i = 0; i < st.starsGot.length; i++) if (st.starsGot[i]) got++;
|
||||
return {
|
||||
won: st.won, failed: st.failed, timeMs: st.timeMs,
|
||||
attempts: st.attempts, stars: { got: got, total: total }
|
||||
};
|
||||
};
|
||||
/* Сбросить результат (как новый уровень) — НЕ считается попыткой. */
|
||||
SimEngineInstance.prototype.resetResult = function () {
|
||||
if (!this._goal) return;
|
||||
var keep = this._goalState ? this._goalState.attempts : 0;
|
||||
this._resetGoalState(false);
|
||||
if (this._goalState) this._goalState.attempts = keep;
|
||||
this._renderHud();
|
||||
};
|
||||
|
||||
SimEngineInstance.prototype.destroy = function () {
|
||||
this.pause();
|
||||
this._destroyed = true;
|
||||
@@ -2018,6 +2459,19 @@
|
||||
this._dragBody = null;
|
||||
this._phys = null;
|
||||
this._bodyById = {};
|
||||
// снять HUD-слушатели/узлы (нет утечек — баланс add/removeEventListener)
|
||||
if (this._hud) {
|
||||
if (this._hud.btnRetry && this._onHudRetry) {
|
||||
this._hud.btnRetry.removeEventListener('click', this._onHudRetry);
|
||||
}
|
||||
if (this._hud.top && this._hud.top.parentNode) this._hud.top.parentNode.removeChild(this._hud.top);
|
||||
if (this._hud.banner && this._hud.banner.parentNode) this._hud.banner.parentNode.removeChild(this._hud.banner);
|
||||
this._hud = null;
|
||||
}
|
||||
this._onHudRetry = null;
|
||||
this._goal = null;
|
||||
this._goalState = null;
|
||||
this._goalCbs = [];
|
||||
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
|
||||
this.el = null; this.canvas = null; this.ctx = null;
|
||||
};
|
||||
@@ -2060,6 +2514,9 @@
|
||||
}
|
||||
function _clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
function _nowMs() { return (global.performance && global.performance.now) ? global.performance.now() : Date.now(); }
|
||||
/* истинность булева SimExpr-результата: SimExpr.fn возвращает число (NaN/∞ -> 0),
|
||||
истина = любое конечное ненулевое значение. */
|
||||
function _truthy(v) { return typeof v === 'number' && isFinite(v) && v !== 0; }
|
||||
|
||||
/* ════════════════════ public ════════════════════ */
|
||||
function mount(host, spec) {
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* dashboard.html используют window.PetSprite.render(...) — без дублей.
|
||||
*/
|
||||
(function () {
|
||||
// Счётчик для УНИКАЛЬНЫХ id градиентов/клипов спрайта. Иначе два питомца с
|
||||
// одинаковыми level/mood/colorKey дают совпадающие id, и url(#id) заливки тела
|
||||
// резолвится в чужой (возможно display:none) градиент → тело без заливки.
|
||||
let _petUidSeq = 0;
|
||||
const PET_PALETTES = {
|
||||
purple:'#9B5DE5', cyan:'#06D6E0', gold:'#F9C74F',
|
||||
red:'#F94144', green:'#38D95A', blue:'#4A90D9',
|
||||
@@ -24,7 +28,7 @@
|
||||
const col = PET_PALETTES[colorKey] || '#9B5DE5';
|
||||
const dark = shadeColor(col, -45);
|
||||
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 eyeY = 52, eyeX1 = 40, eyeX2 = 70;
|
||||
|
||||
+261
-3
@@ -74,7 +74,15 @@
|
||||
params: [],
|
||||
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._selObjId = null; // выбранный для drag-on-preview объект
|
||||
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;
|
||||
// P5: прямое манипулирование + история
|
||||
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); }),
|
||||
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._undo.length = 0; this._redo.length = 0; this._fieldSnapTaken = false;
|
||||
@@ -272,6 +282,15 @@
|
||||
};
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -321,6 +340,71 @@
|
||||
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) {
|
||||
@@ -609,6 +693,41 @@
|
||||
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 будет округлять к шагу сетки). */
|
||||
Builder.prototype.toggleSnap = function () {
|
||||
this._snap = !this._snap;
|
||||
@@ -764,6 +883,18 @@
|
||||
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
|
||||
try {
|
||||
var bytes = new global.Blob([JSON.stringify(this.buildSpec())]).size;
|
||||
@@ -833,7 +964,8 @@
|
||||
this.sectionMeta() +
|
||||
this.sectionParams() +
|
||||
this.sectionObjects() +
|
||||
this.sectionPlotsPhysics();
|
||||
this.sectionPlotsPhysics() +
|
||||
this.sectionGame();
|
||||
this.wirePanels();
|
||||
};
|
||||
|
||||
@@ -1134,6 +1266,72 @@
|
||||
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 () {
|
||||
@@ -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
|
||||
p.querySelectorAll('[data-add]').forEach(function (b) {
|
||||
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; }
|
||||
this.pushHistory();
|
||||
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.scheduleRemount(false);
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Квантик — Законы Мира</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="/css/ls.css"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
/* ════════════════════ Игровая страница «Квантик» ════════════════════ */
|
||||
.qg-wrap { display: flex; flex-direction: column; height: 100vh; min-height: 0; }
|
||||
|
||||
/* ── Topbar ── */
|
||||
.qg-top {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
padding: 11px 20px; border-bottom: 1px solid rgba(148,163,184,0.18);
|
||||
background: #11132A; flex-shrink: 0; position: relative; z-index: 3;
|
||||
}
|
||||
.qg-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.02rem; color: #E2E8F0; white-space: nowrap; }
|
||||
.qg-sub { font-size: .8rem; color: #94A3B8; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.qg-pill { font-size: .66rem; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; padding: 3px 10px; border-radius: 99px; background: rgba(34,211,238,0.16); color: #67E8F9; }
|
||||
.qg-back {
|
||||
display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .82rem; font-weight: 600;
|
||||
color: #CBD5E1; background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.2);
|
||||
border-radius: 99px; padding: 5px 13px; cursor: pointer; transition: .16s;
|
||||
}
|
||||
.qg-back:hover { background: rgba(34,211,238,0.16); color: #67E8F9; border-color: rgba(34,211,238,0.35); }
|
||||
.qg-back .ic { width: 15px; height: 15px; }
|
||||
|
||||
/* ════════════════════ Карта-созвездие ════════════════════ */
|
||||
.qm-root {
|
||||
flex: 1; min-height: 0; position: relative; overflow-y: auto; overflow-x: hidden;
|
||||
background:
|
||||
radial-gradient(1100px 700px at 78% -10%, rgba(167,139,250,0.16), transparent 60%),
|
||||
radial-gradient(900px 600px at 12% 8%, rgba(34,211,238,0.13), transparent 55%),
|
||||
radial-gradient(700px 800px at 50% 120%, rgba(244,114,182,0.10), transparent 60%),
|
||||
linear-gradient(180deg, #0B0B1A 0%, #0D0D1F 55%, #0A0A16 100%);
|
||||
}
|
||||
/* атмосферное зерно поверх фона */
|
||||
.qm-root::before {
|
||||
content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .5;
|
||||
background-image: radial-gradient(rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||
background-size: 3px 3px;
|
||||
}
|
||||
|
||||
/* ── Шапка карты (нарратор + XP + скины) ── */
|
||||
.qm-header { position: relative; z-index: 2; padding: 22px 26px 8px; }
|
||||
.qm-header-inner {
|
||||
display: grid; grid-template-columns: minmax(240px, 1.4fr) minmax(280px, 1.1fr) auto;
|
||||
gap: 18px; align-items: center; max-width: 1180px; margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 920px) { .qm-header-inner { grid-template-columns: 1fr; } }
|
||||
|
||||
.qm-narrator { display: flex; align-items: center; gap: 14px; }
|
||||
.qm-pet { width: 76px; height: 80px; flex-shrink: 0; filter: drop-shadow(0 8px 22px rgba(34,211,238,0.28)); }
|
||||
.qm-pet svg { width: 100%; height: 100%; }
|
||||
.qm-bubble {
|
||||
position: relative; background: rgba(20,22,44,0.78); border: 1px solid rgba(148,163,184,0.18);
|
||||
border-radius: 14px; padding: 12px 15px; backdrop-filter: blur(6px); box-shadow: 0 10px 30px rgba(0,0,0,0.35);
|
||||
}
|
||||
.qm-bubble::before {
|
||||
content: ''; position: absolute; left: -7px; top: 50%; transform: translateY(-50%) rotate(45deg);
|
||||
width: 12px; height: 12px; background: rgba(20,22,44,0.78); border-left: 1px solid rgba(148,163,184,0.18); border-bottom: 1px solid rgba(148,163,184,0.18);
|
||||
}
|
||||
.qm-bubble-t { color: #DCE3EE; font-size: .86rem; line-height: 1.45; }
|
||||
|
||||
.qm-stats { display: flex; align-items: center; gap: 18px; }
|
||||
.qm-level { display: flex; flex-direction: column; align-items: center; line-height: 1; flex-shrink: 0; }
|
||||
.qm-level-num {
|
||||
font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 2rem;
|
||||
background: linear-gradient(135deg, #67E8F9, #A78BFA); -webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.qm-level-lbl { font-size: .62rem; text-transform: uppercase; letter-spacing: .06em; color: #8B9AAE; margin-top: 4px; max-width: 84px; text-align: center; }
|
||||
.qm-xpbox { flex: 1; min-width: 160px; }
|
||||
.qm-xp-head { display: flex; justify-content: space-between; font-size: .74rem; color: #CBD5E1; margin-bottom: 6px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.qm-xp-next { color: #8B9AAE; font-weight: 500; }
|
||||
.qm-xp-bar { height: 9px; border-radius: 99px; background: rgba(148,163,184,0.18); overflow: hidden; }
|
||||
.qm-xp-fill {
|
||||
height: 100%; border-radius: 99px; width: 0;
|
||||
background: linear-gradient(90deg, #22D3EE, #A78BFA);
|
||||
box-shadow: 0 0 14px rgba(103,232,249,0.5);
|
||||
transition: width .9s cubic-bezier(.22,.61,.36,1);
|
||||
}
|
||||
.qm-starcount {
|
||||
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
|
||||
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1rem; color: #FBBF24; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Скины ── */
|
||||
.qm-skins { display: flex; flex-direction: column; gap: 7px; }
|
||||
.qm-skins-lbl { font-size: .62rem; text-transform: uppercase; letter-spacing: .06em; color: #8B9AAE; font-weight: 700; }
|
||||
.qm-skins-row { display: flex; gap: 7px; flex-wrap: wrap; max-width: 220px; }
|
||||
.qm-skin {
|
||||
width: 30px; height: 30px; border-radius: 50%; cursor: pointer; padding: 0;
|
||||
background: radial-gradient(circle at 34% 30%, color-mix(in srgb, var(--sk) 70%, #fff), var(--sk) 70%);
|
||||
border: 2px solid rgba(255,255,255,0.18); position: relative; transition: transform .14s, box-shadow .14s, border-color .14s;
|
||||
}
|
||||
.qm-skin:hover:not(.locked) { transform: scale(1.12); }
|
||||
.qm-skin.active { border-color: #fff; box-shadow: 0 0 0 2px var(--sk), 0 0 14px var(--sk); }
|
||||
.qm-skin.locked { filter: grayscale(.7) brightness(.5); cursor: not-allowed; }
|
||||
.qm-skin-lock { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.qm-skin-lock .ic { width: 12px; height: 12px; }
|
||||
|
||||
/* ── Тело: созвездия ── */
|
||||
.qm-body { position: relative; z-index: 1; max-width: 1180px; margin: 0 auto; padding: 8px 26px 60px; }
|
||||
.qm-constellation { margin-top: 18px; }
|
||||
.qm-con-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; padding-left: 4px; }
|
||||
.qm-con-title {
|
||||
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.1rem; color: #E8EDF5;
|
||||
position: relative; padding-left: 16px;
|
||||
}
|
||||
.qm-con-title::before {
|
||||
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent);
|
||||
}
|
||||
.qm-con-sub { font-size: .8rem; color: #8B9AAE; flex: 1; }
|
||||
.qm-con-stars { display: inline-flex; align-items: center; gap: 4px; font-size: .78rem; color: #FBBF24; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.qm-field { position: relative; height: 220px; margin-top: 2px; }
|
||||
@media (max-width: 620px) { .qm-field { height: 280px; } }
|
||||
.qm-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.qm-link { stroke: rgba(148,163,184,0.22); stroke-width: .35; stroke-dasharray: 1.4 1.6; }
|
||||
.qm-link.on { stroke: var(--accent); stroke-opacity: .55; stroke-dasharray: none; stroke-width: .45; }
|
||||
.qm-tw { animation: qmTwinkle var(--tw, 2.4s) ease-in-out var(--td, 0s) infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes qmTwinkle { 0%,100% { opacity: .25; } 50% { opacity: .9; } }
|
||||
|
||||
/* ── Узлы ── */
|
||||
.qm-node {
|
||||
position: absolute; transform: translate(-50%, -50%); z-index: 2;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
background: none; border: none; cursor: pointer; font: inherit; padding: 0;
|
||||
transition: transform .2s;
|
||||
}
|
||||
.qm-node-core {
|
||||
width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
position: relative; transition: transform .18s, box-shadow .18s;
|
||||
}
|
||||
.qm-node-core .ic { color: #fff; }
|
||||
.qm-node-label { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: .76rem; color: #CBD5E1; white-space: nowrap; text-shadow: 0 1px 4px rgba(0,0,0,.6); }
|
||||
.qm-node-stars { display: inline-flex; gap: 1px; }
|
||||
.qm-node-need { display: inline-flex; align-items: center; gap: 3px; font-size: .68rem; color: #94A3B8; font-weight: 600; }
|
||||
|
||||
/* доступный узел */
|
||||
.qm-available .qm-node-core {
|
||||
background: radial-gradient(circle at 35% 30%, #34D399, #0E9F6E);
|
||||
box-shadow: 0 0 0 4px rgba(52,211,153,0.18), 0 8px 24px rgba(16,185,129,0.4);
|
||||
animation: qmPulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes qmPulse {
|
||||
0%,100% { box-shadow: 0 0 0 4px rgba(52,211,153,0.18), 0 8px 24px rgba(16,185,129,0.4); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(52,211,153,0.10), 0 10px 32px rgba(16,185,129,0.55); }
|
||||
}
|
||||
.qm-available:hover { transform: translate(-50%, -50%) scale(1.07); }
|
||||
.qm-available:hover .qm-node-core { transform: scale(1.05); }
|
||||
|
||||
/* пройденный узел */
|
||||
.qm-completed .qm-node-core {
|
||||
background: radial-gradient(circle at 35% 30%, #67E8F9, #2563EB);
|
||||
box-shadow: 0 0 0 3px rgba(34,211,238,0.2), 0 6px 20px rgba(37,99,235,0.4);
|
||||
}
|
||||
.qm-completed:hover { transform: translate(-50%, -50%) scale(1.06); }
|
||||
.qm-node-order { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.15rem; color: #fff; }
|
||||
|
||||
/* заблокированный узел */
|
||||
.qm-locked { cursor: not-allowed; }
|
||||
.qm-locked .qm-node-core {
|
||||
background: rgba(30,33,58,0.85); border: 1.5px solid rgba(148,163,184,0.22);
|
||||
box-shadow: inset 0 2px 10px rgba(0,0,0,0.4);
|
||||
}
|
||||
.qm-locked .qm-node-label { color: #6B7A90; }
|
||||
|
||||
/* фокус для клавиатуры */
|
||||
.qm-node:focus-visible { outline: none; }
|
||||
.qm-node:focus-visible .qm-node-core { box-shadow: 0 0 0 3px #fff, 0 0 0 6px var(--accent, #22D3EE); }
|
||||
|
||||
/* поэтапное появление */
|
||||
.qm-node.qm-pre { opacity: 0; transform: translate(-50%, -40%) scale(.6); }
|
||||
.qm-node.qm-in { animation: qmNodeIn .5s cubic-bezier(.22,1.2,.4,1) forwards; }
|
||||
@keyframes qmNodeIn {
|
||||
from { opacity: 0; transform: translate(-50%, -40%) scale(.6); }
|
||||
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
}
|
||||
|
||||
/* ════════════════════ Сцена уровня ════════════════════ */
|
||||
.qg-stage { flex: 1; min-height: 0; position: relative; background: #0D0D1A; overflow: hidden; }
|
||||
.qg-stage .sim-spec-root { position: absolute; inset: 0; }
|
||||
.qg-fallback { padding: 40px; color: #cbd5e1; font-family: 'Manrope', sans-serif; max-width: 520px; }
|
||||
|
||||
.qg-view { display: none; flex: 1; min-height: 0; }
|
||||
.qg-view.show { display: flex; flex-direction: column; }
|
||||
|
||||
/* ── Оверлеи (интро / успех) ── */
|
||||
.qg-overlay {
|
||||
position: absolute; inset: 0; z-index: 20;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(7, 7, 18, 0.74); backdrop-filter: blur(5px);
|
||||
}
|
||||
.qg-card {
|
||||
background: linear-gradient(180deg, #15173099, #0F1024EE), #14152C;
|
||||
border: 1px solid rgba(148,163,184,0.18); border-radius: 20px;
|
||||
padding: 26px 30px 24px; width: min(440px, 92vw); text-align: center;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,0.55);
|
||||
animation: qg-pop .26s cubic-bezier(.22,1.1,.4,1);
|
||||
}
|
||||
@keyframes qg-pop { from { transform: scale(.9) translateY(8px); opacity: 0; } to { transform: scale(1) translateY(0); opacity: 1; } }
|
||||
.qg-card-kicker { font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; color: #67E8F9; margin-bottom: 4px; }
|
||||
.qg-card-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.32rem; color: #EAF0F8; margin-bottom: 12px; }
|
||||
.qg-intro-pet, .qg-success-pet { width: 92px; height: 96px; margin: 0 auto 6px; filter: drop-shadow(0 10px 26px rgba(34,211,238,0.3)); }
|
||||
.qg-intro-pet svg, .qg-success-pet svg { width: 100%; height: 100%; }
|
||||
.qg-success-pet { animation: qgBob 1.6s ease-in-out infinite; }
|
||||
@keyframes qgBob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
|
||||
.qg-intro-goal { font-weight: 700; font-size: .98rem; color: #DCE3EE; margin-bottom: 8px; }
|
||||
.qg-intro-hint { font-size: .85rem; color: #A8B4C6; line-height: 1.5; margin-bottom: 18px; max-width: 360px; margin-left: auto; margin-right: auto; }
|
||||
|
||||
.qg-stars { display: flex; justify-content: center; gap: 6px; margin-bottom: 18px; }
|
||||
.qg-star { display: inline-flex; }
|
||||
.qg-star-svg { filter: drop-shadow(0 2px 6px rgba(251,191,36,0.4)); }
|
||||
.qg-star-on { animation: qgStarPop .45s cubic-bezier(.22,1.3,.4,1) backwards; animation-delay: calc(.12s * var(--si, 0) + .15s); }
|
||||
@keyframes qgStarPop { 0% { transform: scale(0) rotate(-30deg); opacity: 0; } 70% { transform: scale(1.25) rotate(6deg); } 100% { transform: scale(1) rotate(0); opacity: 1; } }
|
||||
/* Доступность: уважаем 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-stat { display: flex; flex-direction: column; gap: 3px; }
|
||||
.qg-stat-lbl { font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #8B9AAE; }
|
||||
.qg-stat-val { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.05rem; color: #EAF0F8; font-variant-numeric: tabular-nums; }
|
||||
.qg-actions { display: flex; justify-content: center; gap: 10px; }
|
||||
.qg-btn { min-width: 118px; }
|
||||
|
||||
/* ════════════════════ Квантовые способности (Фаза 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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="app-sidebar"></aside>
|
||||
<main class="sb-content">
|
||||
<div class="qg-wrap">
|
||||
<div class="qg-top">
|
||||
<span class="qg-title" id="qg-title">Квантик — Законы Мира</span>
|
||||
<span class="qg-sub" id="qg-sub"></span>
|
||||
<button class="qg-back" id="qg-back" type="button" style="display:none">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18 9 12 15 6"/></svg>
|
||||
К карте
|
||||
</button>
|
||||
<span class="qg-pill" id="qg-pill">Физика</span>
|
||||
</div>
|
||||
|
||||
<!-- Вид карты -->
|
||||
<div class="qg-view show" id="qg-map-view">
|
||||
<div class="qm-root">
|
||||
<div class="qm-header" id="qg-map-header"></div>
|
||||
<div class="qm-body" id="qg-map-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Вид уровня -->
|
||||
<div class="qg-view" id="qg-level-view">
|
||||
<div class="qg-stage" id="qg-stage"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<!-- модель питомца (нарратор-Квантик) -->
|
||||
<script src="/js/pet-sprite.js"></script>
|
||||
<!-- движок спек-симуляций (тот же путь, что lab.html / sim-builder.html) -->
|
||||
<script src="/js/labs/_sim_expr.js"></script>
|
||||
<script src="/js/labs/_sim_engine.js"></script>
|
||||
<!-- KaTeX для подписей сцены -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||
<!-- уровни (данные) + логика прогресса + карта + способности + игра -->
|
||||
<script src="/js/game/levels.js"></script>
|
||||
<script src="/js/game/progress-logic.js"></script>
|
||||
<script src="/js/game/map.js"></script>
|
||||
<script src="/js/game/quantik-abilities.js"></script>
|
||||
<script src="/js/game/quantik-game.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
// Доступ: любой авторизованный пользователь (играют и ученики).
|
||||
if (!LS.initPage()) { return; }
|
||||
|
||||
var mapView = document.getElementById('qg-map-view');
|
||||
var lvlView = document.getElementById('qg-level-view');
|
||||
var stage = document.getElementById('qg-stage');
|
||||
var backBtn = document.getElementById('qg-back');
|
||||
var titleEl = document.getElementById('qg-title');
|
||||
var subEl = document.getElementById('qg-sub');
|
||||
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 ||
|
||||
!window.QuantikGame || !window.QuantikMap || !window.QuantikProgress) {
|
||||
stage.innerHTML = '<div class="qg-fallback">Движок игры не загрузился. Обновите страницу.</div>';
|
||||
lvlView.classList.add('show'); mapView.classList.remove('show');
|
||||
return;
|
||||
}
|
||||
|
||||
var progressMap = {}; // { level_id: row }
|
||||
var curInst = null; // текущий инстанс движка уровня
|
||||
var map = null;
|
||||
|
||||
function loadProgress() {
|
||||
if (window.LS && window.LS.gameProgressList) {
|
||||
return window.LS.gameProgressList()
|
||||
.then(function (r) { progressMap = window.QuantikProgress.fromProgressList(r && r.progress); })
|
||||
.catch(function () { progressMap = {}; });
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function destroyLevel() {
|
||||
if (curInst) { try { curInst.destroy(); } catch (_e) {} curInst = null; }
|
||||
stage.innerHTML = '';
|
||||
}
|
||||
|
||||
/* ── Показать карту ── */
|
||||
function showMap() {
|
||||
destroyLevel();
|
||||
lvlView.classList.remove('show');
|
||||
mapView.classList.add('show');
|
||||
backBtn.style.display = 'none';
|
||||
titleEl.textContent = 'Квантик — Законы Мира';
|
||||
subEl.textContent = 'Карта мира — выбери уровень и почини закон';
|
||||
if (pillEl) pillEl.textContent = 'Физика';
|
||||
history.replaceState(null, '', '/quantik');
|
||||
// перезагрузить прогресс (мог обновиться после победы) и перерисовать
|
||||
loadProgress().then(function () { map.render(progressMap); });
|
||||
}
|
||||
|
||||
/* ── Запустить уровень (после интро) ── */
|
||||
function launchLevel(level) {
|
||||
destroyLevel();
|
||||
mapView.classList.remove('show');
|
||||
lvlView.classList.add('show');
|
||||
backBtn.style.display = '';
|
||||
titleEl.textContent = level.title || 'Квантик';
|
||||
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || level.hint || '';
|
||||
setPill(level);
|
||||
history.replaceState(null, '', '/quantik?level=' + encodeURIComponent(level.id));
|
||||
|
||||
// Pre-win значение (фолбэк, если пересчёт после победы недоступен).
|
||||
var nextLevel = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
|
||||
|
||||
curInst = window.QuantikGame.start({
|
||||
host: stage,
|
||||
level: level,
|
||||
skin: window.QuantikGame.getSkin(),
|
||||
hasNext: !!nextLevel,
|
||||
// Победа разблокирует след. уровень → перезагружаем прогресс и пересчитываем
|
||||
// «следующий доступный» на свежей карте, чтобы экран успеха показал «Дальше».
|
||||
resolveNext: function () {
|
||||
return loadProgress().then(function () {
|
||||
var nx = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
|
||||
return { hasNext: !!nx, next: nx };
|
||||
});
|
||||
},
|
||||
onNext: function () {
|
||||
// прогресс уже перезагружен в resolveNext → берём след. доступный из свежей карты
|
||||
var nx = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
|
||||
if (nx) openLevel(nx); else showMap();
|
||||
},
|
||||
onMap: showMap
|
||||
});
|
||||
|
||||
if (!curInst) {
|
||||
stage.innerHTML = '<div class="qg-fallback">Не удалось запустить уровень.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Открыть уровень: показать интро-карточку, потом launch ── */
|
||||
function openLevel(level) {
|
||||
destroyLevel();
|
||||
mapView.classList.remove('show');
|
||||
lvlView.classList.add('show');
|
||||
backBtn.style.display = '';
|
||||
titleEl.textContent = level.title || 'Квантик';
|
||||
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || '';
|
||||
setPill(level);
|
||||
|
||||
var intro = window.QuantikGame.buildIntro(level, window.QuantikGame.getSkin());
|
||||
intro.btnGo.addEventListener('click', function () {
|
||||
if (intro.overlay.parentNode) intro.overlay.parentNode.removeChild(intro.overlay);
|
||||
launchLevel(level);
|
||||
});
|
||||
intro.btnBack.addEventListener('click', function () {
|
||||
if (intro.overlay.parentNode) intro.overlay.parentNode.removeChild(intro.overlay);
|
||||
showMap();
|
||||
});
|
||||
stage.appendChild(intro.overlay);
|
||||
}
|
||||
|
||||
/* ── Карта ── */
|
||||
map = window.QuantikMap.create({
|
||||
host: document.getElementById('qg-map-body'),
|
||||
headerHost: document.getElementById('qg-map-header'),
|
||||
onPlay: function (level) { openLevel(level); },
|
||||
getSkin: function () { return window.QuantikGame.getSkin(); },
|
||||
onSkin: function (key) {
|
||||
window.QuantikGame.setSkin(key);
|
||||
map.render(progressMap); // перерисовать (нарратор + активный свотч)
|
||||
}
|
||||
});
|
||||
|
||||
backBtn.addEventListener('click', showMap);
|
||||
|
||||
// Подмешать авторённые уровни (custom_sims cat='game') до рендера карты (Ф5).
|
||||
function ensureCustomLevels() {
|
||||
if (window.QuantikLevels.ensureCustom) {
|
||||
return window.QuantikLevels.ensureCustom().catch(function () {});
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Старт: если ?level=<id> в URL и уровень доступен — открыть его, иначе карта.
|
||||
// Сначала грузим прогресс И авторённые уровни (параллельно), затем deep-link.
|
||||
Promise.all([loadProgress(), ensureCustomLevels()]).then(function () {
|
||||
map.render(progressMap);
|
||||
var params = new URLSearchParams(location.search);
|
||||
var wantId = params.get('level');
|
||||
if (wantId) {
|
||||
// custom:<id> может быть свой draft (нет в списке) — резолвим асинхронно с
|
||||
// проверкой доступа на сервере (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);
|
||||
} else {
|
||||
showMapNoReload();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
showMapNoReload();
|
||||
});
|
||||
|
||||
// показать карту без повторной загрузки прогресса (стартовый случай)
|
||||
function showMapNoReload() {
|
||||
lvlView.classList.remove('show');
|
||||
mapView.classList.add('show');
|
||||
backBtn.style.display = 'none';
|
||||
titleEl.textContent = 'Квантик — Законы Мира';
|
||||
subEl.textContent = 'Карта мира — выбери уровень и почини закон';
|
||||
}
|
||||
|
||||
window.__quantik = { map: map, getInst: function () { return curInst; } };
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -133,6 +133,12 @@
|
||||
.sbu-phys-fields { display: flex; flex-direction: column; gap: 8px; }
|
||||
.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-title { font-size: .72rem; font-weight: 700; color: var(--text-3); margin-bottom: 5px; }
|
||||
|
||||
@@ -1042,10 +1042,11 @@ window.LS = {
|
||||
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
||||
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
|
||||
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
||||
gameProgressList, gameProgressSubmit,
|
||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
||||
fcListDecks, fcCreateDeck, fcAddCard,
|
||||
fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview,
|
||||
escapeHtml, esc,
|
||||
parseDate, fmtRelTime, safeHref,
|
||||
initPage,
|
||||
@@ -1272,6 +1273,8 @@ async function customSimClone(id) { return req('POST', `/custom-sims/${i
|
||||
async function customSimRelated(id) { return req('GET', `/custom-sims/${id}/related`); }
|
||||
async function customSimAddLink(id, d) { return req('POST', `/custom-sims/${id}/links`, d); }
|
||||
async function customSimDelLink(id, lid){ return req('DELETE', `/custom-sims/${id}/links/${lid}`); }
|
||||
async function gameProgressList() { return req('GET', '/game/progress'); }
|
||||
async function gameProgressSubmit(levelId, d) { return req('POST', '/game/progress', { level_id: levelId, time_ms: d && d.time_ms, stars: d && d.stars }); }
|
||||
async function assistantContext() { return req('GET', '/assistant/context'); }
|
||||
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
|
||||
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
||||
@@ -1294,6 +1297,8 @@ async function adminAssistantModels(params) { const q = new URLSearchParams(para
|
||||
async function fcListDecks() { return req('GET', '/flashcards/decks'); }
|
||||
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 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 getFileAccess(id) { return req('GET', `/files/${id}/access`); }
|
||||
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
|
||||
${G('practice', 'Практика и игры', `
|
||||
${L('/lab', 'atom', 'Лаборатория')}
|
||||
${L('/quantik', 'rocket', 'Квантик: Законы Мира')}
|
||||
${L('/sim-builder', 'pencil-ruler', 'Конструктор симуляций', { cls: 'sb-teacher-only', hidden: !isTch })}
|
||||
${L('/biochem', 'flask-conical', 'Биохимия')}
|
||||
${L('/red-book', 'leaf', 'Красная книга')}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Feature Context: Квантик — Законы Мира
|
||||
|
||||
## Current State
|
||||
- Ветка `feature/quantik-game` ответвлена от `feature/sim-builder` (движок P1–P3 там, не в master).
|
||||
- При ответвлении унаследован **чужой uncommitted WIP** sim-builder: `frontend/js/sim-builder.js`,
|
||||
`frontend/sim-builder.html`, `.claude/settings.json` + множество untracked `tmp_*`/мусорных файлов.
|
||||
⛔ НЕ трогать и НЕ коммитить этот WIP — стейджить только свои файлы поимённо.
|
||||
- **Phase 0 реализован** (pending review): слой целей в движке `_sim_engine.js` (блок `goal`,
|
||||
компиляция when/fail/stars через SimExpr, состояние результата, HUD-оверлей, API
|
||||
`onGoal/getResult/resetResult`) + серверный гейт `validateSpec` пропускает `goal`/`game`.
|
||||
Изменены: `frontend/js/labs/_sim_engine.js`, `backend/src/controllers/customSimController.js`.
|
||||
Аддитивно: спека без `goal` ведёт себя ровно как раньше (HUD не создаётся, побед не считается).
|
||||
Смоук 40/40; `npm test` 238 pass / 8 baseline fail; lint:routes 0.
|
||||
- **Phase 1 реализован** (pending review): сквозной играбельный срез. Страница `/quantik`
|
||||
(`frontend/quantik.html` + `frontend/js/game/quantik-game.js`) монтирует уровень-спеку через
|
||||
`SimEngine.mount`; «игровой режим» = HUD из Ф0 (сам по наличию `goal`) + слайдеры params +
|
||||
play/reset. Уровень `phys-artillery-1` — данные в `frontend/js/game/levels.js`
|
||||
(`window.QuantikLevels`): physics-гравитация + body-запуск под углом θ/скоростью v, портал-цель,
|
||||
бонус-звезда. На победу `onGoal` → `LS.gameProgressSubmit` + DOM-оверлей успеха (звёзды/время/попытки).
|
||||
Прогресс: таблица `game_progress` (мигр.**076**), API `/api/game/progress` (GET/POST,
|
||||
`gameController.js`+`routes/game.js`, смонтировано в `server.js` после `/api/custom-sims`),
|
||||
клиент `LS.gameProgressList/Submit`. Сайдбар: `/quantik` (icon `rocket`) виден всем.
|
||||
Новые: `076_game_progress.sql`, `gameController.js`, `routes/game.js`, `quantik.html`,
|
||||
`js/game/levels.js`, `js/game/quantik-game.js`, `tests/game.test.js`. Изменены: `server.js`,
|
||||
`js/api.js`, `js/sidebar.js`. `npm test` 251 pass / 8 baseline fail (game.test.js 13/13);
|
||||
lint:routes 0; миграция применяется чисто.
|
||||
- **Phase 2 реализован** (pending review): одиночный уровень превращён в **играбельный мир**.
|
||||
Карта-созвездие (`frontend/js/game/map.js`, `window.QuantikMap`) на звёздном фоне: 6 физ-уровней
|
||||
в 2 главах (Кинематика 1–3, Динамика 4–6), узлы-«звёзды» со статусом (locked/available/completed+
|
||||
звёзды), линии-связи, поэтапное появление. Шапка: нарратор-Квантик (`PetSprite`), XP-бар + «уровень
|
||||
Квантика», всего звёзд, скин-пикер (8 скинов, часть за XP/звёзды). Контент уровней расширен в
|
||||
`levels.js` (метаданные `chapter/order/par_ms/unlockStars`, по 2 звезды: кристалл + норматив времени).
|
||||
Разблокировка/XP/группировка — ЧИСТЫЕ функции в новом `frontend/js/game/progress-logic.js`
|
||||
(`window.QuantikProgress`), покрыты тестом. Навигация: карта→интро(нарратор)→уровень→успех
|
||||
(нарратор по звёздам)→карта; «Дальше» активирована (`nextPlayable`); скин тинтует героя+нарратора
|
||||
(localStorage `quantik-skin`). **Backend НЕ тронут** — XP клиентская агрегация из `game_progress`.
|
||||
Новые: `js/game/map.js`, `js/game/progress-logic.js`. Изменены: `quantik.html`, `js/game/levels.js`,
|
||||
`js/game/quantik-game.js`. `node --check` все OK; смоуки (логика 16/16, рендер 7/7, winnability 6/6
|
||||
на реальном движке) зелёные и удалены; `npm test` 259/251 pass / 8 baseline fail (без изменений);
|
||||
lint:routes 0.
|
||||
- **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` (L12–L16) + `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
|
||||
- **«Атом» = блок `goal` в спеке** (булево SimExpr). Любой уровень = спека SimForge + `goal`.
|
||||
Движок вычисляет `goal.when` каждый кадр; победа → result + callback. Нет `goal` → no-op.
|
||||
- **Уровни хранятся в `custom_sims`** (cat='game'), а не в новой таблице. Реюз авторинга/шаринга/embed.
|
||||
Новые таблицы — только под ПРОГРЕСС игрока и лидерборд (мигр.).
|
||||
- **Уточнение Ф1**: для MVP уровни — ВСТРОЕННЫЕ ДАННЫЕ в `frontend/js/game/levels.js`
|
||||
(`window.QuantikLevels`, форма `{ id, title, subject?, hint?, spec }`), а не записи `custom_sims`.
|
||||
`custom_sims` cat='game' остаётся целевым хранилищем для авторённых уровней (Ф5); реестр тогда
|
||||
станет асинхронным (загрузка опубликованных + слияние со встроенными той же формы записи).
|
||||
- **Герой Квантик**: в уровне = engine point с `body` + glow + trail (визуал P2). На карте/в
|
||||
диалогах = `PetSprite.render(level, mood, accessories, colorKey, streak, pattern)` (DOM SVG).
|
||||
- **Управление = чинить закон**, а не WASD: игрок крутит `params`-слайдеры движка (угол/скорость/
|
||||
гравитация) или собирает `f(x)`; затем «Запуск» — симуляция проигрывается к цели.
|
||||
- **Безопасность**: цвета — только в canvas-стоки; текст спеки — escape (`& < >`); выражения —
|
||||
только длина на сервере, исполняет безопасный SimExpr на клиенте.
|
||||
|
||||
## Engine touch-points (Phase 0)
|
||||
- Спека v1 формат — в шапке `_sim_engine.js`. Добавить `goal`/`game` СЮДА (документировать).
|
||||
- rAF-цикл `_renderFrame` (вычисляет env). Добавить `_evalGoal()` после построения env.
|
||||
- `mount()` возвращает инстанс — добавить `onGoal`, `getResult`, `resetResult`.
|
||||
- HUD — DOM-оверлей `_labelLayer`/новый слой (как readout-бейджи). Без эмодзи, inline SVG.
|
||||
- Серверный гейт `customSimController.validateSpec` (:93) — разрешить `goal`/`stars`/`hint`/`game`.
|
||||
|
||||
## Cross-Phase Dependencies
|
||||
- Phase 1+ зависят от `goal`/`getResult`/`onGoal` из Phase 0.
|
||||
- Phase 2 (XP/скины) зависит от прогресса Phase 1.
|
||||
- Phase 4 (туннелирование) зависит от флешкарт-SR API.
|
||||
- Phase 5 (авторинг) трогает sim-builder — к этому моменту чужой P4-WIP должен быть смержен в
|
||||
sim-builder; свериться перед стартом фазы (возможен мерж base-ветки).
|
||||
- Phase 6 (живая гонка) зависит от моста `sim_state` (Ф7 sim-builder) — он на base-ветке.
|
||||
|
||||
## Temporary Workarounds
|
||||
(пока нет)
|
||||
|
||||
## Phase 0 — API/гочи (для следующих фаз)
|
||||
- Движковое API цели: `inst.onGoal(cb)` (1 раз при победе, cb получает `getResult()`),
|
||||
`inst.getResult()` → `{won,failed,timeMs,attempts,stars:{got,total}}` (без goal → `null`),
|
||||
`inst.resetResult()` (сброс результата, НЕ считается попыткой). `inst.reset()` = полный
|
||||
перезапуск уровня + `attempts++` (пользовательская попытка; первый авто-reset при mount НЕ считается).
|
||||
- HUD появляется **автоматически** при наличии `goal` в спеке (отдельного флага «game mode» нет).
|
||||
- `timeMs` = **мировое время** `t` от старта (`max(1, round(t*1000))`), детерминизм; не wallclock.
|
||||
- Env цели = весь env кадра + единственный доп.идентификатор **`tries`** (= attempts). Других не вводить.
|
||||
- Серверный `validateSpec` принимает `goal{when,title,hint,hold,fail,stars≤3}` и `game{...}` (резерв Ф1/5);
|
||||
выражения не исполняются (только длина ≤500), текст escape+обрезка.
|
||||
- Победа делает `pause()` в кадре; следующий queued-rAF выходит рано → `onGoal` не задвоится.
|
||||
|
||||
## Open Questions / Notes
|
||||
- Категория `cat='game'` — проверить список `CATS` в customSimController.js, расширить при необходимости.
|
||||
- Ассеты: разрешены CC0/открытые из интернета (выбор пользователя) — фиксировать источник+лицензию
|
||||
в коммите/доке; визуальная база остаётся in-house (PetSprite/canvas/FLUX).
|
||||
- Маршрут страницы игры: clean URL `/quantik` (паттерн `/sim-builder`, `/lab`).
|
||||
@@ -0,0 +1,107 @@
|
||||
# Feature: Квантик — Законы Мира (образовательная 2D-игра)
|
||||
|
||||
**Branch:** `feature/quantik-game`
|
||||
**Base branch:** `feature/sim-builder` (движок P1–P3 и фазы sim-builder ещё не в master)
|
||||
**Created:** 2026-06-13
|
||||
**Status:** ✅ Complete (merged to feature/sim-builder, 2026-06-14)
|
||||
**Strategy:** Incremental
|
||||
**Mode:** Automated
|
||||
**Execution:** Orchestrator
|
||||
|
||||
## Summary
|
||||
2D физика-головоломка-платформер поверх движка **SimForge** (`_sim_engine.js`). Герой —
|
||||
**Квантик** (существующий питомец `PetSprite`): в уровне он светящаяся точка с glow и
|
||||
кометной трассой (P2), на карте/в диалогах — SVG-блоб `PetSprite.render`. Игрок не рулит
|
||||
героем напрямую, а **чинит «закон мира»**: задаёт скорость/угол/гравитацию (физ-уровни на
|
||||
`SimPhysics`), собирает `f(x)` для движения по кривой (граф-уровни на `plot`/`SimExpr`),
|
||||
открывает «ворота» уравниванием реакций/дробей. **Условие победы — булев блок `goal`
|
||||
(SimExpr) в спеке** — это «атом», переиспользуемый всеми типами уровней.
|
||||
|
||||
Уровень = спека SimForge + блок `game/goal` → авторится в sim-builder, хранится в
|
||||
`custom_sims`, открывается тем же конвейером, что и обычные симуляции. Всё новое —
|
||||
**аддитивно и безопасно** (без `eval`/`Function`; нет блока `goal` → движок ведёт себя
|
||||
как раньше).
|
||||
|
||||
Мета-слой: карта-созвездие, XP/скины Квантика, разблокировка по звёздам, класс-лидерборд
|
||||
через classroom SSE. Квантовые способности: суперпозиция, коллапс/пауза, туннелирование
|
||||
(энергия из быстрого SR-повторения флешкарт).
|
||||
|
||||
**MVP играбелен после Фазы 2.**
|
||||
|
||||
## Build & Test Commands
|
||||
- **Build:** нет (vanilla JS, без бандлера; статика через Express)
|
||||
- **Test:** `npm test` в `backend/` (`node --test tests/*.test.js`)
|
||||
- **Lint:** `npm run lint:routes` в `backend/`
|
||||
- ⚠️ После роутов/миграций: `npm run migrate` (живая БД `backend/data/learnspace.db`) + рестарт сервера.
|
||||
- ⚠️ baseline: 3 pre-existing fail (`auth.test.js` — bcrypt/JWT в тест-окружении) + 5 page-тестов (`jsdom` не установлен). Хук толерантен.
|
||||
|
||||
## Project Constraints (соблюдают ВСЕ агенты)
|
||||
- ⛔ Никаких эмодзи в коде — только inline SVG `.ic`.
|
||||
- ⛔ Никакого `eval`/`new Function`. Выражения — ТОЛЬКО через `SimExpr` (безопасный парсер).
|
||||
- Поиск по коду: `ast-index` (символы/usages/callers) + `vex` (semantic). НЕ Grep tool.
|
||||
- БД — встроенный `node:sqlite` (`DatabaseSync`), НЕ better-sqlite3.
|
||||
- Frontend — vanilla JS, `window.LS.*` (js/api.js), без бандлера.
|
||||
- Стейджить файлы **поимённо** (НЕ `git add -A` — в репо много мусорных untracked + чужой WIP sim-builder).
|
||||
- Аддитивность: новые блоки/типы в спеке не ломают существующие симуляции и каталог.
|
||||
- Ассеты: база — in-house (PetSprite + canvas/SVG + встроенный FLUX `/api/imggen`).
|
||||
Разрешены внешние **CC0/открытые** ассеты (звук/арт) с указанием источника/лицензии.
|
||||
|
||||
## Reuse Map (что переиспользуем)
|
||||
- `frontend/js/labs/_sim_engine.js` — рантайм (SimPhysics, plot, glow/trails, zoom/pan, drag).
|
||||
- `frontend/js/labs/_sim_expr.js` — `SimExpr.compile/evalSafe` для `goal`/`stars`.
|
||||
- `frontend/js/pet-sprite.js` — `PetSprite.render(...)` Квантик + палитры → скины/нарратор.
|
||||
- `custom_sims` + `customSimController.validateSpec` — хранение уровней + серверный гейт.
|
||||
- `sim-builder.html`/`sim-builder.js` — авторинг уровней (Фаза 5).
|
||||
- Флешкарты Tier-1 SR (мигр.074) — энергия туннелирования (Фаза 4).
|
||||
- classroom SSE + мост `sim_state`/`apply_sim_state` (Ф7 sim-builder) — живая гонка (Фаза 6).
|
||||
- Паттерн раздачи классу + `pushNotif` + `lab_sim_links` (Ф6 sim-builder).
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] Phase 0: Слой целей в движке (goal/HUD/result) [domain: frontend] → [subplan](./phase-0-objective-layer.md)
|
||||
- [x] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md)
|
||||
- [x] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md)
|
||||
- [x] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md)
|
||||
- [x] Phase 4: Квантовые способности + SR-комнаты [domain: fullstack] → [subplan](./phase-4-quantum-abilities-sr.md)
|
||||
- [x] Phase 5: Авторинг уровней в sim-builder + раздача классу [domain: fullstack] → [subplan](./phase-5-authoring-sharing.md)
|
||||
- ~~Phase 6: Класс-лидерборд / живая гонка (classroom SSE)~~ — **REMOVED** (см. Amendment 1) → [subplan](./phase-6-leaderboard-live.md)
|
||||
|
||||
## Phase Progress Log
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 0: Слой целей в движке | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 2: Карта + мир + XP/скины | fullstack | ✅ Done | ✅ (1 🟡 fixed) | ✅ | ✅ |
|
||||
| Phase 3: Граф-уровни + зоны | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 4: Квантовые способности + SR | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 5: Авторинг + раздача | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| ~~Phase 6: Лидерборд / живая гонка~~ | fullstack | ❌ Removed (Amendment 1) | — | — | — |
|
||||
|
||||
## MVP boundary
|
||||
После **Phase 2** игра играбельна и отгружаема: один полный мир физ-уровней с картой,
|
||||
прогрессом, 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
|
||||
- [x] Comprehensive code review (final-reviewer) — ✅ READY TO MERGE, 0 блокеров (2026-06-14)
|
||||
- [x] Security review (новые API/ввод) — ✅ SECURE, 0 critical (2026-06-14)
|
||||
- [x] Polish-фиксы по ревью применены: game-блок санитизируется (был латентный XSS), prefers-reduced-motion guard, фикс комментария isUnlocked. Тесты 45/45 затронутых, lint 0.
|
||||
- [x] `npm test` без новых регрессий (8 = baseline: 3 auth + 5 jsdom)
|
||||
- [x] `npm run lint:routes` baseline 0
|
||||
- [x] Merged to `feature/sim-builder` — merge commit `dabb370` (--no-ff), 2026-06-14. Post-merge: тесты = baseline, lint:routes 0.
|
||||
|
||||
### 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/подписанные токены).
|
||||
@@ -0,0 +1,135 @@
|
||||
# Phase 0: Слой целей в движке (goal / HUD / result)
|
||||
|
||||
**Status:** ✅ Done (reviewed — PASS, committed)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
## Objective
|
||||
Ввести в `_sim_engine.js` **«атом» игры** — декларативный блок `goal` (условие победы как
|
||||
булево SimExpr-выражение), вычисляемый каждый кадр, с фиксацией результата (победа/время/
|
||||
попытки/звёзды), callback'ом и HUD-оверлеем. Расширить серверный гейт `validateSpec`, чтобы
|
||||
блок проходил валидацию. Всё аддитивно: спека без `goal` ведёт себя как раньше.
|
||||
|
||||
## Спека (контракт, документировать в шапке `_sim_engine.js`)
|
||||
```
|
||||
goal: {
|
||||
when: '<bool expr>', // SimExpr: победа, когда станет истинным (≠0)
|
||||
hint?: 'текст подсказки', // показывается в HUD (escape на сервере)
|
||||
title?: 'Цель уровня', // краткая формулировка цели для HUD
|
||||
hold?: 0.0, // сек, сколько условие должно держаться (деф. 0 = мгновенно)
|
||||
stars?: [ // 0..3 доп.условий-«звёзд» (бонусы)
|
||||
{ when:'<bool expr>', label?:'...' }
|
||||
],
|
||||
fail?: '<bool expr>' // опц.: мгновенный проигрыш (вышел за поле/задел шип)
|
||||
}
|
||||
```
|
||||
- `when`/`stars[].when`/`fail` — компилируются ОДИН раз при mount (как все выражения), env тот же.
|
||||
- Доп. env-поля для целей: `t` (время), `tries` (число reset с начала), плюс всё что уже в env
|
||||
(`<obj>.x/.y/.vx/.vy`, params, w/h, xmin..ymax). НЕ вводить новых небезопасных идентификаторов.
|
||||
- Звезда «залипает»: однажды истинное условие звезды остаётся засчитанным до reset (накопитель).
|
||||
|
||||
## Tasks
|
||||
- [x] Task 1: В шапке `_sim_engine.js` задокументировать блок `goal` (формат v1, как сделано для physics/plot).
|
||||
- [x] Task 2: В `prepare`/mount компилировать `goal.when`, `goal.fail`, каждое `stars[].when`
|
||||
через `SimExpr.compile` (хранить fn + error; кривое выражение → никогда не бросает).
|
||||
- [x] Task 3: Добавить состояние результата на инстанс: `_goalState = { won, failed, timeMs,
|
||||
attempts, starsGot:[], firstWinT }`. Сбрасывать в `reset()` (attempts++ на reset, кроме первого).
|
||||
- [x] Task 4: В rAF-цикле (`_renderFrame`/`_stepPhysics`-соседство) после построения env и шага:
|
||||
вычислить звёзды (накопить), `fail` (→ мягкий проигрыш-оверлей, не победа), `when`
|
||||
(учесть `hold` — таймер удержания) → при победе зафиксировать `timeMs` (мировое t или
|
||||
wallclock от старта play), выставить `won=true`, остановить (`pause`) и вызвать `onGoal` callback.
|
||||
- [x] Task 5: HUD-оверлей (DOM, поверх canvas, как слой readout): строка цели (`title`),
|
||||
индикатор звёзд (inline SVG звезда — заполненная/контур), подсказка (`hint`),
|
||||
баннер «Победа»/«Ещё раз» с кнопкой Reset. Стиль — тёмная плашка как у readout; без эмодзи.
|
||||
HUD появляется ТОЛЬКО при наличии `goal` в спеке.
|
||||
- [x] Task 6: Публичное API инстанса: `onGoal(cb)` (cb получает `getResult()`), `getResult()`
|
||||
→ `{ won, failed, timeMs, attempts, stars:{got,total} }`, `resetResult()`.
|
||||
- [x] Task 7: `destroy()` снимает HUD-узлы/слушатели кнопок (нет утечек).
|
||||
- [x] Task 8: Сервер `backend/src/controllers/customSimController.js` `validateSpec`:
|
||||
разрешить ключ `goal` (объект) и `game` (объект, см. Phase 1/5) на верхнем уровне спеки;
|
||||
проверять `when`/`fail`/`stars[].when` через `checkExpr` (длина ≤ MAX_EXPR_LEN);
|
||||
`hint`/`title`/`stars[].label` — `sanitizeText` (escape + обрезка); `stars` ≤ 3; `hold` число.
|
||||
НЕ исполнять выражения. Обновить whitelist при необходимости.
|
||||
- [x] Task 9: Headless-смоук (vm + ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`,
|
||||
как в P1–P3): (а) спека с `goal.when` достигает победы → `getResult().won`, `timeMs>0`;
|
||||
(б) звёзды накапливаются и не сбрасываются до reset; (в) `fail` ставит failed без won;
|
||||
(г) `hold` требует удержания; (д) спека БЕЗ goal — поведение без изменений, HUD не создан;
|
||||
(е) `onGoal` зовётся один раз; (ж) destroy снимает HUD-слушатели (баланс add/remove).
|
||||
Удалить temp-смоук после прогона. → 40/40 PASS, удалён.
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/js/labs/_sim_engine.js` — блок goal: документация, компиляция, eval в цикле, HUD, API.
|
||||
- `backend/src/controllers/customSimController.js` — `validateSpec`: разрешить `goal`/`game`.
|
||||
- (temp) headless-смоук — создать, прогнать, удалить.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Спека с `goal` показывает цель/звёзды/победу; `getResult()` корректен; `onGoal` срабатывает.
|
||||
- Спека без `goal` рендерится и ведёт себя ровно как раньше (нет HUD, нет накладных вычислений побед).
|
||||
- `validateSpec` пропускает корректный `goal`, режет переразмер/длинные выражения, экранирует текст.
|
||||
- `node --check` обоих файлов OK; headless-смоук зелёный; эмодзи нет; `eval`/`new Function` нет.
|
||||
- `cd backend && npm test` — без новых регрессий; `npm run lint:routes` — без новых ошибок.
|
||||
|
||||
## Notes
|
||||
- Время победы: предпочесть **мировое `t`** (детерминизм, headless-тест), плюс можно хранить wallclock.
|
||||
- `attempts` = число `reset()` (первый mount/авто-reset не считать попыткой; считать пользовательские).
|
||||
- HUD не должен перехватывать pan/drag сцены вне своих интерактивных элементов (pointer-events: none
|
||||
на контейнере, auto — на кнопках), как сделано с overlay-панелью в P1.
|
||||
- Не вводить новые env-идентификаторы помимо `t`/`tries` — безопасность контракта выражений.
|
||||
|
||||
## Review Checklist
|
||||
- [x] Все задачи выполнены
|
||||
- [x] Код следует конвенциям движка (хелперы модульного уровня `_truthy`, инстанс-методы, без рисования по canvas — HUD это DOM-оверлей как readout)
|
||||
- [x] Аддитивность: существующие симуляции/каталог не затронуты (нет goal → `_goal=null`, HUD не создаётся, в rAF ветка `if (self._goal)` пропускается)
|
||||
- [x] Без эмодзи; без eval/Function (звёзды/иконки — inline SVG; выражения только через `SimExpr.compile`)
|
||||
- [x] Build (node --check) и тесты проходят (смоук 40/40; `npm test` 238 pass / 8 baseline fail; lint:routes 0)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### Движковое API цели (для Phase 1)
|
||||
Всё в `frontend/js/labs/_sim_engine.js`. `var inst = SimEngine.mount(host, spec)`:
|
||||
|
||||
- **`inst.onGoal(cb)`** — подписка. `cb` получает `getResult()` и вызывается РОВНО ОДИН РАЗ при
|
||||
первой победе (после `pause()`). Возвращает `inst` (chainable). Можно подписать несколько cb.
|
||||
- **`inst.getResult()`** → `{ won:bool, failed:bool, timeMs:number, attempts:number, stars:{got:number, total:number} }`.
|
||||
Для спеки БЕЗ `goal` возвращает **`null`** — проверяйте на null перед использованием.
|
||||
- **`inst.resetResult()`** — сбросить результат как новый уровень (won/failed/звёзды/таймер обнуляются),
|
||||
но **НЕ считается попыткой** (attempts сохраняется). Для перезапуска уровня используйте `inst.reset()`
|
||||
(это И сбрасывает физику/время И инкрементит attempts как пользовательскую попытку).
|
||||
|
||||
### Как включить «игровой режим» / HUD
|
||||
HUD появляется **автоматически**, как только в спеке есть верхнеуровневый блок `goal`. Отдельного
|
||||
флага «game mode» в движке НЕТ. Никакого вызова не нужно — `mount()` сам создаёт HUD при наличии `goal`.
|
||||
HUD = DOM-оверлей внутри `inst.el` (контейнер `pointer-events:none`, кнопка «Ещё раз» — `pointer-events:auto`,
|
||||
дёргает `inst.reset()`). Спрятать HUD = убрать `goal` из спеки.
|
||||
|
||||
### Как измеряется timeMs
|
||||
**Мировое время** `t` от старта уровня (детерминизм для headless/реплеев): при победе
|
||||
`timeMs = max(1, round(t*1000))`. Это НЕ wallclock — паузы/тротлинг rAF не влияют. `firstWinT`
|
||||
(сырое мировое `t` победы) хранится внутри в `_goalState`, наружу отдаётся только `timeMs`.
|
||||
|
||||
### Форма блока goal/game (что сервер теперь принимает)
|
||||
`validateSpec` (`backend/src/controllers/customSimController.js`) пропускает на верхнем уровне:
|
||||
```
|
||||
goal: {
|
||||
when: '<bool SimExpr>', // ≤500 симв., НЕ исполняется на сервере
|
||||
title?: '...', hint?: '...', // sanitizeText: escape & < > + обрезка (title≤120, hint≤300)
|
||||
hold?: 0, // число (сек удержания when); не-число → 400
|
||||
fail?: '<bool SimExpr>', // ≤500 симв.
|
||||
stars?: [ { when:'<bool SimExpr>', label?:'...' } ] // ≤3 (иначе 400); label sanitizeText ≤120
|
||||
}
|
||||
game?: { ... } // зарезервирован под мета-слой Ф1/5; проходит как есть
|
||||
// (под общими лимитами размер/глубина), НЕ исполняется
|
||||
```
|
||||
Категория `cat='game'` уже в `CATS` (math/phys/chem/bio/game) — расширять не нужно.
|
||||
|
||||
### Env-контракт цели (безопасность)
|
||||
Выражения `goal.when`/`fail`/`stars[].when` видят ВЕСЬ env кадра (params, `t`, `w/h`, `xmin..ymax`,
|
||||
`<obj>.x/.y/.vx/.vy`) ПЛЮС единственный доп.идентификатор **`tries`** (= `attempts`). НЕ вводить
|
||||
новых идентификаторов в env цели — это контракт безопасности шаренных выражений.
|
||||
|
||||
### Гочи для Phase 1
|
||||
- Победа ставит `pause()` внутри rAF-кадра; следующий queued-кадр выходит по `if (!self._running) return`
|
||||
→ `onGoal` не задвоится. НЕ вызывайте `play()` в `onGoal`-колбэке без `resetResult()`/`reset()`.
|
||||
- `goal.when` без `hold` срабатывает на ПЕРВОМ же кадре, где условие истинно (мгновенно).
|
||||
- Звёзды «залипают»: засчитываются и во время play (rAF), и на паузе/предпросмотре (`_renderFrame`).
|
||||
- Истинность булева = `_truthy`: конечное ненулевое число (SimExpr возвращает 0 при NaN/∞/ошибке).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Phase 1: Оболочка игры + 1 физ-уровень + прогресс (MVP)
|
||||
|
||||
**Status:** ✅ Done (reviewed — PASS, committed)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Сквозной играбельный срез: страница `/quantik` грузит уровень-спеку, монтирует движок в
|
||||
«игровом режиме» (управление = слайдеры закона + кнопка «Запуск»), на победу шлёт результат
|
||||
на сервер, показывает экран успеха со звёздами/временем. Прогресс сохраняется в БД.
|
||||
Первый уровень — «Артиллерия Квантика»: угол+скорость, попасть в портал, собрать звезду.
|
||||
|
||||
## Tasks
|
||||
- [x] Task 1: Миграция `076_game_progress.sql` `game_progress`: `id, user_id, level_id TEXT,
|
||||
best_time_ms INTEGER, best_stars INTEGER, attempts INTEGER, completed_at`. UNIQUE(user_id, level_id).
|
||||
- [x] Task 2: Контроллер `gameController.js` + роутер `game.js`, смонтирован в `server.js`
|
||||
(после `/api/custom-sims`). `GET /api/game/progress` (свой прогресс), `POST /api/game/progress`
|
||||
`{level_id, time_ms, stars}` (upsert: min time / max stars; attempts++). auth-only; валидация входа.
|
||||
- [x] Task 3: Клиент `LS.gameProgressList()` / `LS.gameProgressSubmit(levelId, {time_ms, stars})` в js/api.js.
|
||||
- [x] Task 4: Уровень как ДАННЫЕ: `frontend/js/game/levels.js` (`window.QuantikLevels`), встроенная
|
||||
спека `phys-artillery-1` (physics gravity + body launch + goal + 1 star + portal). Источник
|
||||
уровней зафиксирован в CONTEXT.md (встроенные данные сейчас; custom_sims в Ф5).
|
||||
- [x] Task 5: `frontend/quantik.html` + `frontend/js/game/quantik-game.js`: доступ всем авторизованным
|
||||
(LS.initPage()); подключает `_sim_expr.js`+`_sim_engine.js` тем же путём, что lab/sim-builder.
|
||||
Монтирует уровень, `onGoal` → submit + экран успеха.
|
||||
- [x] Task 6: «Игровой режим» — HUD из Ф0 включается сам наличием `goal`; управление = слайдеры params
|
||||
движка + кнопки play/reset (встроены в `inst.el`). Редакторских панелей нет.
|
||||
- [x] Task 7: Экран успеха (DOM-оверлей страницы): звёзды (inline SVG), время, попытки, «Ещё раз»
|
||||
(inst.reset) / «Дальше» (disabled-заглушка для MVP). Без эмодзи.
|
||||
- [x] Task 8: Пункт сайдбара `js/sidebar.js` — `/quantik` в группе practice (icon `rocket`), видим всем.
|
||||
`isActive('/quantik')` подсветка работает на clean URL.
|
||||
- [x] Task 9: Тест `backend/tests/game.test.js` (паттерн lab-links.test.js): submit создаёт строку,
|
||||
лучший перезаписывает / худший нет, attempts++, per-user, требует auth (401), валидирует вход (400).
|
||||
13/13 PASS.
|
||||
|
||||
## Files to Modify/Create
|
||||
- `backend/src/db/migrations/0NN_game_progress.sql` — таблица прогресса.
|
||||
- `backend/src/controllers/gameController.js`, `backend/src/routes/game.js` — API.
|
||||
- `backend/src/server.js` — монтаж роутера.
|
||||
- `frontend/quantik.html`, `frontend/js/game/quantik-game.js`, `frontend/js/game/levels.js` — клиент+уровень.
|
||||
- `frontend/js/api.js` — `LS.gameProgress*`.
|
||||
- `frontend/js/sidebar.js` — пункт меню.
|
||||
- `backend/tests/game.test.js` — тест.
|
||||
|
||||
## Acceptance Criteria
|
||||
- `/quantik` грузится, монтирует уровень, цель видна; «Запуск» проигрывает физику.
|
||||
- Попадание в портал (+звезда) → экран успеха с временем/звёздами; результат записан в `game_progress`.
|
||||
- Повторный худший результат не перезаписывает лучший; attempts растёт.
|
||||
- `npm run migrate` применяет миграцию; `npm test` зелёный (+ новый тест); `lint:routes` baseline 0.
|
||||
|
||||
## Notes
|
||||
- Маршрутизация `/js/game/*`: помнить гочу sim-builder — `/js` мапится на корневой `js/`, а файлы
|
||||
лежат во `frontend/js/game/` → отдаются через `express.static(frontendDir)`. Не трогать server.js static.
|
||||
- Роуты `:id` прикрыть `authMiddleware` на уровне роутера (lint:routes baseline 0).
|
||||
- Время — из `getResult().timeMs` (Ф0).
|
||||
|
||||
## Review Checklist
|
||||
- [x] Все задачи; конвенции (ownership/auth как studentMaterials/customSim); без эмодзи/eval
|
||||
- [x] Миграция применяется; API безопасен; тест зелёный; lint baseline 0; existing тесты не сломаны
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### Реестр уровней (форма данных)
|
||||
`frontend/js/game/levels.js` → `window.QuantikLevels`:
|
||||
- `QuantikLevels.list()` → массив записей уровней (копия); `QuantikLevels.get(id)` → одна запись или null; `QuantikLevels.LEVELS` — сырой массив.
|
||||
- **Запись уровня**: `{ id, title, subject?, hint?, spec }`. `id` == `level_id` для API прогресса.
|
||||
`spec` — обычная спека SimForge с верхнеуровневым блоком `goal` (Ф0). Сейчас один уровень `phys-artillery-1`.
|
||||
- **Добавить уровень** = добавить запись в `LEVELS` (или, в Ф5, подгрузить опубликованные `custom_sims` cat='game' и смержить в реестр — той же формы записи). Источник = данные, не код.
|
||||
- Уровень мог бы прийти и из `custom_sims` (cat='game'): `spec` уже валидируется сервером (validateSpec пропускает goal/game). Реестр в Ф2/Ф5 может стать асинхронным (загрузка + слияние со встроенными).
|
||||
|
||||
### Контракт API прогресса
|
||||
- `GET /api/game/progress` (auth) → `{ progress: [ { level_id, best_time_ms, best_stars, attempts, completed_at } ] }` — все уровни текущего игрока.
|
||||
- `POST /api/game/progress` (auth) body `{ level_id, time_ms, stars }` → `{ ok:true, progress:{...одна строка...} }`. Upsert: best_time_ms=min, best_stars=max, attempts++. Валидация: level_id строка ≤120; time_ms/stars неотрицательные целые; stars 0..3 (иначе 400).
|
||||
- Клиент: `LS.gameProgressList()`, `LS.gameProgressSubmit(levelId, { time_ms, stars })`.
|
||||
- Таблица `game_progress` — миграция **076**, UNIQUE(user_id, level_id), user_id ON DELETE CASCADE.
|
||||
- На Ф6 (лидерборд) — этой таблицы достаточно для «лучшее время по уровню»; агрегаты по классу — JOIN на class_members.
|
||||
|
||||
### Где живёт экран успеха / как монтируется уровень
|
||||
- Монтаж: `QuantikGame.start({ host, level })` → `SimEngine.mount(host, level.spec)` → возвращает `inst`. «Игровой режим» включается САМ (HUD появляется, т.к. в спеке есть `goal`). Управление — слайдеры params + play/reset движка (внутри `inst.el`).
|
||||
- Победа: `inst.onGoal(res => …)` (Ф0; срабатывает 1 раз). В колбэке: `LS.gameProgressSubmit(level.id, { time_ms: res.timeMs, stars: res.stars.got })` (best-effort, .catch офлайн) + экран успеха.
|
||||
- **Экран успеха** = DOM-оверлей `.qg-overlay`, добавляется в `host` (=`#qg-stage`), `QuantikGame.buildSuccessOverlay(state)` строит карточку (звёзды inline SVG, время/звёзды/попытки, кнопки). «Ещё раз» → убрать оверлей + `inst.reset()`. «Дальше» — disabled-заглушка (нет следующего уровня в MVP); Ф2 (карта/мир) активирует её переходом к следующему узлу.
|
||||
- CSS оверлея — в `<style>` `quantik.html` (`.qg-*`). Ф2 переиспользует `buildSuccessOverlay` (можно расширить параметром «следующий уровень»).
|
||||
|
||||
### Гочи для Ф2
|
||||
- `inst.onGoal` срабатывает 1 раз и делает `pause()`. Перезапуск — `inst.reset()` (это И физика, И attempts++). Не звать `play()` в onGoal-колбэке.
|
||||
- `res.timeMs` — мировое время (детерминизм), не wallclock. `res.stars.got`/`res.stars.total` — счётчики звёзд.
|
||||
- Страница не разрушает `inst` явно при навигации; Ф2 при смене уровня без перезагрузки должна вызвать `inst.destroy()` перед монтированием нового (или перезагружать `?level=`).
|
||||
- Сайдбар-пункт `/quantik` видим ВСЕМ (без `hidden`), в отличие от teacher-only `/sim-builder`.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Phase 2: Карта-созвездие + мир физ-уровней + XP/скины (MVP-мир)
|
||||
|
||||
**Status:** ✅ Done (reviewed — PASS w/ notes; «Дальше» stale-hasNext 🟡 fixed; committed)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Превратить одиночный уровень в **играбельный мир**: карта-созвездие из ~5–6 физ-уровней,
|
||||
разблокировка по звёздам, XP, выбор скина Квантика, нарратор-Квантик (`PetSprite`) на интро/
|
||||
победе. После этой фазы игра полноценно отгружаема.
|
||||
|
||||
## Tasks
|
||||
- [x] Task 1: Контент — ~5–6 физ-уровней-спек (данные в `levels.js`), нарастающая сложность:
|
||||
артиллерия → перелёт через стену → отскок (restitution) → пружина/маятник → орбита/гравитация.
|
||||
Каждый: `goal` + 1–3 звезды + норматив времени (`par_ms`) для 3-й звезды.
|
||||
- [x] Task 2: Структура «мир/глава»: метаданные уровня (id, title, chapter, order, par_ms, hint).
|
||||
Карта группирует по главам (созвездиям).
|
||||
- [x] Task 3: Карта-созвездие `frontend/js/game/map.js` (+ разметка в quantik.html): узлы-уровни
|
||||
на SVG/canvas-фоне, линии-связи, статус (заблокирован/доступен/пройден + число звёзд).
|
||||
Разблокировка: уровень открыт, если набрано ≥ threshold звёзд в предыдущих (правило в данных).
|
||||
- [x] Task 4: XP/уровень игрока: XP = сумма звёзд × коэффициент (+ бонус за par). Хранить в
|
||||
прогрессе (расширить `game_progress` агрегацией на клиенте ИЛИ доб. поле/таблицу `game_player`).
|
||||
Полоса XP + «уровень Квантика» в шапке карты.
|
||||
- [x] Task 5: Скины Квантика: выбор `colorKey` из палитр `PetSprite` (+ позже паттерны). Скин
|
||||
влияет на цвет glow-точки героя в уровне (param/проп движка) и на `PetSprite` на карте.
|
||||
Хранить выбор (localStorage сейчас; серверно — опц.). Разблокировка скинов по XP/звёздам.
|
||||
- [x] Task 6: Нарратор: `PetSprite.render(...)` в интро уровня (краткая формулировка «почини закон…»)
|
||||
и на экране победы (реакция по числу звёзд: happy/ecstatic). Реюз mood из pet-sprite.js.
|
||||
- [x] Task 7: Навигация: карта → уровень → результат → возврат на карту с обновлённым статусом/XP.
|
||||
- [x] Task 8: Тесты: разблокировка (логика чистой функцией — юнит-тест), агрегация XP; смоук карты.
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/js/game/levels.js` — контент мира (расширить).
|
||||
- `frontend/js/game/map.js` — карта-созвездие.
|
||||
- `frontend/js/game/quantik-game.js` — навигация карта↔уровень, XP/скин в шапке.
|
||||
- `frontend/quantik.html` — разметка карты/шапки.
|
||||
- (опц.) `backend` — поле/агрегация игрока, если решим серверно; иначе клиентская агрегация прогресса.
|
||||
- тест(ы) разблокировки/XP.
|
||||
|
||||
## Acceptance Criteria
|
||||
- Карта показывает мир, статусы и звёзды; пройденные уровни открывают следующие.
|
||||
- XP/уровень Квантика растут; смена скина видна и на карте, и в уровне.
|
||||
- Нарратор-Квантик появляется на интро/победе с корректным настроением.
|
||||
- Тесты разблокировки/XP зелёные; lint baseline 0; existing тесты не сломаны.
|
||||
|
||||
## Notes
|
||||
- Без эмодзи — звёзды/иконки только inline SVG (`.ic`).
|
||||
- Разблокировку держать **данными/чистой функцией** (легко тестировать и переносить на сервер).
|
||||
- Не плодить серверные таблицы без нужды: прогресс уже в `game_progress` (Ф1); XP можно агрегировать.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] Все задачи; чистая функция разблокировки покрыта тестом; без эмодзи/eval
|
||||
- [ ] Карта/навигация работают; existing тесты целы; lint baseline 0
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### Архитектура карты (`frontend/js/game/map.js`)
|
||||
- `window.QuantikMap.create({ host, headerHost, onPlay(level), getSkin()->key, onSkin(key) }) -> { render(progressMap), destroy() }`.
|
||||
- `render(progressMap)` рисует шапку (нарратор + XP-бар + всего звёзд + скин-пикер) в `headerHost`
|
||||
и созвездия в `host`. `progressMap` — `{ [level_id]: row }` (см. `QuantikProgress.fromProgressList`).
|
||||
- Узел созвездия (`buildNode`) — `<button class="qm-node qm-{locked|available|completed}">` с ядром
|
||||
(`.qm-node-core`), подписью, звёздами/порогом. Позиция в % через `layoutNodes(levels)` (зигзаг-дуга).
|
||||
- Статус узла = `QuantikProgress.nodeStatus`. Клик по available/completed → `onPlay(level)`.
|
||||
- Звёздное небо — SVG `<circle class="qm-tw">` (мерцание CSS), линии-связи `<line class="qm-link[.on]">`.
|
||||
- Поэтапное появление: `staggerReveal` снимает `.qm-pre`/ставит `.qm-in` через setTimeout (70 мс шаг).
|
||||
|
||||
### Как добавить главу (созвездие)
|
||||
- В `levels.js`: дать новым уровням `chapter:'<key>'` + добавить запись в `CHAPTERS`
|
||||
(`{ key, title, subtitle, accent }`). Карта группирует автоматически (`groupByChapter` сохраняет
|
||||
порядок появления глав). Узлы внутри главы сортируются по `order`. Никаких правок map.js не нужно.
|
||||
- **Фаза 3 (граф-уровни) = НОВАЯ глава** (напр. `chapter:'functions'`): добавить уровни-спеки с
|
||||
`objects:[{type:'plot',...}]` + `goal.when` по форме функции; `unlockStars` гейтит её за Динамику.
|
||||
Узел рисуется тем же `buildNode` (тип спеки карте безразличен — она читает только метаданные).
|
||||
|
||||
### Модуль логики прогресса (`frontend/js/game/progress-logic.js`, `window.QuantikProgress`)
|
||||
Чистые функции (без DOM/сети/eval) — переносимы на сервер, покрыты тестом:
|
||||
- `fromProgressList(list)` → карта `{level_id: row}` из ответа `/api/game/progress`.
|
||||
- `starsFor(id, map)` / `isCompleted(id, map)` / `totalStars(levels, map)`.
|
||||
- `isUnlocked(level, map, levels)` — уровень открыт, если Σ звёзд во ВСЕХ уровнях с меньшим `order`
|
||||
≥ `level.unlockStars` (порог в данных уровня). `unlockStars:0` (или нет) → всегда открыт.
|
||||
- `nodeStatus` / `starsToUnlock` — для карты.
|
||||
- `computeXp(levels, map)` = Σ(звёзды·`STAR_XP`=100 + `COMPLETE_XP`=40 за пройденный).
|
||||
- `playerLevel(xp)` → `{ level, xp, xpInto, xpForNext, progress01 }`. Шкала: `xpForLevel(L)=240·(L-1)L/2`.
|
||||
- `groupByChapter(levels)` → `[{ chapter, levels:[…sorted by order] }]`.
|
||||
- `nextPlayable(curId, levels, map)` → след. разблокированный уровень (для кнопки «Дальше») или null.
|
||||
|
||||
### Скины
|
||||
- localStorage ключ **`quantik-skin`** (экспортирован `QuantikGame.SKIN_KEY`). Значение = `colorKey`
|
||||
из `PetSprite.PALETTES` (валидируется при чтении, иначе fallback `'cyan'`).
|
||||
- `QuantikGame.getSkin()/setSkin(key)/skinColor(key)`. Тинт героя — `tintHeroSpec(spec, key)`:
|
||||
глубокая копия спеки (JSON), переписывает `color/glowColor/trailColor` объекта с `id:'ball'`.
|
||||
Гейты скинов — массив `SKIN_GATES` в map.js (needStars/needXp). 8 скинов.
|
||||
|
||||
### Нарратор
|
||||
- `PetSprite.render(level, mood, [], colorKey, 0, 'none')` (DOM SVG-строка). Вызовы:
|
||||
- Карта-шапка: `QuantikMap.renderHeader` (mood по уровню игрока: ecstatic≥5 / happy≥2 / neutral).
|
||||
- Интро уровня: `QuantikGame.buildIntro(level, skin)` (mood `happy`).
|
||||
- Экран успеха: `QuantikGame.buildSuccessOverlay(state, {skin, hasNext})` — mood `ecstatic`, если все
|
||||
звёзды (got≥total и total≥2), иначе `happy`.
|
||||
|
||||
### Навигация (inline-bootstrap в quantik.html)
|
||||
- Два вида: `#qg-map-view` (карта) и `#qg-level-view` (`#qg-stage` под движок). Переключение
|
||||
классом `.show`. `showMap()` перезагружает прогресс (`LS.gameProgressList`) → `map.render`.
|
||||
`openLevel→интро→launchLevel→onGoal→успех→onNext(nextPlayable)|onMap`. При смене уровня
|
||||
ВСЕГДА `destroyLevel()` (= `inst.destroy()` + очистка `#qg-stage`) до нового mount (гоча Ф1).
|
||||
- Deep-link `?level=<id>` открывает уровень, если он разблокирован; иначе карта.
|
||||
|
||||
### Решения/гочи (для ревью и Ф3+)
|
||||
- **XP/прогресс игрока — чисто клиентская агрегация** из `game_progress` (Ф1). Новых таблиц/роутов НЕТ
|
||||
→ lint:routes baseline 0 не тронут, бэкенд-тесты не изменились (259, 251 pass / 8 baseline fail).
|
||||
- Уровни 3/5/6 имеют «лёгкий» выигрышный путь, попутно дающий обе звезды; «честная» механика
|
||||
(отскок/орбита/колодец) присутствует, но не единственно-возможна — НЕ блокер MVP (см. winnability).
|
||||
- На сервер агрегацию XP перенести легко: те же чистые функции в `progress-logic.js` (без DOM).
|
||||
@@ -0,0 +1,96 @@
|
||||
# Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия
|
||||
|
||||
**Status:** ✅ Done (reviewed — PASS, committed)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Новый тип уровня: Квантик движется по кривой `y=f(x)`, которую **собирает игрок** (настраивает
|
||||
параметры/выбирает выражение). Препятствия — «запретные зоны»; цель/звёзды/проигрыш — выражения.
|
||||
Реюз `plot` + `SimExpr`. Сид граф-главы.
|
||||
|
||||
## Tasks
|
||||
- [x] Task 1: «Бегунок по кривой»: герой-точка с `x` = функция t (напр. линейный проход xmin→xmax),
|
||||
`y = f(x)` через ту же скомпилированную функцию, что у `plot`. Кривая рисуется (P3 plot),
|
||||
герой едет по ней с glow/trail. Без физики (кинематический проход), либо мягкая физика — на выбор уровня.
|
||||
→ `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
|
||||
(напр. `zone1.hit`) на основе позиции героя, чтобы `goal`/`fail` ссылались на них.
|
||||
→ `type:'zone'` (shape rect/circle, kind forbidden/target/collect, track). Движок кладёт `<zoneId>.hit`
|
||||
(1/0) в env. ⛔ Никаких inzone()-предикатов в грамматике — только именованные булевы env-поля.
|
||||
- [x] Task 3: Цель = добраться до конца/в целевую зону, не задев запретные (`fail`). Звёзды: пройти
|
||||
под нормативом, собрать бонус-точки (зоны-сборы).
|
||||
→ 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). Безопасно.
|
||||
→ коэффициенты = обычные `params`-слайдеры движка; крутишь → кривая+путь героя перестраиваются.
|
||||
Свободный ввод выражения не понадобился (слайдеры коэффициентов достаточны для MVP-главы).
|
||||
- [x] Task 5: Контент: сид граф-главы (~4–5 уровней): синус под мостом, парабола над ямой,
|
||||
кусочная подгонка, экспонента/логарифм — растущая сложность, привязка к темам алгебры.
|
||||
→ 5 уровней в `functions`: луч (a·x+b), синус (A·sin(k·x)), парабола (a·(x−5)²+k),
|
||||
модуль (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
|
||||
- `frontend/js/labs/_sim_engine.js` — поддержка «бегунка по кривой» (если не выразимо текущими полями)
|
||||
и подготовка булевых полей зон в env. Аддитивно, документировать в шапке.
|
||||
- `frontend/js/game/levels.js` — граф-глава.
|
||||
- `frontend/js/game/quantik-game.js` / `map.js` — новая глава, управление коэффициентами.
|
||||
- тест(ы).
|
||||
|
||||
## Acceptance Criteria
|
||||
- Квантик едет по собранной игроком кривой; правильная `f(x)` проводит между препятствиями к цели.
|
||||
- Задевание запретной зоны → проигрыш; норматив/сборы дают звёзды.
|
||||
- Кривая безопасна (SimExpr, без eval); existing симуляции/уровни не затронуты; тесты зелёные.
|
||||
|
||||
## Notes
|
||||
- НЕ вводить произвольные функции-предикаты в синтаксис выражений (безопасность). Зоны → булевы env-поля.
|
||||
- Переиспользовать P3 plot (несколько кривых, заливка, маркеры) для визуала «земли»/препятствий.
|
||||
|
||||
## Review Checklist
|
||||
- [x] Все задачи; аддитивность движка; без эмодзи/eval; тесты зелёные; lint baseline 0
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### Контракт «бегунка по кривой» (движок, `_sim_engine.js`)
|
||||
- На объекте `plot`: `runner:{ duration?:8, hold?:true }`. Делает из ПЕРВОЙ кривой plot дорожку.
|
||||
- Движок кладёт в env (в `_buildEnv`, ДО формульных центров): `<plotId>.runX` (= `a + (b−a)·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) пройдут гейт.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Phase 4: Квантовые способности + SR-комнаты
|
||||
|
||||
**Status:** ✅ Done (reviewed — PASS, committed)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Фирменные «квантовые» механики, дающие герою идентичность, плюс связка с флешкарт-SR:
|
||||
**суперпозиция** (раздвоение), **коллапс/пауза** (точный прицел), **туннелирование**
|
||||
(проход сквозь тонкую стену за «энергию», которую даёт быстрое SR-повторение).
|
||||
|
||||
## Tasks
|
||||
- [x] Task 1: Суперпозиция: уровни L12/L13 с двумя телами `ball`+`ball2`, общий закон (theta,v)
|
||||
рулит обеими зеркально; `goal.when` ссылается на `ball.*` И `ball2.*` (победа — только обе).
|
||||
Реюз мульти-body физики. ball2 — полупрозрачный «фантом» (tintHeroSpec в quantik-game.js).
|
||||
- [x] Task 2: Коллапс/пауза-прицел: уровень L14 несёт пунктир-`plot` (id 'aim') с предсказанной
|
||||
параболой текущего закона; способность «Прицел» = пауза-тоггл (`inst.pause/play`) для прицела.
|
||||
- [x] Task 3: Туннелирование: барьер = `forbidden`-зона `wall`; `fail:'wall.hit && tunnel<1'`.
|
||||
Способность «Туннель» тратит TUNNEL_COST энергии → `inst.setParam('tunnel',1)` (стена
|
||||
проницаема). `tunnel` — НЕ слайдер (только способность); отсутствует в env → 0 → стена сплошная.
|
||||
- [x] Task 4: SR-комната: `QuantikAbilities.openRestRoom` — мини-сессия повторения в модалке
|
||||
(НЕ iframe). `LS.fcListDecks → fcStudySession → fcReview`; «Знаю/Легко» начисляют энергию.
|
||||
Пусто (нет колод/нет due) → дружелюбное окно + ссылка `/flashcards`.
|
||||
- [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
|
||||
- `frontend/js/labs/_sim_engine.js` — поле `tunnelable` у стены + расход энергии (аддитивно, документировать).
|
||||
- `frontend/js/game/quantik-game.js` — способности, HUD энергии, SR-комната-модалка.
|
||||
- интеграция с флешкарт-SR (клиентский модуль повторения / `LS` API).
|
||||
- `frontend/js/game/levels.js` — уровни способностей.
|
||||
- тест(ы).
|
||||
|
||||
## Acceptance Criteria
|
||||
- Суперпозиция: победа только когда обе копии выполнили условие.
|
||||
- Коллапс: на паузе виден предсказанный путь.
|
||||
- Туннелирование тратит энергию; SR-повторение её пополняет; стена проницаема только при заряде.
|
||||
- Без eval/эмодзи; existing симуляции/SR не сломаны; тесты зелёные; lint baseline 0.
|
||||
|
||||
## Notes
|
||||
- Имя param `e` зарезервировано (число Эйлера в SimExpr) — для энергии брать `energy`/`charge`.
|
||||
- SR-движок повторения уже существует — переиспользовать, не дублировать расписание.
|
||||
|
||||
## Review Checklist
|
||||
- [x] Все задачи; аддитивность; без эмодзи/eval; тесты зелёные; lint baseline 0
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
### Что добавлено (файлы)
|
||||
- **Новый** `frontend/js/game/quantik-abilities.js` (`window.QuantikEnergy` + `window.QuantikAbilities`).
|
||||
- `frontend/js/game/levels.js` — глава `quantum` (L12–L16) + `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.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Phase 5: Авторинг уровней в sim-builder + раздача классу
|
||||
|
||||
**Status:** ✅ Done (reviewed — PASS, committed)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Дать учителю собирать **игровые уровни без кода** в существующем sim-builder: задать цель/звёзды/
|
||||
подсказку/главу/норматив, сохранить как `custom_sims` с `cat='game'`, опубликовать и раздать
|
||||
классу. Игра начинает грузить уровни из БД (а не только встроенные).
|
||||
|
||||
⚠️ ПЕРЕД СТАРТОМ: свериться с base-веткой `feature/sim-builder` — чужой P4-WIP билдера должен
|
||||
быть смержен. При необходимости влить base в `feature/quantik-game` и разрешить конфликты.
|
||||
|
||||
## Tasks
|
||||
- [x] Task 1: Режим «Игровой уровень» в `sim-builder.js`/`.html`: панель цели (`goal.when`,
|
||||
`title`, `hint`, `hold`, `fail`), список звёзд (add/del: `when`+`label`), глава/порядок/`par_ms`.
|
||||
Inline-проверка выражений через `SimExpr.compile().error` (как остальные поля билдера).
|
||||
- [x] Task 2: `buildSpec()` материализует блок `goal`/`game`; `loadFromSim()` раскладывает обратно
|
||||
(round-trip), как сделано с plot-range в Ф4 билдера.
|
||||
- [x] Task 3: Кнопка «Играть» в билдере — открыть текущую спеку в игровом режиме (тест уровня автором).
|
||||
- [x] Task 4: Каталог уровней: игра грузит `custom_sims` c `cat='game'` (свои+published) — реюз
|
||||
`LS.customSimsList`/`Get`. Категория `game` в списке `CATS` (customSimController) + фильтр.
|
||||
- [x] Task 5: Раздача классу: реюз паттерна Ф6 sim-builder (авто-публикация + `pushNotif` ученикам,
|
||||
ссылка `/quantik?level=custom:<id>`); привязка к программе через `lab_sim_links` (`sim_id='custom:<id>'`).
|
||||
- [x] Task 6: Deep-link `/quantik?level=custom:<id>` (паттерн Ф5/Ф7 sim-builder, доступ own|published|admin).
|
||||
- [x] Task 7: Тесты: round-trip goal в билдере (headless как Ф4 sim-builder); доступ к чужому
|
||||
draft запрещён; published-уровень виден; раздача шлёт уведомление.
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/sim-builder.html`, `frontend/js/sim-builder.js` — режим игрового уровня (аддитивно).
|
||||
- `backend/src/controllers/customSimController.js` — `CATS` += 'game'; (goal уже в validateSpec из Ф0).
|
||||
- `frontend/js/game/quantik-game.js` — загрузка уровней из custom_sims + deep-link.
|
||||
- тест(ы).
|
||||
|
||||
## Acceptance Criteria
|
||||
- Учитель собирает уровень с целью/звёздами, тестирует «Играть», сохраняет/публикует.
|
||||
- Игра грузит уровни из БД; deep-link открывает конкретный уровень с проверкой доступа.
|
||||
- Раздача классу публикует + уведомляет; round-trip спеки без потерь; тесты зелёные; lint baseline 0.
|
||||
|
||||
## Notes
|
||||
- Билдер — зона, где мог идти параллельный P4-WIP; правки строго аддитивны, свериться с base.
|
||||
- Санитизация goal-полей — уже на сервере (Ф0). Клиентская валидация зеркалит её (как в Ф4 билдера).
|
||||
|
||||
## Review Checklist
|
||||
- [x] Все задачи; аддитивность билдера; ownership/доступ корректны; без эмодзи/eval; тесты зелёные
|
||||
|
||||
## 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` — обычные симуляции не затронуты.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Phase 6: Класс-лидерборд / живая гонка (classroom SSE)
|
||||
|
||||
> **REMOVED (Amendment 1, 2026-06-14)** — фаза не реализуется по решению пользователя.
|
||||
> Архивный subplan. `game_progress.level_id` (TEXT) уже готов под лидерборд, если фичу вернут.
|
||||
|
||||
**Status:** ❌ Removed
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
## Objective
|
||||
Соревновательный слой: лидерборды по уровню/классу и опциональная **живая гонка** в онлайн-уроке
|
||||
(реюз classroom SSE + моста `sim_state`/`apply_sim_state` из Ф7 sim-builder).
|
||||
|
||||
## Tasks
|
||||
- [ ] Task 1: API лидерборда: `GET /api/game/leaderboard?level_id=...&scope=class|global` — топ по
|
||||
времени/звёздам. Источник — `game_progress` (best per user). Доступ: класс — только своему классу.
|
||||
- [ ] Task 2: UI лидерборда: на экране уровня/победы и на карте — топ класса (имена/время/звёзды),
|
||||
позиция игрока. Inline SVG-медали, без эмодзи.
|
||||
- [ ] Task 3: Живая гонка (опц.): учитель в classroom запускает уровень классу; ученики решают
|
||||
одновременно; прогресс/финиши транслируются через существующий SSE-relay. Реюз iframe-конвейера
|
||||
`/lab?embed=...` НЕ требуется — гонка может жить на `/quantik` с гоночной комнатой по classId.
|
||||
- [ ] Task 4: Сервер: relay результатов гонки (минимальный, поверх существующего SSE), без новых
|
||||
тяжёлых таблиц — эфемерное состояние гонки в памяти/коротком хранилище.
|
||||
- [ ] Task 5: Тесты: лидерборд отдаёт корректный топ и режет чужой класс; submit обновляет позицию;
|
||||
смоук UI.
|
||||
|
||||
## Files to Modify/Create
|
||||
- `backend/src/controllers/gameController.js`, `routes/game.js` — leaderboard (+ гонка-relay).
|
||||
- `frontend/js/game/quantik-game.js` / `map.js` — UI лидерборда + гоночная комната.
|
||||
- (опц.) интеграция кнопки запуска гонки в classroom.html (аддитивно, как Ф7 sim-builder).
|
||||
- тест(ы).
|
||||
|
||||
## Acceptance Criteria
|
||||
- Лидерборд по классу/глобально корректен и изолирован по классу; позиция игрока видна.
|
||||
- Живая гонка (если включена) синхронит финиши классу через SSE; закрытие чистое.
|
||||
- Без эмодзи/eval; existing функционал цел; тесты зелёные; lint baseline 0.
|
||||
|
||||
## Notes
|
||||
- Реюз durable-уведомлений `pushNotif` для приглашения в гонку; эфемерный прогресс — через SSE.
|
||||
- classroom.html — большой; искать через vex по DOM-id, точечный Read (ast-index не индексит inline-script).
|
||||
|
||||
## Review Checklist
|
||||
- [ ] Все задачи; изоляция по классу; аддитивность classroom; без эмодзи/eval; тесты зелёные
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Финальная фаза — далее комплексное ревью и мерж в feature/sim-builder. -->
|
||||
Reference in New Issue
Block a user