Files
Learn_System/frontend/js/game/quantik-abilities.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

519 lines
25 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';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · Квантовые способности + энергия + SR-комната (Фаза 4).
Всё АДДИТИВНО и через БЕЗОПАСНУЮ модель (без eval/Function, без правок движка):
- ЭНЕРГИЯ — клиентский ресурс в localStorage (ключ 'quantik-energy'). Чистая
логика чтения/траты/начисления (window.QuantikEnergy) — тестируется headless.
- СПОСОБНОСТИ на сцене уровня (window.QuantikAbilities.mountBar):
«Туннель» — тратит заряд → inst.setParam('tunnel', 1) (стена-барьер
уровня становится проницаемой; fail:'wall.hit && tunnel<1').
«Прицел» — ставит/снимает паузу: целься по предсказанной траектории
(пунктир-plot уже на сцене) до запуска.
- SR-КОМНАТА (window.QuantikAbilities.openRestRoom) — мини-сессия повторения
флешкарт прямо в игре: список колод → due-карты → лицо/оборот → оценка
(шкала как в flashcards.html: Снова/Трудно/Знаю/Легко) → каждый «Знаю/Легко»
начисляет энергию. Реюз серверного SR (LS.fcListDecks/fcStudySession/fcReview),
НЕ iframe страницы флешкарт. Пусто (нет колод / нет due) — дружелюбное окно
со ссылкой на /flashcards.
⛔ Без эмодзи (только inline SVG .ic). Без eval/Function.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var doc = global.document;
/* ── Энергия: чистая логика над localStorage ─────────────────────────────
Заряд — целое ≥0. Один «заряд туннеля» = TUNNEL_COST. За правильный ответ
в SR-комнате — REWARD_GOOD (Знаю) / REWARD_EASY (Легко). */
var ENERGY_KEY = 'quantik-energy';
var ENERGY_MAX = 99; // потолок (защита от переполнения хранилища)
var TUNNEL_COST = 3; // зарядов на одно туннелирование
var REWARD_GOOD = 1; // энергия за «Знаю»
var REWARD_EASY = 2; // энергия за «Легко»
function _clampEnergy(n) {
n = Math.floor(Number(n));
if (!isFinite(n) || n < 0) n = 0;
if (n > ENERGY_MAX) n = ENERGY_MAX;
return n;
}
function getEnergy() {
try {
var v = global.localStorage && global.localStorage.getItem(ENERGY_KEY);
return _clampEnergy(v == null ? 0 : v);
} catch (_e) { return 0; }
}
function setEnergy(n) {
var v = _clampEnergy(n);
try { if (global.localStorage) global.localStorage.setItem(ENERGY_KEY, String(v)); } catch (_e) {}
_notify(v);
return v;
}
function grantEnergy(n) { return setEnergy(getEnergy() + _clampEnergy(n)); }
function canSpend(n) { return getEnergy() >= _clampEnergy(n); }
/* Потратить n зарядов. Возвращает true при успехе (хватило), иначе false (без списания). */
function spendEnergy(n) {
n = _clampEnergy(n);
var cur = getEnergy();
if (cur < n) return false;
setEnergy(cur - n);
return true;
}
/* Награда за оценку флешкарты (quality по шкале SR): Знаю(4)→GOOD, Легко(5)→EASY,
остальные (Снова/Трудно) — 0. Чистая функция (для тестов). */
function rewardForQuality(q) {
if (q === 5) return REWARD_EASY;
if (q === 4) return REWARD_GOOD;
return 0;
}
/* подписчики на изменение энергии (HUD обновляется без перезагрузки) */
var _subs = [];
function onEnergyChange(cb) { if (typeof cb === 'function') _subs.push(cb); }
function _notify(v) { for (var i = 0; i < _subs.length; i++) { try { _subs[i](v); } catch (_e) {} } }
global.QuantikEnergy = {
getEnergy: getEnergy, setEnergy: setEnergy, grantEnergy: grantEnergy,
spendEnergy: spendEnergy, canSpend: canSpend, rewardForQuality: rewardForQuality,
onEnergyChange: onEnergyChange,
ENERGY_KEY: ENERGY_KEY, TUNNEL_COST: TUNNEL_COST,
REWARD_GOOD: REWARD_GOOD, REWARD_EASY: REWARD_EASY, ENERGY_MAX: ENERGY_MAX
};
/* ════════════════════════════════════════════════════════════════════════
DOM-хелперы (только если есть document — модуль грузится и в headless vm,
где document может быть стабом без полноценного DOM).
════════════════════════════════════════════════════════════════════════ */
function el(tag, cls, html) {
var n = doc.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function escapeText(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* ── inline SVG иконки (без эмодзи) ── */
function boltIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="currentColor" stroke="none">' +
'<path d="M13 2 4 14h6l-1 8 9-12h-6z"/></svg>';
}
function tunnelIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V11a8 8 0 0 1 16 0v9"/>' +
'<path d="M9 20v-6a3 3 0 0 1 6 0v6"/></svg>';
}
function aimIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/>' +
'<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>' +
'<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>' +
'<circle cx="12" cy="12" r="1.6" fill="currentColor"/></svg>';
}
function cardsIcon() {
return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="13" height="15" rx="2"/>' +
'<path d="M8 5V4a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-1"/></svg>';
}
/* ── tunnel-флаг уровня: спека ссылается на param 'tunnel' в fail? ──────────
Если ни goal.fail, ни stars, ни goal.when не упоминают 'tunnel', способность
«Туннель» бессмысленна для уровня → кнопка скрыта. */
function levelHasTunnel(level) {
var g = level && level.spec && level.spec.goal;
if (!g) return false;
var blob = String(g.fail || '') + ' ' + String(g.when || '');
if (Array.isArray(g.stars)) for (var i = 0; i < g.stars.length; i++) blob += ' ' + String(g.stars[i] && g.stars[i].when || '');
return /\btunnel\b/.test(blob);
}
/* ── aim-флаг уровня: на сцене есть объект-предсказание (id 'aim' или plot
с lineStyle 'dashed')? Тогда способность «Прицел» осмысленна. */
function levelHasAim(level) {
var objs = level && level.spec && level.spec.objects;
if (!Array.isArray(objs)) return false;
for (var i = 0; i < objs.length; i++) {
var o = objs[i];
if (o && o.type === 'plot' && (o.id === 'aim' || o.lineStyle === 'dashed')) return true;
}
return false;
}
/* ════════════════════════════════════════════════════════════════════════
Панель способностей + HUD энергии на сцене уровня.
mountBar({ host, inst, level, onOpenRest }) -> { el, destroy, refresh }
host — контейнер сцены (qg-stage). Кнопки появляются только если уместны
для уровня. tunnel сбрасывается в 0 при каждом mount (новый уровень/попытка).
════════════════════════════════════════════════════════════════════════ */
function mountBar(opts) {
opts = opts || {};
var host = opts.host, inst = opts.inst, level = opts.level;
if (!host || !inst) return null;
var onOpenRest = typeof opts.onOpenRest === 'function' ? opts.onOpenRest : function () {};
var hasTunnel = levelHasTunnel(level);
var hasAim = levelHasAim(level);
var tunnelUsed = false; // потрачен ли заряд в этой попытке
// tunnel стартует выключенным каждую попытку (стена сплошная)
try { inst.setParam('tunnel', 0); } catch (_e) {}
var bar = el('div', 'qa-bar');
// ── HUD энергии ──
var meter = el('div', 'qa-energy', boltIcon() + '<span class="qa-energy-n">' + getEnergy() + '</span>');
meter.title = 'Квантовая энергия — копится в комнате повторения';
bar.appendChild(meter);
// ── Кнопка «Комната повторения» (всегда — заработать энергию) ──
var btnRest = el('button', 'qa-btn qa-rest', cardsIcon() + '<span>Повторение</span>');
btnRest.type = 'button';
btnRest.title = 'Повтори флешкарты — заработай квантовую энергию';
btnRest.addEventListener('click', function () { onOpenRest(); });
bar.appendChild(btnRest);
// ── Способность «Туннель» ──
var btnTunnel = null;
if (hasTunnel) {
btnTunnel = el('button', 'qa-btn qa-ability qa-tunnel',
tunnelIcon() + '<span>Туннель</span><span class="qa-cost">' + boltIcon() + TUNNEL_COST + '</span>');
btnTunnel.type = 'button';
btnTunnel.addEventListener('click', function () {
if (tunnelUsed) return;
if (!canSpend(TUNNEL_COST)) { _flashHint(host, 'Не хватает энергии — повтори флешкарты'); refresh(); return; }
if (!spendEnergy(TUNNEL_COST)) { refresh(); return; }
tunnelUsed = true;
try { inst.setParam('tunnel', 1); } catch (_e) {}
_flashHint(host, 'Туннелирование активно — барьер проницаем');
refresh();
});
bar.appendChild(btnTunnel);
}
// ── Способность «Прицел» (пауза-тоггл) ──
var btnAim = null;
if (hasAim) {
btnAim = el('button', 'qa-btn qa-ability qa-aim', aimIcon() + '<span>Прицел</span>');
btnAim.type = 'button';
btnAim.title = 'Поставить паузу и прицелиться по предсказанной траектории';
btnAim.addEventListener('click', function () {
try {
if (inst.isRunning()) { inst.pause(); }
else { inst.play(); }
} catch (_e) {}
refresh();
});
bar.appendChild(btnAim);
}
function refresh() {
var e = getEnergy();
var n = meter.querySelector('.qa-energy-n');
if (n) n.textContent = String(e);
if (btnTunnel) {
var dis = tunnelUsed || !canSpend(TUNNEL_COST);
btnTunnel.disabled = dis;
btnTunnel.classList.toggle('qa-on', tunnelUsed);
btnTunnel.title = tunnelUsed ? 'Туннель уже активен в этой попытке'
: (canSpend(TUNNEL_COST) ? 'Пройти сквозь барьер (−' + TUNNEL_COST + ' энергии)'
: 'Нужно ' + TUNNEL_COST + ' энергии — повтори флешкарты');
}
if (btnAim) {
var running = false; try { running = inst.isRunning(); } catch (_e) {}
btnAim.classList.toggle('qa-on', !running);
var lbl = btnAim.querySelector('span');
if (lbl) lbl.textContent = running ? 'Прицел' : 'Цельтесь';
}
}
// следить за изменением энергии (после SR-комнаты)
var unsub = function () {};
var sub = function (v) { refresh(); };
onEnergyChange(sub);
unsub = function () {
var i = _subs.indexOf(sub);
if (i >= 0) _subs.splice(i, 1);
};
host.appendChild(bar);
refresh();
return {
el: bar,
refresh: refresh,
// сбросить tunnel-состояние (новая попытка того же уровня)
resetAbilities: function () {
tunnelUsed = false;
try { inst.setParam('tunnel', 0); } catch (_e) {}
refresh();
},
destroy: function () {
unsub();
if (bar.parentNode) bar.parentNode.removeChild(bar);
}
};
}
/* всплывающая подсказка над сценой (без эмодзи) */
function _flashHint(host, text) {
var t = el('div', 'qa-toast', escapeText(text));
host.appendChild(t);
requestAnimationFrame(function () { t.classList.add('show'); });
setTimeout(function () {
t.classList.remove('show');
setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 320);
}, 1900);
}
/* ════════════════════════════════════════════════════════════════════════
SR-комната — модальная мини-сессия повторения флешкарт.
openRestRoom({ host, onClose }) — асинхронно тянет колоды и due-карты.
════════════════════════════════════════════════════════════════════════ */
function openRestRoom(opts) {
opts = opts || {};
var host = opts.host || doc.body;
var onClose = typeof opts.onClose === 'function' ? opts.onClose : function () {};
var LS = global.LS;
var overlay = el('div', 'qa-overlay');
var modal = el('div', 'qa-modal');
overlay.appendChild(modal);
host.appendChild(overlay);
var earned = 0; // энергия, начисленная за сессию
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
onClose(earned);
}
overlay.addEventListener('click', function (ev) { if (ev.target === overlay) close(); });
function header(title) {
var h = el('div', 'qa-modal-head');
h.appendChild(el('div', 'qa-modal-title', cardsIcon() + '<span>' + escapeText(title) + '</span>'));
var meter = el('div', 'qa-modal-energy', boltIcon() + '<span class="qa-modal-energy-n">' + getEnergy() + '</span>');
h.appendChild(meter);
var x = el('button', 'qa-modal-x', '&times;');
x.type = 'button'; x.setAttribute('aria-label', 'Закрыть');
x.addEventListener('click', close);
h.appendChild(x);
return h;
}
function setEnergyChip() {
var n = modal.querySelector('.qa-modal-energy-n');
if (n) n.textContent = String(getEnergy());
}
function renderMessage(title, msg, withFlashcardsLink) {
modal.innerHTML = '';
modal.appendChild(header('Комната повторения'));
var body = el('div', 'qa-modal-body');
body.appendChild(el('div', 'qa-empty-title', escapeText(title)));
body.appendChild(el('div', 'qa-empty-msg', escapeText(msg)));
var actions = el('div', 'qa-modal-actions');
if (withFlashcardsLink) {
var open = el('a', 'btn-primary qa-modal-btn', 'Открыть флешкарты');
open.href = '/flashcards';
actions.appendChild(open);
}
var done = el('button', 'btn-ghost qa-modal-btn', 'Закрыть');
done.type = 'button';
done.addEventListener('click', close);
actions.appendChild(done);
body.appendChild(actions);
modal.appendChild(body);
}
function renderLoading() {
modal.innerHTML = '';
modal.appendChild(header('Комната повторения'));
var body = el('div', 'qa-modal-body');
body.appendChild(el('div', 'qa-loading', 'Загрузка колод…'));
modal.appendChild(body);
}
if (!LS || !LS.fcListDecks || !LS.fcStudySession || !LS.fcReview) {
renderMessage('Повторение недоступно', 'Не удалось подключиться к флешкартам. Попробуй позже.', false);
return { el: overlay, close: close };
}
renderLoading();
LS.fcListDecks().then(function (r) {
var decks = (r && r.decks) || [];
if (!decks.length) {
renderMessage('Нет колод', 'Создай колоду флешкарт, чтобы повторять и зарабатывать энергию.', true);
return;
}
// авто-выбор колоды с наибольшим числом due-карт
var withDue = decks.filter(function (d) { return (d.due_count || 0) > 0; });
if (!withDue.length) {
renderMessage('Всё повторено', 'Сейчас нет карточек к повторению. Возвращайся позже — энергия копится повторением.', true);
return;
}
withDue.sort(function (a, b) { return (b.due_count || 0) - (a.due_count || 0); });
// если несколько колод с due — дать выбрать; иначе сразу учить
if (withDue.length === 1) startStudy(withDue[0]);
else renderDeckPicker(withDue);
}).catch(function () {
renderMessage('Ошибка', 'Не удалось загрузить колоды. Проверь соединение.', false);
});
function renderDeckPicker(decks) {
modal.innerHTML = '';
modal.appendChild(header('Выбери колоду'));
var body = el('div', 'qa-modal-body');
var list = el('div', 'qa-deck-list');
decks.forEach(function (d) {
var b = el('button', 'qa-deck', '');
b.type = 'button';
b.style.setProperty('--dk', d.color || '#9B5DE5');
b.innerHTML = '<span class="qa-deck-dot"></span>' +
'<span class="qa-deck-title">' + escapeText(d.title || 'Колода') + '</span>' +
'<span class="qa-deck-due">' + (d.due_count || 0) + ' к повтору</span>';
b.addEventListener('click', function () { startStudy(d); });
list.appendChild(b);
});
body.appendChild(list);
modal.appendChild(body);
}
/* ── Сессия изучения одной колоды ── */
function startStudy(deck) {
renderLoading();
LS.fcStudySession(deck.id).then(function (r) {
var cards = (r && r.cards) || [];
if (!cards.length) {
renderMessage('Всё повторено', 'В этой колоде нет карточек к повторению. Возвращайся позже.', false);
return;
}
runSession(deck, cards.slice());
}).catch(function () {
renderMessage('Ошибка', 'Не удалось загрузить карточки колоды.', false);
});
}
var RQ_GAP = 3; // через сколько карт вернуть недоученную (как FC_RQ_GAP в flashcards.html)
function runSession(deck, queue) {
var idx = 0, done = 0, flipped = false;
var seenCount = 0;
function finish() {
renderMessage('Готово!', 'Повторено ' + seenCount + ' карточек. Заработано энергии: ' + earned + '.', false);
}
function show() {
if (idx >= queue.length) { finish(); return; }
flipped = false;
var card = queue[idx];
modal.innerHTML = '';
modal.appendChild(header(escapeText(deck.title || 'Повторение')));
var body = el('div', 'qa-modal-body qa-study');
// прогресс
var total = done + (queue.length - idx);
var prog = el('div', 'qa-prog');
var fill = el('div', 'qa-prog-fill');
fill.style.width = (total ? (done / total * 100) : 0) + '%';
prog.appendChild(fill);
body.appendChild(prog);
body.appendChild(el('div', 'qa-prog-count', Math.min(done + 1, total) + ' / ' + total));
// карточка
var cardEl = el('div', 'qa-card');
var front = el('div', 'qa-card-side qa-card-front');
front.innerHTML = _cardHtml(card.front, card.front_image);
cardEl.appendChild(front);
var back = el('div', 'qa-card-side qa-card-back');
back.innerHTML = _cardHtml(card.back, card.back_image);
back.style.display = 'none';
cardEl.appendChild(back);
body.appendChild(cardEl);
// кнопка «показать ответ» / оценки
var flipBtn = el('button', 'btn-primary qa-flip', 'Показать ответ');
flipBtn.type = 'button';
body.appendChild(flipBtn);
var grades = el('div', 'qa-grades');
grades.style.display = 'none';
// шкала как в flashcards.html: Снова(0) Трудно(3) Знаю(4) Легко(5)
[
{ q: 0, l: 'Снова', cls: 'qa-g-again' },
{ q: 3, l: 'Трудно', cls: 'qa-g-hard' },
{ q: 4, l: 'Знаю', cls: 'qa-g-good' },
{ q: 5, l: 'Легко', cls: 'qa-g-easy' }
].forEach(function (g) {
var gb = el('button', 'qa-grade ' + g.cls, g.l);
gb.type = 'button';
gb.addEventListener('click', function () { answer(card, g.q); });
grades.appendChild(gb);
});
body.appendChild(grades);
flipBtn.addEventListener('click', function () {
if (flipped) return;
flipped = true;
back.style.display = '';
flipBtn.style.display = 'none';
grades.style.display = '';
});
modal.appendChild(body);
}
function answer(card, quality) {
// награда сразу (оптимистично) — энергия за «Знаю/Легко»
var rw = rewardForQuality(quality);
if (rw > 0) { grantEnergy(rw); earned += rw; setEnergyChip(); }
seenCount++;
// отправляем отзыв; re-queue недоученных в пределах сессии
var requeue = (quality < 3); // фолбэк-эвристика, уточняется ответом
LS.fcReview(card.id, quality).then(function (resp) {
requeue = resp ? !resp.graduated : (quality < 3);
advance(card, requeue);
}).catch(function () {
advance(card, requeue);
});
}
function advance(card, requeue) {
queue.splice(idx, 1);
if (requeue) {
var pos = Math.min(idx + RQ_GAP, queue.length);
queue.splice(pos, 0, card);
} else {
done++;
}
if (idx >= queue.length) finish();
else show();
}
show();
}
return { el: overlay, close: close };
}
/* безопасный рендер стороны карточки (текст escape, картинка — только свой /uploads) */
function _cardHtml(text, image) {
var html = '';
if (image && /^\/uploads\/flashcards\/[A-Za-z0-9._-]+$/.test(image)) {
html += '<img class="qa-card-img" src="' + image + '" alt=""/>';
}
if (text) html += '<div class="qa-card-text">' + escapeText(text) + '</div>';
return html || '<div class="qa-card-text qa-card-empty">—</div>';
}
global.QuantikAbilities = {
mountBar: mountBar,
openRestRoom: openRestRoom,
levelHasTunnel: levelHasTunnel,
levelHasAim: levelHasAim
};
})(typeof window !== 'undefined' ? window : this);