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
+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 () {