'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, '>'); } /* ── inline SVG иконки (без эмодзи) ── */ function boltIcon() { return '' + ''; } function tunnelIcon() { return '' + ''; } function aimIcon() { return '' + '' + '' + ''; } function cardsIcon() { return '' + ''; } /* ── 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() + '' + getEnergy() + ''); meter.title = 'Квантовая энергия — копится в комнате повторения'; bar.appendChild(meter); // ── Кнопка «Комната повторения» (всегда — заработать энергию) ── var btnRest = el('button', 'qa-btn qa-rest', cardsIcon() + 'Повторение'); 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() + 'Туннель' + boltIcon() + TUNNEL_COST + ''); 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() + 'Прицел'); 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() + '' + escapeText(title) + '')); var meter = el('div', 'qa-modal-energy', boltIcon() + '' + getEnergy() + ''); 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 = '' + '' + escapeText(d.title || 'Колода') + '' + '' + (d.due_count || 0) + ' к повтору'; 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 += ''; } if (text) html += '
' + escapeText(text) + '
'; return html || '
'; } global.QuantikAbilities = { mountBar: mountBar, openRestRoom: openRestRoom, levelHasTunnel: levelHasTunnel, levelHasAim: levelHasAim }; })(typeof window !== 'undefined' ? window : this);