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> @
519 lines
25 KiB
JavaScript
519 lines
25 KiB
JavaScript
'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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
/* ── 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', '×');
|
||
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);
|