@
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:
@@ -0,0 +1,195 @@
|
||||
'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);
|
||||
Reference in New Issue
Block a user