0f3e12426a
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> @
386 lines
17 KiB
JavaScript
386 lines
17 KiB
JavaScript
'use strict';
|
|
/* ════════════════════════════════════════════════════════════════════════
|
|
Квантик — Законы Мира · Карта-созвездие (Фаза 2).
|
|
|
|
Рисует мир как звёздную карту: каждая глава (chapter) — отдельное созвездие,
|
|
уровни — узлы-«звёзды», соединённые линиями по порядку. Узел показывает статус
|
|
(заблокирован / доступен / пройден + число звёзд). По клику на доступный узел —
|
|
колбэк onPlay(level).
|
|
|
|
Зависит от:
|
|
window.QuantikLevels — реестр уровней (Ф1/Ф2)
|
|
window.QuantikProgress — чистая логика прогресса/разблокировки/XP (Ф2)
|
|
window.PetSprite — нарратор-Квантик (SVG)
|
|
|
|
window.QuantikMap.create({ host, headerHost, onPlay, getSkin, onSkin }) -> {
|
|
render(progressMap), // перерисовать карту + шапку под новый прогресс
|
|
destroy()
|
|
}
|
|
|
|
⛔ Без эмодзи — звёзды/замки/иконки только inline SVG. Без eval/Function.
|
|
════════════════════════════════════════════════════════════════════════ */
|
|
(function (global) {
|
|
|
|
var doc = global.document;
|
|
var NS = 'http://www.w3.org/2000/svg';
|
|
|
|
function el(tag, cls, html) {
|
|
var n = doc.createElement(tag);
|
|
if (cls) n.className = cls;
|
|
if (html != null) n.innerHTML = html;
|
|
return n;
|
|
}
|
|
function svgEl(tag, attrs) {
|
|
var n = doc.createElementNS(NS, tag);
|
|
if (attrs) for (var k in attrs) if (attrs.hasOwnProperty(k)) n.setAttribute(k, attrs[k]);
|
|
return n;
|
|
}
|
|
|
|
/* ── inline SVG иконки (без эмодзи) ── */
|
|
function starPath() { return 'M12 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 Z'; }
|
|
function starSvg(filled, size) {
|
|
var s = size || 16;
|
|
var fill = filled ? '#FBBF24' : 'none';
|
|
var stroke = filled ? '#FBBF24' : 'rgba(148,163,184,0.55)';
|
|
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="' + fill +
|
|
'" stroke="' + stroke + '" stroke-width="1.5" stroke-linejoin="round"><path d="' + starPath() + '"/></svg>';
|
|
}
|
|
function lockSvg(size) {
|
|
var s = size || 18;
|
|
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" ' +
|
|
'stroke="rgba(226,232,240,0.85)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
|
|
'<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>';
|
|
}
|
|
function playSvg(size) {
|
|
var s = size || 18;
|
|
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="currentColor" ' +
|
|
'stroke="none"><path d="M8 5.5 19 12 8 18.5 Z"/></svg>';
|
|
}
|
|
function checkSvg(size) {
|
|
var s = size || 18;
|
|
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" ' +
|
|
'stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">' +
|
|
'<path d="M4 12.5 10 18.5 20 6"/></svg>';
|
|
}
|
|
|
|
/* ── Раскладка узлов созвездия ──────────────────────────────────────────
|
|
Для каждой главы раскладываем её уровни по «созвездию»: лёгкая зигзаг-дуга
|
|
внутри своего вертикального пояса. Координаты в % ширины ленты главы. */
|
|
function layoutNodes(levels) {
|
|
var n = levels.length;
|
|
var pts = [];
|
|
for (var i = 0; i < n; i++) {
|
|
// x идёт слева-направо, y — мягкий зигзаг (созвездие, не прямая)
|
|
var x = n === 1 ? 50 : (12 + (76 * i / (n - 1)));
|
|
var y = 50 + (i % 2 === 0 ? -16 : 16) + (i % 3 === 0 ? 6 : -4);
|
|
pts.push({ x: x, y: y });
|
|
}
|
|
return pts;
|
|
}
|
|
|
|
/* ── Звёздное небо (статичные точки на canvas-фоне через SVG) ──────────── */
|
|
function buildStarfield(seedCount) {
|
|
var g = svgEl('g', { class: 'qm-stars' });
|
|
var rnd = mulberry32(0x51ec7 + seedCount);
|
|
for (var i = 0; i < seedCount; i++) {
|
|
var cx = rnd() * 100, cy = rnd() * 100;
|
|
var r = 0.08 + rnd() * 0.22;
|
|
var op = 0.25 + rnd() * 0.55;
|
|
var c = svgEl('circle', { cx: cx, cy: cy, r: r, fill: '#E2E8F0', opacity: op.toFixed(2) });
|
|
c.style.setProperty('--tw', (1.6 + rnd() * 3).toFixed(2) + 's');
|
|
c.style.setProperty('--td', (rnd() * 3).toFixed(2) + 's');
|
|
c.classList.add('qm-tw');
|
|
g.appendChild(c);
|
|
}
|
|
return g;
|
|
}
|
|
function mulberry32(a) {
|
|
return function () {
|
|
a |= 0; a = a + 0x6D2B79F5 | 0;
|
|
var t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
/* ════════════════════════ Создание карты ════════════════════════ */
|
|
function create(opts) {
|
|
opts = opts || {};
|
|
var host = opts.host;
|
|
var headerHost = opts.headerHost;
|
|
var onPlay = typeof opts.onPlay === 'function' ? opts.onPlay : function () {};
|
|
var getSkin = typeof opts.getSkin === 'function' ? opts.getSkin : function () { return 'cyan'; };
|
|
var onSkin = typeof opts.onSkin === 'function' ? opts.onSkin : function () {};
|
|
if (!host) return null;
|
|
|
|
var Levels = global.QuantikLevels;
|
|
var Prog = global.QuantikProgress;
|
|
if (!Levels || !Prog) return null;
|
|
|
|
var revealTimer = null;
|
|
|
|
function clearReveal() { if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; } }
|
|
|
|
/* ── Шапка: нарратор + XP-бар + всего звёзд + скины ── */
|
|
function renderHeader(progressMap) {
|
|
if (!headerHost) return;
|
|
headerHost.innerHTML = '';
|
|
|
|
var levels = Levels.list();
|
|
var xp = Prog.computeXp(levels, progressMap);
|
|
var pl = Prog.playerLevel(xp);
|
|
var tStars = Prog.totalStars(levels, progressMap);
|
|
var maxStars = levels.reduce(function (s, L) { return s + (L.spec && L.spec.goal && L.spec.goal.stars ? L.spec.goal.stars.length : 0); }, 0);
|
|
|
|
var wrap = el('div', 'qm-header-inner');
|
|
|
|
// Нарратор-Квантик (mood по уровню игрока)
|
|
var mood = pl.level >= 5 ? 'ecstatic' : (pl.level >= 2 ? 'happy' : 'neutral');
|
|
var narr = el('div', 'qm-narrator');
|
|
if (global.PetSprite) {
|
|
var petLvl = Math.min(8, Math.max(1, pl.level));
|
|
narr.innerHTML = '<div class="qm-pet">' + global.PetSprite.render(petLvl, mood, [], getSkin(), 0, 'none') + '</div>';
|
|
}
|
|
var bubble = el('div', 'qm-bubble');
|
|
bubble.appendChild(el('div', 'qm-bubble-t', narrLine(pl, tStars, maxStars)));
|
|
narr.appendChild(bubble);
|
|
wrap.appendChild(narr);
|
|
|
|
// XP / уровень игрока
|
|
var stats = el('div', 'qm-stats');
|
|
var lvlBox = el('div', 'qm-level');
|
|
lvlBox.innerHTML = '<span class="qm-level-num">' + pl.level + '</span><span class="qm-level-lbl">уровень Квантика</span>';
|
|
stats.appendChild(lvlBox);
|
|
|
|
var xpBox = el('div', 'qm-xpbox');
|
|
var xpHead = el('div', 'qm-xp-head');
|
|
xpHead.innerHTML = '<span>' + xp + ' XP</span><span class="qm-xp-next">' +
|
|
(pl.xpForNext > 0 ? ('до ур. ' + (pl.level + 1) + ': ' + Math.max(0, pl.xpForNext - pl.xpInto) + ' XP') : 'максимум') + '</span>';
|
|
xpBox.appendChild(xpHead);
|
|
var bar = el('div', 'qm-xp-bar');
|
|
var fill = el('div', 'qm-xp-fill');
|
|
fill.style.width = '0%';
|
|
bar.appendChild(fill);
|
|
xpBox.appendChild(bar);
|
|
stats.appendChild(xpBox);
|
|
|
|
// всего звёзд
|
|
var starBox = el('div', 'qm-starcount');
|
|
starBox.innerHTML = starSvg(true, 18) + '<span>' + tStars + ' / ' + maxStars + '</span>';
|
|
stats.appendChild(starBox);
|
|
|
|
wrap.appendChild(stats);
|
|
|
|
// Скины
|
|
wrap.appendChild(buildSkinPicker(xp, tStars));
|
|
|
|
headerHost.appendChild(wrap);
|
|
|
|
// анимация XP-бара (после вставки в DOM)
|
|
requestAnimationFrame(function () {
|
|
fill.style.width = (pl.progress01 * 100).toFixed(1) + '%';
|
|
});
|
|
}
|
|
|
|
function narrLine(pl, tStars, maxStars) {
|
|
if (tStars === 0) return 'Привет! Я — Квантик. Помоги мне починить законы мира — выбери уровень и подкрути формулы.';
|
|
if (tStars >= maxStars) return 'Все звёзды собраны! Ты настоящий мастер законов мира.';
|
|
if (pl.level >= 5) return 'Невероятно! Уровень ' + pl.level + '. Осталось всего ' + (maxStars - tStars) + ' звёзд.';
|
|
if (pl.level >= 3) return 'Отлично идём — уровень ' + pl.level + '. Звёзды открывают новые созвездия.';
|
|
return 'Уже ' + tStars + ' звёзд! Собирай больше, чтобы открыть новые уровни.';
|
|
}
|
|
|
|
/* Палитра скинов: первые открыты, остальные — за XP/звёзды. */
|
|
var SKIN_GATES = [
|
|
{ key: 'cyan', name: 'Циан', need: 0 },
|
|
{ key: 'purple', name: 'Аметист', need: 0 },
|
|
{ key: 'green', name: 'Изумруд', needStars: 2 },
|
|
{ key: 'pink', name: 'Магента', needStars: 4 },
|
|
{ key: 'gold', name: 'Золото', needStars: 7 },
|
|
{ key: 'blue', name: 'Сапфир', needXp: 600 },
|
|
{ key: 'orange', name: 'Янтарь', needXp: 1000 },
|
|
{ key: 'indigo', name: 'Индиго', needStars: 11 }
|
|
];
|
|
function skinUnlocked(g, xp, stars) {
|
|
if (g.needStars && stars < g.needStars) return false;
|
|
if (g.needXp && xp < g.needXp) return false;
|
|
if (g.need && stars < g.need) return false;
|
|
return true;
|
|
}
|
|
function buildSkinPicker(xp, stars) {
|
|
var box = el('div', 'qm-skins');
|
|
box.appendChild(el('div', 'qm-skins-lbl', 'Скин'));
|
|
var row = el('div', 'qm-skins-row');
|
|
var cur = getSkin();
|
|
var pal = (global.PetSprite && global.PetSprite.PALETTES) || {};
|
|
SKIN_GATES.forEach(function (g) {
|
|
var unlocked = skinUnlocked(g, xp, stars);
|
|
var sw = el('button', 'qm-skin' + (cur === g.key ? ' active' : '') + (unlocked ? '' : ' locked'));
|
|
sw.type = 'button';
|
|
sw.style.setProperty('--sk', pal[g.key] || '#06D6E0');
|
|
sw.title = unlocked ? g.name : (g.name + ' — ' + skinReq(g));
|
|
sw.setAttribute('aria-label', g.name + (unlocked ? '' : ' (заблокирован)'));
|
|
if (!unlocked) sw.innerHTML = '<span class="qm-skin-lock">' + lockSvg(12) + '</span>';
|
|
if (unlocked) {
|
|
sw.addEventListener('click', function () { onSkin(g.key); });
|
|
} else {
|
|
sw.disabled = true;
|
|
}
|
|
row.appendChild(sw);
|
|
});
|
|
box.appendChild(row);
|
|
return box;
|
|
}
|
|
function skinReq(g) {
|
|
if (g.needStars) return 'нужно ' + g.needStars + ' звёзд';
|
|
if (g.needXp) return 'нужно ' + g.needXp + ' XP';
|
|
return 'заблокирован';
|
|
}
|
|
|
|
/* ── Тело карты: созвездия по главам ── */
|
|
function renderMap(progressMap) {
|
|
clearReveal();
|
|
host.innerHTML = '';
|
|
|
|
var groups = Prog.groupByChapter(Levels.list());
|
|
var allLevels = Levels.list();
|
|
var revealOrder = []; // узлы для поэтапного появления
|
|
|
|
groups.forEach(function (grp, gi) {
|
|
var meta = Levels.chapter(grp.chapter);
|
|
var section = el('section', 'qm-constellation');
|
|
section.style.setProperty('--accent', meta.accent || '#22D3EE');
|
|
|
|
// заголовок главы
|
|
var head = el('div', 'qm-con-head');
|
|
head.innerHTML = '<span class="qm-con-title">' + escapeHtml(meta.title) + '</span>' +
|
|
'<span class="qm-con-sub">' + escapeHtml(meta.subtitle || '') + '</span>';
|
|
// прогресс главы
|
|
var cStars = 0, cMax = 0;
|
|
grp.levels.forEach(function (L) {
|
|
cStars += Prog.starsFor(L.id, progressMap);
|
|
cMax += (L.spec && L.spec.goal && L.spec.goal.stars) ? L.spec.goal.stars.length : 0;
|
|
});
|
|
var cbadge = el('span', 'qm-con-stars', starSvg(true, 14) + ' ' + cStars + '/' + cMax);
|
|
head.appendChild(cbadge);
|
|
section.appendChild(head);
|
|
|
|
// поле созвездия
|
|
var field = el('div', 'qm-field');
|
|
var pts = layoutNodes(grp.levels);
|
|
|
|
// SVG-слой: звёздное небо + линии-связи
|
|
var svg = svgEl('svg', { class: 'qm-svg', viewBox: '0 0 100 100', preserveAspectRatio: 'none' });
|
|
svg.appendChild(buildStarfield(46 + gi * 7));
|
|
// линии между последовательными узлами
|
|
for (var li = 0; li < pts.length - 1; li++) {
|
|
var a = pts[li], b = pts[li + 1];
|
|
var nextUnlocked = Prog.isUnlocked(grp.levels[li + 1], progressMap, allLevels);
|
|
var line = svgEl('line', {
|
|
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
|
|
class: 'qm-link' + (nextUnlocked ? ' on' : '')
|
|
});
|
|
svg.appendChild(line);
|
|
}
|
|
field.appendChild(svg);
|
|
|
|
// узлы-уровни
|
|
grp.levels.forEach(function (L, idx) {
|
|
var status = Prog.nodeStatus(L, progressMap, allLevels);
|
|
var node = buildNode(L, status, progressMap, allLevels, pts[idx]);
|
|
field.appendChild(node);
|
|
revealOrder.push(node);
|
|
});
|
|
|
|
section.appendChild(field);
|
|
host.appendChild(section);
|
|
});
|
|
|
|
// поэтапное появление узлов
|
|
staggerReveal(revealOrder);
|
|
}
|
|
|
|
function buildNode(level, status, progressMap, allLevels, pt) {
|
|
var stars = Prog.starsFor(level.id, progressMap);
|
|
var total = (level.spec && level.spec.goal && level.spec.goal.stars) ? level.spec.goal.stars.length : 0;
|
|
|
|
var node = el('button', 'qm-node qm-' + status);
|
|
node.type = 'button';
|
|
node.style.left = pt.x + '%';
|
|
node.style.top = pt.y + '%';
|
|
node.setAttribute('data-level', level.id);
|
|
|
|
// ядро узла
|
|
var core = el('span', 'qm-node-core');
|
|
var icon = status === 'locked' ? lockSvg(20)
|
|
: (status === 'completed' ? '<span class="qm-node-order">' + level.order + '</span>' : playSvg(18));
|
|
core.innerHTML = icon;
|
|
node.appendChild(core);
|
|
|
|
// подпись
|
|
var label = el('span', 'qm-node-label', escapeHtml(level.title));
|
|
node.appendChild(label);
|
|
|
|
// звёзды узла (для пройденных) или порог (для заблокированных)
|
|
if (status === 'completed' && total > 0) {
|
|
var sb = el('span', 'qm-node-stars');
|
|
var html = '';
|
|
for (var i = 0; i < total; i++) html += starSvg(i < stars, 13);
|
|
sb.innerHTML = html;
|
|
node.appendChild(sb);
|
|
} else if (status === 'locked') {
|
|
var need = Prog.starsToUnlock(level, progressMap, allLevels);
|
|
var hint = el('span', 'qm-node-need', starSvg(true, 11) + ' ещё ' + need);
|
|
node.appendChild(hint);
|
|
}
|
|
|
|
if (status === 'locked') {
|
|
node.disabled = true;
|
|
node.setAttribute('aria-disabled', 'true');
|
|
node.title = 'Заблокировано — собери больше звёзд в предыдущих уровнях';
|
|
} else {
|
|
node.title = level.title + (status === 'completed' ? ' — пройдено' : ' — играть');
|
|
node.addEventListener('click', function () { onPlay(level); });
|
|
}
|
|
node.setAttribute('aria-label', level.title + ' (' +
|
|
(status === 'locked' ? 'заблокировано' : status === 'completed' ? ('пройдено, ' + stars + ' из ' + total + ' звёзд') : 'доступно') + ')');
|
|
|
|
return node;
|
|
}
|
|
|
|
function staggerReveal(nodes) {
|
|
nodes.forEach(function (n) { n.classList.add('qm-pre'); });
|
|
var i = 0;
|
|
function step() {
|
|
if (i >= nodes.length) { revealTimer = null; return; }
|
|
nodes[i].classList.remove('qm-pre');
|
|
nodes[i].classList.add('qm-in');
|
|
i++;
|
|
revealTimer = setTimeout(step, 70);
|
|
}
|
|
revealTimer = setTimeout(step, 120);
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function render(progressMap) {
|
|
progressMap = progressMap || {};
|
|
renderHeader(progressMap);
|
|
renderMap(progressMap);
|
|
}
|
|
|
|
function destroy() {
|
|
clearReveal();
|
|
if (host) host.innerHTML = '';
|
|
if (headerHost) headerHost.innerHTML = '';
|
|
}
|
|
|
|
return { render: render, destroy: destroy };
|
|
}
|
|
|
|
global.QuantikMap = { create: create };
|
|
|
|
})(typeof window !== 'undefined' ? window : this);
|