Files
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

389 lines
18 KiB
JavaScript
Raw Permalink 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).
Рисует мир как звёздную карту: каждая глава (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 '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s +
'" style="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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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);