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> @
315 lines
16 KiB
JavaScript
315 lines
16 KiB
JavaScript
'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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
/* ── Открыть 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);
|