feat(xp): physics8 + chem9 + phys9 синхронизируют XP с системной геймификацией

- js/textbook-xp-widget.js: shared модуль (monkey-patch addXp +
  para-pill auto-award для учебников без addXp)
- physics8_thermal/electro/optics: добавлены теги /js/xp.js и
  /js/textbook-xp-widget.js — теперь все 74 addXp-хука пробрасываются
  в глобальный gamification (через self-award endpoint с дебаунсом)
- chemistry_9 + physics_9: те же теги. Каждый первый клик по
  .para-pill даёт +5 XP в систему (без правок 23000 LOC)
- Изначальный XP в учебниках не теряется — localStorage остаётся
  кешем, сервер — источник правды
This commit is contained in:
Maxim Dolgolyov
2026-05-27 16:36:43 +03:00
parent c2ef4f4898
commit 033c941b02
6 changed files with 118 additions and 0 deletions
+2
View File
@@ -8,6 +8,8 @@
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body,{delimiters:[{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true}],throwOnError:false})"></script>
<script src="/js/xp.js" defer></script>
<script src="/js/textbook-xp-widget.js" defer></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{
+2
View File
@@ -9,6 +9,8 @@
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script src="/js/xp.js" defer></script>
<script src="/js/textbook-xp-widget.js" defer></script>
<style>
/* ═══════════ ДИЗАЙН-ТОКЕНЫ v2 ═══════════ */
:root{
+2
View File
@@ -9,6 +9,8 @@
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script src="/js/xp.js" defer></script>
<script src="/js/textbook-xp-widget.js" defer></script>
<style>
/* ═══════════ ДИЗАЙН-ТОКЕНЫ v2 ═══════════ */
:root{
+2
View File
@@ -9,6 +9,8 @@
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script src="/js/xp.js" defer></script>
<script src="/js/textbook-xp-widget.js" defer></script>
<style>
/* ═══════════ ДИЗАЙН-ТОКЕНЫ v2 ═══════════ */
:root{
+2
View File
@@ -8,6 +8,8 @@
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body,{delimiters:[{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true}],throwOnError:false})"></script>
<script src="/js/xp.js" defer></script>
<script src="/js/textbook-xp-widget.js" defer></script>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Literata:opsz,wght@7..72,400;7..72,500;7..72,600;7..72,700;7..72,800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
+108
View File
@@ -0,0 +1,108 @@
'use strict';
/**
* textbook-xp-widget.js — синхронизация XP учебников с системной геймификацией.
*
* Для учебников с локальным window.addXp:
* monkey-patch addXp, чтобы каждый вызов также дёргал window.LS.xp.add.
*
* Для всех учебников (в т.ч. chemistry_9, physics_9 без addXp):
* первый клик по .para-pill[data-para] за сессию/навсегда даёт +5 XP.
*
* Зависимость: /js/xp.js должен загрузиться раньше (defer, порядок тегов).
*/
(function () {
/* ── Получить slug текущей страницы ── */
function _slug() {
var m = location.pathname.match(/\/textbook\/([^/?#]+)/);
if (m) return m[1];
return location.pathname.split('/').pop()
.replace(/\.html$/i, '')
.replace(/_/g, '-');
}
/* ── Monkey-patch window.addXp (для учебников, где он есть) ── */
function _patchAddXp(slug) {
var orig = window.addXp;
if (typeof orig !== 'function') return;
window.addXp = function patchedAddXp(amount, source) {
var ret = orig.apply(this, arguments);
// пробрасываем в глобальный XP только при успехе оригинала
if (window.LS && window.LS.xp && amount > 0) {
var src = slug + '-' + (source || 'misc');
window.LS.xp.add(amount, src);
}
return ret;
};
}
/* ── Para-pill auto-award ── */
function _initParaAward(slug) {
var STORAGE_KEY = slug + '_xp_paras';
function _loadSeen() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch (e) {
return [];
}
}
function _saveSeen(arr) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(arr)); } catch (e) {}
}
document.body.addEventListener('click', function (e) {
var pill = e.target.closest('.para-pill[data-para]');
if (!pill) return;
var paraId = pill.getAttribute('data-para');
if (!paraId) return;
// динамически сгенерированные пилюли могут содержать шаблонные значения
if (paraId.indexOf("'") !== -1 || paraId.indexOf('+') !== -1) return;
// пропустить, если LS.xp недоступен (не авторизован)
if (!window.LS || !window.LS.xp) return;
var seen = _loadSeen();
if (seen.indexOf(paraId) !== -1) return; // уже начислено
seen.push(paraId);
_saveSeen(seen);
window.LS.xp.add(5, slug + '-para-' + paraId);
}, /* capture = */ false);
}
/* ── Обновление #hero-xp-badge при изменении XP (обратная совместимость) ── */
function _bindBadge() {
var badge = document.getElementById('hero-xp-badge');
if (!badge) return;
if (!window.LS || !window.LS.xp) return;
window.LS.xp.on('change', function (state) {
if (!state) return;
badge.textContent = state.xp + ' XP · Ур. ' + state.level;
});
}
/* ── Инициализация (ждём DOM + xp.js) ── */
function _init() {
var slug = _slug();
// Загрузить XP с сервера (merge локального + серверного)
if (window.LS && window.LS.xp) {
window.LS.xp.load();
}
_patchAddXp(slug);
_initParaAward(slug);
_bindBadge();
}
// defer гарантирует DOM готов к моменту выполнения,
// но xp.js тоже defer — порядок тегов определяет порядок выполнения.
// На случай гонки: если xp.js загружен первым — LS.xp уже есть.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _init);
} else {
_init();
}
})();