Files
Learn_System/frontend/js/game/quantik-game.js
T
Maxim Dolgolyov 0b1925fd3b @
feat(quantik-game): фаза 4 — квантовые способности + SR-комнаты

Глава-созвездие quantum (L12–L16) и фирменные механики — всё через
безопасную модель спеки, движок и бэкенд НЕ тронуты (engine touch = 0):
- Суперпозиция: два тела ball+ball2, goal.when требует ОБА (зеркальный
  закон). Туннелирование: forbidden-зона wall + fail wall.hit && tunnel<1;
  способность тратит энергию → setParam(tunnel,1). Коллапс/прицел: пунктир-
  plot предсказанной траектории на паузе.
- Энергия — клиентский ресурс (localStorage quantik-energy, QuantikEnergy).
- SR-комната: мини-сессия повторения флешкарт в модалке (НЕ iframe),
  LS.fcStudySession/fcReview; «Знаю/Легко» дают энергию; текст карт
  экранируется, картинки — по regex-вайтлисту.
Все 5 уровней проверены на реальном движке (2★ достижимы; суперпозиция
требует оба тела; туннель-гейт блокирует без заряда). npm test 253/8
baseline; lint:routes 0; цепочка разблокировки проходима.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 10:29:35 +03:00

315 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · логика игрового уровня (Фаза 2).
Монтирует уровень-спеку через SimEngine.mount (тот же движок, что lab.html
и sim-builder.html). «Игровой режим» включается САМ наличием блока goal в
спеке (Фаза 0). На победу (inst.onGoal) шлём результат на сервер и показываем
экран успеха с нарратором-Квантиком; реакция нарратора зависит от числа звёзд.
Фаза 2:
- Скин Квантика (colorKey из палитр PetSprite, localStorage 'quantik-skin')
тинтует glow-точку героя в уровне и нарратора.
- Экран успеха активирует «Дальше» (переход к следующему уровню) через колбэк.
- Интро-карточка с нарратором перед стартом уровня.
window.QuantikGame.start({ host, level, skin?, onNext?, onMap?, hasNext?, resolveNext? }) -> инстанс.
⛔ Без eval/Function. Уровни — данные из window.QuantikLevels.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var doc = global.document;
var SKIN_KEY = 'quantik-skin';
var DEFAULT_SKIN = 'cyan';
function el(tag, cls, html) {
var n = doc.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
/* ── Скин ──────────────────────────────────────────────────────────────── */
function getSkin() {
try {
var v = global.localStorage && global.localStorage.getItem(SKIN_KEY);
if (v && global.PetSprite && global.PetSprite.PALETTES && global.PetSprite.PALETTES[v]) return v;
} catch (_e) {}
return DEFAULT_SKIN;
}
function setSkin(key) {
try { if (global.localStorage) global.localStorage.setItem(SKIN_KEY, key); } catch (_e) {}
}
function skinColor(key) {
var pal = (global.PetSprite && global.PetSprite.PALETTES) || {};
return pal[key || getSkin()] || '#06D6E0';
}
/* Тинтуем героя уровня (объект с id 'ball') цветом скина — БЕЗ исполнения,
просто переписываем цветовые поля спеки-копии перед монтированием.
Фаза 4: вторую копию суперпозиции (id 'ball2') тоже тинтуем, но осветлённым
«фантомным» оттенком (полупрозрачность задаётся самой спекой). */
function tintHeroSpec(spec, skinKey) {
var color = skinColor(skinKey);
var phantom = lighten(color, 0.42);
// глубокая копия (спека — данные, без функций) чтобы не мутировать реестр
var copy = JSON.parse(JSON.stringify(spec));
if (Array.isArray(copy.objects)) {
for (var i = 0; i < copy.objects.length; i++) {
var o = copy.objects[i];
if (!o) continue;
if (o.id === 'ball') {
o.color = color;
if (o.glow) o.glowColor = color;
if (o.trail) o.trailColor = color;
} else if (o.id === 'ball2') {
o.color = phantom;
if (o.glow) o.glowColor = phantom;
if (o.trail) o.trailColor = phantom;
}
}
}
return copy;
}
/* Осветлить hex-цвет к белому на долю t (0..1). Для «фантома» суперпозиции.
Принимает #RGB/#RRGGBB; прочее возвращает как есть. */
function lighten(hex, t) {
if (typeof hex !== 'string') return hex;
var m = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return hex;
var h = m[1];
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
r = Math.round(r + (255 - r) * t); g = Math.round(g + (255 - g) * t); b = Math.round(b + (255 - b) * t);
function hx(n) { var s = n.toString(16); return s.length === 1 ? '0' + s : s; }
return '#' + hx(r) + hx(g) + hx(b);
}
/* ── Inline SVG звезды ── */
function starSvg(filled) {
var fill = filled ? '#FBBF24' : 'none';
var stroke = filled ? '#FBBF24' : '#64748B';
return '<svg class="ic qg-star-svg" viewBox="0 0 24 24" width="34" height="34" fill="' + fill +
'" stroke="' + stroke + '" stroke-width="1.6" stroke-linejoin="round">' +
'<polygon points="12 2 15.1 8.6 22 9.3 17 14.1 18.2 21 12 17.6 5.8 21 7 14.1 2 9.3 8.9 8.6"/></svg>';
}
function fmtTime(ms) {
if (!ms && ms !== 0) return '—';
return (ms / 1000).toFixed(2) + ' с';
}
function petSvg(mood, skinKey) {
if (!global.PetSprite) return '';
return global.PetSprite.render(4, mood, [], skinKey || getSkin(), 0, 'none');
}
/* ── Интро-карточка уровня (нарратор «почини закон…») ───────────────────── */
function buildIntro(level, skinKey) {
var overlay = el('div', 'qg-overlay qg-intro');
var card = el('div', 'qg-card qg-card-intro');
var pet = el('div', 'qg-intro-pet', petSvg('happy', skinKey));
card.appendChild(pet);
card.appendChild(el('div', 'qg-card-kicker', 'Почини закон'));
card.appendChild(el('div', 'qg-card-title', escapeText(level.title)));
var goalT = (level.spec && level.spec.goal && level.spec.goal.title) || '';
if (goalT) card.appendChild(el('div', 'qg-intro-goal', escapeText(goalT)));
if (level.hint) card.appendChild(el('div', 'qg-intro-hint', escapeText(level.hint)));
var actions = el('div', 'qg-actions');
var btnGo = el('button', 'btn-primary qg-btn', 'Начать');
btnGo.type = 'button';
var btnBack = el('button', 'btn-ghost qg-btn', 'К карте');
btnBack.type = 'button';
actions.appendChild(btnGo);
actions.appendChild(btnBack);
card.appendChild(actions);
overlay.appendChild(card);
return { overlay: overlay, btnGo: btnGo, btnBack: btnBack };
}
/* ── Экран успеха ───────────────────────────────────────────────────────── */
function buildSuccessOverlay(state, ctx) {
ctx = ctx || {};
var got = (state && state.stars && state.stars.got) || 0;
var total = (state && state.stars && state.stars.total) || 0;
var overlay = el('div', 'qg-overlay');
var card = el('div', 'qg-card');
// нарратор: все звёзды (>=2) -> ecstatic, иначе happy
var mood = (total > 0 && got >= total && total >= 2) ? 'ecstatic' : (got >= 1 ? 'happy' : 'neutral');
if (global.PetSprite) {
var pet = el('div', 'qg-success-pet', petSvg(mood, ctx.skin));
card.appendChild(pet);
}
card.appendChild(el('div', 'qg-card-title', 'Уровень пройден!'));
var starsBox = el('div', 'qg-stars');
var slots = Math.max(total, got, 1);
for (var i = 0; i < slots; i++) {
var w = el('span', 'qg-star' + (i < got ? ' qg-star-on' : ''));
w.style.setProperty('--si', i);
w.innerHTML = starSvg(i < got);
starsBox.appendChild(w);
}
card.appendChild(starsBox);
var stats = el('div', 'qg-stats');
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Время</span><span class="qg-stat-val">' + fmtTime(state && state.timeMs) + '</span>'));
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Звёзды</span><span class="qg-stat-val">' + got + ' / ' + (total || slots) + '</span>'));
stats.appendChild(el('div', 'qg-stat',
'<span class="qg-stat-lbl">Попытки</span><span class="qg-stat-val">' + ((state && state.attempts) || 0) + '</span>'));
card.appendChild(stats);
var actions = el('div', 'qg-actions');
var btnAgain = el('button', 'btn-ghost qg-btn', 'Ещё раз');
btnAgain.type = 'button';
var btnNext = el('button', 'btn-primary qg-btn', ctx.hasNext ? 'Дальше' : 'К карте');
btnNext.type = 'button';
actions.appendChild(btnAgain);
actions.appendChild(btnNext);
card.appendChild(actions);
overlay.appendChild(card);
return { overlay: overlay, btnAgain: btnAgain, btnNext: btnNext };
}
function escapeText(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* ── Открыть SR-комнату (повторение флешкарт → энергия) ────────────────────
Делегирует в QuantikAbilities.openRestRoom; после закрытия обновляет HUD
панели способностей (энергия могла измениться). */
function openRest(host, abilities) {
if (!global.QuantikAbilities || !global.QuantikAbilities.openRestRoom) return;
global.QuantikAbilities.openRestRoom({
host: host,
onClose: function () { if (abilities) try { abilities.refresh(); } catch (_e) {} }
});
}
/* ── Старт уровня ───────────────────────────────────────────────────────
opts: { host, level, skin?, onNext?(level), onMap?(), hasNext?, resolveNext? }
resolveNext?() -> Promise<{ hasNext, next }>: пересчитать следующий уровень
ПОСЛЕ перезагрузки прогресса (победа разблокирует след. уровень). Если не
задан / упал — откатываемся к pre-win opts.hasNext (ровно прежнее поведение). */
function start(opts) {
opts = opts || {};
var host = opts.host;
var level = opts.level;
if (!host || !level || !level.spec) return null;
if (!global.SimEngine || !global.SimExpr) return null;
var skin = opts.skin || getSkin();
var spec = tintHeroSpec(level.spec, skin);
var inst = global.SimEngine.mount(host, spec);
// ── Панель квантовых способностей + HUD энергии (Фаза 4) ──
// Аддитивно: монтируется только если доступен модуль; кнопки сами решают,
// уместны ли они для уровня (tunnel/aim-флаги). SR-комната открывается отсюда.
var abilities = null;
if (global.QuantikAbilities && global.QuantikAbilities.mountBar) {
abilities = global.QuantikAbilities.mountBar({
host: host,
inst: inst,
level: level,
onOpenRest: function () { openRest(host, abilities); }
});
// Убираем панель при destroy инстанса (оборачиваем существующий destroy).
if (abilities) {
var _origDestroy = inst.destroy.bind(inst);
inst.destroy = function () {
try { abilities.destroy(); } catch (_e) {}
return _origDestroy();
};
}
}
var overlayRef = null;
function clearOverlay() {
if (overlayRef && overlayRef.overlay && overlayRef.overlay.parentNode) {
overlayRef.overlay.parentNode.removeChild(overlayRef.overlay);
}
overlayRef = null;
}
// submitDone — promise сабмита прогресса (или null, если сабмита нет).
// Экран успеха показываем СРАЗУ (без ожидания сети) с pre-win hasNext, затем
// ОБНОВЛЯЕМ кнопку «Дальше/К карте», когда пересчёт после победы (resolveNext)
// увидит свежеразблокированный уровень. Это чинит «мёртвую Дальше» на первом
// прохождении (0 звёзд → доступен только L1 → pre-win nextPlayable == null).
function showSuccess(state, submitDone) {
clearOverlay();
// Текущее решение кнопки. Замыкания ниже читают его «живьём» (мутируем var),
// поэтому если игрок успеет нажать раньше пересчёта — отработает фолбэк,
// а после пересчёта та же кнопка уже ведёт «Дальше».
var canNext = typeof opts.onNext === 'function' && !!opts.hasNext;
overlayRef = buildSuccessOverlay(state, { skin: skin, hasNext: canNext });
overlayRef.btnAgain.addEventListener('click', function () {
clearOverlay();
try { inst.reset(); } catch (_e) {}
if (abilities) try { abilities.resetAbilities(); } catch (_e) {}
});
overlayRef.btnNext.addEventListener('click', function () {
clearOverlay();
if (canNext) opts.onNext(level);
else if (typeof opts.onMap === 'function') opts.onMap();
});
host.appendChild(overlayRef.overlay);
if (typeof opts.resolveNext !== 'function') return;
var btn = overlayRef.btnNext;
// Пересчёт идёт ПОСЛЕ сабмита: победа сначала сохраняется на сервере, и только
// затем перезагрузка прогресса увидит разблокированный уровень.
Promise.resolve(submitDone)
.catch(function () {}) // сабмит best-effort: даже при ошибке пробуем пересчёт
.then(function () { return opts.resolveNext(); })
.then(function (r) {
// overlayRef мог смениться/закрыться, пока шла сеть — обновляем только «свою» кнопку.
if (!r || !overlayRef || overlayRef.btnNext !== btn) return;
var next = typeof opts.onNext === 'function' && !!r.hasNext;
if (next === canNext) return; // ничего не изменилось
canNext = next;
btn.textContent = next ? 'Дальше' : 'К карте';
})
.catch(function () {}); // пересчёт упал → остаёмся на pre-win решении
}
inst.onGoal(function (res) {
if (!res || !res.won) return;
var got = (res.stars && res.stars.got) || 0;
var payload = { time_ms: res.timeMs, stars: got };
var submitDone = null;
try {
if (global.LS && global.LS.gameProgressSubmit) {
submitDone = global.LS.gameProgressSubmit(level.id, payload);
if (submitDone && typeof submitDone.catch === 'function') submitDone.catch(function () {});
}
} catch (_e) {}
showSuccess(res, submitDone);
});
return inst;
}
global.QuantikGame = {
start: start,
buildSuccessOverlay: buildSuccessOverlay,
buildIntro: buildIntro,
getSkin: getSkin,
setSkin: setSkin,
skinColor: skinColor,
SKIN_KEY: SKIN_KEY
};
})(typeof window !== 'undefined' ? window : this);