@
feat(quantik-game): фаза 0 — слой целей в движке (goal/HUD/result) Декларативный блок goal в спеке SimForge (булево SimExpr-условие победы), вычисляемый каждый кадр: фиксация результата (победа/время/попытки/звёзды), callback onGoal, HUD-оверлей (цель/звёзды/подсказка/баннер, inline SVG). API инстанса: onGoal/getResult/resetResult. Серверный validateSpec пропускает goal/game (длина выражений + escape текста, без исполнения). Аддитивно: спека без goal ведёт себя как раньше. Смоук 40/40; npm test 238 pass/8 baseline; lint:routes 0. План фичи (7 фаз) + CONTEXT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
This commit is contained in:
@@ -79,13 +79,29 @@
|
||||
{ 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); сервер его пропускает.
|
||||
}
|
||||
Выражения видят: 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 с начала). Новых небезопасных идентификаторов не вводится.
|
||||
|
||||
── ИНТЕРАКЦИИ (Фаза 1) ──────────────────────────────────────────────────
|
||||
Объект с полем drag:{param, axis, min?, max?, paramY?} становится ручкой:
|
||||
@@ -103,6 +119,10 @@
|
||||
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) {
|
||||
|
||||
@@ -362,6 +382,12 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -502,6 +528,11 @@
|
||||
// подготовить объекты (компиляция привязок один раз)
|
||||
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(); });
|
||||
@@ -749,6 +780,208 @@
|
||||
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;
|
||||
@@ -1295,6 +1528,17 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
/* пружины как зигзаг между концами (наглядно для маятника/осциллятора) */
|
||||
@@ -1945,6 +2189,8 @@
|
||||
}
|
||||
// продвинуть физику фиксированными подшагами (если есть)
|
||||
if (self._phys) self._stepPhysics(dt);
|
||||
// оценить цель после шага (env строится из актуального состояния); победа -> pause
|
||||
if (self._goal) self._evalGoal(self._buildEnv(), dt);
|
||||
self._renderFrame();
|
||||
self._raf = global.requestAnimationFrame(frame);
|
||||
}
|
||||
@@ -1974,9 +2220,31 @@
|
||||
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;
|
||||
@@ -1989,6 +2257,33 @@
|
||||
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;
|
||||
@@ -2018,6 +2313,19 @@
|
||||
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;
|
||||
};
|
||||
@@ -2060,6 +2368,9 @@
|
||||
}
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user