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> @
196 lines
9.6 KiB
JavaScript
196 lines
9.6 KiB
JavaScript
'use strict';
|
|
/* ════════════════════════════════════════════════════════════════════════
|
|
Квантик — Законы Мира · ЧИСТАЯ логика прогресса (Фаза 2).
|
|
|
|
Никакого DOM/сети/движка — только функции над данными. Это делает их
|
|
тривиально тестируемыми (headless vm) и переносимыми на сервер позже.
|
|
|
|
ВХОД везде:
|
|
levels — массив записей уровней (форма QuantikLevels): { id, chapter,
|
|
order, par_ms?, unlockStars?, ... }.
|
|
progressMap — объект { [level_id]: { best_stars, best_time_ms, attempts } },
|
|
агрегируется из LS.gameProgressList() (см. fromProgressList).
|
|
|
|
⛔ Без eval/Function. Без побочных эффектов.
|
|
════════════════════════════════════════════════════════════════════════ */
|
|
(function (global) {
|
|
|
|
/* Превратить ответ /api/game/progress ([{level_id, best_stars, ...}]) в карту. */
|
|
function fromProgressList(list) {
|
|
var map = {};
|
|
if (!Array.isArray(list)) return map;
|
|
for (var i = 0; i < list.length; i++) {
|
|
var row = list[i];
|
|
if (row && row.level_id != null) map[row.level_id] = row;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/* Лучшее число звёзд по уровню (0, если не пройден). */
|
|
function starsFor(levelId, progressMap) {
|
|
var p = progressMap && progressMap[levelId];
|
|
var s = p ? p.best_stars : 0;
|
|
return (typeof s === 'number' && s > 0) ? s : 0;
|
|
}
|
|
|
|
/* Пройден ли уровень (есть хотя бы одна звезда == достигнута цель). */
|
|
function isCompleted(levelId, progressMap) {
|
|
return starsFor(levelId, progressMap) > 0;
|
|
}
|
|
|
|
/* Сумма лучших звёзд по всем уровням. */
|
|
function totalStars(levels, progressMap) {
|
|
var sum = 0;
|
|
for (var i = 0; i < levels.length; i++) sum += starsFor(levels[i].id, progressMap);
|
|
return sum;
|
|
}
|
|
|
|
/* ── Разблокировка ────────────────────────────────────────────────────────
|
|
Уровень открыт, если СУММА звёзд во ВСЕХ предыдущих уровнях той же главы
|
|
(по полю order) ≥ level.unlockStars. Первый уровень главы (минимальный order
|
|
или unlockStars==0) открыт всегда. Глава открывается, если открыт её первый
|
|
уровень — он гейтится суммой звёзд предыдущих глав через unlockStars==0
|
|
первого уровня (по умолчанию) ИЛИ явным порогом.
|
|
|
|
Чистая функция: вход — уровень + карта прогресса + ВЕСЬ список (для подсчёта
|
|
«предыдущих» по order). Возвращает bool. */
|
|
function isUnlocked(level, progressMap, levels) {
|
|
if (!level) return false;
|
|
var need = (typeof level.unlockStars === 'number') ? level.unlockStars : 0;
|
|
if (need <= 0) return true; // нет порога — всегда доступен
|
|
// звёзды, набранные во всех уровнях с меньшим глобальным order
|
|
var have = 0;
|
|
for (var i = 0; i < levels.length; i++) {
|
|
var L = levels[i];
|
|
if (L.id === level.id) continue;
|
|
if ((L.order || 0) < (level.order || 0)) {
|
|
have += starsFor(L.id, progressMap);
|
|
}
|
|
}
|
|
return have >= need;
|
|
}
|
|
|
|
/* Статус узла для карты: 'completed' | 'available' | 'locked'. */
|
|
function nodeStatus(level, progressMap, levels) {
|
|
if (isCompleted(level.id, progressMap)) return 'completed';
|
|
if (isUnlocked(level, progressMap, levels)) return 'available';
|
|
return 'locked';
|
|
}
|
|
|
|
/* ── XP ────────────────────────────────────────────────────────────────────
|
|
XP = сумма (звёзды × STAR_XP) + бонус за каждый пройденный уровень
|
|
(COMPLETE_XP) + бонус за «par» (3-я звезда == уложился в норматив времени,
|
|
она и так считается звездой; дополнительный PAR_BONUS за первое прохождение
|
|
уровня в принципе). Детерминированная функция от карты прогресса. */
|
|
var STAR_XP = 100; // за каждую звезду
|
|
var COMPLETE_XP = 40; // за факт прохождения уровня (≥1 звезда)
|
|
|
|
function computeXp(levels, progressMap) {
|
|
var xp = 0;
|
|
for (var i = 0; i < levels.length; i++) {
|
|
var id = levels[i].id;
|
|
var s = starsFor(id, progressMap);
|
|
if (s > 0) {
|
|
xp += s * STAR_XP + COMPLETE_XP;
|
|
}
|
|
}
|
|
return xp;
|
|
}
|
|
|
|
/* ── Уровень игрока ──────────────────────────────────────────────────────
|
|
Порог уровня растёт линейно-нарастающе: уровень N требует кумулятивно
|
|
XP_PER_LEVEL_BASE·N·(N+1)/2 … упрощаем до квадратичной обратной формулы.
|
|
playerLevel(xp) -> { level, xpInto, xpForNext, progress01, totalForLevel }.
|
|
level начинается с 1. */
|
|
var XP_STEP = 240; // базовый шаг XP (level n требует n*XP_STEP суммарно для перехода)
|
|
|
|
// Кумулятивный XP, нужный чтобы ДОСТИЧЬ уровня L (L>=1). level 1 = 0 XP.
|
|
function xpForLevel(L) {
|
|
if (L <= 1) return 0;
|
|
// сумма k=1..L-1 of k*XP_STEP = XP_STEP * (L-1)*L/2
|
|
return XP_STEP * (L - 1) * L / 2;
|
|
}
|
|
|
|
function playerLevel(xp) {
|
|
if (!(xp > 0)) xp = 0;
|
|
var L = 1;
|
|
// найти максимальный L, чей порог <= xp
|
|
while (xpForLevel(L + 1) <= xp) L++;
|
|
var base = xpForLevel(L);
|
|
var next = xpForLevel(L + 1);
|
|
var span = next - base;
|
|
var into = xp - base;
|
|
return {
|
|
level: L,
|
|
xp: xp,
|
|
xpInto: into,
|
|
xpForNext: span,
|
|
totalForNext: next,
|
|
progress01: span > 0 ? Math.min(1, into / span) : 1
|
|
};
|
|
}
|
|
|
|
/* ── Группировка по главам ────────────────────────────────────────────────
|
|
Возвращает массив { chapter, levels:[...] } в порядке появления глав;
|
|
уровни внутри главы сортируются по order. */
|
|
function groupByChapter(levels) {
|
|
var order = [];
|
|
var byKey = {};
|
|
for (var i = 0; i < levels.length; i++) {
|
|
var L = levels[i];
|
|
var key = L.chapter || 'misc';
|
|
if (!byKey[key]) { byKey[key] = { chapter: key, levels: [] }; order.push(key); }
|
|
byKey[key].levels.push(L);
|
|
}
|
|
return order.map(function (k) {
|
|
var g = byKey[k];
|
|
g.levels = g.levels.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
|
|
return g;
|
|
});
|
|
}
|
|
|
|
/* Следующий разблокированный непройденный уровень после данного (по глоб. order),
|
|
или null. Используется кнопкой «Дальше». */
|
|
function nextPlayable(currentId, levels, progressMap) {
|
|
var sorted = levels.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
|
|
var idx = -1;
|
|
for (var i = 0; i < sorted.length; i++) if (sorted[i].id === currentId) { idx = i; break; }
|
|
// сначала ищем следующий по порядку доступный (предпочтительно непройденный)
|
|
for (var j = idx + 1; j < sorted.length; j++) {
|
|
if (isUnlocked(sorted[j], progressMap, levels)) return sorted[j];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/* Сколько ещё звёзд нужно, чтобы открыть уровень (для подсказки на замке). */
|
|
function starsToUnlock(level, progressMap, levels) {
|
|
var need = (typeof level.unlockStars === 'number') ? level.unlockStars : 0;
|
|
if (need <= 0) return 0;
|
|
var have = 0;
|
|
for (var i = 0; i < levels.length; i++) {
|
|
var L = levels[i];
|
|
if (L.id === level.id) continue;
|
|
if ((L.order || 0) < (level.order || 0)) have += starsFor(L.id, progressMap);
|
|
}
|
|
return Math.max(0, need - have);
|
|
}
|
|
|
|
global.QuantikProgress = {
|
|
fromProgressList: fromProgressList,
|
|
starsFor: starsFor,
|
|
isCompleted: isCompleted,
|
|
totalStars: totalStars,
|
|
isUnlocked: isUnlocked,
|
|
nodeStatus: nodeStatus,
|
|
computeXp: computeXp,
|
|
playerLevel: playerLevel,
|
|
xpForLevel: xpForLevel,
|
|
groupByChapter: groupByChapter,
|
|
nextPlayable: nextPlayable,
|
|
starsToUnlock: starsToUnlock,
|
|
// константы (для отображения/тестов)
|
|
STAR_XP: STAR_XP, COMPLETE_XP: COMPLETE_XP, XP_STEP: XP_STEP
|
|
};
|
|
|
|
})(typeof window !== 'undefined' ? window : this);
|