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> @
2532 lines
129 KiB
JavaScript
2532 lines
129 KiB
JavaScript
'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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
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);
|