Files
Learn_System/frontend/js/game/quantik-game.js
T
Maxim Dolgolyov 6e33be3de1 @
fix(quantik-game): отображать заработанные звёзды на узлах карты и экране победы

Правило .ic в ls.css (fill:none; stroke:currentColor) перебивало
presentation-атрибуты fill/stroke в starSvg → заработанные звёзды
рисовались как пустые (CSS-свойства приоритетнее presentation-атрибутов).
Цвета звёзд теперь задаются inline style (приоритетнее класса) и в map.js,
и в quantik-game.js. Заодно звезда главы становится сплошной золотой.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 10:43:18 +03:00

317 lines
16 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';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · логика игрового уровня (Фаза 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';
// Цвета через inline style: .ic в ls.css (fill:none; stroke:currentColor) иначе
// перебивает атрибуты fill/stroke и заработанные звёзды не закрашиваются.
return '<svg class="ic qg-star-svg" viewBox="0 0 24 24" width="34" height="34" style="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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* ── Открыть 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);