@
feat(quantik-game): фаза 4 — квантовые способности + SR-комнаты Глава-созвездие quantum (L12–L16) и фирменные механики — всё через безопасную модель спеки, движок и бэкенд НЕ тронуты (engine touch = 0): - Суперпозиция: два тела ball+ball2, goal.when требует ОБА (зеркальный закон). Туннелирование: forbidden-зона wall + fail wall.hit && tunnel<1; способность тратит энергию → setParam(tunnel,1). Коллапс/прицел: пунктир- plot предсказанной траектории на паузе. - Энергия — клиентский ресурс (localStorage quantik-energy, QuantikEnergy). - SR-комната: мини-сессия повторения флешкарт в модалке (НЕ iframe), LS.fcStudySession/fcReview; «Знаю/Легко» дают энергию; текст карт экранируется, картинки — по regex-вайтлисту. Все 5 уровней проверены на реальном движке (2★ достижимы; суперпозиция требует оба тела; туннель-гейт блокирует без заряда). npm test 253/8 baseline; lint:routes 0; цепочка разблокировки проходима. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
This commit is contained in:
@@ -248,3 +248,17 @@ git push origin master
|
|||||||
- **Карта/запуск без правок 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`.
|
- **Карта/запуск без правок 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.
|
- **Сервер `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).
|
- **Верификация Ф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`).
|
||||||
|
|||||||
+293
-2
@@ -661,16 +661,307 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
Глава IV — «Квантовые законы» (созвездие): фирменные способности Квантика.
|
||||||
|
Всё выражено через БЕЗОПАСНУЮ модель спеки (params/зоны/plot), без новых
|
||||||
|
механик движка и без eval.
|
||||||
|
|
||||||
|
Суперпозиция — у уровня ДВА тела-копии Квантика (ball + ball2), один общий
|
||||||
|
«закон» (params) рулит обеими; победа — когда ОБЕ копии у своих порталов
|
||||||
|
(goal.when ссылается на ball.* и ball2.*). Реюз мульти-body физики.
|
||||||
|
|
||||||
|
Коллапс/прицел — на сцене всегда есть пунктирная «предсказанная траектория»
|
||||||
|
(plot выражения пути от текущего закона). Способность «Прицел» в игре
|
||||||
|
ставит паузу — игрок целится по предсказанной кривой до запуска.
|
||||||
|
|
||||||
|
Туннелирование — на пути стоит запретная стена-зона (tunnelable). Проигрыш
|
||||||
|
только когда стена задета И заряд не потрачен: fail:'wall.hit && tunnel < 1'.
|
||||||
|
Игровой слой тратит ЭНЕРГИЮ (заработанную в SR-комнате) и делает
|
||||||
|
inst.setParam('tunnel', 1) → стена временно проницаема. tunnel — НЕ слайдер
|
||||||
|
(управляется только способностью); по умолчанию отсутствует в env → 0
|
||||||
|
(неизвестная переменная SimExpr = 0) → стена сплошная.
|
||||||
|
──────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
var PHANTOM = '#C4B5FD'; // полупрозрачный «фантом» — вторая копия (ball2)
|
||||||
|
var AIM = '#38BDF8'; // предсказанная траектория (пунктир)
|
||||||
|
var TUNNEL_WALL = '#F472B6'; // tunnelable-стена (магента — «квантовый барьер»)
|
||||||
|
|
||||||
|
/* helper: вторая копия-герой (ball2) — стартует из (sx,sy), полупрозрачный фантом. */
|
||||||
|
function hero2(sx, sy, vxExpr, vyExpr) {
|
||||||
|
return {
|
||||||
|
id: 'ball2', type: 'point', r: 7, color: PHANTOM, opacity: 0.78,
|
||||||
|
x: sx, y: sy,
|
||||||
|
glow: true, glowColor: PHANTOM, trail: true, trailColor: PHANTOM, trailFade: true,
|
||||||
|
body: { mass: 1, vx: vxExpr, vy: vyExpr }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/* helper: портал-кольцо «фантомного» цвета (цель второй копии). */
|
||||||
|
function portal2Objs(px, py, r) {
|
||||||
|
return [
|
||||||
|
{ type: 'circle', x: px, y: py, r: r, color: PHANTOM, width: 3, glow: true, glowColor: PHANTOM },
|
||||||
|
{ type: 'circle', x: px, y: py, r: r * 0.45, color: PHANTOM, width: 2, opacity: 0.7 },
|
||||||
|
{ type: 'label', x: px, y: py + r + 0.9, text: 'портал-2', color: PHANTOM, size: 12 }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
/* helper: пунктирная предсказанная траектория (plot) — для «прицела». */
|
||||||
|
function aimPath(exprStr, a, b) {
|
||||||
|
return {
|
||||||
|
id: 'aim', type: 'plot', expr: exprStr, var: 'x',
|
||||||
|
range: [a, b], samples: 120, color: AIM, width: 1.6, lineStyle: 'dashed', opacity: 0.6
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Уровень 12 (обучение суперпозиции): «Раздвоение» — две копии Квантика летят
|
||||||
|
симметрично (вправо и влево) под ОДНИМ законом. Один (θ,v) обязан попасть в ОБА
|
||||||
|
зеркальных портала. Симметрия гарантирует решение. */
|
||||||
|
var L12_PX = 6.5, L12_PY = 0.7, L12_PR = 0.95; // правый портал (для ball)
|
||||||
|
var L12_PX2 = -6.5; // левый портал (для ball2, зеркало)
|
||||||
|
var superpos12 = {
|
||||||
|
id: 'quantum-superpos-12',
|
||||||
|
title: 'Раздвоение',
|
||||||
|
chapter: 'quantum',
|
||||||
|
order: 12,
|
||||||
|
unlockStars: 19,
|
||||||
|
par_ms: 1700,
|
||||||
|
subject: 'physics',
|
||||||
|
hint: 'Квантик в суперпозиции — он сразу в двух местах! Обе копии летят под ОДНИМ законом, зеркально. Подбери угол и скорость так, чтобы каждая копия попала в свой портал.',
|
||||||
|
spec: {
|
||||||
|
specVersion: 1,
|
||||||
|
meta: { title: 'Суперпозиция: раздвоение', desc: 'Два тела, один закон. Симметричный бросок.' },
|
||||||
|
viewport: { xmin: -9, xmax: 9, ymin: -1.2, ymax: 7, grid: true, axes: true, bg: BG },
|
||||||
|
params: [
|
||||||
|
{ name: 'theta', label: 'Угол', min: 20, max: 80, step: 1, value: 50, unit: '°' },
|
||||||
|
{ name: 'v', label: 'Скорость', min: 5, max: 18, step: 0.5, value: 9, unit: 'м/с' }
|
||||||
|
],
|
||||||
|
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
|
||||||
|
objects: [
|
||||||
|
{ type: 'segment', x1: -9, y1: 0, x2: 9, y2: 0, color: GROUND, width: 2 }
|
||||||
|
].concat(portalObjs(L12_PX, L12_PY, L12_PR), portal2Objs(L12_PX2, L12_PY, L12_PR), [
|
||||||
|
// ball летит вправо, ball2 — зеркально влево (тот же закон)
|
||||||
|
hero(0, 0, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
|
||||||
|
hero2(0, 0, '-v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
|
||||||
|
{ type: 'readout', label: 'v', expr: 'v', unit: 'м/с', precision: 1 },
|
||||||
|
{ type: 'readout', label: 'θ', expr: 'theta', unit: '°', precision: 0 }
|
||||||
|
]),
|
||||||
|
goal: {
|
||||||
|
title: 'Обе копии — в порталы',
|
||||||
|
hint: 'Победа — когда ОБЕ копии Квантика достигнут своих порталов одновременно.',
|
||||||
|
when: 'hypot(ball.x - ' + L12_PX + ', ball.y - ' + L12_PY + ') < ' + L12_PR +
|
||||||
|
' && hypot(ball2.x - (' + L12_PX2 + '), ball2.y - ' + L12_PY + ') < ' + L12_PR,
|
||||||
|
fail: 't > 6',
|
||||||
|
stars: [
|
||||||
|
{ when: 't*1000 <= 1700', label: 'Быстро (≤1.7 с)' },
|
||||||
|
{ when: 'theta >= 55', label: 'Высокая дуга (θ ≥ 55°)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Уровень 13 (применение суперпозиции): «Две двери» — копии стартуют из РАЗНЫХ
|
||||||
|
точек, но скорости связаны одним законом (ball вправо-вверх, ball2 влево-вверх
|
||||||
|
одним законом). Обе зеркальные дуги должны перелететь центральную стену и
|
||||||
|
одновременно войти в свои порталы на вершинах дуг — нужен точный общий закон. */
|
||||||
|
var L13_PX = 5.0, L13_PY = 3.2, L13_PR = 0.85; // правый портал (вершина дуги ball)
|
||||||
|
var L13_PX2 = -5.0; // левый портал (зеркало, ball2)
|
||||||
|
var L13_WALLH = 2.0; // центральная стена — дуги обязаны её перелететь
|
||||||
|
var superpos13 = {
|
||||||
|
id: 'quantum-superpos-13',
|
||||||
|
title: 'Две двери',
|
||||||
|
chapter: 'quantum',
|
||||||
|
order: 13,
|
||||||
|
unlockStars: 20,
|
||||||
|
par_ms: 2000,
|
||||||
|
subject: 'physics',
|
||||||
|
hint: 'Две копии летят зеркально из центра — вправо и влево, под одним законом. Перебрось обе дуги через центральную стену так, чтобы вершина каждой дуги попала в свой портал.',
|
||||||
|
spec: {
|
||||||
|
specVersion: 1,
|
||||||
|
meta: { title: 'Суперпозиция: две двери', desc: 'Один закон ведёт обе зеркальные копии в свои двери.' },
|
||||||
|
viewport: { xmin: -7.5, xmax: 7.5, ymin: -1.2, ymax: 6, grid: true, axes: true, bg: BG },
|
||||||
|
params: [
|
||||||
|
{ name: 'theta', label: 'Угол', min: 25, max: 80, step: 1, value: 50, unit: '°' },
|
||||||
|
{ name: 'v', label: 'Скорость', min: 6, max: 16, step: 0.5, value: 10, unit: 'м/с' }
|
||||||
|
],
|
||||||
|
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
|
||||||
|
objects: [
|
||||||
|
{ type: 'segment', x1: -7.5, y1: 0, x2: 7.5, y2: 0, color: GROUND, width: 2 },
|
||||||
|
// центральная стена-препятствие (только визуал + ориентир; дуги должны быть выше)
|
||||||
|
{ type: 'segment', x1: 0, y1: 0, x2: 0, y2: L13_WALLH, color: '#475569', width: 6 },
|
||||||
|
{ type: 'label', x: 0, y: L13_WALLH + 0.5, text: 'стена', color: '#94A3B8', size: 11 }
|
||||||
|
].concat(portalObjs(L13_PX, L13_PY, L13_PR), portal2Objs(L13_PX2, L13_PY, L13_PR), [
|
||||||
|
// обе копии стартуют из центра (0, 0.2): ball вправо, ball2 зеркально влево
|
||||||
|
hero(0, 0.2, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
|
||||||
|
hero2(0, 0.2, '-v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
|
||||||
|
{ type: 'readout', label: 'v', expr: 'v', unit: 'м/с', precision: 1 },
|
||||||
|
{ type: 'readout', label: 'θ', expr: 'theta', unit: '°', precision: 0 }
|
||||||
|
]),
|
||||||
|
goal: {
|
||||||
|
title: 'Обе копии — в свои двери',
|
||||||
|
hint: 'Закон один на двоих. Обе зеркальные копии должны войти в свои порталы на вершинах дуг.',
|
||||||
|
when: 'hypot(ball.x - ' + L13_PX + ', ball.y - ' + L13_PY + ') < ' + L13_PR +
|
||||||
|
' && hypot(ball2.x - (' + L13_PX2 + '), ball2.y - ' + L13_PY + ') < ' + L13_PR,
|
||||||
|
fail: 't > 6',
|
||||||
|
stars: [
|
||||||
|
{ when: 't*1000 <= 2000', label: 'Быстро (≤2.0 с)' },
|
||||||
|
{ when: 'v <= 11', label: 'Экономный бросок (v ≤ 11)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Уровень 14 (обучение прицела/коллапса): «Прицел» — бросок под углом к порталу,
|
||||||
|
на сцене всегда видна предсказанная траектория (пунктир). Способность «Прицел»
|
||||||
|
(в игре) ставит паузу: целься по кривой до запуска. */
|
||||||
|
var L14_PX = 8.6, L14_PY = 4.4, L14_PR = 0.8;
|
||||||
|
var L14_CX = 5.0, L14_CY = 5.2, L14_CR = 0.7;
|
||||||
|
// предсказанная траектория: парабола броска y = tan(θ)·x − g·x² / (2·v²·cos²θ)
|
||||||
|
var L14_AIM = 'tan(theta*pi/180)*x - 9.8*x^2 / (2*v^2*cos(theta*pi/180)^2)';
|
||||||
|
var aimer14 = {
|
||||||
|
id: 'quantum-aim-14',
|
||||||
|
title: 'Прицел',
|
||||||
|
chapter: 'quantum',
|
||||||
|
order: 14,
|
||||||
|
unlockStars: 22,
|
||||||
|
par_ms: 1600,
|
||||||
|
subject: 'physics',
|
||||||
|
hint: 'Пунктирная линия — предсказанная траектория Квантика для текущего закона. Способность «Прицел» ставит паузу: целься по кривой, затем коллапсируй (запусти) в портал.',
|
||||||
|
spec: {
|
||||||
|
specVersion: 1,
|
||||||
|
meta: { title: 'Коллапс: прицел', desc: 'Предсказанная траектория параболы броска.' },
|
||||||
|
viewport: { xmin: -1, xmax: 11, ymin: -1.2, ymax: 8, grid: true, axes: true, bg: BG },
|
||||||
|
params: [
|
||||||
|
{ name: 'theta', label: 'Угол', min: 20, max: 80, step: 1, value: 55, unit: '°' },
|
||||||
|
{ name: 'v', label: 'Скорость', min: 6, max: 18, step: 0.5, value: 11, unit: 'м/с' }
|
||||||
|
],
|
||||||
|
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
|
||||||
|
objects: [
|
||||||
|
{ type: 'segment', x1: -1, y1: 0, x2: 11, y2: 0, color: GROUND, width: 2 },
|
||||||
|
aimPath(L14_AIM, 0, 10.5)
|
||||||
|
].concat(crystalObjs(L14_CX, L14_CY, L14_CR), portalObjs(L14_PX, L14_PY, L14_PR), [
|
||||||
|
hero(0, 0, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
|
||||||
|
{ type: 'readout', label: 'v', expr: 'v', unit: 'м/с', precision: 1 },
|
||||||
|
{ type: 'readout', label: 'θ', expr: 'theta', unit: '°', precision: 0 }
|
||||||
|
]),
|
||||||
|
goal: {
|
||||||
|
title: 'Прицелься и попади в портал',
|
||||||
|
hint: 'Веди предсказанную кривую в портал, затем запусти. Бонус: задень кристалл на дуге.',
|
||||||
|
when: 'hypot(ball.x - ' + L14_PX + ', ball.y - ' + L14_PY + ') < ' + L14_PR,
|
||||||
|
fail: 'ball.x > 10.6 || ball.y < -1.0',
|
||||||
|
stars: [
|
||||||
|
{ when: 'hypot(ball.x - ' + L14_CX + ', ball.y - ' + L14_CY + ') < ' + L14_CR, label: 'Собрал кристалл' },
|
||||||
|
{ when: 't*1000 <= 1600', label: 'Быстро (≤1.6 с)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Уровень 15 (обучение туннелирования): «Квантовый барьер» — Квантик едет по
|
||||||
|
прямой к порталу, но на пути СТЕНА (tunnelable-зона). Без заряда стена сплошная
|
||||||
|
(задел → проигрыш). Способность «Туннель» (за энергию) делает стену проницаемой:
|
||||||
|
fail:'wall.hit && tunnel < 1'. */
|
||||||
|
var tunnel15 = {
|
||||||
|
id: 'quantum-tunnel-15',
|
||||||
|
title: 'Квантовый барьер',
|
||||||
|
chapter: 'quantum',
|
||||||
|
order: 15,
|
||||||
|
unlockStars: 24,
|
||||||
|
par_ms: 5200,
|
||||||
|
subject: 'physics',
|
||||||
|
hint: 'Стена-барьер преграждает путь напрямую, обойти её нельзя. Накопи энергию в комнате повторения и потрать заряд туннелирования — Квантик пройдёт СКВОЗЬ барьер.',
|
||||||
|
spec: {
|
||||||
|
specVersion: 1,
|
||||||
|
meta: { title: 'Туннелирование: барьер', desc: 'Прохождение сквозь барьер за квантовый заряд.' },
|
||||||
|
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8, grid: true, axes: true, bg: BG },
|
||||||
|
time: { duration: 5, loop: false },
|
||||||
|
params: [
|
||||||
|
{ name: 'a', label: 'Наклон a', min: -0.3, max: 0.5, step: 0.02, value: 0.1 },
|
||||||
|
{ name: 'b', label: 'Высота b', min: 2.5, max: 4.5, step: 0.1, value: 3.5 }
|
||||||
|
],
|
||||||
|
objects: [
|
||||||
|
// барьер ровно поперёк пути (вертикальный брус в центре, перекрывает весь коридор по y)
|
||||||
|
{ type: 'zone', id: 'wall', kind: 'forbidden', shape: 'rect', x: 5, y: 3.5, w: 0.8, h: 7.2,
|
||||||
|
color: TUNNEL_WALL, label: 'барьер' },
|
||||||
|
// целевой портал справа
|
||||||
|
circZone('gate', 'target', 9.5, 3.5, 0.95, 'портал'),
|
||||||
|
// бонус-монета сразу за барьером
|
||||||
|
circZone('coin', 'collect', 6.5, 3.5, 0.6, 'монета'),
|
||||||
|
startMarker(0, 3.5),
|
||||||
|
road('a*x + b', 0, 9.5, 5),
|
||||||
|
graphHero(),
|
||||||
|
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
|
||||||
|
{ type: 'readout', label: 'b', expr: 'b', precision: 1 }
|
||||||
|
],
|
||||||
|
goal: {
|
||||||
|
title: 'Пройди сквозь барьер',
|
||||||
|
hint: 'Доберись до портала. Барьер задержит Квантика, пока не потрачен заряд туннелирования. Бонус: собери монету за барьером.',
|
||||||
|
when: 'gate.hit',
|
||||||
|
// проигрыш только если задел стену БЕЗ заряда туннеля (tunnel<1 ⇒ заряд не потрачен)
|
||||||
|
fail: 'wall.hit && tunnel < 1',
|
||||||
|
stars: [
|
||||||
|
{ when: 'coin.hit', label: 'Собрал монету' },
|
||||||
|
{ when: 'gate.hit && abs(b - 3.5) < 0.4', label: 'Ровный путь (b ≈ 3.5)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Уровень 16 (капстоун главы): «Сквозь стену в дверь» — комбо: бегунок-парабола,
|
||||||
|
барьер-зона поперёк дуги (нужен туннель) и целевой портал за ним; монета на дуге. */
|
||||||
|
var tunnel16 = {
|
||||||
|
id: 'quantum-tunnel-16',
|
||||||
|
title: 'Сквозь стену',
|
||||||
|
chapter: 'quantum',
|
||||||
|
order: 16,
|
||||||
|
unlockStars: 26,
|
||||||
|
par_ms: 6000,
|
||||||
|
subject: 'physics',
|
||||||
|
hint: 'Дуга-парабола ведёт к высокому порталу, но её пересекает квантовый барьер. Туннелируй сквозь него (за энергию) и подгони дугу так, чтобы попасть в дверь.',
|
||||||
|
spec: {
|
||||||
|
specVersion: 1,
|
||||||
|
meta: { title: 'Туннель сквозь стену', desc: 'Парабола + барьер: туннель в портал.' },
|
||||||
|
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 9, grid: true, axes: true, bg: BG },
|
||||||
|
time: { duration: 6, loop: false },
|
||||||
|
params: [
|
||||||
|
{ name: 'a', label: 'Раскрытие a', min: -0.5, max: -0.15, step: 0.02, value: -0.3 },
|
||||||
|
{ name: 'k', label: 'Высота вершины k', min: 5, max: 8, step: 0.1, value: 6.5 }
|
||||||
|
],
|
||||||
|
objects: [
|
||||||
|
// барьер поперёк восходящей дуги (вертикальный брус)
|
||||||
|
{ type: 'zone', id: 'wall', kind: 'forbidden', shape: 'rect', x: 3.2, y: 4.0, w: 0.7, h: 8.4,
|
||||||
|
color: TUNNEL_WALL, label: 'барьер' },
|
||||||
|
// монета у самой вершины высокой дуги (k≈7) — совместима со 2-й звездой k≥6.8
|
||||||
|
circZone('coin', 'collect', 5, 6.9, 0.85, 'монета'),
|
||||||
|
circZone('gate', 'target', 9.3, 4.6, 0.95, 'портал'),
|
||||||
|
startMarker(0, 0),
|
||||||
|
road('a*(x-5)^2 + k', 0, 9.3, 6),
|
||||||
|
graphHero(),
|
||||||
|
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
|
||||||
|
{ type: 'readout', label: 'k', expr: 'k', precision: 1 }
|
||||||
|
],
|
||||||
|
goal: {
|
||||||
|
title: 'Туннелем в дальний портал',
|
||||||
|
hint: 'Туннелируй сквозь барьер и проведи дугу в портал. Бонус: задень монету на вершине.',
|
||||||
|
when: 'gate.hit',
|
||||||
|
fail: 'wall.hit && tunnel < 1',
|
||||||
|
stars: [
|
||||||
|
{ when: 'coin.hit', label: 'Собрал монету' },
|
||||||
|
{ when: 'gate.hit && k >= 6.8', label: 'Высокая дуга (k ≥ 6.8)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
var LEVELS = [
|
var LEVELS = [
|
||||||
artillery1, arc2, bounce3, pendulum4, orbit5, slingshot6,
|
artillery1, arc2, bounce3, pendulum4, orbit5, slingshot6,
|
||||||
graphLine7, graphSine8, graphParab9, graphAbs10, graphExp11
|
graphLine7, graphSine8, graphParab9, graphAbs10, graphExp11,
|
||||||
|
superpos12, superpos13, aimer14, tunnel15, tunnel16
|
||||||
];
|
];
|
||||||
|
|
||||||
/* Метаданные глав (созвездий) — для заголовков/оформления карты. */
|
/* Метаданные глав (созвездий) — для заголовков/оформления карты. */
|
||||||
var CHAPTERS = {
|
var CHAPTERS = {
|
||||||
kinematics: { key: 'kinematics', title: 'Кинематика', subtitle: 'Полёт и гравитация', accent: '#22D3EE' },
|
kinematics: { key: 'kinematics', title: 'Кинематика', subtitle: 'Полёт и гравитация', accent: '#22D3EE' },
|
||||||
dynamics: { key: 'dynamics', title: 'Динамика', subtitle: 'Силы, пружины, орбиты', accent: '#A78BFA' },
|
dynamics: { key: 'dynamics', title: 'Динамика', subtitle: 'Силы, пружины, орбиты', accent: '#A78BFA' },
|
||||||
functions: { key: 'functions', title: 'Функции', subtitle: 'Едем по кривой y = f(x)', accent: '#67E8F9' }
|
functions: { key: 'functions', title: 'Функции', subtitle: 'Едем по кривой y = f(x)', accent: '#67E8F9' },
|
||||||
|
quantum: { key: 'quantum', title: 'Квантовые законы', subtitle: 'Суперпозиция · прицел · туннель', accent: '#C4B5FD' }
|
||||||
};
|
};
|
||||||
|
|
||||||
function list() { return LEVELS.slice(); }
|
function list() { return LEVELS.slice(); }
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -46,24 +46,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Тинтуем героя уровня (объект с id 'ball') цветом скина — БЕЗ исполнения,
|
/* Тинтуем героя уровня (объект с id 'ball') цветом скина — БЕЗ исполнения,
|
||||||
просто переписываем цветовые поля спеки-копии перед монтированием. */
|
просто переписываем цветовые поля спеки-копии перед монтированием.
|
||||||
|
Фаза 4: вторую копию суперпозиции (id 'ball2') тоже тинтуем, но осветлённым
|
||||||
|
«фантомным» оттенком (полупрозрачность задаётся самой спекой). */
|
||||||
function tintHeroSpec(spec, skinKey) {
|
function tintHeroSpec(spec, skinKey) {
|
||||||
var color = skinColor(skinKey);
|
var color = skinColor(skinKey);
|
||||||
|
var phantom = lighten(color, 0.42);
|
||||||
// глубокая копия (спека — данные, без функций) чтобы не мутировать реестр
|
// глубокая копия (спека — данные, без функций) чтобы не мутировать реестр
|
||||||
var copy = JSON.parse(JSON.stringify(spec));
|
var copy = JSON.parse(JSON.stringify(spec));
|
||||||
if (Array.isArray(copy.objects)) {
|
if (Array.isArray(copy.objects)) {
|
||||||
for (var i = 0; i < copy.objects.length; i++) {
|
for (var i = 0; i < copy.objects.length; i++) {
|
||||||
var o = copy.objects[i];
|
var o = copy.objects[i];
|
||||||
if (o && o.id === 'ball') {
|
if (!o) continue;
|
||||||
|
if (o.id === 'ball') {
|
||||||
o.color = color;
|
o.color = color;
|
||||||
if (o.glow) o.glowColor = color;
|
if (o.glow) o.glowColor = color;
|
||||||
if (o.trail) o.trailColor = color;
|
if (o.trail) o.trailColor = color;
|
||||||
|
} else if (o.id === 'ball2') {
|
||||||
|
o.color = phantom;
|
||||||
|
if (o.glow) o.glowColor = phantom;
|
||||||
|
if (o.trail) o.trailColor = phantom;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Осветлить hex-цвет к белому на долю t (0..1). Для «фантома» суперпозиции.
|
||||||
|
Принимает #RGB/#RRGGBB; прочее возвращает как есть. */
|
||||||
|
function lighten(hex, t) {
|
||||||
|
if (typeof hex !== 'string') return hex;
|
||||||
|
var m = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
|
||||||
|
if (!m) return hex;
|
||||||
|
var h = m[1];
|
||||||
|
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
||||||
|
var r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
||||||
|
r = Math.round(r + (255 - r) * t); g = Math.round(g + (255 - g) * t); b = Math.round(b + (255 - b) * t);
|
||||||
|
function hx(n) { var s = n.toString(16); return s.length === 1 ? '0' + s : s; }
|
||||||
|
return '#' + hx(r) + hx(g) + hx(b);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Inline SVG звезды ── */
|
/* ── Inline SVG звезды ── */
|
||||||
function starSvg(filled) {
|
function starSvg(filled) {
|
||||||
var fill = filled ? '#FBBF24' : 'none';
|
var fill = filled ? '#FBBF24' : 'none';
|
||||||
@@ -164,6 +186,17 @@
|
|||||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
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? }
|
opts: { host, level, skin?, onNext?(level), onMap?(), hasNext?, resolveNext? }
|
||||||
resolveNext?() -> Promise<{ hasNext, next }>: пересчитать следующий уровень
|
resolveNext?() -> Promise<{ hasNext, next }>: пересчитать следующий уровень
|
||||||
@@ -180,6 +213,27 @@
|
|||||||
var spec = tintHeroSpec(level.spec, skin);
|
var spec = tintHeroSpec(level.spec, skin);
|
||||||
var inst = global.SimEngine.mount(host, spec);
|
var inst = global.SimEngine.mount(host, spec);
|
||||||
|
|
||||||
|
// ── Панель квантовых способностей + HUD энергии (Фаза 4) ──
|
||||||
|
// Аддитивно: монтируется только если доступен модуль; кнопки сами решают,
|
||||||
|
// уместны ли они для уровня (tunnel/aim-флаги). SR-комната открывается отсюда.
|
||||||
|
var abilities = null;
|
||||||
|
if (global.QuantikAbilities && global.QuantikAbilities.mountBar) {
|
||||||
|
abilities = global.QuantikAbilities.mountBar({
|
||||||
|
host: host,
|
||||||
|
inst: inst,
|
||||||
|
level: level,
|
||||||
|
onOpenRest: function () { openRest(host, abilities); }
|
||||||
|
});
|
||||||
|
// Убираем панель при destroy инстанса (оборачиваем существующий destroy).
|
||||||
|
if (abilities) {
|
||||||
|
var _origDestroy = inst.destroy.bind(inst);
|
||||||
|
inst.destroy = function () {
|
||||||
|
try { abilities.destroy(); } catch (_e) {}
|
||||||
|
return _origDestroy();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var overlayRef = null;
|
var overlayRef = null;
|
||||||
function clearOverlay() {
|
function clearOverlay() {
|
||||||
if (overlayRef && overlayRef.overlay && overlayRef.overlay.parentNode) {
|
if (overlayRef && overlayRef.overlay && overlayRef.overlay.parentNode) {
|
||||||
@@ -203,6 +257,7 @@
|
|||||||
overlayRef.btnAgain.addEventListener('click', function () {
|
overlayRef.btnAgain.addEventListener('click', function () {
|
||||||
clearOverlay();
|
clearOverlay();
|
||||||
try { inst.reset(); } catch (_e) {}
|
try { inst.reset(); } catch (_e) {}
|
||||||
|
if (abilities) try { abilities.resetAbilities(); } catch (_e) {}
|
||||||
});
|
});
|
||||||
overlayRef.btnNext.addEventListener('click', function () {
|
overlayRef.btnNext.addEventListener('click', function () {
|
||||||
clearOverlay();
|
clearOverlay();
|
||||||
|
|||||||
+111
-1
@@ -226,6 +226,115 @@
|
|||||||
.qg-stat-val { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.05rem; color: #EAF0F8; font-variant-numeric: tabular-nums; }
|
.qg-stat-val { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.05rem; color: #EAF0F8; font-variant-numeric: tabular-nums; }
|
||||||
.qg-actions { display: flex; justify-content: center; gap: 10px; }
|
.qg-actions { display: flex; justify-content: center; gap: 10px; }
|
||||||
.qg-btn { min-width: 118px; }
|
.qg-btn { min-width: 118px; }
|
||||||
|
|
||||||
|
/* ════════════════════ Квантовые способности (Фаза 4) ════════════════════ */
|
||||||
|
/* Панель способностей + HUD энергии — оверлеем поверх сцены уровня. */
|
||||||
|
.qa-bar {
|
||||||
|
position: absolute; right: 12px; bottom: 12px; z-index: 12;
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;
|
||||||
|
pointer-events: auto; max-width: calc(100% - 24px);
|
||||||
|
}
|
||||||
|
.qa-energy {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
background: rgba(17,19,42,0.86); border: 1px solid rgba(251,191,36,0.4);
|
||||||
|
border-radius: 99px; padding: 6px 12px 6px 10px; color: #FBBF24;
|
||||||
|
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: .9rem; font-variant-numeric: tabular-nums;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.qa-energy .ic { width: 16px; height: 16px; color: #FBBF24; }
|
||||||
|
.qa-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .82rem; font-weight: 600;
|
||||||
|
color: #E2E8F0; background: rgba(17,19,42,0.86); border: 1px solid rgba(148,163,184,0.28);
|
||||||
|
border-radius: 99px; padding: 7px 13px; cursor: pointer; transition: .16s;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.qa-btn .ic { width: 16px; height: 16px; }
|
||||||
|
.qa-btn:hover:not(:disabled) { border-color: rgba(196,181,253,0.6); color: #fff; background: rgba(30,33,66,0.94); }
|
||||||
|
.qa-btn:disabled { opacity: .42; cursor: not-allowed; }
|
||||||
|
.qa-rest { color: #67E8F9; border-color: rgba(34,211,238,0.32); }
|
||||||
|
.qa-rest:hover:not(:disabled) { border-color: rgba(34,211,238,0.6); }
|
||||||
|
.qa-tunnel { color: #F0ABFC; border-color: rgba(244,114,182,0.34); }
|
||||||
|
.qa-aim { color: #7DD3FC; border-color: rgba(56,189,248,0.34); }
|
||||||
|
.qa-cost { display: inline-flex; align-items: center; gap: 2px; font-size: .74rem; color: #FBBF24; font-weight: 800; }
|
||||||
|
.qa-cost .ic { width: 12px; height: 12px; color: #FBBF24; }
|
||||||
|
.qa-ability.qa-on {
|
||||||
|
color: #fff; border-color: rgba(196,181,253,0.85);
|
||||||
|
box-shadow: 0 0 0 2px rgba(196,181,253,0.35), 0 6px 18px rgba(167,139,250,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* всплывающая подсказка способности */
|
||||||
|
.qa-toast {
|
||||||
|
position: absolute; left: 50%; bottom: 70px; transform: translateX(-50%) translateY(8px); z-index: 14;
|
||||||
|
background: rgba(13,13,26,0.94); border: 1px solid rgba(196,181,253,0.5); color: #E8EDF5;
|
||||||
|
padding: 9px 16px; border-radius: 12px; font-size: .85rem; font-weight: 600;
|
||||||
|
box-shadow: 0 12px 34px rgba(0,0,0,0.5); opacity: 0; transition: opacity .28s, transform .28s; pointer-events: none;
|
||||||
|
max-width: 84%; text-align: center;
|
||||||
|
}
|
||||||
|
.qa-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
|
||||||
|
/* ── SR-комната (модалка повторения) ── */
|
||||||
|
.qa-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 60;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(7,7,18,0.78); backdrop-filter: blur(6px); padding: 16px;
|
||||||
|
}
|
||||||
|
.qa-modal {
|
||||||
|
background: linear-gradient(180deg, #15173099, #0F1024EE), #14152C;
|
||||||
|
border: 1px solid rgba(148,163,184,0.2); border-radius: 18px;
|
||||||
|
width: min(460px, 96vw); max-height: 92vh; overflow: hidden;
|
||||||
|
display: flex; flex-direction: column; box-shadow: 0 24px 70px rgba(0,0,0,0.6);
|
||||||
|
animation: qg-pop .24s cubic-bezier(.22,1.1,.4,1);
|
||||||
|
}
|
||||||
|
.qa-modal-head {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid rgba(148,163,184,0.16); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.qa-modal-title { display: inline-flex; align-items: center; gap: 8px; font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1rem; color: #EAF0F8; flex: 1; min-width: 0; }
|
||||||
|
.qa-modal-title .ic { width: 18px; height: 18px; color: #67E8F9; flex-shrink: 0; }
|
||||||
|
.qa-modal-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.qa-modal-energy { display: inline-flex; align-items: center; gap: 4px; color: #FBBF24; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||||
|
.qa-modal-energy .ic { width: 15px; height: 15px; color: #FBBF24; }
|
||||||
|
.qa-modal-x { background: none; border: none; color: #94A3B8; font-size: 1.5rem; line-height: 1; cursor: pointer; padding: 0 4px; }
|
||||||
|
.qa-modal-x:hover { color: #fff; }
|
||||||
|
.qa-modal-body { padding: 18px 18px 20px; overflow-y: auto; }
|
||||||
|
.qa-loading { text-align: center; color: #94A3B8; padding: 30px 0; }
|
||||||
|
.qa-empty-title { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.1rem; color: #EAF0F8; text-align: center; margin-bottom: 8px; }
|
||||||
|
.qa-empty-msg { color: #A8B4C6; text-align: center; line-height: 1.5; margin-bottom: 18px; }
|
||||||
|
.qa-modal-actions { display: flex; justify-content: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.qa-modal-btn { min-width: 130px; text-align: center; text-decoration: none; }
|
||||||
|
|
||||||
|
.qa-deck-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.qa-deck {
|
||||||
|
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
|
||||||
|
background: rgba(30,33,58,0.6); border: 1px solid rgba(148,163,184,0.18); border-radius: 12px;
|
||||||
|
padding: 11px 13px; cursor: pointer; font: inherit; transition: .15s;
|
||||||
|
}
|
||||||
|
.qa-deck:hover { border-color: var(--dk, #9B5DE5); background: rgba(40,44,78,0.7); }
|
||||||
|
.qa-deck-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--dk, #9B5DE5); flex-shrink: 0; }
|
||||||
|
.qa-deck-title { color: #E2E8F0; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.qa-deck-due { color: #67E8F9; font-size: .76rem; font-weight: 700; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* сессия повторения */
|
||||||
|
.qa-prog { height: 6px; border-radius: 99px; background: rgba(148,163,184,0.2); overflow: hidden; margin-bottom: 6px; }
|
||||||
|
.qa-prog-fill { height: 100%; background: linear-gradient(90deg, #22D3EE, #A78BFA); border-radius: 99px; transition: width .3s; }
|
||||||
|
.qa-prog-count { text-align: right; font-size: .72rem; color: #8B9AAE; margin-bottom: 12px; font-variant-numeric: tabular-nums; }
|
||||||
|
.qa-card {
|
||||||
|
min-height: 130px; background: rgba(20,22,44,0.72); border: 1px solid rgba(148,163,184,0.18);
|
||||||
|
border-radius: 14px; padding: 20px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
.qa-card-side { display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
||||||
|
.qa-card-back { border-top: 1px dashed rgba(148,163,184,0.3); padding-top: 12px; }
|
||||||
|
.qa-card-text { color: #E8EDF5; font-size: 1.02rem; line-height: 1.45; text-align: center; word-break: break-word; }
|
||||||
|
.qa-card-empty { color: #64748B; }
|
||||||
|
.qa-card-img { max-width: 100%; max-height: 160px; border-radius: 10px; }
|
||||||
|
.qa-flip { width: 100%; }
|
||||||
|
.qa-grades { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
|
||||||
|
.qa-grade { font: inherit; font-size: .82rem; font-weight: 700; color: #fff; border: none; border-radius: 10px; padding: 10px 4px; cursor: pointer; transition: filter .14s; }
|
||||||
|
.qa-grade:hover { filter: brightness(1.12); }
|
||||||
|
.qa-g-again { background: #DC2626; }
|
||||||
|
.qa-g-hard { background: #D97706; }
|
||||||
|
.qa-g-good { background: #2563EB; }
|
||||||
|
.qa-g-easy { background: #16A34A; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -271,10 +380,11 @@
|
|||||||
<!-- KaTeX для подписей сцены -->
|
<!-- KaTeX для подписей сцены -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||||
<!-- уровни (данные) + логика прогресса + карта + игра -->
|
<!-- уровни (данные) + логика прогресса + карта + способности + игра -->
|
||||||
<script src="/js/game/levels.js"></script>
|
<script src="/js/game/levels.js"></script>
|
||||||
<script src="/js/game/progress-logic.js"></script>
|
<script src="/js/game/progress-logic.js"></script>
|
||||||
<script src="/js/game/map.js"></script>
|
<script src="/js/game/map.js"></script>
|
||||||
|
<script src="/js/game/quantik-abilities.js"></script>
|
||||||
<script src="/js/game/quantik-game.js"></script>
|
<script src="/js/game/quantik-game.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -1046,7 +1046,7 @@ window.LS = {
|
|||||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
||||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
||||||
fcListDecks, fcCreateDeck, fcAddCard,
|
fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview,
|
||||||
escapeHtml, esc,
|
escapeHtml, esc,
|
||||||
parseDate, fmtRelTime, safeHref,
|
parseDate, fmtRelTime, safeHref,
|
||||||
initPage,
|
initPage,
|
||||||
@@ -1297,6 +1297,8 @@ async function adminAssistantModels(params) { const q = new URLSearchParams(para
|
|||||||
async function fcListDecks() { return req('GET', '/flashcards/decks'); }
|
async function fcListDecks() { return req('GET', '/flashcards/decks'); }
|
||||||
async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); }
|
async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); }
|
||||||
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
|
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
|
||||||
|
async function fcStudySession(deckId){ return req('GET', `/flashcards/decks/${deckId}/study`); }
|
||||||
|
async function fcReview(cardId, quality) { return req('POST', `/flashcards/cards/${cardId}/review`, { quality }); }
|
||||||
async function deleteFile(id) { return req('DELETE', `/files/${id}`); }
|
async function deleteFile(id) { return req('DELETE', `/files/${id}`); }
|
||||||
async function getFileAccess(id) { return req('GET', `/files/${id}/access`); }
|
async function getFileAccess(id) { return req('GET', `/files/${id}/access`); }
|
||||||
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }
|
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }
|
||||||
|
|||||||
@@ -50,6 +50,23 @@
|
|||||||
`quantik.html` (per-level бейдж темы). Новые тесты: custom-sims.test.js +2 (приём zone+runner, отказ
|
`quantik.html` (per-level бейдж темы). Новые тесты: custom-sims.test.js +2 (приём zone+runner, отказ
|
||||||
unknown type) — 26/26. Headless vm-смоук (per-level solvability + logic 29/29) зелёный и удалён.
|
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.
|
`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.
|
||||||
|
|
||||||
## Key Architecture Decisions
|
## Key Architecture Decisions
|
||||||
- **«Атом» = блок `goal` в спеке** (булево SimExpr). Любой уровень = спека SimForge + `goal`.
|
- **«Атом» = блок `goal` в спеке** (булево SimExpr). Любой уровень = спека SimForge + `goal`.
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
- [x] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md)
|
- [x] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md)
|
||||||
- [x] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md)
|
- [x] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md)
|
||||||
- [x] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md)
|
- [x] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md)
|
||||||
- [ ] Phase 4: Квантовые способности + SR-комнаты [domain: fullstack] → [subplan](./phase-4-quantum-abilities-sr.md)
|
- [x] Phase 4: Квантовые способности + SR-комнаты [domain: fullstack] → [subplan](./phase-4-quantum-abilities-sr.md)
|
||||||
- [ ] Phase 5: Авторинг уровней в sim-builder + раздача классу [domain: fullstack] → [subplan](./phase-5-authoring-sharing.md)
|
- [ ] Phase 5: Авторинг уровней в sim-builder + раздача классу [domain: fullstack] → [subplan](./phase-5-authoring-sharing.md)
|
||||||
- [ ] Phase 6: Класс-лидерборд / живая гонка (classroom SSE) [domain: fullstack] → [subplan](./phase-6-leaderboard-live.md)
|
- [ ] Phase 6: Класс-лидерборд / живая гонка (classroom SSE) [domain: fullstack] → [subplan](./phase-6-leaderboard-live.md)
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||||
| Phase 2: Карта + мир + XP/скины | fullstack | ✅ Done | ✅ (1 🟡 fixed) | ✅ | ✅ |
|
| Phase 2: Карта + мир + XP/скины | fullstack | ✅ Done | ✅ (1 🟡 fixed) | ✅ | ✅ |
|
||||||
| Phase 3: Граф-уровни + зоны | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
| Phase 3: Граф-уровни + зоны | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||||
| Phase 4: Квантовые способности + SR | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 4: Квантовые способности + SR | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||||
| Phase 5: Авторинг + раздача | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 5: Авторинг + раздача | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
| Phase 6: Лидерборд / живая гонка | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 6: Лидерборд / живая гонка | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 4: Квантовые способности + SR-комнаты
|
# Phase 4: Квантовые способности + SR-комнаты
|
||||||
|
|
||||||
**Status:** ⬜ Not Started
|
**Status:** ✅ Done (reviewed — PASS, committed)
|
||||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
**Domain:** fullstack
|
**Domain:** fullstack
|
||||||
|
|
||||||
@@ -10,19 +10,21 @@
|
|||||||
(проход сквозь тонкую стену за «энергию», которую даёт быстрое SR-повторение).
|
(проход сквозь тонкую стену за «энергию», которую даёт быстрое SR-повторение).
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
- [ ] Task 1: Суперпозиция: уровень с двумя телами-копиями Квантика; общий «закон» (params) рулит
|
- [x] Task 1: Суперпозиция: уровни L12/L13 с двумя телами `ball`+`ball2`, общий закон (theta,v)
|
||||||
обеими; цель — обе достигают порталов/условий (`goal.when` ссылается на оба `<id>.x/.y`).
|
рулит обеими зеркально; `goal.when` ссылается на `ball.*` И `ball2.*` (победа — только обе).
|
||||||
Реюз существующей мульти-body физики. Визуал — две glow-точки (полупрозрачные «фантомы»).
|
Реюз мульти-body физики. ball2 — полупрозрачный «фантом» (tintHeroSpec в quantik-game.js).
|
||||||
- [ ] Task 2: Коллапс/пауза-прицел: на паузе показать предсказанную траекторию (`plot trace`/
|
- [x] Task 2: Коллапс/пауза-прицел: уровень L14 несёт пунктир-`plot` (id 'aim') с предсказанной
|
||||||
пунктир) текущего закона до запуска — «прицеливание». Реюз предпросмотра старта (P1/P2).
|
параболой текущего закона; способность «Прицел» = пауза-тоггл (`inst.pause/play`) для прицела.
|
||||||
- [ ] Task 3: Туннелирование: «энергетический заряд» расходуется, чтобы пройти сквозь помеченную
|
- [x] Task 3: Туннелирование: барьер = `forbidden`-зона `wall`; `fail:'wall.hit && tunnel<1'`.
|
||||||
`tunnelable:true` стену (стена временно проницаема). Энергия в HUD.
|
Способность «Туннель» тратит TUNNEL_COST энергии → `inst.setParam('tunnel',1)` (стена
|
||||||
- [ ] Task 4: SR-комната: перед/в уровне — мини-сессия повторения флешкарт (реюз Tier-1 SR API,
|
проницаема). `tunnel` — НЕ слайдер (только способность); отсутствует в env → 0 → стена сплошная.
|
||||||
мигр.074). Правильные ответы дают «энергию туннелирования». Открыть существующий движок
|
- [x] Task 4: SR-комната: `QuantikAbilities.openRestRoom` — мини-сессия повторения в модалке
|
||||||
повторения в модалке/панели игры; начислять заряды по результату.
|
(НЕ iframe). `LS.fcListDecks → fcStudySession → fcReview`; «Знаю/Легко» начисляют энергию.
|
||||||
- [ ] Task 5: Контент: 2–3 уровня под каждую способность (обучающий + применение).
|
Пусто (нет колод/нет due) → дружелюбное окно + ссылка `/flashcards`.
|
||||||
- [ ] Task 6: Тесты: суперпозиция (оба тела в `goal`), расход/начисление энергии (чистая логика),
|
- [x] Task 5: Контент — глава `quantum` (5 уровней): L12/L13 суперпозиция, L14 прицел, L15/L16 туннель.
|
||||||
проницаемость стены при заряде; смоук.
|
- [x] Task 6: Тесты (headless vm + РЕАЛЬНЫЕ движки): суперпозиция (оба тела), энергия (grant/spend/
|
||||||
|
reward чистая логика), tunnel flips fail, per-level solvability sweep (5/5 выигрываемы,
|
||||||
|
full-star достижим), регресс существующих 11 уровней без throw. 48/48; harness удалён.
|
||||||
|
|
||||||
## Files to Modify/Create
|
## Files to Modify/Create
|
||||||
- `frontend/js/labs/_sim_engine.js` — поле `tunnelable` у стены + расход энергии (аддитивно, документировать).
|
- `frontend/js/labs/_sim_engine.js` — поле `tunnelable` у стены + расход энергии (аддитивно, документировать).
|
||||||
@@ -42,7 +44,45 @@
|
|||||||
- SR-движок повторения уже существует — переиспользовать, не дублировать расписание.
|
- SR-движок повторения уже существует — переиспользовать, не дублировать расписание.
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
- [ ] Все задачи; аддитивность; без эмодзи/eval; тесты зелёные; lint baseline 0
|
- [x] Все задачи; аддитивность; без эмодзи/eval; тесты зелёные; lint baseline 0
|
||||||
|
|
||||||
## Handoff to Next Phase
|
## Handoff to Next Phase
|
||||||
<!-- Заполняет агент-имплементер. -->
|
|
||||||
|
### Что добавлено (файлы)
|
||||||
|
- **Новый** `frontend/js/game/quantik-abilities.js` (`window.QuantikEnergy` + `window.QuantikAbilities`).
|
||||||
|
- `frontend/js/game/levels.js` — глава `quantum` (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.
|
||||||
|
|||||||
Reference in New Issue
Block a user