@
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:
@@ -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]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user