Files
Maxim Dolgolyov 978448d99b @
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>
@
2026-06-13 17:07:33 +03:00

2532 lines
129 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ════════════════════════════════════════════════════════════════════════
SimEngine — рантайм спек-симуляций конструктора (Фаза 0).
Берёт JSON-спеку (данные, не код) и монтирует интерактивную сцену в host:
- canvas для геометрии/объектов (мир→экран по viewport, ось Y вверх);
- оверлей-слой <div> для подписей с LaTeX (рендер через KaTeX — тем же путём,
что graph.js: katex.renderToString);
- панель контролов: слайдеры из spec.params[] + кнопки play/pause/reset.
Любое числовое свойство объекта может быть числом ИЛИ строкой-выражением.
Выражения компилируются ОДИН РАЗ при mount (через SimExpr), в rAF-цикле — только
evaluate по окружению env = { t, ...params, <obj>.x, <obj>.y, w, h }.
Никакого eval/new Function. Кривая формула не роняет цикл (SimExpr.evaluate -> 0).
── ФОРМАТ СПЕКИ v1 ──────────────────────────────────────────────────────
{
specVersion: 1,
meta: { title, desc }, // подпись топбара/каталога
viewport: { xmin, xmax, ymin, ymax, // мировые границы (мат. оси)
grid?:true, axes?:true,
bg?:'#0D0D1A' },
params: [ // слайдеры -> переменные env
{ name:'v', label:'Скорость', min:0, max:30, step:0.5, value:18, unit:'м/с' },
{ name:'theta', label:'Угол', min:0, max:90, step:1, value:45, unit:'°' }
],
time: { autoplay?:false, loop?:true, duration?:0, speed?:1 }, // t-цикл
objects: [ // числа ИЛИ строки-выражения
{ id:'p', type:'point', x:'v*cos(theta*pi/180)*t', y:'...', r:6, color:'#06D6E0',
trail?:true, trailColor?:'#06D6E0' },
{ type:'segment', x1:0,y1:0, x2:'p.x', y2:'p.y', color:'#fff', width:2 },
{ type:'vector', x1:..,y1:.., x2:..,y2:.., color, width },
{ type:'circle', x, y, r, color, fill?, width },
{ type:'rect', x, y, w, h, color, fill?, width }, // x,y = центр
{ type:'polyline', points:[[x,y],...], color, width, closed? },
{ type:'path', points:[[x,y],...], ... }, // alias polyline
{ type:'label', x, y, text:'LaTeX', latex?:true, color, size?:14 },
// ── Фаза 1 ──
{ type:'plot', // график выражения в мир-коорд.
expr:'sin(x)', // f(<var>) (или f(t) при trace)
var:'x', // имя свободной переменной (деф. 'x')
range:[a,b], // отрезок построения (деф. xmin..xmax)
samples?:200, // число точек (деф. 200, клампится)
trace?:false, // true -> точка (varValue=t) пишется в след по времени
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', // живой числовой бейдж
label?:'R', expr:'...', unit?:'м', precision?:2,
x?, y?, // мир-координаты (деф. угол вьюпорта)
color? },
// Любой point/circle может стать перетаскиваемой ручкой:
{ type:'point', x:'x0', y:'y0',
drag:{ param:'x0', axis:'x'|'y'|'xy', min?, max?, paramY? } },
// ── Фаза 2 (физика) ──
// Любой point/circle может стать физическим телом (интегрируется движком,
// а не формулой). Начальные x/y/vx/vy — числа ИЛИ выражения от params/констант
// (вычисляются один раз при reset/init, далее интегрируются).
{ id:'ball', type:'circle', r:0.6,
x:'x0', y:'y0', // начальная позиция (вычисляется на reset)
body:{ mass?:1, vx?:'0', vy?:'0', fixed?:false } }
],
// ── ФИЗИКА (Фаза 2) ── глобальный блок сил/мира. enabled включает интегратор.
physics: {
enabled: true,
gravity: { x:0, y:-9.8 }, // ускорение свободного падения (мир/с^2)
friction?: 0, // линейное вязкое затухание скорости (1/с)
restitution?: 0.9, // упругость столкновений 0..1 (деф. 1)
dt?: 1/240, // фикс. шаг интегратора (деф. 1/240, кламп)
walls?: [ // отражающие стены (мир-координаты)
{ side:'bottom'|'top'|'left'|'right' }, // авто из viewport-границы
{ x1,y1, x2,y2 } // произвольный отрезок-стена
],
springs?: [
{ a:'ballId'|[x,y], b:'ballId'|[x,y], // концы: id тела ИЛИ якорь-точка
k:40, length:2, damping?:0.5 }
]
},
// ── ЦЕЛЬ / ИГРА (Квантик, Фаза 0) ── декларативный слой победы.
// Аддитивно: спека БЕЗ goal ведёт себя как раньше (нет HUD, нет вычислений побед).
goal: {
when: '<bool expr>', // SimExpr: победа, когда станет истинным (≠0)
title?: 'Цель уровня', // краткая формулировка цели для HUD (escape на сервере)
hint?: 'текст подсказки', // показывается в HUD (escape на сервере)
hold?: 0, // сек: сколько when должно держаться непрерывно (деф. 0)
fail?: '<bool expr>', // опц.: мягкий проигрыш (вышел за поле/задел шип)
stars?: [ // 0..3 доп.условий-«звёзд» (бонусы, «залипают» до reset)
{ when:'<bool expr>', label?:'...' }
]
}
// game?: {...} — зарезервированный блок мета-слоя (Фаза 1/5); сервер его пропускает.
// ── ГРАФ-УРОВНИ (Квантик, Фаза 3) ── «бегунок по кривой» + зоны-препятствия.
// Аддитивно: спека без runner/zone ведёт себя как раньше.
//
// БЕГУНОК ПО КРИВОЙ: на объекте plot поле runner:{ duration?, hold? } делает
// из ПЕРВОЙ кривой plot «дорожку»: за время t от 0 до duration (деф. 8 с)
// свободная переменная (x) линейно проходит range[a..b], а герой едет по
// точке (x, f(x)) ТОЙ ЖЕ скомпилированной функции, что рисует кривую — видимая
// кривая и путь героя идентичны (нет рассинхрона). Движок кладёт в env поля
// <plotId>.runX — текущий x бегунка (a + (b-a)·clamp(t/duration,0,1));
// <plotId>.runY — f(runX) первой кривой (тот же exprFn, что у кривой);
// <plotId>.runDone — 1, когда бегунок дошёл до конца (t>=duration), иначе 0.
// Герой = ОБЫЧНЫЙ point с x:'curve.runX', y:'curve.runY', glow+trail (визуал P2).
// Так нет само-ссылки (точка не ссылается на собственный x в одном проходе env):
// f компилируется один раз и питает И кривую, И бегунок. hold:true оставляет
// бегунок на последней точке после конца (иначе t зацикливается по time.loop).
// ⛔ Никакого eval: f — это SimExpr-выражение кривой (компилируется как обычно).
//
// ЗОНЫ-ПРЕПЯТСТВИЯ: объект type:'zone' — прямоугольная/круговая область в мире.
// { type:'zone', id:'pit', shape:'rect'|'circle',
// kind:'forbidden'|'target'|'collect', // цвет/семантика (деф. forbidden)
// // rect: x,y (центр), w, h ; circle: x,y (центр), r — числа ИЛИ выражения
// track?:'ball', // чью позицию проверять (деф. 'ball')
// color?, fill?, label? }
// Движок кладёт в env булево поле <zoneId>.hit = 1, если точка track сейчас
// ВНУТРИ зоны, иначе 0. goal.when/fail/stars[].when ссылаются на него
// (напр. fail:'pit.hit', goal:'gate.hit', stars:[{when:'coin.hit'}]).
// ⛔ В синтаксис выражений предикаты НЕ добавляются (безопасность контракта) —
// только именованные булевы env-поля, как `t`/`tries` (Фаза 0).
}
Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также
<objId>.x / <objId>.y для объектов, у которых заданы числовые/выраж. x,y.
Для физических тел (body) в env кладутся <objId>.x/.y/.vx/.vy ИЗ СОСТОЯНИЯ
интегратора (а не из выражения) — это снимает проблему forward-ref однопроходного
env для тел: их позиция/скорость не пересчитываются формулой каждый кадр.
Выражения цели (goal.when/fail/stars[].when) видят ВЕСЬ env кадра ПЛЮС `tries`
(число пользовательских reset с начала). Граф-уровни (Ф3) добавляют ИМЕНОВАННЫЕ
булевы/числовые env-поля: <plotId>.runX/.runY/.runDone (бегунок) и <zoneId>.hit
(попадание в зону). Это данные env, а не функции синтаксиса — контракт выражений
остаётся закрытым (никаких inzone()/предикатов). Новых небезопасных идентификаторов нет.
── ИНТЕРАКЦИИ (Фаза 1) ──────────────────────────────────────────────────
Объект с полем drag:{param, axis, min?, max?, paramY?} становится ручкой:
перетаскивание мышью/тачем (pointer events) пишет мир-координату курсора в
параметр(ы). axis:'x' -> param=x; 'y' -> param=y; 'xy' -> param=x, paramY=y
(или param используется для x, paramY для y). Позиция ручки следует за
параметром, когда её не тащат (x/y объекта обычно = тот же параметр).
Хит-тест — в экранных пикселях с допуском; ручки имеют приоритет.
── API инстанса ──────────────────────────────────────────────────────────
var inst = SimEngine.mount(host, spec);
inst.play() inst.pause() inst.reset()
inst.setParam(name, value)
inst.getParam(name) -> number
inst.isRunning() -> bool
inst.destroy()
inst.el -> корневой DOM-узел (для скрытия/показа адаптером)
// ── цель/игра (Фаза 0) ──
inst.onGoal(cb) -> подписка: cb(getResult()) при первой победе
inst.getResult() -> { won, failed, timeMs, attempts, stars:{got,total} }
inst.resetResult() -> сбросить состояние результата (как новый уровень)
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var DEFAULT_BG = '#0D0D1A';
/* Приятная дефолт-палитра объектов (если color не задан в спеке) — циклически по
индексу объекта. Холодно-яркие тона, контрастные на тёмном фоне DEFAULT_BG. */
var DEFAULT_PALETTE = [
'#22D3EE', // cyan
'#A78BFA', // violet
'#F472B6', // pink
'#34D399', // emerald
'#FBBF24', // amber
'#60A5FA', // blue
'#FB7185', // rose
'#4ADE80' // green
];
function num(v, dflt) { return typeof v === 'number' && isFinite(v) ? v : dflt; }
/* dash-паттерн для lineStyle (в пикселях; масштабируется толщиной линии). */
function _dashFor(style, width) {
var w = (typeof width === 'number' && width > 0) ? width : 2;
if (style === 'dashed') return [Math.max(6, w * 3), Math.max(4, w * 2.2)];
if (style === 'dotted') return [Math.max(1, w * 0.9), Math.max(3, w * 2)];
return null; // solid
}
/* нормализовать opacity к [0..1]; не число -> 1 (без прозрачности). */
function _opacity(v) {
if (typeof v !== 'number' || !isFinite(v)) return 1;
return v < 0 ? 0 : (v > 1 ? 1 : v);
}
/* нормализовать стиль маркера узлов кривой plot: 'dot'|'ring'|'none' (деф. none). */
function _markerStyle(v) {
return (v === 'dot' || v === 'ring') ? v : 'none';
}
/* полупрозрачная версия цвета для заливки под кривой. #RGB/#RRGGBB -> rgba(...,a);
прочие форматы (rgb()/named) оставляем как есть (canvas сам применит globalAlpha). */
function _fillAlpha(color, a) {
if (typeof color !== 'string') return color;
var m = color.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
if (!m) return color;
var h = m[1], r, g, b;
if (h.length === 3) {
r = parseInt(h[0] + h[0], 16); g = parseInt(h[1] + h[1], 16); b = parseInt(h[2] + h[2], 16);
} else {
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
}
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
/* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */
function bind(value, dflt) {
if (value === undefined || value === null) {
var d = dflt;
return { ev: function () { return d; }, constant: true };
}
var c = global.SimExpr ? global.SimExpr.compileValue(value)
: { fn: function () { return num(value, dflt); }, constant: true };
return { ev: c.fn, constant: !!c.constant, error: c.error || null };
}
/* ════════════════════════════════════════════════════════════════════════
SimPhysics — фиксированно-шаговый физический интегратор для спек-движка.
Опирается на ту же математику, что и LabFX.motion.spring (_fx_motion.js):
полу-неявный (симплектический) метод Эйлера
a = F/m ; v += a*dt ; x += v*dt
— но обобщён на набор связанных тел с гравитацией, пружинами, вязким
трением и упругими столкновениями (круг-круг, круг-стена). Шаг dt
фиксированный (накопитель), что даёт устойчивость (энергия не «взрывается»).
Тело: { id, x, y, vx, vy, mass, radius, fixed }.
world.step(bodies, springs, opts, dt) — продвигает состояние на один кадр.
Без eval/Function, без DOM — переиспользуемо headless (Ф3/билдер/тесты).
════════════════════════════════════════════════════════════════════════ */
var SimPhysics = (function () {
var MAX_V = 1e4; // кламп скорости (защита от взрыва численной нестабильности)
var MAX_SUBSTEPS = 8; // не более N фикс-шагов на кадр (защита от спирали смерти)
function clampV(v) { return v > MAX_V ? MAX_V : (v < -MAX_V ? -MAX_V : v); }
/* Один фиксированный шаг интегратора (полу-неявный Эйлер). */
function integrate(bodies, springs, opts, dt) {
var gx = opts.gx || 0, gy = opts.gy || 0;
var friction = opts.friction || 0; // вязкое затухание (1/с)
var i, b;
// 1) силы -> ускорение -> скорость (симплектический Эйлер): начинаем с гравитации
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
if (b.fixed) { b.vx = 0; b.vy = 0; continue; }
b.fx = gx * b.mass;
b.fy = gy * b.mass;
}
// 2) силы пружин (Гука + демпфирование по относительной скорости вдоль оси)
for (i = 0; i < springs.length; i++) {
applySpring(springs[i], dt);
}
// 3) интеграция скорости и позиции
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
if (b.fixed) continue;
var ax = b.fx / b.mass, ay = b.fy / b.mass;
b.vx += ax * dt;
b.vy += ay * dt;
// вязкое трение: экспоненциальное затухание скорости (устойчиво при любом dt)
if (friction > 0) {
var damp = Math.exp(-friction * dt);
b.vx *= damp; b.vy *= damp;
}
b.vx = clampV(b.vx); b.vy = clampV(b.vy);
b.x += b.vx * dt;
b.y += b.vy * dt;
}
}
/* Пружина между двумя концами. Конец — тело {x,y,vx,vy,mass,fixed} или
якорь {x,y,fixed:true,mass:Infinity}. Сила добавляется в b.fx/b.fy тел. */
function applySpring(s, dt) {
var a = s.a, b = s.b;
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1e-6) return; // совпали — направление не определено
var nx = dx / dist, ny = dy / dist;
var ext = dist - s.length; // растяжение (>0) / сжатие (<0)
var fMag = s.k * ext; // закон Гука
// демпфирование вдоль оси пружины
if (s.damping) {
var rvx = (b.vx || 0) - (a.vx || 0);
var rvy = (b.vy || 0) - (a.vy || 0);
var relAlong = rvx * nx + rvy * ny;
fMag += s.damping * relAlong;
}
var fxs = fMag * nx, fys = fMag * ny;
// сила тянет a к b (если растянута) и отталкивает (если сжата)
if (!a.fixed && a.fx !== undefined) { a.fx += fxs; a.fy += fys; }
if (!b.fixed && b.fx !== undefined) { b.fx -= fxs; b.fy -= fys; }
}
/* Столкновения круг-круг (упругие, по нормали) + круг-стена/границы. */
function resolveCollisions(bodies, walls, restitution) {
var e = (typeof restitution === 'number') ? restitution : 1;
var i, j;
// круг-круг (broadphase O(n^2) — N мало)
for (i = 0; i < bodies.length; i++) {
for (j = i + 1; j < bodies.length; j++) {
collideCircles(bodies[i], bodies[j], e);
}
}
// круг-стена
for (i = 0; i < bodies.length; i++) {
for (j = 0; j < walls.length; j++) {
collideWall(bodies[i], walls[j], e);
}
}
}
function collideCircles(a, b, e) {
if (a.fixed && b.fixed) return;
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.sqrt(dx * dx + dy * dy);
var minD = a.radius + b.radius;
if (dist >= minD || dist < 1e-9) return;
var nx = dx / dist, ny = dy / dist;
// позиционная коррекция (распихать, чтобы не слипались) — по обратным массам
var imA = a.fixed ? 0 : 1 / a.mass;
var imB = b.fixed ? 0 : 1 / b.mass;
var imSum = imA + imB; if (imSum === 0) return;
var pen = minD - dist;
a.x -= nx * pen * (imA / imSum);
a.y -= ny * pen * (imA / imSum);
b.x += nx * pen * (imB / imSum);
b.y += ny * pen * (imB / imSum);
// относительная скорость вдоль нормали
var rvx = b.vx - a.vx, rvy = b.vy - a.vy;
var velN = rvx * nx + rvy * ny;
if (velN > 0) return; // уже разлетаются
var jImp = -(1 + e) * velN / imSum;
var ix = jImp * nx, iy = jImp * ny;
a.vx -= ix * imA; a.vy -= iy * imA;
b.vx += ix * imB; b.vy += iy * imB;
}
/* Стена: либо нормаль+offset (из границы viewport), либо отрезок x1y1-x2y2. */
function collideWall(b, wall, e) {
if (b.fixed) return;
var nx = wall.nx, ny = wall.ny; // нормаль внутрь рабочей области
// расстояние от центра тела до плоскости стены вдоль нормали
var d = (b.x - wall.px) * nx + (b.y - wall.py) * ny;
if (d >= b.radius) return; // не касается
var pen = b.radius - d;
b.x += nx * pen; b.y += ny * pen; // вытолкнуть
var velN = b.vx * nx + b.vy * ny;
if (velN < 0) { // движется в стену — отразить
b.vx -= (1 + e) * velN * nx;
b.vy -= (1 + e) * velN * ny;
}
}
/* Продвинуть мир на dtFrame секунд фиксированными подшагами dt (накопитель). */
function step(state, dtFrame) {
var dt = state.dt;
state.acc += dtFrame;
var n = 0;
while (state.acc >= dt && n < MAX_SUBSTEPS) {
integrate(state.bodies, state.springs, state.opts, dt);
resolveCollisions(state.bodies, state.walls, state.opts.restitution);
state.acc -= dt;
n++;
}
// защита от спирали смерти: если накопилось слишком много — сбросить остаток
if (state.acc > dt) state.acc = 0;
}
return {
step: step,
integrate: integrate,
resolveCollisions: resolveCollisions,
_MAX_V: MAX_V
};
})();
global.SimPhysics = SimPhysics;
/* ════════════════════ Instance ════════════════════ */
function SimEngineInstance(host, spec) {
this.host = host;
this.spec = spec || {};
this.params = {}; // name -> текущее значение (number)
this._sliders = {}; // name -> input element
this._paramRange = {}; // name -> { min, max } (для clamp при drag)
this._objs = []; // подготовленные объекты с привязками
this._trails = {}; // objId -> [[x,y],...] (мир-координаты)
this._t = 0;
this._running = false;
this._raf = 0;
this._last = 0;
this._dpr = 1;
this._cw = 0; this._ch = 0;
this._destroyed = false;
this._ro = null;
// ── вид (zoom/pan) ──
this._scale = 1; this._offX = 0; this._offY = 0; // эффективный transform
this._baseScale = 1; this._baseOffX = 0; this._baseOffY = 0; // базовый fit
this._zoom = 1; // пользовательский множитель к базовому масштабу
this._viewLocked = false; // true после зума/пана — ресайз не сбрасывает вид
this._panning = null; // активный pan { lastX, lastY }
this._dragging = null; // текущая перетаскиваемая ручка (drag)
this._readoutSlot = 0; // счётчик автопозиционируемых readout-бейджей
// ── физика (Фаза 2) ──
this._phys = null; // состояние интегратора { bodies, springs, walls, opts, dt, acc }
this._bodyById = {}; // objId -> body (для drag/env/пружин)
this._dragBody = null; // активный захват физ-тела { body, lastW, lastT, vx, vy }
// ── цель/игра (Фаза 0 «Квантик») ──
this._goal = null; // скомпилированный блок цели { whenFn, failFn, hold, stars:[{fn,label}], title, hint } | null
this._goalState = null; // { won, failed, timeMs, attempts, starsGot:[], firstWinT } | null (только при наличии goal)
this._goalHoldT = 0; // сколько секунд (мирового t) условие when держится непрерывно
this._goalCbs = []; // подписчики onGoal
this._hud = null; // DOM-узлы HUD-оверлея (только при наличии goal)
this._build();
}
SimEngineInstance.prototype._vp = function () {
var v = this.spec.viewport || {};
return {
xmin: num(v.xmin, -1), xmax: num(v.xmax, 10),
ymin: num(v.ymin, -1), ymax: num(v.ymax, 10),
grid: v.grid !== false,
axes: v.axes !== false,
bg: v.bg || DEFAULT_BG
};
};
SimEngineInstance.prototype._build = function () {
var self = this;
var spec = this.spec;
// корень
var root = document.createElement('div');
root.className = 'sim-spec-root';
root.style.cssText = 'flex:1;min-height:0;position:relative;display:block;width:100%;height:100%;' +
'overflow:hidden;background:' + (this._vp().bg) + ';color:#fff;font-family:Manrope,system-ui,sans-serif';
this.el = root;
// ── сцена на ВСЮ площадь хоста (canvas + оверлей подписей) ──
// (раньше сцена делила строку flex с фикс-панелью 260px и визуально съезжала
// вправо у пустых симуляций — теперь сцена занимает весь root, центрирована.)
var stage = document.createElement('div');
stage.className = 'sim-spec-stage';
stage.style.cssText = 'position:absolute;inset:0;overflow:hidden';
var canvas = document.createElement('canvas');
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none';
stage.appendChild(canvas);
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
var labels = document.createElement('div');
labels.className = 'sim-spec-labels';
labels.style.cssText = 'position:absolute;inset:0;pointer-events:none';
stage.appendChild(labels);
this._labelLayer = labels;
root.appendChild(stage);
this._stage = stage;
// ── плавающая панель контролов (overlay поверх сцены, сворачиваемая) ──
// pointer-events только на самой карточке: пустое место сцены под ней доступно
// для pan/drag. Если параметров нет — панель минимальна (только play/reset).
var panel = document.createElement('div');
panel.className = 'sim-spec-panel';
panel.style.cssText = 'position:absolute;left:10px;top:10px;z-index:5;max-width:248px;' +
'max-height:calc(100% - 20px);display:flex;flex-direction:column;gap:10px;' +
'background:rgba(13,13,26,0.82);border:1px solid rgba(255,255,255,0.10);border-radius:14px;' +
'padding:10px 12px;overflow-y:auto;backdrop-filter:blur(6px);pointer-events:auto;' +
'box-shadow:0 8px 28px rgba(0,0,0,0.35)';
this._panel = panel;
// заголовок: play/pause/reset + кнопка сворачивания
var ctrlRow = document.createElement('div');
ctrlRow.style.cssText = 'display:flex;gap:6px;align-items:center';
var btnPlay = this._btn(this._playIcon(true), 'Запустить / пауза');
var btnReset = this._btn(this._resetIcon(), 'Сброс');
this._btnPlay = btnPlay;
btnPlay.addEventListener('click', function () { self._running ? self.pause() : self.play(); });
btnReset.addEventListener('click', function () { self.reset(); });
ctrlRow.appendChild(btnPlay);
ctrlRow.appendChild(btnReset);
// тело панели (слайдеры) — сворачивается
var pBody = document.createElement('div');
pBody.style.cssText = 'display:flex;flex-direction:column;gap:10px';
this._panelBody = pBody;
var params = Array.isArray(spec.params) ? spec.params : [];
// кнопка сворачивания — только если есть что сворачивать (есть параметры)
if (params.length) {
var btnCollapse = this._btn(this._chevIcon(true), 'Свернуть панель');
this._btnCollapse = btnCollapse;
btnCollapse.style.marginLeft = 'auto';
btnCollapse.addEventListener('click', function () { self._togglePanel(); });
ctrlRow.appendChild(btnCollapse);
}
panel.appendChild(ctrlRow);
// слайдеры параметров
params.forEach(function (p) {
if (!p || !p.name) return;
var min = num(p.min, 0), max = num(p.max, 100), step = num(p.step, 1);
var val = num(p.value, min);
self.params[p.name] = val;
self._paramRange[p.name] = { min: min, max: max }; // для clamp при drag
var wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;flex-direction:column;gap:4px';
var lblRow = document.createElement('div');
lblRow.style.cssText = 'display:flex;justify-content:space-between;gap:8px;font-size:.74rem;color:rgba(255,255,255,.6)';
var lblName = document.createElement('span');
lblName.textContent = p.label || p.name;
var lblVal = document.createElement('span');
lblVal.style.cssText = 'color:#06D6E0;font-weight:700;font-variant-numeric:tabular-nums';
lblVal.textContent = _fmt(val) + (p.unit ? ' ' + p.unit : '');
lblRow.appendChild(lblName); lblRow.appendChild(lblVal);
var slider = document.createElement('input');
slider.type = 'range';
slider.min = String(min); slider.max = String(max); slider.step = String(step);
slider.value = String(val);
slider.style.cssText = 'width:100%;accent-color:#9B5DE5;cursor:pointer';
slider.addEventListener('input', function () {
var v = parseFloat(slider.value);
self.params[p.name] = v;
lblVal.textContent = _fmt(v) + (p.unit ? ' ' + p.unit : '');
if (!self._running) {
// на паузе в начале — пересобрать нач. условия физ-тел (предпросмотр старта)
if (self._phys && self._t === 0) self._preparePhysics();
self._renderFrame(); // живой предпросмотр на паузе
}
});
wrap.appendChild(lblRow);
wrap.appendChild(slider);
pBody.appendChild(wrap);
self._sliders[p.name] = slider;
});
if (params.length) panel.appendChild(pBody);
stage.appendChild(panel);
// ── кнопки управления видом («Вписать» / «Сбросить вид») в углу сцены ──
this._buildViewControls(stage);
this.host.appendChild(root);
// подготовить объекты (компиляция привязок один раз)
this._prepareObjects();
// подготовить цель/игру (компиляция when/fail/stars один раз) + HUD-оверлей.
// Аддитивно: при отсутствии goal в спеке _goal остаётся null и HUD не создаётся.
this._prepareGoal();
if (this._goal) this._buildHud(stage);
// resize
if (global.ResizeObserver) {
this._ro = new ResizeObserver(function () { self._fit(); self._renderFrame(); });
this._ro.observe(stage);
}
// drag-интеракции (мышь + тач через pointer events)
this._setupDrag();
// zoom (колесо) + pan (drag пустого места)
this._setupZoomPan();
// первичная подгонка после layout
requestAnimationFrame(function () {
self._fit();
self.reset();
var time = spec.time || {};
if (time.autoplay) self.play();
});
};
/* свернуть/развернуть тело панели параметров */
SimEngineInstance.prototype._togglePanel = function () {
if (!this._panelBody) return;
this._panelCollapsed = !this._panelCollapsed;
this._panelBody.style.display = this._panelCollapsed ? 'none' : 'flex';
if (this._btnCollapse) this._btnCollapse.innerHTML = this._chevIcon(!this._panelCollapsed);
};
/* кнопки вида в правом-нижнем углу сцены: «Вписать» и «Сбросить вид» */
SimEngineInstance.prototype._buildViewControls = function (stage) {
var self = this;
var bar = document.createElement('div');
bar.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:5;display:flex;gap:6px;pointer-events:auto';
var btnFit = this._btn(this._fitIcon(), 'Вписать в окно');
var btnResetView = this._btn(this._resetViewIcon(), 'Сбросить вид');
btnFit.addEventListener('click', function () { self.fitView(); });
btnResetView.addEventListener('click', function () { self.resetView(); });
bar.appendChild(btnFit);
bar.appendChild(btnResetView);
stage.appendChild(bar);
this._viewBar = bar;
};
/* кнопка контрола (inline SVG, без эмодзи) */
SimEngineInstance.prototype._btn = function (innerSvg, title) {
var b = document.createElement('button');
b.title = title || '';
b.innerHTML = innerSvg;
b.style.cssText = 'min-width:34px;height:34px;border-radius:10px;border:1.5px solid rgba(255,255,255,.18);' +
'background:transparent;color:rgba(255,255,255,.75);cursor:pointer;display:flex;align-items:center;' +
'justify-content:center;padding:0 9px;transition:all .15s';
b.addEventListener('mouseenter', function () { b.style.borderColor = '#9B5DE5'; b.style.color = '#9B5DE5'; });
b.addEventListener('mouseleave', function () { b.style.borderColor = 'rgba(255,255,255,.18)'; b.style.color = 'rgba(255,255,255,.75)'; });
return b;
};
SimEngineInstance.prototype._playIcon = function (play) {
return play
? '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>'
: '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>';
};
SimEngineInstance.prototype._resetIcon = function () {
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>';
};
// шеврон: up=панель развёрнута (клик свернёт), down=панель свёрнута (клик развернёт)
SimEngineInstance.prototype._chevIcon = function (expanded) {
return expanded
? '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 15 12 9 18 15"/></svg>'
: '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 9 12 15 18 9"/></svg>';
};
// «вписать»: рамка-кадрирование
SimEngineInstance.prototype._fitIcon = function () {
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M4 8V5a1 1 0 0 1 1-1h3"/><path d="M16 4h3a1 1 0 0 1 1 1v3"/><path d="M20 16v3a1 1 0 0 1-1 1h-3"/><path d="M8 20H5a1 1 0 0 1-1-1v-3"/></svg>';
};
// «сбросить вид»: круговая стрелка с точкой-прицелом
SimEngineInstance.prototype._resetViewIcon = function () {
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 3v3"/><path d="M12 18v3"/><path d="M3 12h3"/><path d="M18 12h3"/></svg>';
};
SimEngineInstance.prototype._syncPlayBtn = function () {
if (this._btnPlay) this._btnPlay.innerHTML = this._playIcon(!this._running);
};
/* Компиляция привязок объектов один раз. */
SimEngineInstance.prototype._prepareObjects = function () {
var raw = Array.isArray(this.spec.objects) ? this.spec.objects : [];
var out = [];
for (var i = 0; i < raw.length; i++) {
var o = raw[i] || {};
var type = o.type || 'point';
var prep = { id: o.id || ('obj' + i), type: type, raw: o, b: {} };
// общие визуальные поля (не привязки)
// цвет: явный из спеки, иначе циклическая дефолт-палитра (приятнее единого #06D6E0)
prep.color = o.color || DEFAULT_PALETTE[i % DEFAULT_PALETTE.length];
prep.fillColor = o.fill || o.fillColor || null;
prep.width = num(o.width, 2);
prep.trail = !!o.trail;
prep.trailColor = o.trailColor || prep.color;
prep.latex = o.latex !== false; // подписи по умолчанию рендерятся KaTeX
prep.size = num(o.size, 14);
prep.text = o.text != null ? String(o.text) : '';
// ── P2: расширенные стили графики (рендерятся ТОЛЬКО на canvas — XSS-безопасно) ──
// lineStyle: solid|dashed|dotted -> setLineDash; opacity 0..1 -> globalAlpha
prep.lineStyle = (o.lineStyle === 'dashed' || o.lineStyle === 'dotted') ? o.lineStyle : 'solid';
prep.opacity = (o.opacity === undefined || o.opacity === null) ? 1 : _opacity(o.opacity);
// glow/shadow: свечение акцентных объектов (по умолчанию ВЫКЛ — производительность)
prep.glow = (o.glow === true || (o.shadow && o.shadow !== false)) || false;
prep.glowColor = (o.shadow && typeof o.shadow === 'string') ? o.shadow
: (o.glowColor || prep.color);
prep.glowBlur = num((o.shadow && typeof o.shadow === 'object') ? o.shadow.blur : o.glowBlur, 12);
// линейный градиент заливки: gradient:[c0,c1] (цвета только в canvas-стоки)
prep.gradient = (Array.isArray(o.gradient) && o.gradient.length >= 2)
? [String(o.gradient[0]), String(o.gradient[1])] : null;
// стиль точки: filled|hollow|cross|ring (деф. filled — заполненный кружок с обводкой)
prep.pointStyle = (o.pointStyle === 'hollow' || o.pointStyle === 'cross' || o.pointStyle === 'ring')
? o.pointStyle : 'filled';
// трасса: затухание (fade — старые сегменты прозрачнее, деф. вкл), длина и толщина
prep.trailFade = (o.trailFade === false) ? false : true;
prep.trailWidth = num(o.trailWidth, 1.6);
prep.trailLen = (typeof o.trailLen === 'number' && o.trailLen > 1)
? Math.min(5000, o.trailLen | 0) : 2000;
// числовые/выражения-привязки по типу
var B = prep.b;
function bp(key, dflt) { B[key] = bind(o[key], dflt); }
if (type === 'point') { bp('x', 0); bp('y', 0); B.r = bind(o.r, 6); }
else if (type === 'segment') { bp('x1', 0); bp('y1', 0); bp('x2', 1); bp('y2', 1); }
else if (type === 'vector') {
// vector: либо x1/y1/x2/y2, либо origin:[ox,oy] + dx/dy
if (Array.isArray(o.origin) || o.dx !== undefined || o.dy !== undefined) {
var org = Array.isArray(o.origin) ? o.origin : [0, 0];
B.x1 = bind(org[0], 0); B.y1 = bind(org[1], 0);
// конец = origin + (dx,dy): сумма привязок
var ox = B.x1, oy = B.y1;
var dxB = bind(o.dx, 1), dyB = bind(o.dy, 1);
B.x2 = { ev: function (env) { return ox.ev(env) + dxB.ev(env); } };
B.y2 = { ev: function (env) { return oy.ev(env) + dyB.ev(env); } };
} else {
bp('x1', 0); bp('y1', 0); bp('x2', 1); bp('y2', 1);
}
}
else if (type === 'circle') { bp('x', 0); bp('y', 0); bp('r', 1); }
else if (type === 'rect') { bp('x', 0); bp('y', 0); bp('w', 1); bp('h', 1); }
else if (type === 'polyline' || type === 'path') {
prep.pts = (Array.isArray(o.points) ? o.points : []).map(function (pt) {
return { x: bind(pt[0], 0), y: bind(pt[1], 0) };
});
prep.closed = !!o.closed;
} else if (type === 'label') {
bp('x', 0); bp('y', 0);
} else if (type === 'plot') {
prep.varName = (typeof o['var'] === 'string' && o['var']) ? o['var'] : 'x';
var rng = Array.isArray(o.range) ? o.range : null;
prep.rangeA = bind(rng ? rng[0] : null, null);
prep.rangeB = bind(rng ? rng[1] : null, null);
prep.hasRange = !!rng;
prep.samples = Math.max(2, Math.min(2000, num(o.samples, 200) | 0));
prep.trace = !!o.trace;
// ── P3: несколько кривых на одном plot ──
// Источник кривых (приоритет): curves[] -> exprs[] -> одиночный expr (легаси).
// Каждой кривой свой нормализованный стиль; цвет — явный или из палитры по индексу.
var curveDefs = [];
if (Array.isArray(o.curves) && o.curves.length) {
curveDefs = o.curves.map(function (cv) {
return (cv && typeof cv === 'object') ? cv : { expr: cv };
});
} else if (Array.isArray(o.exprs) && o.exprs.length) {
curveDefs = o.exprs.map(function (ex) { return { expr: ex }; });
} else {
curveDefs = [{ expr: o.expr != null ? o.expr : '0' }];
}
var plotMarker = _markerStyle(o.marker);
// plot-уровневые дефолты заливки/маркера наследуются кривыми (если у кривой не задано)
prep.curves = curveDefs.map(function (cv, ci) {
cv = cv || {};
var cFill = (cv.fill !== undefined) ? cv.fill : o.fill;
return {
exprFn: bind(cv.expr != null ? cv.expr : '0', 0),
color: cv.color || o.color || DEFAULT_PALETTE[ci % DEFAULT_PALETTE.length],
label: (cv.label != null) ? String(cv.label) : '',
width: num(cv.width, prep.width),
lineStyle: (cv.lineStyle === 'dashed' || cv.lineStyle === 'dotted') ? cv.lineStyle
: prep.lineStyle,
opacity: (cv.opacity === undefined || cv.opacity === null) ? prep.opacity : _opacity(cv.opacity),
// заливка под кривой: true -> полупрозрачный цвет кривой; строка -> явный цвет
fill: (cFill === true || (typeof cFill === 'string' && cFill)) ? cFill : false,
// маркеры узлов: none|dot|ring (наследует plot-уровень)
marker: (cv.marker !== undefined) ? _markerStyle(cv.marker) : plotMarker,
glow: prep.glow,
glowColor: prep.glowColor,
glowBlur: prep.glowBlur
};
});
// легаси: одиночное выражение для trace-режима (накопление по t)
prep.exprFn = prep.curves[0].exprFn;
// легенда: показывать, если есть хотя бы одна подпись (можно явно 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')
: { fn: function () { return 0; }, ast: null };
prep.exprFn = { ev: rc.fn };
prep.exprAst = rc.ast;
prep.label = o.label != null ? String(o.label) : '';
prep.unit = o.unit != null ? String(o.unit) : '';
prep.precision = Math.max(0, Math.min(8, num(o.precision, 2) | 0));
prep.hasPos = (o.x !== undefined && o.y !== undefined);
if (prep.hasPos) { bp('x', 0); bp('y', 0); }
}
// физическое тело (point/circle): интегрируется движком, а не формулой.
// Начальные x/y/vx/vy компилируем как выражения от params/констант (вычисляются
// один раз при reset/init), масса/радиус/fixed — статические.
if (o.body && (type === 'point' || type === 'circle')) {
var bd = o.body;
prep.body = {
mass0: bind(bd.mass !== undefined ? bd.mass : 1, 1), // масса: число/выражение от params
fixed: !!bd.fixed,
vx0: bind(bd.vx, 0), // нач. скорость X (число/выражение)
vy0: bind(bd.vy, 0), // нач. скорость Y
// радиус: для circle — мировой r (выражение); для point — экранный, мир-эквивалент
isCircle: (type === 'circle')
};
}
// drag-интеракция (point/circle): объект становится ручкой
if (o.drag && (type === 'point' || type === 'circle')) {
var dg = o.drag;
prep.drag = {
param: dg.param || null, // axis x|y -> этот параметр; xy -> X
paramY: dg.paramY || null, // axis xy -> Y (обязателен для 2D-ручки)
axis: (dg.axis === 'y' || dg.axis === 'xy') ? dg.axis : 'x',
min: typeof dg.min === 'number' ? dg.min : -Infinity,
max: typeof dg.max === 'number' ? dg.max : Infinity
};
}
// привязки для центра объекта (для obj.x/obj.y в env): point/circle/rect/label
// (zone — область, не точка: его x/y не кладём в env как центр объекта)
if (B.x && B.y && type !== 'zone') { prep.hasCenter = true; }
out.push(prep);
}
this._objs = out;
};
/* ════════════════════ Цель / игра (Фаза 0 «Квантик») ════════════════════
Декларативный слой победы: булевы SimExpr-выражения, компилируемые ОДИН РАЗ
(как все выражения движка). В rAF после построения env — оценка. Безопасно:
никакого eval, выражения исполняет SimExpr (кривое выражение -> 0, не бросает). */
/* Скомпилировать блок goal (when/fail/каждое stars[].when) один раз при mount.
Спека без goal -> _goal остаётся null (полная аддитивность). */
SimEngineInstance.prototype._prepareGoal = function () {
var g = this.spec.goal;
if (!g || typeof g !== 'object' || Array.isArray(g)) { this._goal = null; this._goalState = null; return; }
var compile = (global.SimExpr && global.SimExpr.compile)
? global.SimExpr.compile
: function () { return { fn: function () { return 0; }, ast: null, error: null }; };
var whenC = compile(g.when != null ? g.when : '0');
var failC = (g.fail != null) ? compile(g.fail) : null;
var rawStars = Array.isArray(g.stars) ? g.stars.slice(0, 3) : []; // не более 3 звёзд
var stars = rawStars.map(function (s) {
s = (s && typeof s === 'object') ? s : { when: s };
var c = compile(s.when != null ? s.when : '0');
return { fn: c.fn, label: (s.label != null) ? String(s.label) : '' };
});
this._goal = {
whenFn: whenC.fn,
failFn: failC ? failC.fn : null,
hold: (typeof g.hold === 'number' && isFinite(g.hold) && g.hold > 0) ? g.hold : 0,
stars: stars,
title: (g.title != null) ? String(g.title) : '',
hint: (g.hint != null) ? String(g.hint) : ''
};
// первичное состояние результата (attempts=0; первый mount/авто-reset попыткой не считается)
this._goalState = {
won: false, failed: false, timeMs: 0,
attempts: 0, starsGot: stars.map(function () { return false; }), firstWinT: null
};
this._goalHoldT = 0;
};
/* Оценить цель за кадр (после построения env и шага физики). Накапливает звёзды,
проверяет fail (мягкий проигрыш), then when с учётом hold (удержание). При победе
фиксирует timeMs (мировое t, детерминизм), ставит won, ставит на паузу, дёргает onGoal. */
SimEngineInstance.prototype._evalGoal = function (env, dt) {
var g = this._goal, st = this._goalState;
if (!g || !st) return;
// tries — число пользовательских reset; добавляем ТОЛЬКО его (безопасность контракта).
env.tries = st.attempts;
// звёзды «залипают»: однажды истинное условие остаётся засчитанным до reset.
for (var i = 0; i < g.stars.length; i++) {
if (!st.starsGot[i] && _truthy(g.stars[i].fn(env))) st.starsGot[i] = true;
}
if (st.won || st.failed) return; // итог зафиксирован — больше не пересчитываем
// мягкий проигрыш: fail имеет приоритет над when (НЕ победа)
if (g.failFn && _truthy(g.failFn(env))) {
st.failed = true;
this._goalHoldT = 0;
this.pause();
this._renderHud();
return;
}
// победа: when (с учётом hold — условие должно держаться hold секунд)
if (_truthy(g.whenFn(env))) {
this._goalHoldT += (typeof dt === 'number' && dt > 0) ? dt : 0;
if (this._goalHoldT >= g.hold) {
st.won = true;
st.firstWinT = this._t;
// время победы: мировое t от старта уровня (детерминизм, headless-тест)
st.timeMs = Math.max(1, Math.round(this._t * 1000));
this.pause();
this._fireGoal();
this._renderHud();
}
} else {
this._goalHoldT = 0; // условие пропало до удержания — сброс таймера
}
};
/* Вызвать onGoal-подписчиков один раз (после первой победы). */
SimEngineInstance.prototype._fireGoal = function () {
var res = this.getResult();
var cbs = this._goalCbs.slice();
for (var i = 0; i < cbs.length; i++) {
try { cbs[i](res); } catch (e) { /* подписчик не должен ронять цикл */ }
}
};
/* ════════════════════ HUD цели (DOM-оверлей) ════════════════════
Появляется ТОЛЬКО при наличии goal. Контейнер — pointer-events:none (не крадёт
pan/drag сцены), интерактивные кнопки — pointer-events:auto. Стиль — тёмная
плашка как у readout-бейджей. Без эмодзи: звёзды/иконки — inline SVG. */
SimEngineInstance.prototype._buildHud = function (stage) {
var self = this;
var hud = {};
// ── верхняя плашка: цель + звёзды (по центру сверху) ──
var top = document.createElement('div');
top.style.cssText = 'position:absolute;left:50%;top:10px;transform:translateX(-50%);z-index:6;' +
'pointer-events:none;display:flex;flex-direction:column;gap:5px;align-items:center;max-width:80%';
var objLine = document.createElement('div');
objLine.style.cssText = 'display:flex;align-items:center;gap:8px;' + _readoutBadgeCss('#fff') +
';font-size:.82rem;font-weight:600;pointer-events:none';
var titleSpan = document.createElement('span');
var starsWrap = document.createElement('span');
starsWrap.style.cssText = 'display:inline-flex;gap:3px;align-items:center';
objLine.appendChild(titleSpan);
objLine.appendChild(starsWrap);
top.appendChild(objLine);
hud.titleSpan = titleSpan;
hud.starsWrap = starsWrap;
var hintEl = document.createElement('div');
hintEl.style.cssText = _readoutBadgeCss('rgba(255,255,255,0.72)') +
';font-size:.74rem;pointer-events:none;max-width:100%;white-space:normal;text-align:center';
top.appendChild(hintEl);
hud.hintEl = hintEl;
stage.appendChild(top);
hud.top = top;
// ── центральный баннер «Победа» / «Ещё раз» (скрыт по умолчанию) ──
var banner = document.createElement('div');
banner.style.cssText = 'position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:7;' +
'display:none;flex-direction:column;align-items:center;gap:10px;pointer-events:none;' +
'background:rgba(13,13,26,0.92);border:1px solid rgba(255,255,255,0.16);border-radius:16px;' +
'padding:18px 24px;box-shadow:0 12px 40px rgba(0,0,0,0.5);text-align:center';
var bannerTitle = document.createElement('div');
bannerTitle.style.cssText = 'font-size:1.1rem;font-weight:800;letter-spacing:.3px';
var bannerStars = document.createElement('div');
bannerStars.style.cssText = 'display:flex;gap:4px;align-items:center';
var btnRetry = this._btn(this._resetIcon(), 'Ещё раз');
btnRetry.style.pointerEvents = 'auto';
btnRetry.style.minWidth = '120px';
btnRetry.innerHTML = this._resetIcon() + '<span style="margin-left:7px;font-weight:700">Ещё раз</span>';
this._onHudRetry = function () { self.reset(); };
btnRetry.addEventListener('click', this._onHudRetry);
banner.appendChild(bannerTitle);
banner.appendChild(bannerStars);
banner.appendChild(btnRetry);
stage.appendChild(banner);
hud.banner = banner;
hud.bannerTitle = bannerTitle;
hud.bannerStars = bannerStars;
hud.btnRetry = btnRetry;
this._hud = hud;
this._renderHud();
};
/* SVG-звезда: заполненная (got) или контурная (ещё не получена). Без эмодзи. */
SimEngineInstance.prototype._starIcon = function (got, size) {
var s = size || 15;
var fill = got ? '#FBBF24' : 'none';
var stroke = got ? '#FBBF24' : 'rgba(255,255,255,0.42)';
return '<svg viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="' + fill +
'" stroke="' + stroke + '" stroke-width="1.6" stroke-linejoin="round">' +
'<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
};
/* Перерисовать HUD по текущему состоянию цели (вызывается каждый кадр + при reset). */
SimEngineInstance.prototype._renderHud = function () {
var hud = this._hud, g = this._goal, st = this._goalState;
if (!hud || !g || !st) return;
// строка цели
hud.titleSpan.textContent = g.title || 'Цель';
// индикаторы звёзд (только если есть звёзды)
var starsHtml = '';
for (var i = 0; i < g.stars.length; i++) starsHtml += this._starIcon(st.starsGot[i], 15);
hud.starsWrap.innerHTML = starsHtml;
// подсказка
if (g.hint) { hud.hintEl.style.display = ''; hud.hintEl.textContent = g.hint; }
else hud.hintEl.style.display = 'none';
// баннер итога
if (st.won || st.failed) {
hud.banner.style.display = 'flex';
if (st.won) {
var got = 0;
for (var k = 0; k < st.starsGot.length; k++) if (st.starsGot[k]) got++;
hud.bannerTitle.textContent = 'Победа!';
hud.bannerTitle.style.color = '#34D399';
var bs = '';
for (var j = 0; j < g.stars.length; j++) bs += this._starIcon(st.starsGot[j], 22);
hud.bannerStars.innerHTML = bs;
hud.bannerStars.style.display = g.stars.length ? 'flex' : 'none';
} else {
hud.bannerTitle.textContent = 'Не вышло';
hud.bannerTitle.style.color = '#FB7185';
hud.bannerStars.innerHTML = '';
hud.bannerStars.style.display = 'none';
}
} else {
hud.banner.style.display = 'none';
}
};
/* ── физика: есть ли в спеке тела/включён ли интегратор ── */
SimEngineInstance.prototype._physEnabled = function () {
var ph = this.spec.physics;
if (!ph || ph.enabled === false) return false;
for (var i = 0; i < this._objs.length; i++) if (this._objs[i].body) return true;
return false;
};
/* Собрать/пересобрать состояние физики (вызывается на reset/init).
Начальные позиции/скорости вычисляются из выражений по env с params (без t). */
SimEngineInstance.prototype._preparePhysics = function () {
this._phys = null;
this._bodyById = {};
if (!this._physEnabled() || !global.SimPhysics) return;
var ph = this.spec.physics || {};
var vp = this._vp();
// env без t (нач. условия зависят только от params/констант/размеров вьюпорта)
var env = this._buildParamEnv();
var bodies = [];
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (!o.body) continue;
var x0 = o.b.x ? o.b.x.ev(env) : 0;
var y0 = o.b.y ? o.b.y.ev(env) : 0;
// радиус тела в МИРОВЫХ единицах (для коллизий). circle: o.r — мировой.
// point: o.r — экранные px -> переводим в мир через текущий масштаб (фолбэк 0.3).
var rWorld;
if (o.body.isCircle) {
rWorld = o.b.r ? Math.abs(o.b.r.ev(env)) : 0.5;
if (!isFinite(rWorld) || rWorld <= 0) rWorld = 0.5;
} else {
var rpx = o.b.r ? o.b.r.ev(env) : 6;
rWorld = (this._scale && isFinite(rpx)) ? rpx / this._scale : 0.3;
if (!isFinite(rWorld) || rWorld <= 0) rWorld = 0.3;
}
var mass = num(o.body.mass0.ev(env), 1);
if (!(mass > 0) || !isFinite(mass)) mass = 1; // защита: масса > 0
var body = {
id: o.id,
x: num(x0, 0), y: num(y0, 0),
vx: num(o.body.vx0.ev(env), 0),
vy: num(o.body.vy0.ev(env), 0),
mass: mass,
radius: rWorld,
fixed: o.body.fixed,
fx: 0, fy: 0
};
bodies.push(body);
this._bodyById[o.id] = body;
}
// пружины: концы — id тела или якорь-точка [x,y]
var springs = [];
var rawSprings = Array.isArray(ph.springs) ? ph.springs : [];
for (var s = 0; s < rawSprings.length; s++) {
var rs = rawSprings[s] || {};
var endA = this._resolveSpringEnd(rs.a, env);
var endB = this._resolveSpringEnd(rs.b, env);
if (!endA || !endB) continue;
springs.push({
a: endA, b: endB,
k: num(bind(rs.k, 20).ev(env), 20), // k/length/damping — числа ИЛИ выражения от params
length: num(bind(rs.length, 1).ev(env), 1),
damping: num(bind(rs.damping, 0).ev(env), 0)
});
}
// стены: именованные стороны (из границ viewport) + произвольные отрезки
var walls = [];
var rawWalls = Array.isArray(ph.walls) ? ph.walls : [];
for (var w = 0; w < rawWalls.length; w++) {
var wl = this._buildWall(rawWalls[w], vp);
if (wl) walls.push(wl);
}
var dt = num(ph.dt, 1 / 240);
dt = _clamp(dt, 1 / 2000, 1 / 30); // кламп шага для устойчивости
this._phys = {
bodies: bodies,
springs: springs,
walls: walls,
opts: {
// gravity/friction/restitution — числа ИЛИ выражения от params (вычисляются на reset)
gx: ph.gravity ? num(bind(ph.gravity.x, 0).ev(env), 0) : 0,
gy: ph.gravity ? num(bind(ph.gravity.y, 0).ev(env), 0) : 0,
friction: num(bind(ph.friction, 0).ev(env), 0),
restitution: (ph.restitution === undefined || ph.restitution === null)
? 1 : _clamp(num(bind(ph.restitution, 1).ev(env), 1), 0, 1)
},
dt: dt,
acc: 0
};
};
/* конец пружины: строка-id тела -> сам body; массив [x,y]/[xExpr,yExpr] -> якорь. */
SimEngineInstance.prototype._resolveSpringEnd = function (end, env) {
if (typeof end === 'string') {
return this._bodyById[end] || null;
}
if (Array.isArray(end)) {
var ax = bind(end[0], 0).ev(env);
var ay = bind(end[1], 0).ev(env);
// якорь — «бесконечно тяжёлое» неподвижное тело (без fx-аккумуляции)
return { x: num(ax, 0), y: num(ay, 0), vx: 0, vy: 0, fixed: true };
}
return null;
};
/* стена -> { px,py (точка на стене), nx,ny (нормаль внутрь области) }. */
SimEngineInstance.prototype._buildWall = function (wl, vp) {
if (!wl) return null;
if (wl.side) {
switch (wl.side) {
case 'bottom': return { px: 0, py: vp.ymin, nx: 0, ny: 1 };
case 'top': return { px: 0, py: vp.ymax, nx: 0, ny: -1 };
case 'left': return { px: vp.xmin, py: 0, nx: 1, ny: 0 };
case 'right': return { px: vp.xmax, py: 0, nx: -1, ny: 0 };
}
return null;
}
// произвольный отрезок: нормаль = перпендикуляр, ориентированная к центру области
if (wl.x1 !== undefined && wl.x2 !== undefined) {
var x1 = num(wl.x1, 0), y1 = num(wl.y1, 0), x2 = num(wl.x2, 0), y2 = num(wl.y2, 0);
var dx = x2 - x1, dy = y2 - y1;
var len = Math.sqrt(dx * dx + dy * dy) || 1;
var nx = -dy / len, ny = dx / len;
// ориентировать нормаль к центру viewport
var cx = (vp.xmin + vp.xmax) / 2, cy = (vp.ymin + vp.ymax) / 2;
var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
if ((cx - mx) * nx + (cy - my) * ny < 0) { nx = -nx; ny = -ny; }
return { px: x1, py: y1, nx: nx, ny: ny };
}
return null;
};
/* env только из params/вьюпорта (без t, без obj.x) — для нач. условий тел. */
SimEngineInstance.prototype._buildParamEnv = function () {
var env = {};
var p = this.params;
for (var k in p) if (Object.prototype.hasOwnProperty.call(p, k)) env[k] = p[k];
env.t = 0;
var vp = this._vp();
env.w = vp.xmax - vp.xmin; env.h = vp.ymax - vp.ymin;
env.xmin = vp.xmin; env.xmax = vp.xmax; env.ymin = vp.ymin; env.ymax = vp.ymax;
return env;
};
/* Окружение для evaluate: t, params, w/h, и центры объектов (obj.x/obj.y). */
SimEngineInstance.prototype._buildEnv = function () {
var env = {};
var p = this.params;
for (var k in p) if (Object.prototype.hasOwnProperty.call(p, k)) env[k] = p[k];
env.t = this._t;
var vp = this._vp();
env.w = vp.xmax - vp.xmin;
env.h = vp.ymax - vp.ymin;
env.xmin = vp.xmin; env.xmax = vp.xmax; env.ymin = vp.ymin; env.ymax = vp.ymax;
// 1) физ-тела: x/y/vx/vy берутся ИЗ СОСТОЯНИЯ ИНТЕГРАТОРА, а не из выражения.
// Кладём их в env ПЕРВЫМИ — снимает forward-ref проблему однопроходного env:
// кинематические объекты, ссылающиеся на тело, видят его актуальную позицию.
if (this._phys) {
var bs = this._phys.bodies;
for (var bi = 0; bi < bs.length; bi++) {
var bb = bs[bi];
env[bb.id + '.x'] = bb.x;
env[bb.id + '.y'] = bb.y;
env[bb.id + '.vx'] = bb.vx;
env[bb.id + '.vy'] = bb.vy;
}
}
// 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) {
var x = o.b.x.ev(env);
var y = o.b.y.ev(env);
env[o.id + '.x'] = x;
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/размер и базу;
при активном пользовательском виде (_viewLocked) ресайз НЕ сбрасывает зум/пан —
мир-центр и пользовательский масштаб сохраняются. */
SimEngineInstance.prototype._fit = function () {
var c = this.canvas; if (!c) return;
var dpr = Math.min(global.devicePixelRatio || 1, 2);
var stage = c.parentElement || c;
var r = stage.getBoundingClientRect();
var w = Math.max(1, Math.round(r.width));
var h = Math.max(1, Math.round(r.height));
// запомнить мир-точку под центром canvas (для сохранения вида при ресайзе)
var hadView = this._viewLocked && this._cw && this._ch && this._scale;
var worldCx, worldCy;
if (hadView) { var wc = this._toWorld(this._cw / 2, this._ch / 2); worldCx = wc[0]; worldCy = wc[1]; }
this._dpr = dpr; this._cw = w; this._ch = h;
c.width = w * dpr; c.height = h * dpr;
if (this.ctx) this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// базовый масштаб «вписать viewport, равные оси»
var vp = this._vp();
var vw = vp.xmax - vp.xmin || 1;
var vh = vp.ymax - vp.ymin || 1;
var pad = 16;
var sx = (w - pad * 2) / vw;
var sy = (h - pad * 2) / vh;
var s = Math.min(sx, sy);
var cxWorld = (vp.xmin + vp.xmax) / 2;
var cyWorld = (vp.ymin + vp.ymax) / 2;
this._baseScale = s;
this._baseOffX = w / 2 - cxWorld * s;
this._baseOffY = h / 2 + cyWorld * s; // +: т.к. Y инвертируется
if (hadView) {
// сохранить пользовательский зум-коэффициент и мир-центр после ресайза
var z = this._zoom || 1;
this._scale = s * z;
this._offX = w / 2 - worldCx * this._scale;
this._offY = h / 2 + worldCy * this._scale;
} else {
// нет активного пользовательского вида — эффективный = базовый
this._zoom = 1;
this._scale = this._baseScale;
this._offX = this._baseOffX;
this._offY = this._baseOffY;
}
};
SimEngineInstance.prototype._toPx = function (mx, my) {
return [this._offX + mx * this._scale, this._offY - my * this._scale];
};
/* экран (px, относит. stage) -> мир (обратно к _toPx) */
SimEngineInstance.prototype._toWorld = function (px, py) {
var s = this._scale || 1;
return [(px - this._offX) / s, (this._offY - py) / s];
};
/* «Вписать»: вернуться к базовому fit-виду по viewport (как при mount). */
SimEngineInstance.prototype.fitView = function () {
this._viewLocked = false;
this._zoom = 1;
this._fit();
this._renderFrame();
};
/* «Сбросить вид» — то же, что «Вписать» (база = центрированный viewport). */
SimEngineInstance.prototype.resetView = function () { this.fitView(); };
/* ════════════════════ Drag-интеракции (мышь + тач) ════════════════════
Перетаскиваемы: (1) объекты с prep.drag — ручки, пишут мир-коорд. в параметр;
(2) физ-тела (prep.body, не fixed) — тащишь напрямую: задаёт позицию тела, при
отпускании сообщает скорость (бросок). Слушаем pointer events на canvas. */
SimEngineInstance.prototype._hasHandles = function () {
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.drag) return true;
if (o.body && !o.body.fixed) return true;
}
return false;
};
var HIT_PX = 16; // допуск хит-теста ручек/тел в экранных пикселях
/* локальные координаты pointer-события относительно canvas */
SimEngineInstance.prototype._localXY = function (ev) {
var r = this.canvas.getBoundingClientRect();
return [ev.clientX - r.left, ev.clientY - r.top];
};
/* хит-тест: ближайшая ручка/тело под (lx,ly) в пределах допуска, иначе null.
Общий для drag (приоритет ручек) и zoom/pan (pan только когда вернулся null). */
SimEngineInstance.prototype._pickHandleAt = function (lx, ly) {
var env = this._buildEnv();
var best = null, bestD = HIT_PX * HIT_PX;
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
var ox, oy, hit = false;
if (o.body && !o.body.fixed) {
var body = this._bodyById[o.id];
if (!body) continue;
ox = body.x; oy = body.y; hit = true;
// у тела допуск = max(HIT_PX, его экранный радиус)
} else if (o.drag && o.b.x && o.b.y) {
ox = o.b.x.ev(env); oy = o.b.y.ev(env); hit = true;
}
if (!hit) continue;
var p = this._toPx(ox, oy);
var dx = p[0] - lx, dy = p[1] - ly;
var d = dx * dx + dy * dy;
var tol = bestD;
if (o.body) {
var rb = this._bodyById[o.id];
var rpx = rb ? rb.radius * (this._scale || 1) : HIT_PX;
var t = Math.max(HIT_PX, rpx); t = t * t;
if (d <= t && d <= bestD) { bestD = d; best = o; }
continue;
}
if (d <= tol) { bestD = d; best = o; }
}
return best;
};
SimEngineInstance.prototype._setupDrag = function () {
if (!this.canvas || !this._hasHandles()) return;
var self = this;
function localXY(ev) { return self._localXY(ev); }
function pickHandle(lx, ly) { return self._pickHandleAt(lx, ly); }
this._onPointerDown = function (ev) {
if (ev.button != null && ev.button !== 0) return; // только левая кнопка/тач
var xy = localXY(ev);
var h = pickHandle(xy[0], xy[1]);
if (!h) return;
self._dragging = h;
ev.preventDefault();
try { self.canvas.setPointerCapture(ev.pointerId); } catch (e) {}
self.canvas.style.cursor = 'grabbing';
if (h.body) {
// захват тела: запоминаем для оценки скорости броска
var body = self._bodyById[h.id];
self._dragBody = { body: body, lastW: self._toWorld(xy[0], xy[1]), lastT: _nowMs(), vx: 0, vy: 0 };
if (body) { body.vx = 0; body.vy = 0; }
} else {
self._dragBody = null;
}
self._applyDrag(h, xy[0], xy[1]);
};
this._onPointerMove = function (ev) {
var xy = localXY(ev);
if (self._dragging) {
ev.preventDefault();
self._applyDrag(self._dragging, xy[0], xy[1]);
} else if (!self._panning) {
// hover-курсор: 'grab' и над ручкой/телом, и над пустым местом (pan)
self.canvas.style.cursor = 'grab';
}
};
this._onPointerUp = function (ev) {
if (!self._dragging) return;
// тело: при отпускании сообщить накопленную скорость (бросок)
if (self._dragBody && self._dragBody.body && !self._dragBody.body.fixed) {
self._dragBody.body.vx = SimEngineInstance._clampThrow(self._dragBody.vx);
self._dragBody.body.vy = SimEngineInstance._clampThrow(self._dragBody.vy);
}
self._dragging = null;
self._dragBody = null;
try { self.canvas.releasePointerCapture(ev.pointerId); } catch (e) {}
self.canvas.style.cursor = 'default';
};
var c = this.canvas;
c.style.touchAction = 'none'; // не давать браузеру скроллить/зумить при тач-drag
c.addEventListener('pointerdown', this._onPointerDown);
c.addEventListener('pointermove', this._onPointerMove);
c.addEventListener('pointerup', this._onPointerUp);
c.addEventListener('pointercancel', this._onPointerUp);
};
/* ════════════════════ Zoom (колесо к курсору) + Pan (drag пустого места) ════
Колесо масштабирует относительно позиции курсора: мир-точка под курсором
остаётся под курсором. Pan — перетаскивание ПУСТОГО места (приоритет у ручек/
тел: если pickHandle нашёл объект или drag уже активен — pan не стартует).
Множитель зума ограничен [MIN_ZOOM, MAX_ZOOM] относительно базового fit. */
SimEngineInstance.prototype._setupZoomPan = function () {
if (!this.canvas) return;
var self = this;
var c = this.canvas;
c.style.touchAction = 'none';
c.style.cursor = 'grab'; // пустое место сцены можно панить
// ── колесо: зум к курсору ──
this._onWheel = function (ev) {
ev.preventDefault();
var xy = self._localXY(ev);
var factor = Math.pow(1.0015, -ev.deltaY); // плавно; вверх (deltaY<0) — приблизить
self._zoomAt(xy[0], xy[1], factor);
};
c.addEventListener('wheel', this._onWheel, { passive: false });
// ── pan: pointer на пустом месте ──
this._onPanDown = function (ev) {
if (ev.button != null && ev.button !== 0) return;
if (self._dragging) return; // ручка/тело уже захвачены
var xy = self._localXY(ev);
if (self._pickHandleAt(xy[0], xy[1])) return; // попали в ручку/тело — не паним
self._panning = { x: xy[0], y: xy[1], pid: ev.pointerId };
ev.preventDefault();
try { c.setPointerCapture(ev.pointerId); } catch (e) {}
c.style.cursor = 'grabbing';
};
this._onPanMove = function (ev) {
if (!self._panning) return;
ev.preventDefault();
var xy = self._localXY(ev);
var dx = xy[0] - self._panning.x, dy = xy[1] - self._panning.y;
self._panning.x = xy[0]; self._panning.y = xy[1];
self._offX += dx; self._offY += dy;
self._viewLocked = true;
if (!self._running) self._renderFrame();
};
this._onPanUp = function (ev) {
if (!self._panning) return;
self._panning = null;
try { c.releasePointerCapture(ev.pointerId); } catch (e) {}
c.style.cursor = 'default';
};
c.addEventListener('pointerdown', this._onPanDown);
c.addEventListener('pointermove', this._onPanMove);
c.addEventListener('pointerup', this._onPanUp);
c.addEventListener('pointercancel', this._onPanUp);
};
/* зум вокруг экранной точки (lx,ly): мир-точка под курсором не сдвигается. */
SimEngineInstance.prototype._zoomAt = function (lx, ly, factor) {
var MIN_ZOOM = 0.1, MAX_ZOOM = 50;
var base = this._baseScale || this._scale || 1;
var newZoom = _clamp((this._zoom || 1) * factor, MIN_ZOOM, MAX_ZOOM);
if (newZoom === this._zoom) return;
// мир под курсором до зума
var w = this._toWorld(lx, ly);
this._zoom = newZoom;
this._scale = base * newZoom;
// сдвинуть offset так, чтобы (w) снова оказался под (lx,ly)
this._offX = lx - w[0] * this._scale;
this._offY = ly + w[1] * this._scale; // Y инвертирована
this._viewLocked = true;
if (!this._running) this._renderFrame();
};
/* кламп скорости броска (мир/с), чтобы рывок мыши не запускал тело в космос */
SimEngineInstance._clampThrow = function (v) {
var MAX = 40;
if (!isFinite(v)) return 0;
return v > MAX ? MAX : (v < -MAX ? -MAX : v);
};
/* записать мир-координату курсора: в параметр(ы) ручки ИЛИ в позицию тела */
SimEngineInstance.prototype._applyDrag = function (h, lx, ly) {
var w = this._toWorld(lx, ly);
// ── перетаскивание физ-тела: ставим позицию, оцениваем скорость для броска ──
if (h.body && this._dragBody && this._dragBody.body) {
var body = this._dragBody.body;
body.x = w[0]; body.y = w[1];
var now = _nowMs();
var dt = (now - this._dragBody.lastT) / 1000;
if (dt > 0.0005) {
// экспоненциальное сглаживание оценки скорости
var ivx = (w[0] - this._dragBody.lastW[0]) / dt;
var ivy = (w[1] - this._dragBody.lastW[1]) / dt;
this._dragBody.vx = this._dragBody.vx * 0.5 + ivx * 0.5;
this._dragBody.vy = this._dragBody.vy * 0.5 + ivy * 0.5;
this._dragBody.lastW = w;
this._dragBody.lastT = now;
}
body.vx = 0; body.vy = 0; // пока держим — тело не интегрируется по скорости
if (!this._running) this._renderFrame();
return;
}
// ── перетаскивание ручки (Ф1): пишем в параметр(ы) ──
var d = h.drag;
if (d.axis === 'x') {
if (d.param) this._setParamClamped(d.param, w[0], d);
} else if (d.axis === 'y') {
if (d.param) this._setParamClamped(d.param, w[1], d);
} else { // 'xy'
if (d.param) this._setParamClamped(d.param, w[0], d);
if (d.paramY) this._setParamClamped(d.paramY, w[1], d);
}
if (!this._running) this._renderFrame(); // живой отклик на паузе
};
SimEngineInstance.prototype._setParamClamped = function (name, value, d) {
var v = _clamp(value, d.min, d.max);
// дополнительно зажать в диапазон самого параметра (слайдера), не полагаясь на DOM
var pr = this._paramRange[name];
if (pr) v = _clamp(v, pr.min, pr.max);
if (!isFinite(v)) return;
this.params[name] = v;
var sl = this._sliders[name];
if (sl) {
sl.value = String(v);
// синхронизировать подпись значения слайдера (input-обработчик обновит её)
sl.dispatchEvent(new Event('input'));
}
};
/* ════════════════════ Рендер кадра ════════════════════ */
SimEngineInstance.prototype._renderFrame = function () {
var ctx = this.ctx; if (!ctx) return;
var W = this._cw, H = this._ch;
if (!W || !H) return;
var vp = this._vp();
ctx.fillStyle = vp.bg;
ctx.fillRect(0, 0, W, H);
if (vp.grid) this._drawGrid(ctx, W, H, vp);
if (vp.axes) this._drawAxes(ctx, W, H, vp);
var env = this._buildEnv();
this._readoutSlot = 0; // сброс счётчика бейджей-readout на каждый кадр
// обновить трассы (point/circle с trail + plot с trace)
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.trail && o.hasCenter) {
var tx = env[o.id + '.x'], ty = env[o.id + '.y'];
if (typeof tx === 'number' && typeof ty === 'number') {
var arr = this._trails[o.id] || (this._trails[o.id] = []);
arr.push([tx, ty]);
if (arr.length > o.trailLen) arr.shift();
}
} else if (o.type === 'plot' && o.trace) {
this._accumPlotTrace(o, env);
}
}
// нарисовать трассы под объектами
for (var ti = 0; ti < this._objs.length; ti++) {
var ot = this._objs[ti];
if ((ot.trail || (ot.type === 'plot' && ot.trace)) &&
this._trails[ot.id] && this._trails[ot.id].length > 1) {
this._drawTrail(ctx, this._trails[ot.id], ot);
}
}
// пружины (под объектами, поверх трасс)
if (this._phys && this._phys.springs.length) this._drawSprings(ctx);
// объекты
this._labelLayer.innerHTML = '';
for (var j = 0; j < this._objs.length; j++) {
this._drawObject(ctx, this._objs[j], env);
}
// HUD цели (звёзды могут засчитываться и на паузе/предпросмотре по текущему env)
if (this._goal && this._goalState) {
env.tries = this._goalState.attempts; // тот же доп. идентификатор, что в _evalGoal
for (var gi = 0; gi < this._goal.stars.length; gi++) {
if (!this._goalState.starsGot[gi] && _truthy(this._goal.stars[gi].fn(env))) {
this._goalState.starsGot[gi] = true;
}
}
this._renderHud();
}
};
/* пружины как зигзаг между концами (наглядно для маятника/осциллятора) */
SimEngineInstance.prototype._drawSprings = function (ctx) {
var sp = this._phys.springs;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1.6;
ctx.lineJoin = 'round';
for (var i = 0; i < sp.length; i++) {
var s = sp[i];
var a = this._toPx(s.a.x, s.a.y);
var b = this._toPx(s.b.x, s.b.y);
var dx = b[0] - a[0], dy = b[1] - a[1];
var len = Math.sqrt(dx * dx + dy * dy);
if (len < 1) { ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke(); continue; }
var ux = dx / len, uy = dy / len; // вдоль
var px = -uy, py = ux; // перпендикуляр
var coils = Math.max(4, Math.min(24, Math.round(len / 14)));
var amp = 5;
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
for (var c = 1; c < coils; c++) {
var f = c / coils;
var off = (c % 2 === 0) ? amp : -amp;
ctx.lineTo(a[0] + ux * len * f + px * off, a[1] + uy * len * f + py * off);
}
ctx.lineTo(b[0], b[1]);
ctx.stroke();
}
ctx.restore();
};
/* накопить точку plot-trace (var=t) во след по времени */
SimEngineInstance.prototype._accumPlotTrace = function (o, env) {
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
var prev = env[o.varName];
env[o.varName] = env.t;
var ty = o.exprFn.ev(env);
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
if (typeof ty === 'number' && isFinite(ty)) {
var arr = this._trails[o.id] || (this._trails[o.id] = []);
arr.push([env.t, ty]);
if (arr.length > 2000) arr.shift();
}
};
/* Трасса: ломаная по накопленным точкам. С затуханием (trailFade) старые сегменты
рисуются прозрачнее новых (по-сегментно с растущей alpha) — «комета». Без fade —
одной линией (быстрее). Цвет/толщина/длина — из полей объекта (trailColor/trailWidth). */
SimEngineInstance.prototype._drawTrail = function (ctx, pts, o) {
var color = (o.trailColor || o.color);
var lw = o.trailWidth || 1.6;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.setLineDash([]);
var n = pts.length;
if (!o.trailFade || n < 3) {
// без затухания — одной линией с постоянной полупрозрачностью
ctx.globalAlpha = 0.55;
ctx.beginPath();
for (var i = 0; i < n; i++) {
var px = this._toPx(pts[i][0], pts[i][1]);
i === 0 ? ctx.moveTo(px[0], px[1]) : ctx.lineTo(px[0], px[1]);
}
ctx.stroke();
ctx.restore();
return;
}
// с затуханием: посегментно, alpha растёт от хвоста (старые) к голове (новые)
var prev = this._toPx(pts[0][0], pts[0][1]);
for (var j = 1; j < n; j++) {
var cur = this._toPx(pts[j][0], pts[j][1]);
var f = j / (n - 1); // 0 (хвост) .. 1 (голова)
ctx.globalAlpha = 0.08 + f * 0.6; // плавно ярче к голове
ctx.beginPath();
ctx.moveTo(prev[0], prev[1]);
ctx.lineTo(cur[0], cur[1]);
ctx.stroke();
prev = cur;
}
ctx.restore();
};
/* Применить общие стили объекта к ctx (вызывать ВНУТРИ ctx.save()/restore() в каждой
ветке _drawObject, чтобы состояние не «протекало» между объектами). Ставит:
globalAlpha (opacity), lineWidth, lineJoin/lineCap (round — сглаженные стыки/торцы),
setLineDash (lineStyle), shadow (glow). Цвета НЕ ставит (их задаёт ветка по типу). */
SimEngineInstance.prototype._applyStroke = function (ctx, o) {
ctx.globalAlpha = o.opacity;
ctx.lineWidth = o.width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
var dash = _dashFor(o.lineStyle, o.width);
ctx.setLineDash(dash || []);
if (o.glow) { ctx.shadowColor = o.glowColor; ctx.shadowBlur = o.glowBlur; }
};
/* Построить заливку для объекта: либо линейный градиент gradient:[c0,c1] по bbox
(x0,y0)-(x1,y1) в экранных px, либо сплошной fillColor. Возвращает CanvasGradient
или строку-цвет (оба — безопасные canvas-стоки), либо null если заливки нет. */
SimEngineInstance.prototype._fillStyleFor = function (ctx, o, x0, y0, x1, y1) {
if (o.gradient) {
try {
var g = ctx.createLinearGradient(x0, y0, x1, y1);
g.addColorStop(0, o.gradient[0]);
g.addColorStop(1, o.gradient[1]);
return g;
} catch (e) { /* мусорный цвет в градиенте — упасть на fillColor */ }
}
return o.fillColor || null;
};
SimEngineInstance.prototype._drawObject = function (ctx, o, env) {
var B = o.b;
switch (o.type) {
case 'point': {
// физ-тело: позиция из интегратора (env уже содержит obj.x/obj.y тела);
// формульная точка: из выражения.
var pxw = o.body ? env[o.id + '.x'] : B.x.ev(env);
var pyw = o.body ? env[o.id + '.y'] : B.y.ev(env);
var p = this._toPx(pxw, pyw);
// r точки — экранный радиус в пикселях (выражение допустимо)
var r = Math.max(1, B.r.ev(env) || 6);
this._drawPoint(ctx, o, p[0], p[1], r);
break;
}
case 'segment':
case 'vector': {
var a = this._toPx(B.x1.ev(env), B.y1.ev(env));
var b = this._toPx(B.x2.ev(env), B.y2.ev(env));
ctx.save();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
if (o.type === 'vector') {
// укоротить тело стрелки на длину головы, чтобы линия не торчала сквозь остриё
var ang0 = Math.atan2(b[1] - a[1], b[0] - a[0]);
var headLen = this._arrowHeadLen(o.width);
var bx = b[0] - Math.cos(ang0) * headLen * 0.9;
var by = b[1] - Math.sin(ang0) * headLen * 0.9;
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(bx, by); ctx.stroke();
ctx.setLineDash([]); // голова — всегда сплошная заливка
this._arrowHead(ctx, a, b, o.color, o.width);
} else {
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
}
ctx.restore();
break;
}
case 'circle': {
var cxw = o.body ? env[o.id + '.x'] : B.x.ev(env);
var cyw = o.body ? env[o.id + '.y'] : B.y.ev(env);
var c0 = this._toPx(cxw, cyw);
var rad = Math.abs(B.r.ev(env)) * this._scale;
ctx.save();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
// заливка: градиент (вертикальный по bbox круга) или сплошной fillColor
var cFill = this._fillStyleFor(ctx, o, c0[0], c0[1] - rad, c0[0], c0[1] + rad);
ctx.beginPath(); ctx.arc(c0[0], c0[1], rad, 0, Math.PI * 2);
if (cFill) { ctx.fillStyle = cFill; ctx.setLineDash([]); ctx.fill(); }
if (o.width > 0) {
var cDash = _dashFor(o.lineStyle, o.width);
ctx.setLineDash(cDash || []);
ctx.stroke();
}
ctx.restore();
break;
}
case 'rect': {
var cx = B.x.ev(env), cy = B.y.ev(env);
var rw = Math.abs(B.w.ev(env)), rh = Math.abs(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.save();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
var rFill = this._fillStyleFor(ctx, o, tl[0], tl[1], tl[0], tl[1] + ph); // верт. градиент
if (rFill) { ctx.fillStyle = rFill; ctx.setLineDash([]); ctx.fillRect(tl[0], tl[1], pw, ph); }
if (o.width > 0) {
var rDash = _dashFor(o.lineStyle, o.width);
ctx.setLineDash(rDash || []);
ctx.strokeRect(tl[0], tl[1], pw, ph);
}
ctx.restore();
break;
}
case 'polyline':
case 'path': {
if (!o.pts || !o.pts.length) break;
ctx.save();
this._applyStroke(ctx, o);
ctx.strokeStyle = o.color;
// bbox в экранных px для градиента (если задан)
var minX = Infinity, minY = Infinity, maxY = -Infinity;
var screenPts = [];
for (var k = 0; k < o.pts.length; k++) {
var pp = this._toPx(o.pts[k].x.ev(env), o.pts[k].y.ev(env));
screenPts.push(pp);
if (pp[0] < minX) minX = pp[0];
if (pp[1] < minY) minY = pp[1];
if (pp[1] > maxY) maxY = pp[1];
}
var plFill = this._fillStyleFor(ctx, o, minX, minY, minX, maxY);
ctx.beginPath();
for (var k2 = 0; k2 < screenPts.length; k2++) {
k2 === 0 ? ctx.moveTo(screenPts[k2][0], screenPts[k2][1])
: ctx.lineTo(screenPts[k2][0], screenPts[k2][1]);
}
if (o.closed) ctx.closePath();
// заливка только для замкнутого контура (path closed / polygon)
if (plFill && o.closed) { ctx.fillStyle = plFill; var savedDash = ctx.getLineDash ? ctx.getLineDash() : []; ctx.setLineDash([]); ctx.fill(); ctx.setLineDash(savedDash); }
if (o.width > 0) ctx.stroke();
ctx.restore();
break;
}
case 'label': {
var lp = this._toPx(B.x.ev(env), B.y.ev(env));
this._drawLabel(o, lp[0], lp[1]);
break;
}
case 'plot': {
this._drawPlot(ctx, o, env);
break;
}
case 'readout': {
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]);
}
};
/* ── plot: график f(var) на отрезке range (мир-координаты) ──
P3: несколько кривых (o.curves[]), заливка под кривой (к оси y=0), маркеры узлов,
легенда (на canvas). Trace-режим (накопление по t) рисуется отдельно через _drawTrail. */
SimEngineInstance.prototype._drawPlot = function (ctx, o, env) {
// trace без явного range — только накапливаемый след (статической кривой нет)
if (o.trace && !o.hasRange) return;
var vp = this._vp();
var W = this._cw, H = this._ch;
var a = o.rangeA.ev(env), b = o.rangeB.ev(env);
if (!o.hasRange || !isFinite(a) || !isFinite(b)) { a = vp.xmin; b = vp.xmax; }
if (a === b) return;
var n = o.samples;
var step = (b - a) / (n - 1);
// env-копия с дополнительной свободной переменной (не мутируем общий env навсегда)
var prev = env[o.varName];
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
var curves = o.curves || [];
var legendItems = [];
// y=0 в экранных px (для клипа заливки к оси X) + видимая область для клипа
var zeroPy = this._toPx(0, 0)[1];
for (var ci = 0; ci < curves.length; ci++) {
var cv = curves[ci];
// сэмплинг: экранные точки [px,py,worldY], разрывы как null (не-finite y -> пропуск сегмента)
var pts = [];
for (var i = 0; i < n; i++) {
var xv = a + step * i;
env[o.varName] = xv;
var yv = cv.exprFn.ev(env);
if (typeof yv !== 'number' || !isFinite(yv)) { pts.push(null); continue; }
var p = this._toPx(xv, yv);
pts.push([p[0], p[1], yv]);
}
ctx.save();
// заливка под кривой (между кривой и осью y=0), клиппится к видимой высоте
if (cv.fill) {
var fillCol = (cv.fill === true) ? _fillAlpha(cv.color, 0.18) : cv.fill;
ctx.save();
ctx.globalAlpha = cv.opacity;
ctx.fillStyle = fillCol;
ctx.shadowBlur = 0;
this._fillUnderCurve(ctx, pts, _clamp(zeroPy, 0, H));
ctx.restore();
}
// линия кривой (через _applyStroke: dash/opacity/glow/width)
this._applyStroke(ctx, cv);
ctx.strokeStyle = cv.color;
ctx.beginPath();
var started = false;
for (var k = 0; k < pts.length; k++) {
if (!pts[k]) { started = false; continue; }
if (!started) { ctx.moveTo(pts[k][0], pts[k][1]); started = true; }
else ctx.lineTo(pts[k][0], pts[k][1]);
}
ctx.stroke();
// маркеры узлов (с прореживанием: не чаще ~28px по экрану)
if (cv.marker && cv.marker !== 'none') {
this._drawCurveMarkers(ctx, pts, cv);
}
ctx.restore();
if (o.legend && cv.label) legendItems.push({ color: cv.color, label: cv.label });
}
// восстановить env
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
// легенда (поверх кривых, в углу области plot, на canvas)
if (legendItems.length) this._drawLegend(ctx, W, H, legendItems);
};
/* Заливка области под полилинией к базовой линии y=baseY (экранные px). Каждый
непрерывный сегмент (между разрывами null) заливается отдельным замкнутым контуром:
curve-up -> вдоль кривой -> curve-down к baseY -> закрыть. baseY клиппится к canvas. */
SimEngineInstance.prototype._fillUnderCurve = function (ctx, pts, baseY) {
var i = 0, n = pts.length;
while (i < n) {
// найти начало непрерывного сегмента
while (i < n && !pts[i]) i++;
var startI = i;
while (i < n && pts[i]) i++;
var endI = i; // [startI, endI)
if (endI - startI < 2) continue; // сегмент из <2 точек — заливать нечего
ctx.beginPath();
ctx.moveTo(pts[startI][0], baseY);
for (var k = startI; k < endI; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.lineTo(pts[endI - 1][0], baseY);
ctx.closePath();
ctx.fill();
}
};
/* Маркеры узлов кривой (dot|ring) с прореживанием по экранному расстоянию (~28px),
чтобы не рисовать сотни точек. Цвет — цвет кривой; opacity наследуется от ctx. */
SimEngineInstance.prototype._drawCurveMarkers = function (ctx, pts, cv) {
var MIN_PX = 28; // минимальный шаг между маркерами по экрану
var r = Math.max(2.5, (cv.width || 2) + 1.5);
var marker = { color: cv.color, fillColor: cv.color, opacity: cv.opacity, glow: false,
pointStyle: (cv.marker === 'ring') ? 'hollow' : 'filled', width: cv.width || 2 };
var lastX = -1e9, lastY = -1e9;
for (var k = 0; k < pts.length; k++) {
var p = pts[k];
if (!p) continue;
var dx = p[0] - lastX, dy = p[1] - lastY;
if (dx * dx + dy * dy < MIN_PX * MIN_PX) continue;
this._drawPoint(ctx, marker, p[0], p[1], r);
lastX = p[0]; lastY = p[1];
}
};
/* Компактная легенда в углу области plot (на canvas, без DOM): цветная метка + текст.
Позиция: верх-право, со смещением вниз, чтобы не наезжать на ось Y/подписи. */
SimEngineInstance.prototype._drawLegend = function (ctx, W, H, items) {
if (!items.length) return;
ctx.save();
ctx.font = '12px Manrope,system-ui,sans-serif';
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
var pad = 8, rowH = 18, swatch = 11, gap = 7;
// ширина по самой длинной подписи
var maxTxt = 0;
for (var i = 0; i < items.length; i++) {
var w = ctx.measureText(items[i].label).width;
if (w > maxTxt) maxTxt = w;
}
var boxW = pad * 2 + swatch + gap + Math.ceil(maxTxt);
var boxH = pad * 2 + items.length * rowH - (rowH - 14);
// верх-право; отступ от края, не наезжает на бар кнопок (right/bottom) и оси
var bx = W - boxW - 12, by = 12;
if (bx < 6) bx = 6;
// фон-плашка (полупрозрачная тёмная, как readout-бейджи)
ctx.globalAlpha = 1;
ctx.fillStyle = 'rgba(13,13,26,0.78)';
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); ctx.stroke(); }
else { ctx.fillRect(bx, by, boxW, boxH); ctx.strokeRect(bx, by, boxW, boxH); }
for (var j = 0; j < items.length; j++) {
var cy = by + pad + 7 + j * rowH;
// цветная метка (линия-свотч)
ctx.strokeStyle = items[j].color;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(bx + pad, cy); ctx.lineTo(bx + pad + swatch, cy);
ctx.stroke();
// текст метки (светлый, без пользовательского цвета в DOM)
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillText(items[j].label, bx + pad + swatch + gap, cy);
}
ctx.restore();
};
/* ── readout: живое значение выражения как бейдж на оверлее ── */
SimEngineInstance.prototype._drawReadout = function (o, env) {
// мягкая ошибка через evalSafe (NaN/∞/синтаксис -> error, бейдж покажет «—»)
var res = (global.SimExpr && o.exprAst)
? global.SimExpr.evalSafe(o.exprAst, env)
: { value: o.exprFn.ev(env), error: null };
var el = document.createElement('div');
var px, py;
if (o.hasPos) {
var p = this._toPx(o.b.x.ev(env), o.b.y.ev(env));
px = p[0]; py = p[1];
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);' + _readoutBadgeCss(o.color);
} else {
// дефолт: верх-правый угол сцены, бейджи столбиком
var idx = (this._readoutSlot = (this._readoutSlot || 0) + 1) - 1;
el.style.cssText = 'position:absolute;right:10px;top:' + (10 + idx * 30) + 'px;' +
_readoutBadgeCss(o.color);
}
if (o.hasPos) { el.style.left = px + 'px'; el.style.top = py + 'px'; }
var valTxt = res.error ? '—' : _fmtFixed(res.value, o.precision);
var html = '';
if (o.label) html += '<span style="opacity:.7;margin-right:5px">' + _esc(o.label) + '</span>';
html += '<span style="font-weight:700;font-variant-numeric:tabular-nums">' + _esc(valTxt) + '</span>';
if (o.unit && !res.error) html += '<span style="opacity:.6;margin-left:3px">' + _esc(o.unit) + '</span>';
el.innerHTML = html;
this._labelLayer.appendChild(el);
};
/* длина головы стрелки (px) — масштабируется от толщины линии (мин. 9px). */
SimEngineInstance.prototype._arrowHeadLen = function (width) {
var w = (typeof width === 'number' && width > 0) ? width : 2;
return Math.max(9, w * 3.2);
};
/* Аккуратная заполненная стрелка-голова (треугольник с лёгким вырезом у основания —
«барбед» силуэт, не «галочка»). Масштаб от width. Рисуется сплошной заливкой. */
SimEngineInstance.prototype._arrowHead = function (ctx, a, b, color, width) {
var ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
var len = this._arrowHeadLen(width);
var halfW = len * 0.42; // полуширина основания
var notch = len * 0.26; // глубина выреза у основания (барб)
ctx.save();
ctx.fillStyle = color;
ctx.lineJoin = 'round';
ctx.translate(b[0], b[1]); ctx.rotate(ang);
ctx.beginPath();
ctx.moveTo(0, 0); // остриё
ctx.lineTo(-len, -halfW); // левое крыло
ctx.lineTo(-len + notch, 0); // вырез у основания
ctx.lineTo(-len, halfW); // правое крыло
ctx.closePath();
ctx.fill();
ctx.restore();
};
/* Точка: стиль pointStyle (filled|hollow|cross|ring). Применяет opacity/glow.
filled — заполненный кружок с тонкой обводкой (красивый дефолт);
hollow — только обводка; ring — толстое кольцо; cross — крестик. */
SimEngineInstance.prototype._drawPoint = function (ctx, o, px, py, r) {
ctx.save();
ctx.globalAlpha = o.opacity;
if (o.glow) { ctx.shadowColor = o.glowColor; ctx.shadowBlur = o.glowBlur; }
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
var style = o.pointStyle;
if (style === 'cross') {
ctx.strokeStyle = o.color;
ctx.lineWidth = Math.max(1.5, o.width);
var c = r;
ctx.beginPath();
ctx.moveTo(px - c, py - c); ctx.lineTo(px + c, py + c);
ctx.moveTo(px - c, py + c); ctx.lineTo(px + c, py - c);
ctx.stroke();
} else if (style === 'hollow') {
ctx.strokeStyle = o.color;
ctx.lineWidth = Math.max(1.5, o.width);
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.stroke();
} else if (style === 'ring') {
ctx.strokeStyle = o.color;
ctx.lineWidth = Math.max(2, r * 0.5);
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.stroke();
} else {
// filled (деф.): заполненный кружок + тонкая контрастная обводка для чёткости
var fill = (o.fillColor || o.color);
ctx.fillStyle = fill;
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.fill();
if (!o.glow) { // тонкая обводка чётче без свечения
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.stroke();
}
}
ctx.restore();
};
/* Подпись: KaTeX (тем же путём, что graph.js) с фолбэком на текст. */
SimEngineInstance.prototype._drawLabel = function (o, px, py) {
var el = document.createElement('div');
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);font-size:' + o.size +
'px;color:' + o.color + ';white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.6)';
el.style.left = px + 'px';
el.style.top = py + 'px';
var txt = o.text || '';
if (o.latex && typeof global.katex !== 'undefined' && txt) {
try {
el.innerHTML = global.katex.renderToString(txt, { throwOnError: false, strict: false, displayMode: false });
} catch (e) { el.textContent = txt; }
} else {
el.textContent = txt;
}
this._labelLayer.appendChild(el);
};
/* видимые мир-границы текущего вьюпорта экрана (учёт zoom/pan).
Возвращает {xmin,xmax,ymin,ymax} в мировых координатах для всей области canvas. */
SimEngineInstance.prototype._visibleWorld = function (W, H) {
var a = this._toWorld(0, 0); // верх-лево экрана
var b = this._toWorld(W, H); // низ-право экрана
return {
xmin: Math.min(a[0], b[0]), xmax: Math.max(a[0], b[0]),
ymin: Math.min(a[1], b[1]), ymax: Math.max(a[1], b[1])
};
};
/* «красивый» шаг сетки (1/2/5·10^n), чтобы минорная линия была ~targetPx пикселей.
Завязан на текущий масштаб _scale (мир→px), поэтому адаптивен к zoom. */
SimEngineInstance.prototype._niceStep = function (targetPx) {
var s = this._scale || 1;
var worldPerTarget = targetPx / s; // сколько мир-единиц в targetPx
var p = Math.pow(10, Math.floor(Math.log10(worldPerTarget || 1)));
var arr = [1, 2, 5, 10];
for (var i = 0; i < arr.length; i++) if (arr[i] * p >= worldPerTarget) return arr[i] * p;
return p * 10;
};
/* ── сетка: минорные (бледные/тонкие) + мажорные (каждые 5 минорных) ── */
SimEngineInstance.prototype._drawGrid = function (ctx, W, H, vp) {
var vw = this._visibleWorld(W, H);
var minor = this._niceStep(34); // целевой шаг минорной линии ~34px
var major = minor * 5; // мажор каждые 5 минорных
this._gridStep = major; // запомнить для подписей осей
var EPS = minor * 1e-6;
// минорные
ctx.save();
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.045)';
this._gridLines(ctx, W, H, vw, minor, EPS);
// мажорные (поверх, ярче)
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
this._gridLines(ctx, W, H, vw, major, EPS);
ctx.restore();
};
/* нарисовать набор линий с шагом step через всю область canvas (вертикали по X,
горизонтали по Y). Координаты округляются к .5px для резкости (без «ступенек»).
Major vs minor различаются ТОЛЬКО step + strokeStyle, который ставит вызывающий. */
SimEngineInstance.prototype._gridLines = function (ctx, W, H, vw, step, EPS) {
if (!(step > 0) || !isFinite(step)) return;
var x, y;
var x0 = Math.floor(vw.xmin / step) * step;
for (x = x0; x <= vw.xmax + EPS; x += step) {
var pxv = Math.round(this._toPx(x, 0)[0]) + 0.5;
ctx.beginPath(); ctx.moveTo(pxv, 0); ctx.lineTo(pxv, H); ctx.stroke();
}
var y0 = Math.floor(vw.ymin / step) * step;
for (y = y0; y <= vw.ymax + EPS; y += step) {
var pyv = Math.round(this._toPx(0, y)[1]) + 0.5;
ctx.beginPath(); ctx.moveTo(0, pyv); ctx.lineTo(W, pyv); ctx.stroke();
}
};
/* ── оси X/Y с числовыми подписями делений + маркер origin (0,0) ── */
SimEngineInstance.prototype._drawAxes = function (ctx, W, H, vp) {
var vw = this._visibleWorld(W, H);
var o = this._toPx(0, 0);
var step = this._gridStep || this._niceStep(34) * 5;
var EPS = step * 1e-6;
// позиции осей: на 0, либо прижаты к краю canvas, если 0 вне видимой области
var axisYpx = _clamp(o[1], 0, H); // экранная Y линии оси X
var axisXpx = _clamp(o[0], 0, W); // экранная X линии оси Y
var xAtEdge = (o[1] < 0 || o[1] > H);
var yAtEdge = (o[0] < 0 || o[0] > W);
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.42)';
ctx.lineWidth = 1.4;
// ось X
ctx.beginPath(); ctx.moveTo(0, Math.round(axisYpx) + 0.5); ctx.lineTo(W, Math.round(axisYpx) + 0.5); ctx.stroke();
// ось Y
ctx.beginPath(); ctx.moveTo(Math.round(axisXpx) + 0.5, 0); ctx.lineTo(Math.round(axisXpx) + 0.5, H); ctx.stroke();
// ── подписи делений (на тёмном фоне: светлый текст с тенью) ──
ctx.font = '11px Manrope,system-ui,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.72)';
ctx.shadowColor = 'rgba(0,0,0,0.85)';
ctx.shadowBlur = 3;
var dec = _stepDecimals(step);
// подписи по X (под осью X)
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
var labY = _clamp(axisYpx + 4, 2, H - 14);
var x, x0 = Math.floor(vw.xmin / step) * step;
for (x = x0; x <= vw.xmax + EPS; x += step) {
if (Math.abs(x) < EPS) continue; // origin подпишем отдельно
var px = this._toPx(x, 0)[0];
if (px < 14 || px > W - 4) continue;
ctx.fillText(_axisNum(x, dec), px, labY);
}
// подписи по Y (слева от оси Y)
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
var labX = _clamp(axisXpx - 6, 22, W - 2);
var y, y0 = Math.floor(vw.ymin / step) * step;
for (y = y0; y <= vw.ymax + EPS; y += step) {
if (Math.abs(y) < EPS) continue;
var py = this._toPx(0, y)[1];
if (py < 8 || py > H - 8) continue;
ctx.fillText(_axisNum(y, dec), labX, py);
}
ctx.shadowBlur = 0;
// ── маркер origin (0,0): точка + подпись «0», только если 0 в видимой области ──
if (!xAtEdge && !yAtEdge) {
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.arc(o[0], o[1], 2.5, 0, Math.PI * 2); ctx.fill();
ctx.font = '11px Manrope,system-ui,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.shadowColor = 'rgba(0,0,0,0.85)'; ctx.shadowBlur = 3;
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
ctx.fillText('0', o[0] - 5, o[1] + 4);
ctx.shadowBlur = 0;
}
ctx.restore();
};
/* ════════════════════ Жизненный цикл ════════════════════ */
SimEngineInstance.prototype.play = function () {
if (this._running || this._destroyed) return;
this._running = true;
this._syncPlayBtn();
var self = this;
var time = this.spec.time || {};
var speed = num(time.speed, 1);
var dur = num(time.duration, 0);
var loop = time.loop !== false;
this._last = (global.performance || Date).now();
function frame(now) {
if (!self._running) return;
var dt = Math.min((now - self._last) / 1000, 0.05) * speed;
self._last = now;
self._t += dt;
if (dur > 0 && self._t > dur) {
if (loop) { self._t = 0; self._trails = {}; self._preparePhysics(); }
else { self._t = dur; self._renderFrame(); self.pause(); return; }
}
// продвинуть физику фиксированными подшагами (если есть)
if (self._phys) self._stepPhysics(dt);
// оценить цель после шага (env строится из актуального состояния); победа -> pause
if (self._goal) self._evalGoal(self._buildEnv(), dt);
self._renderFrame();
self._raf = global.requestAnimationFrame(frame);
}
this._raf = global.requestAnimationFrame(frame);
};
/* продвинуть физику; удерживаемое тело временно «приколото» (fixed), чтобы
интегратор не уносил его, пока его тащит палец/мышь. */
SimEngineInstance.prototype._stepPhysics = function (dtFrame) {
if (!global.SimPhysics) return;
var held = (this._dragBody && this._dragBody.body) ? this._dragBody.body : null;
var wasFixed = held ? held.fixed : false;
if (held) { held.fixed = true; held.vx = 0; held.vy = 0; }
global.SimPhysics.step(this._phys, dtFrame);
if (held) held.fixed = wasFixed;
};
SimEngineInstance.prototype.pause = function () {
this._running = false;
if (this._raf) { global.cancelAnimationFrame(this._raf); this._raf = 0; }
this._syncPlayBtn();
};
SimEngineInstance.prototype.reset = function () {
this.pause();
this._t = 0;
this._trails = {};
this._dragBody = null;
this._preparePhysics(); // пересобрать тела/пружины с нач. условиями из params
// сбросить состояние цели: attempts++ только на ПОЛЬЗОВАТЕЛЬСКОМ reset
// (первый авто-reset при mount попыткой не считается).
if (this._goalState) {
var userReset = this._goalInited === true;
this._goalInited = true;
this._resetGoalState(userReset);
} else {
this._goalInited = true;
}
this._renderFrame();
};
/* Сбросить состояние результата к началу уровня. bumpAttempt=true -> attempts++. */
SimEngineInstance.prototype._resetGoalState = function (bumpAttempt) {
if (!this._goal) return;
var prevAttempts = this._goalState ? this._goalState.attempts : 0;
this._goalState = {
won: false, failed: false, timeMs: 0,
attempts: prevAttempts + (bumpAttempt ? 1 : 0),
starsGot: this._goal.stars.map(function () { return false; }),
firstWinT: null
};
this._goalHoldT = 0;
};
SimEngineInstance.prototype.setParam = function (name, value) {
var v = parseFloat(value);
if (!isFinite(v)) return;
this.params[name] = v;
var sl = this._sliders[name];
if (sl) { sl.value = String(v); sl.dispatchEvent(new Event('input')); }
else if (!this._running) this._renderFrame();
};
SimEngineInstance.prototype.getParam = function (name) { return this.params[name]; };
SimEngineInstance.prototype.isRunning = function () { return this._running; };
/* ════════════════════ Цель / игра: публичное API ════════════════════ */
/* Подписаться на победу: cb(getResult()) вызывается один раз при первой победе. */
SimEngineInstance.prototype.onGoal = function (cb) {
if (typeof cb === 'function') this._goalCbs.push(cb);
return this;
};
/* Текущий результат уровня. Для спеки без goal -> null. */
SimEngineInstance.prototype.getResult = function () {
var st = this._goalState;
if (!st) return null;
var total = this._goal ? this._goal.stars.length : 0;
var got = 0;
for (var i = 0; i < st.starsGot.length; i++) if (st.starsGot[i]) got++;
return {
won: st.won, failed: st.failed, timeMs: st.timeMs,
attempts: st.attempts, stars: { got: got, total: total }
};
};
/* Сбросить результат (как новый уровень) — НЕ считается попыткой. */
SimEngineInstance.prototype.resetResult = function () {
if (!this._goal) return;
var keep = this._goalState ? this._goalState.attempts : 0;
this._resetGoalState(false);
if (this._goalState) this._goalState.attempts = keep;
this._renderHud();
};
SimEngineInstance.prototype.destroy = function () {
this.pause();
this._destroyed = true;
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
// снять drag-слушатели
if (this.canvas) {
var c = this.canvas;
if (this._onPointerDown) {
c.removeEventListener('pointerdown', this._onPointerDown);
c.removeEventListener('pointermove', this._onPointerMove);
c.removeEventListener('pointerup', this._onPointerUp);
c.removeEventListener('pointercancel', this._onPointerUp);
}
// снять zoom/pan-слушатели
if (this._onWheel) c.removeEventListener('wheel', this._onWheel);
if (this._onPanDown) {
c.removeEventListener('pointerdown', this._onPanDown);
c.removeEventListener('pointermove', this._onPanMove);
c.removeEventListener('pointerup', this._onPanUp);
c.removeEventListener('pointercancel', this._onPanUp);
}
}
this._onPointerDown = this._onPointerMove = this._onPointerUp = null;
this._onWheel = this._onPanDown = this._onPanMove = this._onPanUp = null;
this._dragging = null;
this._panning = null;
this._dragBody = null;
this._phys = null;
this._bodyById = {};
// снять HUD-слушатели/узлы (нет утечек — баланс add/removeEventListener)
if (this._hud) {
if (this._hud.btnRetry && this._onHudRetry) {
this._hud.btnRetry.removeEventListener('click', this._onHudRetry);
}
if (this._hud.top && this._hud.top.parentNode) this._hud.top.parentNode.removeChild(this._hud.top);
if (this._hud.banner && this._hud.banner.parentNode) this._hud.banner.parentNode.removeChild(this._hud.banner);
this._hud = null;
}
this._onHudRetry = null;
this._goal = null;
this._goalState = null;
this._goalCbs = [];
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
this.el = null; this.canvas = null; this.ctx = null;
};
/* ── format helpers ── */
function _fmt(v) {
if (!isFinite(v)) return '—';
if (Number.isInteger(v)) return String(v);
return parseFloat(v.toFixed(3)).toString();
}
function _fmtFixed(v, prec) {
if (typeof v !== 'number' || !isFinite(v)) return '—';
return v.toFixed(prec);
}
/* сколько знаков после запятой нужно для шага сетки (1/2/5·10^n) */
function _stepDecimals(step) {
if (!(step > 0) || !isFinite(step)) return 0;
var d = Math.ceil(-Math.log10(step));
return d > 0 ? Math.min(d, 6) : 0;
}
/* подпись деления оси: компактно, без лишних нулей; крупные/мелкие — экспонента */
function _axisNum(v, dec) {
if (!isFinite(v)) return '';
var a = Math.abs(v);
if (a !== 0 && (a >= 1e5 || a < 1e-4)) return v.toExponential(0).replace('e+', 'e');
var s = v.toFixed(dec);
// убрать хвостовые нули после точки
if (s.indexOf('.') >= 0) s = s.replace(/\.?0+$/, '');
return s;
}
function _esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function _readoutBadgeCss(color) {
return 'pointer-events:none;font-family:Manrope,system-ui,sans-serif;font-size:.78rem;' +
'color:' + (color || '#06D6E0') + ';background:rgba(13,13,26,0.78);' +
'border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:3px 9px;' +
'white-space:nowrap;backdrop-filter:blur(2px)';
}
function _clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function _nowMs() { return (global.performance && global.performance.now) ? global.performance.now() : Date.now(); }
/* истинность булева SimExpr-результата: SimExpr.fn возвращает число (NaN/∞ -> 0),
истина = любое конечное ненулевое значение. */
function _truthy(v) { return typeof v === 'number' && isFinite(v) && v !== 0; }
/* ════════════════════ public ════════════════════ */
function mount(host, spec) {
if (!host) throw new Error('SimEngine.mount: нет host-элемента');
return new SimEngineInstance(host, spec || {});
}
global.SimEngine = {
mount: mount,
_Instance: SimEngineInstance // экспонируем для тестов/билдера (Фаза 4)
};
})(typeof window !== 'undefined' ? window : globalThis);