'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') цветом скина — БЕЗ исполнения, просто переписываем цветовые поля спеки-копии перед монтированием. */ function tintHeroSpec(spec, skinKey) { var color = skinColor(skinKey); // глубокая копия (спека — данные, без функций) чтобы не мутировать реестр 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 && o.id === 'ball') { o.color = color; if (o.glow) o.glowColor = color; if (o.trail) o.trailColor = color; } } } return copy; } /* ── Inline SVG звезды ── */ function starSvg(filled) { var fill = filled ? '#FBBF24' : 'none'; var stroke = filled ? '#FBBF24' : '#64748B'; return '' + ''; } 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', 'Время' + fmtTime(state && state.timeMs) + '')); stats.appendChild(el('div', 'qg-stat', 'Звёзды' + got + ' / ' + (total || slots) + '')); stats.appendChild(el('div', 'qg-stat', 'Попытки' + ((state && state.attempts) || 0) + '')); 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, '>'); } /* ── Старт уровня ─────────────────────────────────────────────────────── 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); 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) {} }); 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);