'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. Уровень с unlockStars==0 (или без него) открыт всегда. Так первый уровень главы гейтится суммой звёзд всех предыдущих глав через свой порог unlockStars. Чистая функция: вход — уровень + карта прогресса + ВЕСЬ список (для подсчёта «предыдущих» по 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);