'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)'; // Цвета — через inline style, а НЕ presentation-атрибуты: правило .ic в ls.css // (fill:none; stroke:currentColor) перебивает атрибуты fill/stroke, из-за чего // заработанные звёзды узлов не закрашивались. Inline style приоритетнее класса. return ''; } function lockSvg(size) { var s = size || 18; return '' + ''; } function playSvg(size) { var s = size || 18; return ''; } function checkSvg(size) { var s = size || 18; return '' + ''; } /* ── Раскладка узлов созвездия ────────────────────────────────────────── Для каждой главы раскладываем её уровни по «созвездию»: лёгкая зигзаг-дуга внутри своего вертикального пояса. Координаты в % ширины ленты главы. */ 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 = '
' + global.PetSprite.render(petLvl, mood, [], getSkin(), 0, 'none') + '
'; } 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 = '' + pl.level + 'уровень Квантика'; stats.appendChild(lvlBox); var xpBox = el('div', 'qm-xpbox'); var xpHead = el('div', 'qm-xp-head'); xpHead.innerHTML = '' + xp + ' XP' + (pl.xpForNext > 0 ? ('до ур. ' + (pl.level + 1) + ': ' + Math.max(0, pl.xpForNext - pl.xpInto) + ' XP') : 'максимум') + ''; 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) + '' + tStars + ' / ' + maxStars + ''; 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 = '' + lockSvg(12) + ''; 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 = '' + escapeHtml(meta.title) + '' + '' + escapeHtml(meta.subtitle || '') + ''; // прогресс главы 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' ? '' + level.order + '' : 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, '>'); } 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);