@
feat(quantik-game): фаза 2 — карта-созвездие + мир + XP/скины (MVP-мир) Одиночный уровень → играбельный мир: карта-созвездие из 6 физ-уровней (2 главы, нарастающая сложность), разблокировка по звёздам, клиентский XP/уровень игрока, пикер из 8 скинов (тинт героя+нарратора), нарратор PetSprite на интро/победе (mood по звёздам). Навигация карта→интро→игра→ успех→карта/дальше; кнопка «Дальше» пересчитывает nextPlayable после дозагрузки прогресса (фикс stale-hasNext). Логика прогресса — чистый модуль progress-logic.js (unlock/XP/группировка). Только фронт, без бэкенда: XP агрегируется из game_progress (Ф1). Каждый уровень проверен на реальном движке (выигрываем + обе звезды достижимы); цепочка разблокировки доказуемо проходима. npm test 251/8 baseline; lint:routes 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
This commit is contained in:
@@ -1,19 +1,26 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Квантик — Законы Мира · логика игровой страницы (Фаза 1, MVP).
|
||||
Квантик — Законы Мира · логика игрового уровня (Фаза 2).
|
||||
|
||||
Монтирует уровень-спеку через SimEngine.mount (тот же движок, что lab.html
|
||||
и sim-builder.html). «Игровой режим» включается САМ наличием блока goal в
|
||||
спеке (Фаза 0: HUD с целью/звёздами появляется автоматически). Управление —
|
||||
собственные слайдеры params движка + кнопки Запуск/Сброс. На победу
|
||||
(inst.onGoal) шлём результат на сервер и показываем экран успеха.
|
||||
спеке (Фаза 0). На победу (inst.onGoal) шлём результат на сервер и показываем
|
||||
экран успеха с нарратором-Квантиком; реакция нарратора зависит от числа звёзд.
|
||||
|
||||
window.QuantikGame.start({ host, level }) -> инстанс движка (или null).
|
||||
Фаза 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);
|
||||
@@ -22,7 +29,42 @@
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Inline SVG звезды (заполненная / контур) — без эмодзи (правило проекта). */
|
||||
/* ── Скин ──────────────────────────────────────────────────────────────── */
|
||||
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';
|
||||
@@ -33,25 +75,64 @@
|
||||
|
||||
function fmtTime(ms) {
|
||||
if (!ms && ms !== 0) return '—';
|
||||
var s = ms / 1000;
|
||||
return s.toFixed(2) + ' с';
|
||||
return (ms / 1000).toFixed(2) + ' с';
|
||||
}
|
||||
|
||||
/* ── Экран успеха (DOM-оверлей страницы, поверх сцены) ─────────────────── */
|
||||
function buildSuccessOverlay(state) {
|
||||
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', 'Уровень пройден!'));
|
||||
|
||||
// звёзды: total «слотов», got заполнено
|
||||
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');
|
||||
var w = el('span', 'qg-star' + (i < got ? ' qg-star-on' : ''));
|
||||
w.style.setProperty('--si', i);
|
||||
w.innerHTML = starSvg(i < got);
|
||||
starsBox.appendChild(w);
|
||||
}
|
||||
@@ -67,12 +148,10 @@
|
||||
card.appendChild(stats);
|
||||
|
||||
var actions = el('div', 'qg-actions');
|
||||
var btnAgain = el('button', 'btn-primary qg-btn', 'Ещё раз');
|
||||
var btnAgain = el('button', 'btn-ghost qg-btn', 'Ещё раз');
|
||||
btnAgain.type = 'button';
|
||||
var btnNext = el('button', 'btn-ghost qg-btn', 'Дальше');
|
||||
var btnNext = el('button', 'btn-primary qg-btn', ctx.hasNext ? 'Дальше' : 'К карте');
|
||||
btnNext.type = 'button';
|
||||
btnNext.disabled = true; // MVP: следующий уровень появится в Фазе 2
|
||||
btnNext.title = 'Скоро: больше уровней';
|
||||
actions.appendChild(btnAgain);
|
||||
actions.appendChild(btnNext);
|
||||
card.appendChild(actions);
|
||||
@@ -81,8 +160,15 @@
|
||||
return { overlay: overlay, btnAgain: btnAgain, btnNext: btnNext };
|
||||
}
|
||||
|
||||
function escapeText(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/* ── Старт уровня ───────────────────────────────────────────────────────
|
||||
host — DOM-контейнер сцены. level — запись из QuantikLevels (с .spec/.id). */
|
||||
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;
|
||||
@@ -90,7 +176,9 @@
|
||||
if (!host || !level || !level.spec) return null;
|
||||
if (!global.SimEngine || !global.SimExpr) return null;
|
||||
|
||||
var inst = global.SimEngine.mount(host, level.spec);
|
||||
var skin = opts.skin || getSkin();
|
||||
var spec = tintHeroSpec(level.spec, skin);
|
||||
var inst = global.SimEngine.mount(host, spec);
|
||||
|
||||
var overlayRef = null;
|
||||
function clearOverlay() {
|
||||
@@ -100,34 +188,72 @@
|
||||
overlayRef = null;
|
||||
}
|
||||
|
||||
function showSuccess(state) {
|
||||
// submitDone — promise сабмита прогресса (или null, если сабмита нет).
|
||||
// Экран успеха показываем СРАЗУ (без ожидания сети) с pre-win hasNext, затем
|
||||
// ОБНОВЛЯЕМ кнопку «Дальше/К карте», когда пересчёт после победы (resolveNext)
|
||||
// увидит свежеразблокированный уровень. Это чинит «мёртвую Дальше» на первом
|
||||
// прохождении (0 звёзд → доступен только L1 → pre-win nextPlayable == null).
|
||||
function showSuccess(state, submitDone) {
|
||||
clearOverlay();
|
||||
overlayRef = buildSuccessOverlay(state);
|
||||
// Текущее решение кнопки. Замыкания ниже читают его «живьём» (мутируем 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) {}
|
||||
});
|
||||
// Дальше — заглушка для MVP (нет следующего уровня).
|
||||
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;
|
||||
// Время победы — мировое t из движка (Ф0): res.timeMs.
|
||||
var payload = { time_ms: res.timeMs, stars: got };
|
||||
// Submit best-effort: экран успеха показываем независимо от сети.
|
||||
var submitDone = null;
|
||||
try {
|
||||
if (global.LS && global.LS.gameProgressSubmit) {
|
||||
global.LS.gameProgressSubmit(level.id, payload).catch(function () { /* офлайн — ок */ });
|
||||
submitDone = global.LS.gameProgressSubmit(level.id, payload);
|
||||
if (submitDone && typeof submitDone.catch === 'function') submitDone.catch(function () {});
|
||||
}
|
||||
} catch (_e) { /* нет клиента — всё равно показываем успех */ }
|
||||
showSuccess(res);
|
||||
} catch (_e) {}
|
||||
showSuccess(res, submitDone);
|
||||
});
|
||||
|
||||
return inst;
|
||||
}
|
||||
|
||||
global.QuantikGame = { start: start, buildSuccessOverlay: buildSuccessOverlay };
|
||||
global.QuantikGame = {
|
||||
start: start,
|
||||
buildSuccessOverlay: buildSuccessOverlay,
|
||||
buildIntro: buildIntro,
|
||||
getSkin: getSkin,
|
||||
setSkin: setSkin,
|
||||
skinColor: skinColor,
|
||||
SKIN_KEY: SKIN_KEY
|
||||
};
|
||||
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
|
||||
Reference in New Issue
Block a user