feat(quantik-game): фаза 3 — граф-уровни (движение по f(x)) + зоны

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Maxim Dolgolyov
2026-06-13 17:07:33 +03:00
parent 02ab886bee
commit 978448d99b
9 changed files with 569 additions and 19 deletions
+10
View File
@@ -238,3 +238,13 @@ git push origin master
- **Навигация (inline-bootstrap quantik.html)**: 2 вида `#qg-map-view`/`#qg-level-view` (класс `.show`). `showMap` перезагружает прогресс (`LS.gameProgressList`) → `map.render`. `openLevel→интро→launchLevel→onGoal→успех→onNext(nextPlayable)|onMap`. **Смена уровня ВСЕГДА через `destroyLevel()` (=`inst.destroy()`)** до нового mount (гоча Ф1). Deep-link `?level=` открывает только разблокированный.
- **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+(ba)·clamp(t/duration,0,1)` по range кривой), `<plotId>.runY` (= f(runX) ТОЙ ЖЕ скомпил. `cv.exprFn`, что рисует кривую → видимая кривая и путь героя идентичны), `<plotId>.runDone` (1 при t≥duration). **Само-ссылку снимает разделение**: герой = ОБЫЧНЫЙ `point` с `x:'curve.runX', y:'curve.runY'` (glow+trail, визуал P2), а f компилируется один раз и питает И кривую, И бегунок — точка НЕ ссылается на собственный x в одном проходе env. `hold:true` оставляет бегунок на конце (иначе зацикливание по `time.loop`). Кинематический проход (без физики) — герой не тело.
- **Зоны — `type:'zone'` + булево env-поле `<zoneId>.hit`, БЕЗ предикатов в грамматике.** `{type:'zone', id, shape:'rect'|'circle', kind:'forbidden'|'target'|'collect', track?:'ball', x,y,w,h|r, color?, label?}`. Движок считает `<zoneId>.hit` (1/0) в `_buildEnv` **последним** (нужна актуальная позиция героя из тела/формулы) через `_zoneHit(z,env)` (геометрия в мире). `goal.when/fail/stars[].when` ссылаются на поле (`when:'gate.hit'`, `fail:'pit.hit'`). ⛔ **Никаких `inzone(...)` в синтаксис SimExpr** — контракт выражений закрыт, добавляются только именованные env-поля (та же модель безопасности, что `t`/`tries` из Ф0). Рисует `_drawZone` (forbidden=красный пунктир, target=зелёный, collect=золотой пунктир) — цвета ТОЛЬКО в canvas-стоки (fillStyle/strokeStyle), XSS нет. Зона НЕ кладёт `<id>.x/.y` как центр (`hasCenter` пропущен для `type==='zone'` — это область, не точка).
- **ГОЧА имён param (повтор Ф4 SimForge, укусила здесь): `t/w/h/pi/e/E/PI/tau` зарезервированы движком.** `_buildEnv` ставит `env.h = ymaxymin` (высота вьюпорта) и `env.w` — поэтому param с именем `h` (планировался под вершину модуля `a·|xh|+1`) затирался: `abs(xh)` видел h=10 (высота), а не значение слайдера → 0 решающих комбинаций. Фикс — переименовать в `m`. **При добавлении граф-уровней проверять имена коэффициентов против этого списка.** (Сетка-смоук solvability ловит такую ошибку как «0 wins» — обязательна.)
- **Контент: глава `functions` (5 уровней) через хелперы-данные.** `road(exprStr,a,b,dur)` (plot+runner, id 'curve'), `graphHero()` (point ball на curve.runX/runY), `rectZone/circZone(id,kind,...)`, `startMarker`. Уровни: луч `a·x+b`, синус `A·sin(k·x)`, парабола `a·(x5)²+k`, модуль `a·|xm|+1`, экспонента `c·e^(r·x)`. `time:{duration,loop:false}` синхронизирован с `runner.duration`. Управление = обычные `params`-слайдеры коэффициентов (крутишь → кривая+путь перестраиваются live); свободный ввод выражения не понадобился. Звёзды: collect-зона + доп. условие формы кривой (sticky через механизм stars Ф0).
- **Карта/запуск без правок map.js** (подтверждён хэндофф Ф2): глава `functions` в `CHAPTERS` (key/title/subtitle/accent) — узлы рисуются по метаданным, тип спеки карте безразличен. `unlockStars` 9/11/13/15/17 ≤ 18 (макс звёзд 6 физ-уровней) → **нет дедлока** (даже только физ-главы дают 18 ≥ 17). `QuantikGame.start``SimEngine.mount` тот же; спец-вайринг управления НЕ нужен (те же слайдеры). `tintHeroSpec` тинтует point-героя на `curve.runX/runY` штатно. quantik.html: бейдж темы стал per-level (`level.subject`→Физика/Алгебра) — аддитивно, id `qg-pill`.
- **Сервер `validateSpec` (customSimController.js): `zone` в OBJECT_TYPES + поля.** `zone.track` санитизируется как id; `plot.runner.duration` — checkExpr (длина). Готовит авторённые граф-уровни Ф5. x/y/w/h/r зон проходят общий expr-loop. Тест custom-sims.test.js +2 (приём zone+runner спеки; отказ unknown type при разрешённой zone) → 26/26.
- **Верификация Ф3**: `node --check` всех изменённых JS + inline-`<script>` quantik.html — OK; headless vm-смоук (РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`+`levels.js`, DOM/canvas-стаб + виртуальные часы): **per-level solvability** (сетка коэффициентов 625 комбо/уровень) — line 59/625, sine 290/625, parab 88/625, abs 231/625, exp 36/625, у КАЖДОГО найден full-star комбо; **logic** — правильная f→победа без forbidden, плоская f→fail (зашёл в forbidden), zone.hit флипается по позиции, runX/runY/runDone корректны, регресс всех типов + физики без throw, ctx сбалансирован → 29/29. E2E `QuantikGame.start`→onGoal на graph-line-7 → won 2/2. Смоуки удалены. `npm test` 261/253 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function — 0 (только пре-существующие →/⛔ в комментариях; зоны/звёзды — canvas/inline SVG).
@@ -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;
});
+28
View File
@@ -203,6 +203,34 @@ describe('/api/custom-sims', () => {
assert.ok(txt.includes('&lt;img'), 'escaped form present');
});
it('accepts graph-level spec with zone + runner (Квантик Ф3)', async () => {
const spec = {
specVersion: 1,
meta: { title: 'Граф-уровень' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8 },
params: [{ name: 'a', min: -1, max: 2, step: 0.05, value: 0.5 }],
objects: [
{ id: 'curve', type: 'plot', expr: 'a*x', var: 'x', range: [0, 10], runner: { duration: 5 } },
{ id: 'ball', type: 'point', x: 'curve.runX', y: 'curve.runY', r: 7 },
{ type: 'zone', id: 'pit', kind: 'forbidden', shape: 'rect', x: 5, y: 0, w: 4, h: 2, track: 'ball', label: 'яма' },
{ type: 'zone', id: 'gate', kind: 'target', shape: 'circle', x: 10, y: 5, r: 1, track: 'ball' },
],
goal: { when: 'gate.hit', fail: 'pit.hit', stars: [{ when: 'gate.hit' }] },
};
const res = await inject('POST', '/api/custom-sims', { spec }, teacherToken);
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, teacherToken);
const objs = get.body.sim.spec.objects;
assert.ok(objs.find(o => o.type === 'zone' && o.id === 'pit'), 'zone object preserved');
assert.ok(objs.find(o => o.type === 'plot' && o.runner), 'runner block preserved');
});
it('rejects unknown object type even with zone allowed (400)', async () => {
const bad = { ...VALID_SPEC, objects: [{ type: 'zoney_fake', x: 0, y: 0 }] };
const res = await inject('POST', '/api/custom-sims', { spec: bad }, teacherToken);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('owner can DELETE own sim (then 404)', async () => {
const del = await inject('DELETE', `/api/custom-sims/${simId}`, null, teacherToken);
assert.equal(del.status, 200, `got ${del.status}`);
+289 -2
View File
@@ -378,12 +378,299 @@
}
};
var LEVELS = [artillery1, arc2, bounce3, pendulum4, orbit5, slingshot6];
/* ─────────────────────────────────────────────────────────────────────────
Глава III — «Функции» (созвездие): Квантик едет по кривой y=f(x), которую
СОБИРАЕТ игрок (слайдеры коэффициентов). Препятствия — зоны (Ф3 движка):
forbidden — задел → проигрыш (fail);
target — добрался → победа (goal);
collect — задел → звезда-бонус (sticky).
Реюз: plot+runner рисует кривую и ведёт по ней героя (одна скомпил. функция),
зоны дают булевы env-поля <id>.hit. Без eval — только SimExpr-выражения.
──────────────────────────────────────────────────────────────────────── */
var FORB = '#F87171'; // запретная зона (красный)
var TARG = '#34D399'; // целевая зона (зелёный)
var COIN = '#FBBF24'; // зона-сбор (золото)
var CURVE = '#67E8F9'; // цвет кривой-«дороги»
/* helper: герой-точка, едет по бегунку кривой (id 'curve'): x=curve.runX, y=curve.runY.
glow+trail (визуал P2). НЕ тело (кинематический проход) → не само-ссылка. */
function graphHero() {
return {
id: 'ball', type: 'point', r: 7, color: HERO,
x: 'curve.runX', y: 'curve.runY',
glow: true, glowColor: HERO, trail: true, trailColor: HERO, trailFade: true
};
}
/* helper: plot-«дорога» = кривая f(x) с бегунком. exprStr — выражение кривой. */
function road(exprStr, a, b, dur) {
return {
id: 'curve', type: 'plot', expr: exprStr, var: 'x',
range: [a, b], samples: 220, color: CURVE, width: 3, glow: true, glowColor: CURVE,
runner: { duration: dur, hold: true }
};
}
function rectZone(id, kind, x, y, w, h, label) {
var col = kind === 'target' ? TARG : kind === 'collect' ? COIN : FORB;
return { type: 'zone', id: id, kind: kind, shape: 'rect', x: x, y: y, w: w, h: h,
color: col, label: label || '' };
}
function circZone(id, kind, x, y, r, label) {
var col = kind === 'target' ? TARG : kind === 'collect' ? COIN : FORB;
return { type: 'zone', id: id, kind: kind, shape: 'circle', x: x, y: y, r: r,
color: col, label: label || '' };
}
function startMarker(x, y) {
return { type: 'circle', x: x, y: y, r: 0.16, color: '#94A3B8', width: 0, fill: '#94A3B8' };
}
/* Уровень 7: «Луч через ворота» — прямая f(x)=a·x+b. Сверху и снизу в центре —
запретные брусья; проведи луч между ними в зелёный портал справа. Монета по центру. */
var graphLine7 = {
id: 'graph-line-7',
title: 'Луч через ворота',
chapter: 'functions',
order: 7,
unlockStars: 9,
par_ms: 5200,
subject: 'algebra',
hint: 'Квантик поедет по прямой y = a·x + b. Подбери наклон a и сдвиг b, чтобы луч прошёл между брусьями в воротах и достиг зелёного портала. Монета — точно в центре прохода.',
spec: {
specVersion: 1,
meta: { title: 'Луч через ворота', desc: 'Линейная функция: y = a·x + b.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8, grid: true, axes: true, bg: BG },
time: { duration: 5, loop: false },
params: [
{ name: 'a', label: 'Наклон a', min: -0.5, max: 1.5, step: 0.05, value: 0.2 },
{ name: 'b', label: 'Сдвиг b', min: 0, max: 5, step: 0.1, value: 1 }
],
objects: [
// ворота в центре: верхний и нижний брус (проход 1..6 по y при x≈5)
rectZone('topbar', 'forbidden', 5, 7.0, 4, 2.4, 'стена'),
rectZone('botbar', 'forbidden', 5, -0.2, 4, 2.4, 'стена'),
// монета в центре прохода
circZone('coin', 'collect', 5, 3, 0.7, 'монета'),
// целевой портал справа
circZone('gate', 'target', 10, 5, 0.95, 'портал'),
startMarker(0, 1),
road('a*x + b', 0, 10, 5),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'b', expr: 'b', precision: 1 }
],
goal: {
title: 'Проведи луч в портал',
hint: 'Достигни зелёного портала, не задев красные брусья. Бонус: собери монету.',
when: 'gate.hit',
fail: 'topbar.hit || botbar.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && b < 1.6', label: 'Низкий старт (b < 1.6)' }
]
}
}
};
/* Уровень 8: «Синус сквозь врата» — f(x)=A·sin(k·x). Сверху/снизу — зубцы-стены
с просветами; подбери амплитуду и частоту, чтобы волна прошла сквозь все просветы. */
var graphSine8 = {
id: 'graph-sine-8',
title: 'Синус сквозь врата',
chapter: 'functions',
order: 8,
unlockStars: 11,
par_ms: 6000,
subject: 'algebra',
hint: 'Квантик едет по волне y = A·sin(k·x). Подбери амплитуду A и частоту k, чтобы волна прошла сквозь просветы между зубцами. Монеты — в гребне и впадине волны.',
spec: {
specVersion: 1,
meta: { title: 'Синус сквозь врата', desc: 'Синусоида: y = A·sin(k·x).' },
viewport: { xmin: -0.5, xmax: 12.5, ymin: -5, ymax: 5, grid: true, axes: true, bg: BG },
time: { duration: 6, loop: false },
params: [
{ name: 'A', label: 'Амплитуда A', min: 1, max: 4, step: 0.1, value: 2 },
{ name: 'k', label: 'Частота k', min: 0.3, max: 1.6, step: 0.05, value: 0.6 }
],
objects: [
// зубцы: верхний брус над гребнем (x≈π/2k) и нижний под впадиной (x≈3π/2k)
// при целевом k≈0.79 (π/4): гребень x≈2, впадина x≈6, гребень x≈10
rectZone('spikeTop', 'forbidden', 2, 4.4, 1.2, 2.4, ''),
rectZone('spikeBot', 'forbidden', 6, -4.4, 1.2, 2.4, ''),
rectZone('spikeTop2', 'forbidden', 10, 4.4, 1.2, 2.4, ''),
// монеты в гребне/впадине
circZone('coinTop', 'collect', 2, 3, 0.6, ''),
circZone('coinBot', 'collect', 6, -3, 0.6, ''),
// портал на третьем гребне
circZone('gate', 'target', 11.2, 0, 1.0, 'портал'),
startMarker(0, 0),
road('A*sin(k*x)', 0, 11.5, 6),
graphHero(),
{ type: 'readout', label: 'A', expr: 'A', precision: 1 },
{ type: 'readout', label: 'k', expr: 'k', precision: 2 }
],
goal: {
title: 'Проведи волну в портал',
hint: 'Подбери волну так, чтобы пройти все просветы и достичь портала. Бонус: собери монеты в гребне и впадине.',
when: 'gate.hit',
fail: 'spikeTop.hit || spikeBot.hit || spikeTop2.hit',
stars: [
{ when: 'coinTop.hit', label: 'Монета-гребень' },
{ when: 'coinBot.hit', label: 'Монета-впадина' }
]
}
}
};
/* Уровень 9: «Парабола над ямой» — f(x)=a·(x-3)^2+k (вершина в (3,k)... меняем).
В центре — запретная яма; перебрось параболу-дугу над ней в портал. */
var graphParab9 = {
id: 'graph-parab-9',
title: 'Парабола над ямой',
chapter: 'functions',
order: 9,
unlockStars: 13,
par_ms: 6000,
subject: 'algebra',
hint: 'Квантик едет по параболе y = a·(x − 5)² + k. Подбери раскрытие a (вниз — отрицательное) и высоту вершины k, чтобы дуга прошла над ямой в портал. Монета — на вершине дуги.',
spec: {
specVersion: 1,
meta: { title: 'Парабола над ямой', desc: 'Квадратичная: y = a·(x 5)² + k.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 9, grid: true, axes: true, bg: BG },
time: { duration: 6, loop: false },
params: [
{ name: 'a', label: 'Раскрытие a', min: -0.6, max: -0.1, step: 0.02, value: -0.3 },
{ name: 'k', label: 'Высота вершины k', min: 4, max: 8, step: 0.1, value: 6 }
],
objects: [
// яма в центре (запретная) — занимает низ по центру
rectZone('pit', 'forbidden', 5, 1.4, 5.0, 3.0, 'яма'),
// монета на вершине
circZone('coin', 'collect', 5, 6, 0.7, 'монета'),
// портал у правого края, низко (нужно спуститься на нисходящей ветви)
circZone('gate', 'target', 9.5, 4.4, 0.95, 'портал'),
startMarker(0.5, 0), // ориентир старта (фактический старт = f(0))
road('a*(x-5)^2 + k', 0, 9.5, 6),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'k', expr: 'k', precision: 1 }
],
goal: {
title: 'Дугой над ямой в портал',
hint: 'Перебрось дугу над ямой к порталу. Бонус: задень монету на вершине.',
when: 'gate.hit',
fail: 'pit.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && k >= 6.4', label: 'Высокая дуга (k ≥ 6.4)' }
]
}
}
};
/* Уровень 10: «Угол модуля» — f(x)=a·abs(x-h)+1. V-образная кривая: подбери
раскрытие a и положение вершины h, чтобы «галочка» обошла два бруса и попала в портал. */
var graphAbs10 = {
id: 'graph-abs-10',
title: 'Угол модуля',
chapter: 'functions',
order: 10,
unlockStars: 15,
par_ms: 6000,
subject: 'algebra',
hint: 'Квантик едет по «галочке» y = a·|x − m| + 1. Подбери крутизну a и положение вершины m, чтобы остриё прошло в проход и луч попал в портал. Монета — у самого острия.',
spec: {
specVersion: 1,
meta: { title: 'Угол модуля', desc: 'Модуль: y = a·|x m| + 1.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 9, grid: true, axes: true, bg: BG },
time: { duration: 6, loop: false },
params: [
// m = положение вершины (имя 'h' зарезервировано движком под высоту вьюпорта!)
{ name: 'a', label: 'Крутизна a', min: 0.6, max: 2.2, step: 0.05, value: 1.2 },
{ name: 'm', label: 'Вершина m', min: 3, max: 7, step: 0.1, value: 5 }
],
objects: [
// нижние брусья слева и справа от прохода-острия (вершина должна попасть в проход)
rectZone('floorL', 'forbidden', 2.4, -0.2, 2.4, 1.4, ''),
rectZone('floorR', 'forbidden', 7.6, -0.2, 2.4, 1.4, ''),
// монета у острия (низко, по центру)
circZone('coin', 'collect', 5, 1, 0.7, 'монета'),
// портал справа-вверху (на восходящей ветви)
circZone('gate', 'target', 9.6, 6.4, 1.0, 'портал'),
startMarker(0, 7),
road('a*abs(x-m) + 1', 0, 9.6, 6),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'm', expr: 'm', precision: 1 }
],
goal: {
title: 'Галочкой в портал',
hint: 'Проведи остриё галочки в проход между брусьями и попади в портал. Бонус: задень монету у острия.',
when: 'gate.hit',
fail: 'floorL.hit || floorR.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && m >= 4.6 && m <= 5.4', label: 'Вершина по центру' }
]
}
}
};
/* Уровень 11 (капстоун): «Экспонента-подъём» — f(x)=c·exp(r·x). Барьер-«потолок»
слева заставляет держать низкий старт; затем экспонента взмывает в высокий портал. */
var graphExp11 = {
id: 'graph-exp-11',
title: 'Экспонента-подъём',
chapter: 'functions',
order: 11,
unlockStars: 17,
par_ms: 6500,
subject: 'algebra',
hint: 'Квантик едет по экспоненте y = c·e^(r·x). Подбери начальную высоту c и скорость роста r, чтобы пройти под потолком слева, но взмыть в высокий портал справа. Монета — на разгоне.',
spec: {
specVersion: 1,
meta: { title: 'Экспонента-подъём', desc: 'Экспонента: y = c·e^(r·x).' },
viewport: { xmin: -0.5, xmax: 8.5, ymin: -0.5, ymax: 9, grid: true, axes: true, bg: BG },
time: { duration: 6.5, loop: false },
params: [
{ name: 'c', label: 'Старт c', min: 0.2, max: 1.5, step: 0.05, value: 0.6 },
{ name: 'r', label: 'Рост r', min: 0.2, max: 0.7, step: 0.02, value: 0.4 }
],
objects: [
// потолок слева (низкий старт): запретный брус над началом x∈[1,4], y от 2.5 вверх
rectZone('ceil', 'forbidden', 2.5, 4.0, 3.2, 3.0, 'потолок'),
// пол-яма в начале (нельзя слишком низко/нулём): брус снизу x∈[0,5]
rectZone('floor', 'forbidden', 2.4, -0.5, 5.0, 1.0, ''),
circZone('coin', 'collect', 5, 3.3, 0.7, 'монета'),
circZone('gate', 'target', 7.6, 7.2, 1.05, 'портал'),
startMarker(0, 0.6),
road('c*exp(r*x)', 0, 7.7, 6.5),
graphHero(),
{ type: 'readout', label: 'c', expr: 'c', precision: 2 },
{ type: 'readout', label: 'r', expr: 'r', precision: 2 }
],
goal: {
title: 'Взмой в портал',
hint: 'Пройди под потолком слева и взмой экспонентой в высокий портал. Бонус: задень монету на разгоне.',
when: 'gate.hit',
fail: 'ceil.hit || floor.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && r >= 0.42', label: 'Крутой рост (r ≥ 0.42)' }
]
}
}
};
var LEVELS = [
artillery1, arc2, bounce3, pendulum4, orbit5, slingshot6,
graphLine7, graphSine8, graphParab9, graphAbs10, graphExp11
];
/* Метаданные глав (созвездий) — для заголовков/оформления карты. */
var CHAPTERS = {
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' }
};
function list() { return LEVELS.slice(); }
+150 -4
View File
@@ -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', // живой числовой бейдж
@@ -94,6 +96,35 @@
]
}
// 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.
@@ -101,7 +132,10 @@
интегратора (а не из выражения) — это снимает проблему forward-ref однопроходного
env для тел: их позиция/скорость не пересчитываются формулой каждый кадр.
Выражения цели (goal.when/fail/stars[].when) видят ВЕСЬ env кадра ПЛЮС `tries`
(число пользовательских reset с начала). Новых небезопасных идентификаторов не вводится.
(число пользовательских reset с начала). Граф-уровни (Ф3) добавляют ИМЕНОВАННЫЕ
булевы/числовые env-поля: <plotId>.runX/.runY/.runDone (бегунок) и <zoneId>.hit
(попадание в зону). Это данные env, а не функции синтаксиса — контракт выражений
остаётся закрытым (никаких inzone()/предикатов). Новых небезопасных идентификаторов нет.
── ИНТЕРАКЦИИ (Фаза 1) ──────────────────────────────────────────────────
Объект с полем drag:{param, axis, min?, max?, paramY?} становится ручкой:
@@ -732,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')
@@ -773,7 +826,8 @@
}
// привязки для центра объекта (для 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);
}
@@ -1157,7 +1211,32 @@
}
}
// 2) центры формульных объектов (одношагово; тела пропускаем — их x/y уже в env).
// 2) бегунок по кривой (Ф3): <plotId>.runX/.runY/.runDone — ДО формульных центров,
// чтобы герой-точка (x:'curve.runX') увидела актуальную позицию в том же кадре.
// runX линейно проходит range за runner.duration сек (по мировому t); runY = f(runX)
// ТОЙ ЖЕ скомпилированной функции, что рисует кривую (нет рассинхрона, нет само-ссылки).
for (var ri = 0; ri < this._objs.length; ri++) {
var pr = this._objs[ri];
if (pr.type !== 'plot' || !pr.runner) continue;
var aR = pr.rangeA.ev(env), bR = pr.rangeB.ev(env);
if (!pr.hasRange || !isFinite(aR) || !isFinite(bR)) { aR = vp.xmin; bR = vp.xmax; }
var frac = pr.runner.duration > 0 ? (env.t / pr.runner.duration) : 1;
var done = frac >= 1;
if (frac < 0) frac = 0; if (frac > 1) frac = 1;
var rx = aR + (bR - aR) * frac;
// y = f(runX): подставляем runX во временную копию свободной переменной
var hadV = Object.prototype.hasOwnProperty.call(env, pr.varName);
var prevV = env[pr.varName];
env[pr.varName] = rx;
var ry = pr.exprFn.ev(env);
if (hadV) env[pr.varName] = prevV; else delete env[pr.varName];
if (typeof ry !== 'number' || !isFinite(ry)) ry = 0;
env[pr.id + '.runX'] = rx;
env[pr.id + '.runY'] = ry;
env[pr.id + '.runDone'] = done ? 1 : 0;
}
// 3) центры формульных объектов (одношагово; тела пропускаем — их x/y уже в env).
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.hasCenter && !o.body) {
@@ -1167,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/размер и базу;
@@ -1771,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]);
}
};
+12 -1
View File
@@ -240,7 +240,7 @@
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18 9 12 15 6"/></svg>
К карте
</button>
<span class="qg-pill">Физика</span>
<span class="qg-pill" id="qg-pill">Физика</span>
</div>
<!-- Вид карты -->
@@ -287,6 +287,14 @@
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) {
@@ -321,6 +329,7 @@
backBtn.style.display = 'none';
titleEl.textContent = 'Квантик — Законы Мира';
subEl.textContent = 'Карта мира — выбери уровень и почини закон';
if (pillEl) pillEl.textContent = 'Физика';
history.replaceState(null, '', '/quantik');
// перезагрузить прогресс (мог обновиться после победы) и перерисовать
loadProgress().then(function () { map.render(progressMap); });
@@ -334,6 +343,7 @@
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 значение (фолбэк, если пересчёт после победы недоступен).
@@ -373,6 +383,7 @@
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 () {
+12
View File
@@ -38,6 +38,18 @@
`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.
## Key Architecture Decisions
- **«Атом» = блок `goal` в спеке** (булево SimExpr). Любой уровень = спека SimForge + `goal`.
+2 -2
View File
@@ -61,7 +61,7 @@
- [x] Phase 0: Слой целей в движке (goal/HUD/result) [domain: frontend] → [subplan](./phase-0-objective-layer.md)
- [x] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md)
- [x] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md)
- [ ] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md)
- [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)
- [ ] Phase 5: Авторинг уровней в sim-builder + раздача классу [domain: fullstack] → [subplan](./phase-5-authoring-sharing.md)
- [ ] Phase 6: Класс-лидерборд / живая гонка (classroom SSE) [domain: fullstack] → [subplan](./phase-6-leaderboard-live.md)
@@ -73,7 +73,7 @@
| Phase 0: Слой целей в движке | frontend | ✅ Done | ✅ | ✅ | ✅ |
| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ✅ Done | ✅ | ✅ | ✅ |
| Phase 2: Карта + мир + XP/скины | fullstack | ✅ Done | ✅ (1 🟡 fixed) | ✅ | ✅ |
| Phase 3: Граф-уровни + зоны | fullstack | ⬜ Not Started | | | |
| Phase 3: Граф-уровни + зоны | fullstack | ✅ Done | | | |
| Phase 4: Квантовые способности + SR | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 5: Авторинг + раздача | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 6: Лидерборд / живая гонка | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
+57 -10
View File
@@ -1,6 +1,6 @@
# Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия
**Status:** ⬜ Not Started
**Status:** ✅ Done (reviewed — PASS, committed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
@@ -10,21 +10,34 @@
Реюз `plot` + `SimExpr`. Сид граф-главы.
## Tasks
- [ ] Task 1: «Бегунок по кривой»: герой-точка с `x` = функция t (напр. линейный проход xmin→xmax),
- [x] Task 1: «Бегунок по кривой»: герой-точка с `x` = функция t (напр. линейный проход xmin→xmax),
`y = f(x)` через ту же скомпилированную функцию, что у `plot`. Кривая рисуется (P3 plot),
герой едет по ней с glow/trail. Без физики (кинематический проход), либо мягкая физика — на выбор уровня.
- [ ] Task 2: Тип объекта/поле «зона» (forbidden/target): прямоугольник/круг в мире + удобные
`plot.runner:{duration,hold}` кладёт в env `<plotId>.runX/.runY/.runDone`; герой = обычный point
с `x:'curve.runX', y:'curve.runY'`, glow+trail. f компилируется 1 раз и питает И кривую, И бегунок.
- [x] Task 2: Тип объекта/поле «зона» (forbidden/target): прямоугольник/круг в мире + удобные
env-предикаты (или документированный паттерн: `fail:'inzone(...)'`). Реализовать helper-предикаты
БЕЗ расширения небезопасного синтаксиса — предпочесть готовить булевы поля зон в env
(напр. `zone1.hit`) на основе позиции героя, чтобы `goal`/`fail` ссылались на них.
- [ ] Task 3: Цель = добраться до конца/в целевую зону, не задев запретные (`fail`). Звёзды: пройти
`type:'zone'` (shape rect/circle, kind forbidden/target/collect, track). Движок кладёт `<zoneId>.hit`
(1/0) в env. ⛔ Никаких inzone()-предикатов в грамматике — только именованные булевы env-поля.
- [x] Task 3: Цель = добраться до конца/в целевую зону, не задев запретные (`fail`). Звёзды: пройти
под нормативом, собрать бонус-точки (зоны-сборы).
- [ ] Task 4: Управление: слайдеры коэффициентов `f(x)` (a·sin(b·x+c)+d и т.п.) ИЛИ выбор/набор
→ goal.when=`'gate.hit'`, fail=`'pit.hit'`, stars=[collect-zone hit, доп. условие формы кривой].
- [x] Task 4: Управление: слайдеры коэффициентов `f(x)` (a·sin(b·x+c)+d и т.п.) ИЛИ выбор/набор
выражения с inline-проверкой `SimExpr.compile(...).error` (как в sim-builder). Безопасно.
- [ ] Task 5: Контент: сид граф-главы (~4–5 уровней): синус под мостом, парабола над ямой,
→ коэффициенты = обычные `params`-слайдеры движка; крутишь → кривая+путь героя перестраиваются.
Свободный ввод выражения не понадобился (слайдеры коэффициентов достаточны для MVP-главы).
- [x] Task 5: Контент: сид граф-главы (~4–5 уровней): синус под мостом, парабола над ямой,
кусочная подгонка, экспонента/логарифм — растущая сложность, привязка к темам алгебры.
- [ ] Task 6: Интеграция в карту (Ф2): новая глава-созвездие; общий конвейер результата/XP.
- [ ] Task 7: Тесты: проход по кривой достигает цели; задевание зоны → fail; смоук рендера кривой+героя.
→ 5 уровней в `functions`: луч (a·x+b), синус (A·sin(k·x)), парабола (a·(x5)²+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` — поддержка «бегунка по кривой» (если не выразимо текущими полями)
@@ -43,7 +56,41 @@
- Переиспользовать P3 plot (несколько кривых, заливка, маркеры) для визуала «земли»/препятствий.
## Review Checklist
- [ ] Все задачи; аддитивность движка; без эмодзи/eval; тесты зелёные; lint baseline 0
- [x] Все задачи; аддитивность движка; без эмодзи/eval; тесты зелёные; lint baseline 0
## Handoff to Next Phase
<!-- Заполняет агент-имплементер. -->
### Контракт «бегунка по кривой» (движок, `_sim_engine.js`)
- На объекте `plot`: `runner:{ duration?:8, hold?:true }`. Делает из ПЕРВОЙ кривой plot дорожку.
- Движок кладёт в env (в `_buildEnv`, ДО формульных центров): `<plotId>.runX` (= `a + (ba)·clamp(t/duration,0,1)`),
`<plotId>.runY` (= f(runX) ТОЙ ЖЕ скомпил. функции, что рисует кривую), `<plotId>.runDone` (1 при t≥duration).
- Герой = обычный `point` с `x:'curve.runX', y:'curve.runY'` + glow + trail. НЕ тело → нет само-ссылки
(f компилируется один раз, питает И кривую, И бегунок). `hold:true` — остаётся на конце; иначе зацикливание по `time.loop`.
- ⛔ Никакого eval: f — обычное SimExpr-выражение кривой.
### Контракт зон (движок)
- `type:'zone'`, `id`, `shape:'rect'|'circle'`, `kind:'forbidden'|'target'|'collect'` (цвет/семантика),
геометрия (rect: x,y центр + w,h; circle: x,y + r — числа ИЛИ выражения), `track?:'ball'` (чью позицию тестить), `label?`, `color?`.
- Движок кладёт `<zoneId>.hit` (1/0) в env (последним — нужна актуальная позиция героя). `goal.when/fail/stars[].when` ссылаются на него.
- ⛔ Предикаты в синтаксис выражений НЕ добавлялись — только именованные булевы env-поля (модель безопасности `t`/`tries` из Ф0).
- Рисуется в `_drawObject`/`_drawZone`: forbidden=красный пунктир, target=зелёный, collect=золотой пунктир. Цвета — только canvas-стоки.
- Зона НЕ кладёт `<zoneId>.x/.y` как центр объекта (`hasCenter` пропущен для type==='zone').
### Как определяется граф-уровень (данные, `levels.js`)
- Хелперы: `road(exprStr,a,b,dur)` (plot+runner, id 'curve'), `graphHero()` (point ball на curve.runX/runY),
`rectZone/circZone(id,kind,...)`, `startMarker`. Уровень = спека с этими объектами + `goal{when:'gate.hit',fail:'<forb>.hit',stars}`.
- ⚠️ ГОЧА: имена param `t/w/h/pi/e/E/PI/tau` зарезервированы движком (`h`=высота вьюпорта!). abs-уровень
использует `m` (вершина), НЕ `h`. При добавлении уровней проверять имена коэффициентов.
- `time:{duration,loop:false}` синхронизирован с `runner.duration` — герой доезжает до конца за один проход.
### Карта / запуск
- Глава `functions` добавлена в `CHAPTERS` (key/title/subtitle/accent). map.js НЕ тронут — узлы рисуются по метаданным,
тип спеки карте безразличен. Разблокировка: `unlockStars` 9/11/13/15/17 (≤ 18 макс. звёзд физ-глав → нет дедлока).
- Запуск тот же (`QuantikGame.start``SimEngine.mount`); граф-уровни используют те же слайдеры params, спец-вайринг
НЕ нужен. Бейдж темы в quantik.html — per-level по `level.subject` (аддитивно).
### Для Ф4 (квантовые способности)
- `runDone`/`runX`/`.hit` — готовые env-поля для условий способностей (напр. «туннель» = временно игнорить forbidden.hit
в `fail`). Способность может менять `params` (коэффициенты) или подменять выражение кривой — всё через тот же SimExpr-конвейер.
- Зоны kind:'collect' уже «залипают» через механизм stars (Ф0). Новая способность = новый env-флаг + условие, БЕЗ eval.
- Сервер уже принимает `zone`+`runner` (validateSpec, OBJECT_TYPES) — авторённые граф-уровни (Ф5) пройдут гейт.