@
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:
@@ -225,3 +225,16 @@ git push origin master
|
||||
- **Доступ страницы**: `LS.initPage()` (без `{requireLogin:false}`) сам редиректит на `/login` если не авторизован и возвращает null → бутстрап выходит. Любой авторизованный играет.
|
||||
- **Верификация P1**: `node --check` всех новых/изменённых JS — OK; `npm run migrate` 076 применяется чисто; `npm test` 251 pass / 8 baseline fail (3 auth + 5 jsdom page-тестов — пре-существующие; **game.test.js 13/13 PASS**); `lint:routes` 247 :id-роутов, 0 unprotected (baseline 0). Эмодзи в коде нет (флагуются только `→`/`⛔` в комментариях — конвенция проекта); eval/new Function — 0. Спека без goal по-прежнему работает (Ф0 не задет).
|
||||
- **На Phase 2 (карта/мир/XP)**: реестр уровней расширяемый (добавить запись в `LEVELS`); `game_progress`-API готов; экран успеха `buildSuccessOverlay` переиспользуем (расширить «следующим уровнем», активировать «Дальше»); при смене уровня без перезагрузки — `inst.destroy()` перед новым mount.
|
||||
|
||||
### Phase 2 — Learnings (Карта-созвездие + мир физ-уровней + XP/скины)
|
||||
|
||||
- **Phase 2 = FRONTEND-ONLY** (осознанное решение): XP/уровень игрока агрегируются на КЛИЕНТЕ из `game_progress` (Ф1), скин — localStorage. Без новых таблиц/роутов/миграций → `lint:routes` baseline 0 не тронут, `npm test` ровно как в Ф1 (259 tests / 251 pass / 8 baseline fail). Перенос XP на сервер позже тривиален — те же чистые функции `progress-logic.js`.
|
||||
- **Чистая логика в отдельном модуле `frontend/js/game/progress-logic.js`** (`window.QuantikProgress`, без DOM/сети/eval — тестируемо в изоляции): `isUnlocked(level,map,levels)` (Σ звёзд во всех уровнях с меньшим `order` ≥ `level.unlockStars`; порог в ДАННЫХ уровня), `computeXp`(звёзды·100+40/пройден), `playerLevel(xp)` (квадратичная шкала `xpForLevel(L)=240·(L-1)L/2`), `groupByChapter`, `nextPlayable`, `fromProgressList`, `starsFor/starsToUnlock/nodeStatus`. Гоча тестов: `assert.deepEqual` через `vm`-границу сравнивает массивы РАЗНЫХ реалмов (прототипы ≠) → ложный fail; сравнивать через `JSON.stringify`.
|
||||
- **Карта `frontend/js/game/map.js`** (`window.QuantikMap.create({host,headerHost,onPlay,getSkin,onSkin})->{render(progressMap),destroy()}`): созвездия по главам (`groupByChapter`), узлы — `<button class="qm-node qm-{locked|available|completed}">`, позиция в % через `layoutNodes` (зигзаг-дуга), статус из `nodeStatus`. Звёздное небо — SVG `<circle class="qm-tw">` (CSS-мерцание, seeded `mulberry32`), линии-связи `<line>`. Поэтапное появление — `staggerReveal` (`.qm-pre`→`.qm-in`, setTimeout 70 мс). Тип спеки уровня карте безразличен — читает только метаданные → Ф3 граф-уровни = НОВАЯ глава без правок map.js.
|
||||
- **Метаданные уровня (Ф2)**: `{ id, title, chapter, order, unlockStars?, par_ms?, hint, spec }`. Главы — `QuantikLevels.CHAPTERS` (`{key,title,subtitle,accent}`). 6 уровней: Кинематика (артиллерия/перелёт-через-стену/отскок-от-стены) + Динамика (маятник-на-пружине/орбита-в-колодце/гравиманёвр). По 2 звезды: кристалл (`stars[0]`) + норматив времени `t*1000<=par_ms` (`stars[1]` — par-звезда выражается через мировое `t`, идентификатор `tries` для неё НЕ нужен).
|
||||
- **Физика «силовых» уровней через ПРУЖИНУ** (движок не имеет central-gravity): маятник — пружина к якорю-точке с короткой `length` (растянута → сильный возврат) + горизонтальный толчок; орбита — пружина к центру с `length:0` (== гармонический осциллятор `F=-k·r` == эллиптические орбиты); гравиманёвр — гравитация вниз + пружина-«колодец» к центру. k/толчок/сила = params-слайдеры.
|
||||
- **Скин: тинт без исполнения.** `tintHeroSpec(spec,key)` — глубокая JSON-копия спеки (данные!), переписывает `color/glowColor/trailColor` объекта `id:'ball'` цветом из `PetSprite.PALETTES[key]`. localStorage ключ **`quantik-skin`** (валидируется при чтении). Скин тинтует и героя, и нарратора (`PetSprite.render(...,colorKey,...)`). Гейты — массив `SKIN_GATES` (needStars/needXp).
|
||||
- **Нарратор = `PetSprite.render(level,mood,[],skin,0,'none')`** на карте-шапке (mood по уровню игрока), интро (`buildIntro`, happy) и успехе (`buildSuccessOverlay`, ecstatic при всех звёздах≥2 / happy при ≥1). `quantik.html` грузит `/js/pet-sprite.js` (как dashboard/pet).
|
||||
- **Навигация (inline-bootstrap quantik.html)**: 2 вида `#qg-map-view`/`#qg-level-view` (класс `.show`). `showMap` перезагружает прогресс (`LS.gameProgressList`) → `map.render`. `openLevel→интро→launchLevel→onGoal→успех→onNext(nextPlayable)|onMap`. **Смена уровня ВСЕГДА через `destroyLevel()` (=`inst.destroy()`)** до нового mount (гоча Ф1). Deep-link `?level=` открывает только разблокированный.
|
||||
- **Per-level winnability обязательна** (как Ф1): harness грузит РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js` в `vm`, свипует слайдеры через движок, проверяет `getResult().won`. Гоча OOM: **переиспользовать ОДИН `inst` через `reset()` по сотням комбо ТЕЧЁТ** (накопление через goal-state/bodyById-замыкания) → mount+`destroy()` СВЕЖИЙ inst на каждое комбо (leak-proof). Headless `_renderFrame` рано выходит при `_cw/_ch==0` (рендер не нужен, физика/`_evalGoal` идут в `play`-кадре независимо); для point-радиуса в физике выставить `inst._scale`. Виртуальные часы синхронны с `performance.now()`/rAF-timestamp. Результат: ВСЕ 6 winnable, у всех достижимы обе звезды (combos: artillery 28/196, arc 5/196, bounce 92/343, pendulum 189/196, orbit 94/196, gravimanёvr 170/343).
|
||||
- **Верификация P2**: `node --check` всех новых/изменённых JS + inline-`<script>` quantik.html — OK; смоуки (логика 16/16, рендер карты/оверлеев 7/7, winnability 6/6) зелёные и удалены; `npm test` 259/251 pass/8 baseline fail (без новых падений); `lint:routes` baseline 0. Эмодзи/`★`/eval/new Function — 0 (звёзды UI — inline SVG; в комментариях `★` заменён на «зв.»).
|
||||
|
||||
+357
-63
@@ -1,107 +1,401 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Квантик — Законы Мира · Реестр уровней (Фаза 1, MVP).
|
||||
Квантик — Законы Мира · Реестр уровней (Фаза 2 — мир из 6 физ-уровней).
|
||||
|
||||
Уровень = СПЕКА SimForge (данные, не код) + блок `goal` (победа), который
|
||||
движок (_sim_engine.js) умеет с Фазы 0. Игрок не управляет героем напрямую —
|
||||
он «чинит закон мира»: крутит слайдеры params (угол/скорость), затем «Запуск»,
|
||||
и симуляция проигрывается к цели.
|
||||
он «чинит закон мира»: крутит слайдеры params (угол/скорость/жёсткость…),
|
||||
затем «Запуск», и симуляция проигрывается к цели.
|
||||
|
||||
ИСТОЧНИК УРОВНЕЙ (решение зафиксировано в CONTEXT.md):
|
||||
— СЕЙЧАС (Фаза 1): встроенные данные здесь, window.QuantikLevels.
|
||||
— ПОЗЖЕ (Фаза 5): уровни авторятся в sim-builder и хранятся в custom_sims
|
||||
(cat='game'); реестр пополнится загрузкой опубликованных спек с сервера.
|
||||
── МЕТАДАННЫЕ УРОВНЯ (Фаза 2) ───────────────────────────────────────────
|
||||
{
|
||||
id, // == level_id для /api/game/progress
|
||||
title, // отображаемое имя узла карты
|
||||
chapter, // ключ главы-созвездия (группировка на карте)
|
||||
order, // глобальный порядок (для «предыдущих» при разблокировке и «Дальше»)
|
||||
unlockStars, // порог: сумма звёзд во ВСЕХ предыдущих уровнях, чтобы открыть (деф. 0)
|
||||
par_ms?, // норматив времени для 3-й звезды (мс мирового времени)
|
||||
subject?, // тема (физика)
|
||||
hint?, // подсказка-нарратив для интро
|
||||
spec // обычная спека SimForge с блоком goal
|
||||
}
|
||||
Звёзды: зв.1 — достичь цели; зв.2 — собрать бонус (кристалл); зв.3 — уложиться
|
||||
в par_ms. par-звезда выражается напрямую через мировое время t: `t*1000 <= PAR`
|
||||
(вычисляется в момент победы; идентификатор tries для неё не нужен).
|
||||
|
||||
Форма записи уровня:
|
||||
{ id, title, subject?, hint?, spec }
|
||||
где spec — обычная спека SimForge с блоком goal. id == level_id для
|
||||
/api/game/progress (LS.gameProgressSubmit(id, ...)).
|
||||
ИСТОЧНИК УРОВНЕЙ: встроенные данные здесь (window.QuantikLevels). Авторённые
|
||||
уровни (custom_sims cat='game') подмешаются в Фазе 5 (реестр станет асинхронным).
|
||||
|
||||
⛔ Без eval/Function. Все «числовые» поля могут быть числом ИЛИ строкой-
|
||||
выражением (их безопасно вычисляет SimExpr на клиенте).
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
|
||||
/* ── Уровень 1: «Артиллерия Квантика» ──────────────────────────────────
|
||||
Герой — светящаяся точка-тело (body) с кометной трассой (P2). Запускается
|
||||
из начала координат под углом θ со скоростью v; гравитация тянет вниз.
|
||||
Цель — попасть в портал; бонус-звезда — собрать кристалл по дороге.
|
||||
Параметры подобраны так, чтобы уровень был ПРОХОДИМ в пределах слайдеров. */
|
||||
var PORTAL_X = 8; // центр портала по X (мир)
|
||||
var PORTAL_Y = 0; // центр портала по Y (на «земле» y=0)
|
||||
var PORTAL_R = 0.7; // радиус попадания
|
||||
var STAR_X = 4; // бонус-кристалл (на восходящей ветви хорошей дуги)
|
||||
var STAR_Y = 2.6;
|
||||
var STAR_R = 0.65;
|
||||
var BG = '#0D0D1A';
|
||||
var GROUND = '#334155';
|
||||
var HERO = '#22D3EE'; // дефолтный цвет героя (тинтуется скином — см. quantik-game.js)
|
||||
var PORTAL = '#A78BFA'; // фиолет — цель
|
||||
var CRYSTAL = '#F472B6'; // розовый — бонус
|
||||
|
||||
/* helper: общий объект «герой-тело» (point с body) — стартует из (sx,sy). */
|
||||
function hero(sx, sy, vxExpr, vyExpr) {
|
||||
return {
|
||||
id: 'ball', type: 'point', r: 7, color: HERO,
|
||||
x: sx, y: sy,
|
||||
glow: true, glowColor: HERO, trail: true, trailColor: HERO, trailFade: true,
|
||||
body: { mass: 1, vx: vxExpr, vy: vyExpr }
|
||||
};
|
||||
}
|
||||
/* helper: светящийся портал-кольцо (визуал цели). */
|
||||
function portalObjs(px, py, r) {
|
||||
return [
|
||||
{ type: 'circle', x: px, y: py, r: r, color: PORTAL, width: 3, glow: true, glowColor: PORTAL },
|
||||
{ type: 'circle', x: px, y: py, r: r * 0.45, color: PORTAL, width: 2, opacity: 0.7 },
|
||||
{ type: 'label', x: px, y: py + r + 0.9, text: 'портал', color: PORTAL, size: 12 }
|
||||
];
|
||||
}
|
||||
function crystalObjs(cx, cy, r) {
|
||||
return [
|
||||
{ type: 'circle', x: cx, y: cy, r: r, color: CRYSTAL, width: 2, glow: true, glowColor: CRYSTAL },
|
||||
{ type: 'label', x: cx, y: cy + r + 0.8, text: 'кристалл', color: CRYSTAL, size: 11 }
|
||||
];
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
Глава I — «Кинематика» (созвездие): полёт под действием гравитации.
|
||||
──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Уровень 1: «Артиллерия Квантика» — базовый бросок под углом. */
|
||||
var L1_PX = 8, L1_PY = 0.7, L1_PR = 0.75;
|
||||
var L1_CX = 4, L1_CY = 2.7, L1_CR = 0.7;
|
||||
var artillery1 = {
|
||||
id: 'phys-artillery-1',
|
||||
title: 'Артиллерия Квантика',
|
||||
title: 'Артиллерия',
|
||||
chapter: 'kinematics',
|
||||
order: 1,
|
||||
unlockStars: 0,
|
||||
par_ms: 1500,
|
||||
subject: 'physics',
|
||||
hint: 'Подберите угол и скорость, чтобы Квантик долетел до портала. Соберите кристалл по дороге — это бонусная звезда.',
|
||||
hint: 'Подбери угол и скорость, чтобы Квантик долетел до портала. Собери кристалл по дороге — это вторая звезда. Быстрый бросок даст третью.',
|
||||
spec: {
|
||||
specVersion: 1,
|
||||
meta: { title: 'Артиллерия Квантика', desc: 'Закон движения: бросок под углом к горизонту.' },
|
||||
viewport: { xmin: -1, xmax: 12, ymin: -1.2, ymax: 7, grid: true, axes: true, bg: '#0D0D1A' },
|
||||
viewport: { xmin: -1, xmax: 12, ymin: -1.2, ymax: 7, grid: true, axes: true, bg: BG },
|
||||
params: [
|
||||
{ name: 'theta', label: 'Угол', min: 10, max: 80, step: 1, value: 45, unit: '°' },
|
||||
{ name: 'v', label: 'Скорость', min: 5, max: 20, step: 0.5, value: 10, unit: 'м/с' }
|
||||
{ name: 'v', label: 'Скорость', min: 5, max: 20, step: 0.5, value: 10, unit: 'м/с' }
|
||||
],
|
||||
physics: {
|
||||
enabled: true,
|
||||
gravity: { x: 0, y: -9.8 }
|
||||
},
|
||||
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
|
||||
objects: [
|
||||
// «Земля» — линия y=0 для ориентира.
|
||||
{ type: 'segment', x1: -1, y1: 0, x2: 12, y2: 0, color: '#334155', width: 2 },
|
||||
|
||||
// Бонус-кристалл (звезда). Контурный кружок-маркер.
|
||||
{ type: 'circle', x: STAR_X, y: STAR_Y, r: STAR_R, color: '#FBBF24', width: 2, glow: true },
|
||||
{ type: 'label', x: STAR_X, y: STAR_Y + 0.7, text: 'кристалл', color: '#FBBF24', size: 12 },
|
||||
|
||||
// Портал — цель. Светящийся кружок.
|
||||
{ type: 'circle', x: PORTAL_X, y: PORTAL_Y + PORTAL_R, r: PORTAL_R, color: '#22D3EE', width: 3, glow: true, glowColor: '#22D3EE' },
|
||||
{ type: 'label', x: PORTAL_X, y: PORTAL_Y + 2.0, text: 'портал', color: '#22D3EE', size: 12 },
|
||||
|
||||
// Герой Квантик — физ-тело, стартует из (0,0) со скоростью (vx,vy).
|
||||
// glow + кометная трасса (P2).
|
||||
{
|
||||
id: 'ball', type: 'point', r: 7, color: '#06D6E0',
|
||||
x: 0, y: 0,
|
||||
glow: true, glowColor: '#06D6E0', trail: true, trailColor: '#06D6E0',
|
||||
body: {
|
||||
mass: 1,
|
||||
vx: 'v*cos(theta*pi/180)',
|
||||
vy: 'v*sin(theta*pi/180)'
|
||||
}
|
||||
},
|
||||
|
||||
// Живые показания скорости (бейдж-оверлей).
|
||||
{ type: 'segment', x1: -1, y1: 0, x2: 12, y2: 0, color: GROUND, width: 2 }
|
||||
].concat(crystalObjs(L1_CX, L1_CY, L1_CR), portalObjs(L1_PX, L1_PY, L1_PR), [
|
||||
hero(0, 0, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
|
||||
{ type: 'readout', label: 'v', expr: 'v', unit: 'м/с', precision: 1 },
|
||||
{ type: 'readout', label: 'θ', expr: 'theta', unit: '°', precision: 0 }
|
||||
],
|
||||
]),
|
||||
goal: {
|
||||
title: 'Попади в портал',
|
||||
hint: 'Квантик должен достичь портала. Бонус: собери кристалл по дороге.',
|
||||
// Победа: герой в радиусе портала.
|
||||
when: 'hypot(ball.x - ' + PORTAL_X + ', ball.y - ' + (PORTAL_Y + PORTAL_R) + ') < ' + PORTAL_R,
|
||||
// Мягкий проигрыш: улетел далеко за поле (промах) — можно перезапустить.
|
||||
hint: 'Достигни портала. Бонус: собери кристалл и уложись в норматив.',
|
||||
when: 'hypot(ball.x - ' + L1_PX + ', ball.y - ' + L1_PY + ') < ' + L1_PR,
|
||||
fail: 'ball.x > 11.5 || ball.y < -1.0',
|
||||
stars: [
|
||||
{ when: 'hypot(ball.x - ' + STAR_X + ', ball.y - ' + STAR_Y + ') < ' + STAR_R, label: 'Собрал кристалл' }
|
||||
{ when: 'hypot(ball.x - ' + L1_CX + ', ball.y - ' + L1_CY + ') < ' + L1_CR, label: 'Собрал кристалл' },
|
||||
{ when: 't*1000 <= 1500', label: 'Быстро (≤1.5 с)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var LEVELS = [artillery1];
|
||||
/* Уровень 2: «Перелёт через стену» — между стартом и порталом стоит высокая
|
||||
стена; нужно перебросить Квантика по дуге. Кристалл — на вершине дуги. */
|
||||
var L2_PX = 9.5, L2_PY = 0.7, L2_PR = 0.8;
|
||||
var L2_WALLX = 5, L2_WALLH = 3.6; // вертикальная стена-препятствие
|
||||
var L2_CX = 5, L2_CY = 4.4, L2_CR = 0.7; // кристалл над стеной
|
||||
var arc2 = {
|
||||
id: 'phys-arc-2',
|
||||
title: 'Перелёт через стену',
|
||||
chapter: 'kinematics',
|
||||
order: 2,
|
||||
unlockStars: 1,
|
||||
par_ms: 1800,
|
||||
subject: 'physics',
|
||||
hint: 'Стена преграждает прямой путь. Подбери крутую дугу — переброс Квантика через гребень в портал. Кристалл ждёт на вершине.',
|
||||
spec: {
|
||||
specVersion: 1,
|
||||
meta: { title: 'Перелёт через стену', desc: 'Дальность и высота броска: перебрось препятствие.' },
|
||||
viewport: { xmin: -1, xmax: 13, ymin: -1.2, ymax: 8, grid: true, axes: true, bg: BG },
|
||||
params: [
|
||||
{ name: 'theta', label: 'Угол', min: 20, max: 85, step: 1, value: 60, unit: '°' },
|
||||
{ name: 'v', label: 'Скорость', min: 6, max: 22, step: 0.5, value: 12, unit: 'м/с' }
|
||||
],
|
||||
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
|
||||
objects: [
|
||||
{ type: 'segment', x1: -1, y1: 0, x2: 13, y2: 0, color: GROUND, width: 2 },
|
||||
// стена-препятствие
|
||||
{ type: 'segment', x1: L2_WALLX, y1: 0, x2: L2_WALLX, y2: L2_WALLH, color: '#475569', width: 6 },
|
||||
{ type: 'label', x: L2_WALLX, y: L2_WALLH + 0.5, text: 'стена', color: '#94A3B8', size: 11 }
|
||||
].concat(crystalObjs(L2_CX, L2_CY, L2_CR), portalObjs(L2_PX, L2_PY, L2_PR), [
|
||||
hero(0, 0, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
|
||||
{ type: 'readout', label: 'v', expr: 'v', unit: 'м/с', precision: 1 },
|
||||
{ type: 'readout', label: 'θ', expr: 'theta', unit: '°', precision: 0 }
|
||||
]),
|
||||
goal: {
|
||||
title: 'Перебрось стену в портал',
|
||||
hint: 'Перелети стену и попади в портал. Бонус: задень кристалл на вершине.',
|
||||
when: 'hypot(ball.x - ' + L2_PX + ', ball.y - ' + L2_PY + ') < ' + L2_PR,
|
||||
// проигрыш: врезался в стену (рядом с ней и ниже её верха) либо улетел/упал за поле
|
||||
fail: '(abs(ball.x - ' + L2_WALLX + ') < 0.22 && ball.y < ' + L2_WALLH + ') || ball.x > 12.5 || ball.y < -1.0',
|
||||
stars: [
|
||||
{ when: 'hypot(ball.x - ' + L2_CX + ', ball.y - ' + L2_CY + ') < ' + L2_CR, label: 'Собрал кристалл' },
|
||||
{ when: 't*1000 <= 1800', label: 'Быстро (≤1.8 с)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* Уровень 3: «Отскок» — рабочая зона как упругий ящик (пол + правая стена).
|
||||
Прямого пути к высокому порталу слева-вверху нет: брось вправо, упругий
|
||||
отскок от правой стены и пола приводит Квантика по ломаной к порталу.
|
||||
Кристалл — у правой стены (на дуге до отскока). Тюнинг угла/скорости/упругости. */
|
||||
var L3_PX = 1.6, L3_PY = 4.6, L3_PR = 0.95; // портал слева-вверху
|
||||
var L3_CX = 8.4, L3_CY = 3.4, L3_CR = 0.85; // кристалл у правой стены
|
||||
var bounce3 = {
|
||||
id: 'phys-bounce-3',
|
||||
title: 'Отскок',
|
||||
chapter: 'kinematics',
|
||||
order: 3,
|
||||
unlockStars: 2,
|
||||
par_ms: 2800,
|
||||
subject: 'physics',
|
||||
hint: 'Портал слева-вверху, прямого пути нет. Брось вправо — упругая стена отразит Квантика обратно. Поиграй упругостью: чем жёстче отскок, тем выше дуга назад.',
|
||||
spec: {
|
||||
specVersion: 1,
|
||||
meta: { title: 'Отскок', desc: 'Упругое столкновение: отскок от стены.' },
|
||||
viewport: { xmin: -1, xmax: 11, ymin: -1.0, ymax: 8, grid: true, axes: true, bg: BG },
|
||||
params: [
|
||||
{ name: 'theta', label: 'Угол', min: 20, max: 75, step: 1, value: 50, unit: '°' },
|
||||
{ name: 'v', label: 'Скорость', min: 8, max: 24, step: 0.5, value: 16, unit: 'м/с' },
|
||||
{ name: 'el', label: 'Упругость', min: 0.55, max: 0.95, step: 0.01, value: 0.8 }
|
||||
],
|
||||
physics: {
|
||||
enabled: true,
|
||||
gravity: { x: 0, y: -9.8 },
|
||||
restitution: 'el',
|
||||
walls: [{ side: 'bottom' }, { side: 'right' }]
|
||||
},
|
||||
objects: [
|
||||
{ type: 'segment', x1: -1, y1: 0, x2: 11, y2: 0, color: GROUND, width: 3 },
|
||||
{ type: 'segment', x1: 11, y1: 0, x2: 11, y2: 8, color: '#475569', width: 4 },
|
||||
{ type: 'label', x: 10.2, y: 7.2, text: 'упр. стена', color: '#94A3B8', size: 11 }
|
||||
].concat(crystalObjs(L3_CX, L3_CY, L3_CR), portalObjs(L3_PX, L3_PY, L3_PR), [
|
||||
hero(0, 0.2, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
|
||||
{ type: 'readout', label: 'упр', expr: 'el', precision: 2 },
|
||||
{ type: 'readout', label: 'v', expr: 'v', unit: 'м/с', precision: 1 }
|
||||
]),
|
||||
goal: {
|
||||
title: 'Отскоком в портал',
|
||||
hint: 'Отрази Квантика от правой стены так, чтобы он вернулся в портал слева-вверху. Бонус: задень кристалл у стены.',
|
||||
when: 'hypot(ball.x - ' + L3_PX + ', ball.y - ' + L3_PY + ') < ' + L3_PR,
|
||||
fail: 't > 8',
|
||||
stars: [
|
||||
{ when: 'hypot(ball.x - ' + L3_CX + ', ball.y - ' + L3_CY + ') < ' + L3_CR, label: 'Собрал кристалл' },
|
||||
{ when: 't*1000 <= 2800', label: 'Быстро (≤2.8 с)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
Глава II — «Динамика» (созвездие): силы, пружины, орбиты.
|
||||
──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Уровень 4: «Маятник» — Квантик подвешен на пружине к якорю сверху; даём ему
|
||||
горизонтальный толчок. Пружина (закон Гука) тянет назад — он качается дугой.
|
||||
Подбери начальную скорость и жёсткость, чтобы нижняя точка дуги прошла через
|
||||
портал. Без гравитации вниз — чистая пружинная динамика к центру. */
|
||||
var L4_ANCHOR_X = 4, L4_ANCHOR_Y = 7.4;
|
||||
var L4_REST = 1.2; // короткая длина покоя -> пружина растянута -> сильный возврат
|
||||
var L4_START_Y = 2.6; // тело висит ниже якоря (растяжение ~3.6)
|
||||
var L4_PX = 7.4, L4_PY = 4.6, L4_PR = 1.0; // портал на правом плече дуги
|
||||
var L4_CX = 4.0, L4_CY = 6.0, L4_CR = 0.9; // кристалл у верхней точки качания (ближе к якорю)
|
||||
var pendulum4 = {
|
||||
id: 'phys-pendulum-4',
|
||||
title: 'Маятник',
|
||||
chapter: 'dynamics',
|
||||
order: 4,
|
||||
unlockStars: 4,
|
||||
par_ms: 3600,
|
||||
subject: 'physics',
|
||||
hint: 'Квантик висит на растянутой пружине у якоря. Толкни его вбок — закон Гука раскачает дугу вверх. Подбери толчок и жёсткость, чтобы плечо дуги достало портал.',
|
||||
spec: {
|
||||
specVersion: 1,
|
||||
meta: { title: 'Маятник на пружине', desc: 'Закон Гука: гармонические колебания.' },
|
||||
viewport: { xmin: -2, xmax: 11, ymin: -1, ymax: 9, grid: true, axes: true, bg: BG },
|
||||
params: [
|
||||
{ name: 'push', label: 'Толчок', min: 4, max: 18, step: 0.5, value: 10, unit: 'м/с' },
|
||||
{ name: 'k', label: 'Жёсткость', min: 8, max: 50, step: 1, value: 24 }
|
||||
],
|
||||
physics: {
|
||||
enabled: true,
|
||||
gravity: { x: 0, y: 0 }, // чисто пружинная динамика (анализ закона Гука)
|
||||
springs: [
|
||||
{ a: [L4_ANCHOR_X, L4_ANCHOR_Y], b: 'ball', k: 'k', length: L4_REST }
|
||||
]
|
||||
},
|
||||
objects: [
|
||||
// якорь
|
||||
{ type: 'circle', x: L4_ANCHOR_X, y: L4_ANCHOR_Y, r: 0.18, color: '#94A3B8', width: 0, fill: '#94A3B8' },
|
||||
{ type: 'label', x: L4_ANCHOR_X, y: L4_ANCHOR_Y + 0.6, text: 'якорь', color: '#94A3B8', size: 11 },
|
||||
// линия-пружина (визуальная связь якорь→тело)
|
||||
{ type: 'segment', x1: L4_ANCHOR_X, y1: L4_ANCHOR_Y, x2: 'ball.x', y2: 'ball.y', color: '#475569', width: 1, lineStyle: 'dashed' }
|
||||
].concat(crystalObjs(L4_CX, L4_CY, L4_CR), portalObjs(L4_PX, L4_PY, L4_PR), [
|
||||
// тело висит ниже якоря, горизонтальный толчок вправо
|
||||
hero(L4_ANCHOR_X, L4_START_Y, 'push', '0'),
|
||||
{ type: 'readout', label: 'толчок', expr: 'push', unit: 'м/с', precision: 1 },
|
||||
{ type: 'readout', label: 'k', expr: 'k', precision: 0 }
|
||||
]),
|
||||
goal: {
|
||||
title: 'Качни в портал',
|
||||
hint: 'Раскачай Квантика на пружине так, чтобы дуга прошла через портал. Бонус: задень кристалл у верхней точки.',
|
||||
when: 'hypot(ball.x - ' + L4_PX + ', ball.y - ' + L4_PY + ') < ' + L4_PR,
|
||||
fail: 't > 12',
|
||||
stars: [
|
||||
{ when: 'hypot(ball.x - ' + L4_CX + ', ball.y - ' + L4_CY + ') < ' + L4_CR, label: 'Собрал кристалл' },
|
||||
{ when: 't*1000 <= 3600', label: 'Быстро (≤3.6 с)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* Уровень 5: «Орбита» — центральная пружина-«гравитационный колодец» к центру
|
||||
(закон Гука к центру == гармонический осциллятор == эллиптические орбиты).
|
||||
Даём тангенциальную скорость; подбери её и силу колодца, чтобы орбита прошла
|
||||
через портал-кольцо на её пути. */
|
||||
var L5_CENTER_X = 4, L5_CENTER_Y = 3;
|
||||
var L5_START_X = 4, L5_START_Y = 6; // старт над центром (радиус 3)
|
||||
var L5_PX = 6.6, L5_PY = 3, L5_PR = 0.95; // портал на правом плече орбиты (внутри замкнутого витка)
|
||||
var L5_CX = 4, L5_CY = 0.1, L5_CR = 0.9; // кристалл в нижней точке орбиты
|
||||
var orbit5 = {
|
||||
id: 'phys-orbit-5',
|
||||
title: 'Орбита',
|
||||
chapter: 'dynamics',
|
||||
order: 5,
|
||||
unlockStars: 6,
|
||||
par_ms: 4200,
|
||||
subject: 'physics',
|
||||
hint: 'Колодец притягивает Квантика к центру (закон Гука). Дай ему боковой разгон — он выйдет на орбиту. Подбери скорость и силу колодца, чтобы виток прошёл сквозь портал.',
|
||||
spec: {
|
||||
specVersion: 1,
|
||||
meta: { title: 'Орбита', desc: 'Центральная сила: замкнутая орбита через цель.' },
|
||||
viewport: { xmin: -2, xmax: 11, ymin: -3, ymax: 9, grid: true, axes: true, bg: BG },
|
||||
params: [
|
||||
{ name: 'vt', label: 'Боковой разгон', min: 2, max: 14, step: 0.25, value: 6, unit: 'м/с' },
|
||||
{ name: 'g', label: 'Сила колодца', min: 4, max: 30, step: 0.5, value: 12 }
|
||||
],
|
||||
physics: {
|
||||
enabled: true,
|
||||
gravity: { x: 0, y: 0 },
|
||||
// пружина к центру с нулевой длиной покоя == центральная гармоническая сила F=-k·r
|
||||
springs: [
|
||||
{ a: [L5_CENTER_X, L5_CENTER_Y], b: 'ball', k: 'g', length: 0 }
|
||||
]
|
||||
},
|
||||
objects: [
|
||||
// центр-колодец
|
||||
{ type: 'circle', x: L5_CENTER_X, y: L5_CENTER_Y, r: 0.3, color: '#F59E0B', width: 0, fill: '#F59E0B', glow: true, glowColor: '#F59E0B' },
|
||||
{ type: 'label', x: L5_CENTER_X, y: L5_CENTER_Y - 0.9, text: 'колодец', color: '#F59E0B', size: 11 }
|
||||
].concat(crystalObjs(L5_CX, L5_CY, L5_CR), portalObjs(L5_PX, L5_PY, L5_PR), [
|
||||
// старт над центром, скорость вправо (vt) -> орбита по часовой
|
||||
hero(L5_START_X, L5_START_Y, 'vt', '0'),
|
||||
{ type: 'readout', label: 'разгон', expr: 'vt', unit: 'м/с', precision: 2 },
|
||||
{ type: 'readout', label: 'сила', expr: 'g', precision: 1 }
|
||||
]),
|
||||
goal: {
|
||||
title: 'Выйди на орбиту через портал',
|
||||
hint: 'Орбита Квантика должна пройти через портал-кольцо. Бонус: задень кристалл в дальней точке.',
|
||||
when: 'hypot(ball.x - ' + L5_PX + ', ball.y - ' + L5_PY + ') < ' + L5_PR,
|
||||
fail: 't > 14',
|
||||
stars: [
|
||||
{ when: 'hypot(ball.x - ' + L5_CX + ', ball.y - ' + L5_CY + ') < ' + L5_CR, label: 'Собрал кристалл' },
|
||||
{ when: 't*1000 <= 4200', label: 'Быстро (≤4.2 с)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* Уровень 6: «Гравитационный манёвр» — капстоун главы. Гравитация тянет вниз,
|
||||
но в центре поля — притягивающий колодец (пружина к центру), искривляющий путь.
|
||||
Брось Квантика так, чтобы колодец завернул его дугу в портал в дальнем верхнем
|
||||
углу. Комбинируем бросок под углом, гравитацию и центральную силу. */
|
||||
var L6_WELL_X = 5, L6_WELL_Y = 3;
|
||||
var L6_PX = 9.4, L6_PY = 5.6, L6_PR = 1.0; // портал в дальнем верхнем углу
|
||||
var L6_CX = 5, L6_CY = 4.3, L6_CR = 0.85; // кристалл над колодцем (на восходящей дуге)
|
||||
var slingshot6 = {
|
||||
id: 'phys-slingshot-6',
|
||||
title: 'Гравиманёвр',
|
||||
chapter: 'dynamics',
|
||||
order: 6,
|
||||
unlockStars: 8,
|
||||
par_ms: 3400,
|
||||
subject: 'physics',
|
||||
hint: 'Гравитация тянет вниз, а колодец в центре притягивает к себе. Брось Квантика так, чтобы колодец завернул его дугу в портал в дальнем верхнем углу. Подбери угол, скорость и силу колодца.',
|
||||
spec: {
|
||||
specVersion: 1,
|
||||
meta: { title: 'Гравитационный манёвр', desc: 'Гравитация + центральная сила: манёвр у колодца.' },
|
||||
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8, grid: true, axes: true, bg: BG },
|
||||
params: [
|
||||
{ name: 'theta', label: 'Угол', min: 20, max: 80, step: 1, value: 55, unit: '°' },
|
||||
{ name: 'v', label: 'Скорость', min: 8, max: 22, step: 0.5, value: 14, unit: 'м/с' },
|
||||
{ name: 'g', label: 'Сила колодца', min: 0, max: 16, step: 0.5, value: 6 }
|
||||
],
|
||||
physics: {
|
||||
enabled: true,
|
||||
gravity: { x: 0, y: -9.8 },
|
||||
springs: [
|
||||
{ a: [L6_WELL_X, L6_WELL_Y], b: 'ball', k: 'g', length: 0 }
|
||||
]
|
||||
},
|
||||
objects: [
|
||||
{ type: 'segment', x1: -1, y1: 0, x2: 11, y2: 0, color: GROUND, width: 3 },
|
||||
// колодец-масса
|
||||
{ type: 'circle', x: L6_WELL_X, y: L6_WELL_Y, r: 0.3, color: '#F59E0B', width: 0, fill: '#F59E0B', glow: true, glowColor: '#F59E0B' },
|
||||
{ type: 'label', x: L6_WELL_X, y: L6_WELL_Y - 0.9, text: 'колодец', color: '#F59E0B', size: 11 }
|
||||
].concat(crystalObjs(L6_CX, L6_CY, L6_CR), portalObjs(L6_PX, L6_PY, L6_PR), [
|
||||
hero(0, 0.2, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
|
||||
{ type: 'readout', label: 'сила', expr: 'g', precision: 1 },
|
||||
{ type: 'readout', label: 'θ', expr: 'theta', unit: '°', precision: 0 }
|
||||
]),
|
||||
goal: {
|
||||
title: 'Манёвром в портал',
|
||||
hint: 'Используй колодец, чтобы завернуть дугу Квантика в дальний верхний портал. Бонус: задень кристалл на восходящей дуге.',
|
||||
when: 'hypot(ball.x - ' + L6_PX + ', ball.y - ' + L6_PY + ') < ' + L6_PR,
|
||||
fail: 'ball.y < -1 || t > 10',
|
||||
stars: [
|
||||
{ when: 'hypot(ball.x - ' + L6_CX + ', ball.y - ' + L6_CY + ') < ' + L6_CR, label: 'Собрал кристалл' },
|
||||
{ when: 't*1000 <= 3400', label: 'Быстро (≤3.4 с)' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var LEVELS = [artillery1, arc2, bounce3, pendulum4, orbit5, slingshot6];
|
||||
|
||||
/* Метаданные глав (созвездий) — для заголовков/оформления карты. */
|
||||
var CHAPTERS = {
|
||||
kinematics: { key: 'kinematics', title: 'Кинематика', subtitle: 'Полёт и гравитация', accent: '#22D3EE' },
|
||||
dynamics: { key: 'dynamics', title: 'Динамика', subtitle: 'Силы, пружины, орбиты', accent: '#A78BFA' }
|
||||
};
|
||||
|
||||
function list() { return LEVELS.slice(); }
|
||||
function get(id) {
|
||||
for (var i = 0; i < LEVELS.length; i++) if (LEVELS[i].id === id) return LEVELS[i];
|
||||
return null;
|
||||
}
|
||||
function chapter(key) { return CHAPTERS[key] || { key: key, title: key, subtitle: '', accent: '#22D3EE' }; }
|
||||
|
||||
global.QuantikLevels = { list: list, get: get, LEVELS: LEVELS };
|
||||
global.QuantikLevels = {
|
||||
list: list, get: get, chapter: chapter,
|
||||
LEVELS: LEVELS, CHAPTERS: CHAPTERS
|
||||
};
|
||||
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
'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)';
|
||||
return '<svg class="ic" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" 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, '&').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);
|
||||
@@ -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);
|
||||
@@ -1,19 +1,26 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Квантик — Законы Мира · логика игровой страницы (Фаза 1, MVP).
|
||||
Квантик — Законы Мира · логика игрового уровня (Фаза 2).
|
||||
|
||||
Монтирует уровень-спеку через SimEngine.mount (тот же движок, что lab.html
|
||||
и sim-builder.html). «Игровой режим» включается САМ наличием блока goal в
|
||||
спеке (Фаза 0: HUD с целью/звёздами появляется автоматически). Управление —
|
||||
собственные слайдеры params движка + кнопки Запуск/Сброс. На победу
|
||||
(inst.onGoal) шлём результат на сервер и показываем экран успеха.
|
||||
спеке (Фаза 0). На победу (inst.onGoal) шлём результат на сервер и показываем
|
||||
экран успеха с нарратором-Квантиком; реакция нарратора зависит от числа звёзд.
|
||||
|
||||
window.QuantikGame.start({ host, level }) -> инстанс движка (или null).
|
||||
Фаза 2:
|
||||
- Скин Квантика (colorKey из палитр PetSprite, localStorage 'quantik-skin')
|
||||
тинтует glow-точку героя в уровне и нарратора.
|
||||
- Экран успеха активирует «Дальше» (переход к следующему уровню) через колбэк.
|
||||
- Интро-карточка с нарратором перед стартом уровня.
|
||||
|
||||
window.QuantikGame.start({ host, level, skin?, onNext?, onMap?, hasNext?, resolveNext? }) -> инстанс.
|
||||
⛔ Без eval/Function. Уровни — данные из window.QuantikLevels.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
|
||||
var doc = global.document;
|
||||
var SKIN_KEY = 'quantik-skin';
|
||||
var DEFAULT_SKIN = 'cyan';
|
||||
|
||||
function el(tag, cls, html) {
|
||||
var n = doc.createElement(tag);
|
||||
@@ -22,7 +29,42 @@
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Inline SVG звезды (заполненная / контур) — без эмодзи (правило проекта). */
|
||||
/* ── Скин ──────────────────────────────────────────────────────────────── */
|
||||
function getSkin() {
|
||||
try {
|
||||
var v = global.localStorage && global.localStorage.getItem(SKIN_KEY);
|
||||
if (v && global.PetSprite && global.PetSprite.PALETTES && global.PetSprite.PALETTES[v]) return v;
|
||||
} catch (_e) {}
|
||||
return DEFAULT_SKIN;
|
||||
}
|
||||
function setSkin(key) {
|
||||
try { if (global.localStorage) global.localStorage.setItem(SKIN_KEY, key); } catch (_e) {}
|
||||
}
|
||||
function skinColor(key) {
|
||||
var pal = (global.PetSprite && global.PetSprite.PALETTES) || {};
|
||||
return pal[key || getSkin()] || '#06D6E0';
|
||||
}
|
||||
|
||||
/* Тинтуем героя уровня (объект с id 'ball') цветом скина — БЕЗ исполнения,
|
||||
просто переписываем цветовые поля спеки-копии перед монтированием. */
|
||||
function tintHeroSpec(spec, skinKey) {
|
||||
var color = skinColor(skinKey);
|
||||
// глубокая копия (спека — данные, без функций) чтобы не мутировать реестр
|
||||
var copy = JSON.parse(JSON.stringify(spec));
|
||||
if (Array.isArray(copy.objects)) {
|
||||
for (var i = 0; i < copy.objects.length; i++) {
|
||||
var o = copy.objects[i];
|
||||
if (o && o.id === 'ball') {
|
||||
o.color = color;
|
||||
if (o.glow) o.glowColor = color;
|
||||
if (o.trail) o.trailColor = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/* ── Inline SVG звезды ── */
|
||||
function starSvg(filled) {
|
||||
var fill = filled ? '#FBBF24' : 'none';
|
||||
var stroke = filled ? '#FBBF24' : '#64748B';
|
||||
@@ -33,25 +75,64 @@
|
||||
|
||||
function fmtTime(ms) {
|
||||
if (!ms && ms !== 0) return '—';
|
||||
var s = ms / 1000;
|
||||
return s.toFixed(2) + ' с';
|
||||
return (ms / 1000).toFixed(2) + ' с';
|
||||
}
|
||||
|
||||
/* ── Экран успеха (DOM-оверлей страницы, поверх сцены) ─────────────────── */
|
||||
function buildSuccessOverlay(state) {
|
||||
function petSvg(mood, skinKey) {
|
||||
if (!global.PetSprite) return '';
|
||||
return global.PetSprite.render(4, mood, [], skinKey || getSkin(), 0, 'none');
|
||||
}
|
||||
|
||||
/* ── Интро-карточка уровня (нарратор «почини закон…») ───────────────────── */
|
||||
function buildIntro(level, skinKey) {
|
||||
var overlay = el('div', 'qg-overlay qg-intro');
|
||||
var card = el('div', 'qg-card qg-card-intro');
|
||||
|
||||
var pet = el('div', 'qg-intro-pet', petSvg('happy', skinKey));
|
||||
card.appendChild(pet);
|
||||
|
||||
card.appendChild(el('div', 'qg-card-kicker', 'Почини закон'));
|
||||
card.appendChild(el('div', 'qg-card-title', escapeText(level.title)));
|
||||
var goalT = (level.spec && level.spec.goal && level.spec.goal.title) || '';
|
||||
if (goalT) card.appendChild(el('div', 'qg-intro-goal', escapeText(goalT)));
|
||||
if (level.hint) card.appendChild(el('div', 'qg-intro-hint', escapeText(level.hint)));
|
||||
|
||||
var actions = el('div', 'qg-actions');
|
||||
var btnGo = el('button', 'btn-primary qg-btn', 'Начать');
|
||||
btnGo.type = 'button';
|
||||
var btnBack = el('button', 'btn-ghost qg-btn', 'К карте');
|
||||
btnBack.type = 'button';
|
||||
actions.appendChild(btnGo);
|
||||
actions.appendChild(btnBack);
|
||||
card.appendChild(actions);
|
||||
|
||||
overlay.appendChild(card);
|
||||
return { overlay: overlay, btnGo: btnGo, btnBack: btnBack };
|
||||
}
|
||||
|
||||
/* ── Экран успеха ───────────────────────────────────────────────────────── */
|
||||
function buildSuccessOverlay(state, ctx) {
|
||||
ctx = ctx || {};
|
||||
var got = (state && state.stars && state.stars.got) || 0;
|
||||
var total = (state && state.stars && state.stars.total) || 0;
|
||||
|
||||
var overlay = el('div', 'qg-overlay');
|
||||
var card = el('div', 'qg-card');
|
||||
|
||||
// нарратор: все звёзды (>=2) -> ecstatic, иначе happy
|
||||
var mood = (total > 0 && got >= total && total >= 2) ? 'ecstatic' : (got >= 1 ? 'happy' : 'neutral');
|
||||
if (global.PetSprite) {
|
||||
var pet = el('div', 'qg-success-pet', petSvg(mood, ctx.skin));
|
||||
card.appendChild(pet);
|
||||
}
|
||||
|
||||
card.appendChild(el('div', 'qg-card-title', 'Уровень пройден!'));
|
||||
|
||||
// звёзды: total «слотов», got заполнено
|
||||
var starsBox = el('div', 'qg-stars');
|
||||
var slots = Math.max(total, got, 1);
|
||||
for (var i = 0; i < slots; i++) {
|
||||
var w = el('span', 'qg-star');
|
||||
var w = el('span', 'qg-star' + (i < got ? ' qg-star-on' : ''));
|
||||
w.style.setProperty('--si', i);
|
||||
w.innerHTML = starSvg(i < got);
|
||||
starsBox.appendChild(w);
|
||||
}
|
||||
@@ -67,12 +148,10 @@
|
||||
card.appendChild(stats);
|
||||
|
||||
var actions = el('div', 'qg-actions');
|
||||
var btnAgain = el('button', 'btn-primary qg-btn', 'Ещё раз');
|
||||
var btnAgain = el('button', 'btn-ghost qg-btn', 'Ещё раз');
|
||||
btnAgain.type = 'button';
|
||||
var btnNext = el('button', 'btn-ghost qg-btn', 'Дальше');
|
||||
var btnNext = el('button', 'btn-primary qg-btn', ctx.hasNext ? 'Дальше' : 'К карте');
|
||||
btnNext.type = 'button';
|
||||
btnNext.disabled = true; // MVP: следующий уровень появится в Фазе 2
|
||||
btnNext.title = 'Скоро: больше уровней';
|
||||
actions.appendChild(btnAgain);
|
||||
actions.appendChild(btnNext);
|
||||
card.appendChild(actions);
|
||||
@@ -81,8 +160,15 @@
|
||||
return { overlay: overlay, btnAgain: btnAgain, btnNext: btnNext };
|
||||
}
|
||||
|
||||
function escapeText(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/* ── Старт уровня ───────────────────────────────────────────────────────
|
||||
host — DOM-контейнер сцены. level — запись из QuantikLevels (с .spec/.id). */
|
||||
opts: { host, level, skin?, onNext?(level), onMap?(), hasNext?, resolveNext? }
|
||||
resolveNext?() -> Promise<{ hasNext, next }>: пересчитать следующий уровень
|
||||
ПОСЛЕ перезагрузки прогресса (победа разблокирует след. уровень). Если не
|
||||
задан / упал — откатываемся к pre-win opts.hasNext (ровно прежнее поведение). */
|
||||
function start(opts) {
|
||||
opts = opts || {};
|
||||
var host = opts.host;
|
||||
@@ -90,7 +176,9 @@
|
||||
if (!host || !level || !level.spec) return null;
|
||||
if (!global.SimEngine || !global.SimExpr) return null;
|
||||
|
||||
var inst = global.SimEngine.mount(host, level.spec);
|
||||
var skin = opts.skin || getSkin();
|
||||
var spec = tintHeroSpec(level.spec, skin);
|
||||
var inst = global.SimEngine.mount(host, spec);
|
||||
|
||||
var overlayRef = null;
|
||||
function clearOverlay() {
|
||||
@@ -100,34 +188,72 @@
|
||||
overlayRef = null;
|
||||
}
|
||||
|
||||
function showSuccess(state) {
|
||||
// submitDone — promise сабмита прогресса (или null, если сабмита нет).
|
||||
// Экран успеха показываем СРАЗУ (без ожидания сети) с pre-win hasNext, затем
|
||||
// ОБНОВЛЯЕМ кнопку «Дальше/К карте», когда пересчёт после победы (resolveNext)
|
||||
// увидит свежеразблокированный уровень. Это чинит «мёртвую Дальше» на первом
|
||||
// прохождении (0 звёзд → доступен только L1 → pre-win nextPlayable == null).
|
||||
function showSuccess(state, submitDone) {
|
||||
clearOverlay();
|
||||
overlayRef = buildSuccessOverlay(state);
|
||||
// Текущее решение кнопки. Замыкания ниже читают его «живьём» (мутируем var),
|
||||
// поэтому если игрок успеет нажать раньше пересчёта — отработает фолбэк,
|
||||
// а после пересчёта та же кнопка уже ведёт «Дальше».
|
||||
var canNext = typeof opts.onNext === 'function' && !!opts.hasNext;
|
||||
overlayRef = buildSuccessOverlay(state, { skin: skin, hasNext: canNext });
|
||||
overlayRef.btnAgain.addEventListener('click', function () {
|
||||
clearOverlay();
|
||||
try { inst.reset(); } catch (_e) {}
|
||||
});
|
||||
// Дальше — заглушка для MVP (нет следующего уровня).
|
||||
overlayRef.btnNext.addEventListener('click', function () {
|
||||
clearOverlay();
|
||||
if (canNext) opts.onNext(level);
|
||||
else if (typeof opts.onMap === 'function') opts.onMap();
|
||||
});
|
||||
host.appendChild(overlayRef.overlay);
|
||||
|
||||
if (typeof opts.resolveNext !== 'function') return;
|
||||
var btn = overlayRef.btnNext;
|
||||
// Пересчёт идёт ПОСЛЕ сабмита: победа сначала сохраняется на сервере, и только
|
||||
// затем перезагрузка прогресса увидит разблокированный уровень.
|
||||
Promise.resolve(submitDone)
|
||||
.catch(function () {}) // сабмит best-effort: даже при ошибке пробуем пересчёт
|
||||
.then(function () { return opts.resolveNext(); })
|
||||
.then(function (r) {
|
||||
// overlayRef мог смениться/закрыться, пока шла сеть — обновляем только «свою» кнопку.
|
||||
if (!r || !overlayRef || overlayRef.btnNext !== btn) return;
|
||||
var next = typeof opts.onNext === 'function' && !!r.hasNext;
|
||||
if (next === canNext) return; // ничего не изменилось
|
||||
canNext = next;
|
||||
btn.textContent = next ? 'Дальше' : 'К карте';
|
||||
})
|
||||
.catch(function () {}); // пересчёт упал → остаёмся на pre-win решении
|
||||
}
|
||||
|
||||
inst.onGoal(function (res) {
|
||||
if (!res || !res.won) return;
|
||||
var got = (res.stars && res.stars.got) || 0;
|
||||
// Время победы — мировое t из движка (Ф0): res.timeMs.
|
||||
var payload = { time_ms: res.timeMs, stars: got };
|
||||
// Submit best-effort: экран успеха показываем независимо от сети.
|
||||
var submitDone = null;
|
||||
try {
|
||||
if (global.LS && global.LS.gameProgressSubmit) {
|
||||
global.LS.gameProgressSubmit(level.id, payload).catch(function () { /* офлайн — ок */ });
|
||||
submitDone = global.LS.gameProgressSubmit(level.id, payload);
|
||||
if (submitDone && typeof submitDone.catch === 'function') submitDone.catch(function () {});
|
||||
}
|
||||
} catch (_e) { /* нет клиента — всё равно показываем успех */ }
|
||||
showSuccess(res);
|
||||
} catch (_e) {}
|
||||
showSuccess(res, submitDone);
|
||||
});
|
||||
|
||||
return inst;
|
||||
}
|
||||
|
||||
global.QuantikGame = { start: start, buildSuccessOverlay: buildSuccessOverlay };
|
||||
global.QuantikGame = {
|
||||
start: start,
|
||||
buildSuccessOverlay: buildSuccessOverlay,
|
||||
buildIntro: buildIntro,
|
||||
getSkin: getSkin,
|
||||
setSkin: setSkin,
|
||||
skinColor: skinColor,
|
||||
SKIN_KEY: SKIN_KEY
|
||||
};
|
||||
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
|
||||
+353
-41
@@ -10,46 +10,222 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
/* ── Раскладка игровой страницы ── */
|
||||
/* ════════════════════ Игровая страница «Квантик» ════════════════════ */
|
||||
.qg-wrap { display: flex; flex-direction: column; height: 100vh; min-height: 0; }
|
||||
|
||||
/* ── Topbar ── */
|
||||
.qg-top {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
padding: 11px 20px; border-bottom: 1px solid rgba(148,163,184,0.18);
|
||||
background: #11132A; flex-shrink: 0; position: relative; z-index: 3;
|
||||
}
|
||||
.qg-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.05rem; color: var(--text); white-space: nowrap; }
|
||||
.qg-sub { font-size: .8rem; color: var(--text-3); flex: 1; min-width: 0; }
|
||||
.qg-pill { font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; padding: 3px 10px; border-radius: 99px; background: rgba(34,211,238,0.14); color: #0e7c8a; }
|
||||
.qg-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.02rem; color: #E2E8F0; white-space: nowrap; }
|
||||
.qg-sub { font-size: .8rem; color: #94A3B8; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.qg-pill { font-size: .66rem; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; padding: 3px 10px; border-radius: 99px; background: rgba(34,211,238,0.16); color: #67E8F9; }
|
||||
.qg-back {
|
||||
display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .82rem; font-weight: 600;
|
||||
color: #CBD5E1; background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.2);
|
||||
border-radius: 99px; padding: 5px 13px; cursor: pointer; transition: .16s;
|
||||
}
|
||||
.qg-back:hover { background: rgba(34,211,238,0.16); color: #67E8F9; border-color: rgba(34,211,238,0.35); }
|
||||
.qg-back .ic { width: 15px; height: 15px; }
|
||||
|
||||
/* сцена: на всю площадь, тёмный фон (full-bleed root движка растягивается inset:0) */
|
||||
/* ════════════════════ Карта-созвездие ════════════════════ */
|
||||
.qm-root {
|
||||
flex: 1; min-height: 0; position: relative; overflow-y: auto; overflow-x: hidden;
|
||||
background:
|
||||
radial-gradient(1100px 700px at 78% -10%, rgba(167,139,250,0.16), transparent 60%),
|
||||
radial-gradient(900px 600px at 12% 8%, rgba(34,211,238,0.13), transparent 55%),
|
||||
radial-gradient(700px 800px at 50% 120%, rgba(244,114,182,0.10), transparent 60%),
|
||||
linear-gradient(180deg, #0B0B1A 0%, #0D0D1F 55%, #0A0A16 100%);
|
||||
}
|
||||
/* атмосферное зерно поверх фона */
|
||||
.qm-root::before {
|
||||
content: ''; position: absolute; inset: 0; pointer-events: none; opacity: .5;
|
||||
background-image: radial-gradient(rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||
background-size: 3px 3px;
|
||||
}
|
||||
|
||||
/* ── Шапка карты (нарратор + XP + скины) ── */
|
||||
.qm-header { position: relative; z-index: 2; padding: 22px 26px 8px; }
|
||||
.qm-header-inner {
|
||||
display: grid; grid-template-columns: minmax(240px, 1.4fr) minmax(280px, 1.1fr) auto;
|
||||
gap: 18px; align-items: center; max-width: 1180px; margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 920px) { .qm-header-inner { grid-template-columns: 1fr; } }
|
||||
|
||||
.qm-narrator { display: flex; align-items: center; gap: 14px; }
|
||||
.qm-pet { width: 76px; height: 80px; flex-shrink: 0; filter: drop-shadow(0 8px 22px rgba(34,211,238,0.28)); }
|
||||
.qm-pet svg { width: 100%; height: 100%; }
|
||||
.qm-bubble {
|
||||
position: relative; background: rgba(20,22,44,0.78); border: 1px solid rgba(148,163,184,0.18);
|
||||
border-radius: 14px; padding: 12px 15px; backdrop-filter: blur(6px); box-shadow: 0 10px 30px rgba(0,0,0,0.35);
|
||||
}
|
||||
.qm-bubble::before {
|
||||
content: ''; position: absolute; left: -7px; top: 50%; transform: translateY(-50%) rotate(45deg);
|
||||
width: 12px; height: 12px; background: rgba(20,22,44,0.78); border-left: 1px solid rgba(148,163,184,0.18); border-bottom: 1px solid rgba(148,163,184,0.18);
|
||||
}
|
||||
.qm-bubble-t { color: #DCE3EE; font-size: .86rem; line-height: 1.45; }
|
||||
|
||||
.qm-stats { display: flex; align-items: center; gap: 18px; }
|
||||
.qm-level { display: flex; flex-direction: column; align-items: center; line-height: 1; flex-shrink: 0; }
|
||||
.qm-level-num {
|
||||
font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 2rem;
|
||||
background: linear-gradient(135deg, #67E8F9, #A78BFA); -webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.qm-level-lbl { font-size: .62rem; text-transform: uppercase; letter-spacing: .06em; color: #8B9AAE; margin-top: 4px; max-width: 84px; text-align: center; }
|
||||
.qm-xpbox { flex: 1; min-width: 160px; }
|
||||
.qm-xp-head { display: flex; justify-content: space-between; font-size: .74rem; color: #CBD5E1; margin-bottom: 6px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.qm-xp-next { color: #8B9AAE; font-weight: 500; }
|
||||
.qm-xp-bar { height: 9px; border-radius: 99px; background: rgba(148,163,184,0.18); overflow: hidden; }
|
||||
.qm-xp-fill {
|
||||
height: 100%; border-radius: 99px; width: 0;
|
||||
background: linear-gradient(90deg, #22D3EE, #A78BFA);
|
||||
box-shadow: 0 0 14px rgba(103,232,249,0.5);
|
||||
transition: width .9s cubic-bezier(.22,.61,.36,1);
|
||||
}
|
||||
.qm-starcount {
|
||||
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
|
||||
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1rem; color: #FBBF24; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Скины ── */
|
||||
.qm-skins { display: flex; flex-direction: column; gap: 7px; }
|
||||
.qm-skins-lbl { font-size: .62rem; text-transform: uppercase; letter-spacing: .06em; color: #8B9AAE; font-weight: 700; }
|
||||
.qm-skins-row { display: flex; gap: 7px; flex-wrap: wrap; max-width: 220px; }
|
||||
.qm-skin {
|
||||
width: 30px; height: 30px; border-radius: 50%; cursor: pointer; padding: 0;
|
||||
background: radial-gradient(circle at 34% 30%, color-mix(in srgb, var(--sk) 70%, #fff), var(--sk) 70%);
|
||||
border: 2px solid rgba(255,255,255,0.18); position: relative; transition: transform .14s, box-shadow .14s, border-color .14s;
|
||||
}
|
||||
.qm-skin:hover:not(.locked) { transform: scale(1.12); }
|
||||
.qm-skin.active { border-color: #fff; box-shadow: 0 0 0 2px var(--sk), 0 0 14px var(--sk); }
|
||||
.qm-skin.locked { filter: grayscale(.7) brightness(.5); cursor: not-allowed; }
|
||||
.qm-skin-lock { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.qm-skin-lock .ic { width: 12px; height: 12px; }
|
||||
|
||||
/* ── Тело: созвездия ── */
|
||||
.qm-body { position: relative; z-index: 1; max-width: 1180px; margin: 0 auto; padding: 8px 26px 60px; }
|
||||
.qm-constellation { margin-top: 18px; }
|
||||
.qm-con-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; padding-left: 4px; }
|
||||
.qm-con-title {
|
||||
font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.1rem; color: #E8EDF5;
|
||||
position: relative; padding-left: 16px;
|
||||
}
|
||||
.qm-con-title::before {
|
||||
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent);
|
||||
}
|
||||
.qm-con-sub { font-size: .8rem; color: #8B9AAE; flex: 1; }
|
||||
.qm-con-stars { display: inline-flex; align-items: center; gap: 4px; font-size: .78rem; color: #FBBF24; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.qm-field { position: relative; height: 220px; margin-top: 2px; }
|
||||
@media (max-width: 620px) { .qm-field { height: 280px; } }
|
||||
.qm-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.qm-link { stroke: rgba(148,163,184,0.22); stroke-width: .35; stroke-dasharray: 1.4 1.6; }
|
||||
.qm-link.on { stroke: var(--accent); stroke-opacity: .55; stroke-dasharray: none; stroke-width: .45; }
|
||||
.qm-tw { animation: qmTwinkle var(--tw, 2.4s) ease-in-out var(--td, 0s) infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes qmTwinkle { 0%,100% { opacity: .25; } 50% { opacity: .9; } }
|
||||
|
||||
/* ── Узлы ── */
|
||||
.qm-node {
|
||||
position: absolute; transform: translate(-50%, -50%); z-index: 2;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
background: none; border: none; cursor: pointer; font: inherit; padding: 0;
|
||||
transition: transform .2s;
|
||||
}
|
||||
.qm-node-core {
|
||||
width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
position: relative; transition: transform .18s, box-shadow .18s;
|
||||
}
|
||||
.qm-node-core .ic { color: #fff; }
|
||||
.qm-node-label { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: .76rem; color: #CBD5E1; white-space: nowrap; text-shadow: 0 1px 4px rgba(0,0,0,.6); }
|
||||
.qm-node-stars { display: inline-flex; gap: 1px; }
|
||||
.qm-node-need { display: inline-flex; align-items: center; gap: 3px; font-size: .68rem; color: #94A3B8; font-weight: 600; }
|
||||
|
||||
/* доступный узел */
|
||||
.qm-available .qm-node-core {
|
||||
background: radial-gradient(circle at 35% 30%, #34D399, #0E9F6E);
|
||||
box-shadow: 0 0 0 4px rgba(52,211,153,0.18), 0 8px 24px rgba(16,185,129,0.4);
|
||||
animation: qmPulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes qmPulse {
|
||||
0%,100% { box-shadow: 0 0 0 4px rgba(52,211,153,0.18), 0 8px 24px rgba(16,185,129,0.4); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(52,211,153,0.10), 0 10px 32px rgba(16,185,129,0.55); }
|
||||
}
|
||||
.qm-available:hover { transform: translate(-50%, -50%) scale(1.07); }
|
||||
.qm-available:hover .qm-node-core { transform: scale(1.05); }
|
||||
|
||||
/* пройденный узел */
|
||||
.qm-completed .qm-node-core {
|
||||
background: radial-gradient(circle at 35% 30%, #67E8F9, #2563EB);
|
||||
box-shadow: 0 0 0 3px rgba(34,211,238,0.2), 0 6px 20px rgba(37,99,235,0.4);
|
||||
}
|
||||
.qm-completed:hover { transform: translate(-50%, -50%) scale(1.06); }
|
||||
.qm-node-order { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.15rem; color: #fff; }
|
||||
|
||||
/* заблокированный узел */
|
||||
.qm-locked { cursor: not-allowed; }
|
||||
.qm-locked .qm-node-core {
|
||||
background: rgba(30,33,58,0.85); border: 1.5px solid rgba(148,163,184,0.22);
|
||||
box-shadow: inset 0 2px 10px rgba(0,0,0,0.4);
|
||||
}
|
||||
.qm-locked .qm-node-label { color: #6B7A90; }
|
||||
|
||||
/* фокус для клавиатуры */
|
||||
.qm-node:focus-visible { outline: none; }
|
||||
.qm-node:focus-visible .qm-node-core { box-shadow: 0 0 0 3px #fff, 0 0 0 6px var(--accent, #22D3EE); }
|
||||
|
||||
/* поэтапное появление */
|
||||
.qm-node.qm-pre { opacity: 0; transform: translate(-50%, -40%) scale(.6); }
|
||||
.qm-node.qm-in { animation: qmNodeIn .5s cubic-bezier(.22,1.2,.4,1) forwards; }
|
||||
@keyframes qmNodeIn {
|
||||
from { opacity: 0; transform: translate(-50%, -40%) scale(.6); }
|
||||
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
}
|
||||
|
||||
/* ════════════════════ Сцена уровня ════════════════════ */
|
||||
.qg-stage { flex: 1; min-height: 0; position: relative; background: #0D0D1A; overflow: hidden; }
|
||||
.qg-stage .sim-spec-root { position: absolute; inset: 0; }
|
||||
|
||||
.qg-fallback { padding: 40px; color: #cbd5e1; font-family: 'Manrope', sans-serif; max-width: 520px; }
|
||||
|
||||
/* ── Экран успеха (оверлей) ── */
|
||||
.qg-view { display: none; flex: 1; min-height: 0; }
|
||||
.qg-view.show { display: flex; flex-direction: column; }
|
||||
|
||||
/* ── Оверлеи (интро / успех) ── */
|
||||
.qg-overlay {
|
||||
position: absolute; inset: 0; z-index: 20;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(7, 7, 18, 0.72); backdrop-filter: blur(4px);
|
||||
background: rgba(7, 7, 18, 0.74); backdrop-filter: blur(5px);
|
||||
}
|
||||
.qg-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 18px;
|
||||
padding: 28px 30px; width: min(420px, 90vw); text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
|
||||
animation: qg-pop .22s ease;
|
||||
background: linear-gradient(180deg, #15173099, #0F1024EE), #14152C;
|
||||
border: 1px solid rgba(148,163,184,0.18); border-radius: 20px;
|
||||
padding: 26px 30px 24px; width: min(440px, 92vw); text-align: center;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,0.55);
|
||||
animation: qg-pop .26s cubic-bezier(.22,1.1,.4,1);
|
||||
}
|
||||
@keyframes qg-pop { from { transform: scale(.92); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
.qg-card-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.3rem; color: var(--text); margin-bottom: 14px; }
|
||||
@keyframes qg-pop { from { transform: scale(.9) translateY(8px); opacity: 0; } to { transform: scale(1) translateY(0); opacity: 1; } }
|
||||
.qg-card-kicker { font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; color: #67E8F9; margin-bottom: 4px; }
|
||||
.qg-card-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.32rem; color: #EAF0F8; margin-bottom: 12px; }
|
||||
.qg-intro-pet, .qg-success-pet { width: 92px; height: 96px; margin: 0 auto 6px; filter: drop-shadow(0 10px 26px rgba(34,211,238,0.3)); }
|
||||
.qg-intro-pet svg, .qg-success-pet svg { width: 100%; height: 100%; }
|
||||
.qg-success-pet { animation: qgBob 1.6s ease-in-out infinite; }
|
||||
@keyframes qgBob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
|
||||
.qg-intro-goal { font-weight: 700; font-size: .98rem; color: #DCE3EE; margin-bottom: 8px; }
|
||||
.qg-intro-hint { font-size: .85rem; color: #A8B4C6; line-height: 1.5; margin-bottom: 18px; max-width: 360px; margin-left: auto; margin-right: auto; }
|
||||
|
||||
.qg-stars { display: flex; justify-content: center; gap: 6px; margin-bottom: 18px; }
|
||||
.qg-star { display: inline-flex; }
|
||||
.qg-star-svg { filter: drop-shadow(0 2px 6px rgba(251,191,36,0.4)); }
|
||||
.qg-stats { display: flex; justify-content: center; gap: 22px; margin-bottom: 22px; }
|
||||
.qg-star-on { animation: qgStarPop .45s cubic-bezier(.22,1.3,.4,1) backwards; animation-delay: calc(.12s * var(--si, 0) + .15s); }
|
||||
@keyframes qgStarPop { 0% { transform: scale(0) rotate(-30deg); opacity: 0; } 70% { transform: scale(1.25) rotate(6deg); } 100% { transform: scale(1) rotate(0); opacity: 1; } }
|
||||
.qg-stats { display: flex; justify-content: center; gap: 22px; margin-bottom: 20px; }
|
||||
.qg-stat { display: flex; flex-direction: column; gap: 3px; }
|
||||
.qg-stat-lbl { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-3); }
|
||||
.qg-stat-val { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.05rem; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.qg-stat-lbl { font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #8B9AAE; }
|
||||
.qg-stat-val { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 1.05rem; color: #EAF0F8; font-variant-numeric: tabular-nums; }
|
||||
.qg-actions { display: flex; justify-content: center; gap: 10px; }
|
||||
.qg-btn { min-width: 110px; }
|
||||
.qg-btn { min-width: 118px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -60,9 +236,25 @@
|
||||
<div class="qg-top">
|
||||
<span class="qg-title" id="qg-title">Квантик — Законы Мира</span>
|
||||
<span class="qg-sub" id="qg-sub"></span>
|
||||
<button class="qg-back" id="qg-back" type="button" style="display:none">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18 9 12 15 6"/></svg>
|
||||
К карте
|
||||
</button>
|
||||
<span class="qg-pill">Физика</span>
|
||||
</div>
|
||||
<div class="qg-stage" id="qg-stage"></div>
|
||||
|
||||
<!-- Вид карты -->
|
||||
<div class="qg-view show" id="qg-map-view">
|
||||
<div class="qm-root">
|
||||
<div class="qm-header" id="qg-map-header"></div>
|
||||
<div class="qm-body" id="qg-map-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Вид уровня -->
|
||||
<div class="qg-view" id="qg-level-view">
|
||||
<div class="qg-stage" id="qg-stage"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -71,48 +263,168 @@
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<!-- модель питомца (нарратор-Квантик) -->
|
||||
<script src="/js/pet-sprite.js"></script>
|
||||
<!-- движок спек-симуляций (тот же путь, что lab.html / sim-builder.html) -->
|
||||
<script src="/js/labs/_sim_expr.js"></script>
|
||||
<script src="/js/labs/_sim_engine.js"></script>
|
||||
<!-- KaTeX для подписей сцены -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||
<!-- уровни (данные) + логика игры -->
|
||||
<!-- уровни (данные) + логика прогресса + карта + игра -->
|
||||
<script src="/js/game/levels.js"></script>
|
||||
<script src="/js/game/progress-logic.js"></script>
|
||||
<script src="/js/game/map.js"></script>
|
||||
<script src="/js/game/quantik-game.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
// Доступ: любой авторизованный пользователь (играют и ученики).
|
||||
if (!LS.initPage()) { return; } // initPage сам редиректит на /login, если не авторизован
|
||||
if (!LS.initPage()) { return; }
|
||||
|
||||
var stage = document.getElementById('qg-stage');
|
||||
var mapView = document.getElementById('qg-map-view');
|
||||
var lvlView = document.getElementById('qg-level-view');
|
||||
var stage = document.getElementById('qg-stage');
|
||||
var backBtn = document.getElementById('qg-back');
|
||||
var titleEl = document.getElementById('qg-title');
|
||||
var subEl = document.getElementById('qg-sub');
|
||||
|
||||
if (!window.SimEngine || !window.SimExpr || !window.QuantikLevels || !window.QuantikGame) {
|
||||
if (!window.SimEngine || !window.SimExpr || !window.QuantikLevels ||
|
||||
!window.QuantikGame || !window.QuantikMap || !window.QuantikProgress) {
|
||||
stage.innerHTML = '<div class="qg-fallback">Движок игры не загрузился. Обновите страницу.</div>';
|
||||
lvlView.classList.add('show'); mapView.classList.remove('show');
|
||||
return;
|
||||
}
|
||||
|
||||
// Уровень: ?level=<id> или первый из реестра (MVP — один уровень).
|
||||
var params = new URLSearchParams(location.search);
|
||||
var wantId = params.get('level');
|
||||
var level = wantId ? window.QuantikLevels.get(wantId) : null;
|
||||
if (!level) level = window.QuantikLevels.list()[0] || null;
|
||||
var progressMap = {}; // { level_id: row }
|
||||
var curInst = null; // текущий инстанс движка уровня
|
||||
var map = null;
|
||||
|
||||
if (!level) {
|
||||
stage.innerHTML = '<div class="qg-fallback">Уровень не найден.</div>';
|
||||
return;
|
||||
function loadProgress() {
|
||||
if (window.LS && window.LS.gameProgressList) {
|
||||
return window.LS.gameProgressList()
|
||||
.then(function (r) { progressMap = window.QuantikProgress.fromProgressList(r && r.progress); })
|
||||
.catch(function () { progressMap = {}; });
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
document.getElementById('qg-title').textContent = level.title || 'Квантик';
|
||||
document.getElementById('qg-sub').textContent = level.hint || '';
|
||||
|
||||
var inst = window.QuantikGame.start({ host: stage, level: level });
|
||||
if (!inst) {
|
||||
stage.innerHTML = '<div class="qg-fallback">Не удалось запустить уровень.</div>';
|
||||
return;
|
||||
function destroyLevel() {
|
||||
if (curInst) { try { curInst.destroy(); } catch (_e) {} curInst = null; }
|
||||
stage.innerHTML = '';
|
||||
}
|
||||
|
||||
window.__quantik = inst; // для отладки
|
||||
/* ── Показать карту ── */
|
||||
function showMap() {
|
||||
destroyLevel();
|
||||
lvlView.classList.remove('show');
|
||||
mapView.classList.add('show');
|
||||
backBtn.style.display = 'none';
|
||||
titleEl.textContent = 'Квантик — Законы Мира';
|
||||
subEl.textContent = 'Карта мира — выбери уровень и почини закон';
|
||||
history.replaceState(null, '', '/quantik');
|
||||
// перезагрузить прогресс (мог обновиться после победы) и перерисовать
|
||||
loadProgress().then(function () { map.render(progressMap); });
|
||||
}
|
||||
|
||||
/* ── Запустить уровень (после интро) ── */
|
||||
function launchLevel(level) {
|
||||
destroyLevel();
|
||||
mapView.classList.remove('show');
|
||||
lvlView.classList.add('show');
|
||||
backBtn.style.display = '';
|
||||
titleEl.textContent = level.title || 'Квантик';
|
||||
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || level.hint || '';
|
||||
history.replaceState(null, '', '/quantik?level=' + encodeURIComponent(level.id));
|
||||
|
||||
// Pre-win значение (фолбэк, если пересчёт после победы недоступен).
|
||||
var nextLevel = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
|
||||
|
||||
curInst = window.QuantikGame.start({
|
||||
host: stage,
|
||||
level: level,
|
||||
skin: window.QuantikGame.getSkin(),
|
||||
hasNext: !!nextLevel,
|
||||
// Победа разблокирует след. уровень → перезагружаем прогресс и пересчитываем
|
||||
// «следующий доступный» на свежей карте, чтобы экран успеха показал «Дальше».
|
||||
resolveNext: function () {
|
||||
return loadProgress().then(function () {
|
||||
var nx = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
|
||||
return { hasNext: !!nx, next: nx };
|
||||
});
|
||||
},
|
||||
onNext: function () {
|
||||
// прогресс уже перезагружен в resolveNext → берём след. доступный из свежей карты
|
||||
var nx = window.QuantikProgress.nextPlayable(level.id, window.QuantikLevels.list(), progressMap);
|
||||
if (nx) openLevel(nx); else showMap();
|
||||
},
|
||||
onMap: showMap
|
||||
});
|
||||
|
||||
if (!curInst) {
|
||||
stage.innerHTML = '<div class="qg-fallback">Не удалось запустить уровень.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Открыть уровень: показать интро-карточку, потом launch ── */
|
||||
function openLevel(level) {
|
||||
destroyLevel();
|
||||
mapView.classList.remove('show');
|
||||
lvlView.classList.add('show');
|
||||
backBtn.style.display = '';
|
||||
titleEl.textContent = level.title || 'Квантик';
|
||||
subEl.textContent = (level.spec && level.spec.goal && level.spec.goal.title) || '';
|
||||
|
||||
var intro = window.QuantikGame.buildIntro(level, window.QuantikGame.getSkin());
|
||||
intro.btnGo.addEventListener('click', function () {
|
||||
if (intro.overlay.parentNode) intro.overlay.parentNode.removeChild(intro.overlay);
|
||||
launchLevel(level);
|
||||
});
|
||||
intro.btnBack.addEventListener('click', function () {
|
||||
if (intro.overlay.parentNode) intro.overlay.parentNode.removeChild(intro.overlay);
|
||||
showMap();
|
||||
});
|
||||
stage.appendChild(intro.overlay);
|
||||
}
|
||||
|
||||
/* ── Карта ── */
|
||||
map = window.QuantikMap.create({
|
||||
host: document.getElementById('qg-map-body'),
|
||||
headerHost: document.getElementById('qg-map-header'),
|
||||
onPlay: function (level) { openLevel(level); },
|
||||
getSkin: function () { return window.QuantikGame.getSkin(); },
|
||||
onSkin: function (key) {
|
||||
window.QuantikGame.setSkin(key);
|
||||
map.render(progressMap); // перерисовать (нарратор + активный свотч)
|
||||
}
|
||||
});
|
||||
|
||||
backBtn.addEventListener('click', showMap);
|
||||
|
||||
// Старт: если ?level=<id> в URL и уровень доступен — открыть его, иначе карта.
|
||||
loadProgress().then(function () {
|
||||
map.render(progressMap);
|
||||
var params = new URLSearchParams(location.search);
|
||||
var wantId = params.get('level');
|
||||
if (wantId) {
|
||||
var lvl = window.QuantikLevels.get(wantId);
|
||||
if (lvl && window.QuantikProgress.isUnlocked(lvl, progressMap, window.QuantikLevels.list())) {
|
||||
openLevel(lvl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
showMapNoReload();
|
||||
});
|
||||
|
||||
// показать карту без повторной загрузки прогресса (стартовый случай)
|
||||
function showMapNoReload() {
|
||||
lvlView.classList.remove('show');
|
||||
mapView.classList.add('show');
|
||||
backBtn.style.display = 'none';
|
||||
titleEl.textContent = 'Квантик — Законы Мира';
|
||||
subEl.textContent = 'Карта мира — выбери уровень и почини закон';
|
||||
}
|
||||
|
||||
window.__quantik = { map: map, getInst: function () { return curInst; } };
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -24,6 +24,20 @@
|
||||
`js/game/levels.js`, `js/game/quantik-game.js`, `tests/game.test.js`. Изменены: `server.js`,
|
||||
`js/api.js`, `js/sidebar.js`. `npm test` 251 pass / 8 baseline fail (game.test.js 13/13);
|
||||
lint:routes 0; миграция применяется чисто.
|
||||
- **Phase 2 реализован** (pending review): одиночный уровень превращён в **играбельный мир**.
|
||||
Карта-созвездие (`frontend/js/game/map.js`, `window.QuantikMap`) на звёздном фоне: 6 физ-уровней
|
||||
в 2 главах (Кинематика 1–3, Динамика 4–6), узлы-«звёзды» со статусом (locked/available/completed+
|
||||
звёзды), линии-связи, поэтапное появление. Шапка: нарратор-Квантик (`PetSprite`), XP-бар + «уровень
|
||||
Квантика», всего звёзд, скин-пикер (8 скинов, часть за XP/звёзды). Контент уровней расширен в
|
||||
`levels.js` (метаданные `chapter/order/par_ms/unlockStars`, по 2 звезды: кристалл + норматив времени).
|
||||
Разблокировка/XP/группировка — ЧИСТЫЕ функции в новом `frontend/js/game/progress-logic.js`
|
||||
(`window.QuantikProgress`), покрыты тестом. Навигация: карта→интро(нарратор)→уровень→успех
|
||||
(нарратор по звёздам)→карта; «Дальше» активирована (`nextPlayable`); скин тинтует героя+нарратора
|
||||
(localStorage `quantik-skin`). **Backend НЕ тронут** — XP клиентская агрегация из `game_progress`.
|
||||
Новые: `js/game/map.js`, `js/game/progress-logic.js`. Изменены: `quantik.html`, `js/game/levels.js`,
|
||||
`js/game/quantik-game.js`. `node --check` все OK; смоуки (логика 16/16, рендер 7/7, winnability 6/6
|
||||
на реальном движке) зелёные и удалены; `npm test` 259/251 pass / 8 baseline fail (без изменений);
|
||||
lint:routes 0.
|
||||
|
||||
## Key Architecture Decisions
|
||||
- **«Атом» = блок `goal` в спеке** (булево SimExpr). Любой уровень = спека SimForge + `goal`.
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
- [x] Phase 0: Слой целей в движке (goal/HUD/result) [domain: frontend] → [subplan](./phase-0-objective-layer.md)
|
||||
- [x] Phase 1: Оболочка игры + 1 физ-уровень + прогресс [domain: fullstack] → [subplan](./phase-1-shell-first-level.md)
|
||||
- [ ] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md)
|
||||
- [x] Phase 2: Карта-созвездие + мир физ-уровней + XP/скины [domain: fullstack] → [subplan](./phase-2-map-world-xp.md)
|
||||
- [ ] Phase 3: Граф-уровни (движение по f(x)) + зоны-препятствия [domain: fullstack] → [subplan](./phase-3-graph-levels.md)
|
||||
- [ ] Phase 4: Квантовые способности + SR-комнаты [domain: fullstack] → [subplan](./phase-4-quantum-abilities-sr.md)
|
||||
- [ ] Phase 5: Авторинг уровней в sim-builder + раздача классу [domain: fullstack] → [subplan](./phase-5-authoring-sharing.md)
|
||||
@@ -72,7 +72,7 @@
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 0: Слой целей в движке | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 1: Оболочка + 1 уровень + прогресс | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 2: Карта + мир + XP/скины | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 2: Карта + мир + XP/скины | fullstack | ✅ Done | ✅ (1 🟡 fixed) | ✅ | ✅ |
|
||||
| Phase 3: Граф-уровни + зоны | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 4: Квантовые способности + SR | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 5: Авторинг + раздача | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 2: Карта-созвездие + мир физ-уровней + XP/скины (MVP-мир)
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Status:** ✅ Done (reviewed — PASS w/ notes; «Дальше» stale-hasNext 🟡 fixed; committed)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
@@ -10,24 +10,24 @@
|
||||
победе. После этой фазы игра полноценно отгружаема.
|
||||
|
||||
## Tasks
|
||||
- [ ] Task 1: Контент — ~5–6 физ-уровней-спек (данные в `levels.js`), нарастающая сложность:
|
||||
- [x] Task 1: Контент — ~5–6 физ-уровней-спек (данные в `levels.js`), нарастающая сложность:
|
||||
артиллерия → перелёт через стену → отскок (restitution) → пружина/маятник → орбита/гравитация.
|
||||
Каждый: `goal` + 1–3 звезды + норматив времени (`par_ms`) для 3-й звезды.
|
||||
- [ ] Task 2: Структура «мир/глава»: метаданные уровня (id, title, chapter, order, par_ms, hint).
|
||||
- [x] Task 2: Структура «мир/глава»: метаданные уровня (id, title, chapter, order, par_ms, hint).
|
||||
Карта группирует по главам (созвездиям).
|
||||
- [ ] Task 3: Карта-созвездие `frontend/js/game/map.js` (+ разметка в quantik.html): узлы-уровни
|
||||
- [x] Task 3: Карта-созвездие `frontend/js/game/map.js` (+ разметка в quantik.html): узлы-уровни
|
||||
на SVG/canvas-фоне, линии-связи, статус (заблокирован/доступен/пройден + число звёзд).
|
||||
Разблокировка: уровень открыт, если набрано ≥ threshold звёзд в предыдущих (правило в данных).
|
||||
- [ ] Task 4: XP/уровень игрока: XP = сумма звёзд × коэффициент (+ бонус за par). Хранить в
|
||||
- [x] Task 4: XP/уровень игрока: XP = сумма звёзд × коэффициент (+ бонус за par). Хранить в
|
||||
прогрессе (расширить `game_progress` агрегацией на клиенте ИЛИ доб. поле/таблицу `game_player`).
|
||||
Полоса XP + «уровень Квантика» в шапке карты.
|
||||
- [ ] Task 5: Скины Квантика: выбор `colorKey` из палитр `PetSprite` (+ позже паттерны). Скин
|
||||
- [x] Task 5: Скины Квантика: выбор `colorKey` из палитр `PetSprite` (+ позже паттерны). Скин
|
||||
влияет на цвет glow-точки героя в уровне (param/проп движка) и на `PetSprite` на карте.
|
||||
Хранить выбор (localStorage сейчас; серверно — опц.). Разблокировка скинов по XP/звёздам.
|
||||
- [ ] Task 6: Нарратор: `PetSprite.render(...)` в интро уровня (краткая формулировка «почини закон…»)
|
||||
- [x] Task 6: Нарратор: `PetSprite.render(...)` в интро уровня (краткая формулировка «почини закон…»)
|
||||
и на экране победы (реакция по числу звёзд: happy/ecstatic). Реюз mood из pet-sprite.js.
|
||||
- [ ] Task 7: Навигация: карта → уровень → результат → возврат на карту с обновлённым статусом/XP.
|
||||
- [ ] Task 8: Тесты: разблокировка (логика чистой функцией — юнит-тест), агрегация XP; смоук карты.
|
||||
- [x] Task 7: Навигация: карта → уровень → результат → возврат на карту с обновлённым статусом/XP.
|
||||
- [x] Task 8: Тесты: разблокировка (логика чистой функцией — юнит-тест), агрегация XP; смоук карты.
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/js/game/levels.js` — контент мира (расширить).
|
||||
@@ -53,4 +53,61 @@
|
||||
- [ ] Карта/навигация работают; existing тесты целы; lint baseline 0
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Заполняет агент-имплементер. -->
|
||||
|
||||
### Архитектура карты (`frontend/js/game/map.js`)
|
||||
- `window.QuantikMap.create({ host, headerHost, onPlay(level), getSkin()->key, onSkin(key) }) -> { render(progressMap), destroy() }`.
|
||||
- `render(progressMap)` рисует шапку (нарратор + XP-бар + всего звёзд + скин-пикер) в `headerHost`
|
||||
и созвездия в `host`. `progressMap` — `{ [level_id]: row }` (см. `QuantikProgress.fromProgressList`).
|
||||
- Узел созвездия (`buildNode`) — `<button class="qm-node qm-{locked|available|completed}">` с ядром
|
||||
(`.qm-node-core`), подписью, звёздами/порогом. Позиция в % через `layoutNodes(levels)` (зигзаг-дуга).
|
||||
- Статус узла = `QuantikProgress.nodeStatus`. Клик по available/completed → `onPlay(level)`.
|
||||
- Звёздное небо — SVG `<circle class="qm-tw">` (мерцание CSS), линии-связи `<line class="qm-link[.on]">`.
|
||||
- Поэтапное появление: `staggerReveal` снимает `.qm-pre`/ставит `.qm-in` через setTimeout (70 мс шаг).
|
||||
|
||||
### Как добавить главу (созвездие)
|
||||
- В `levels.js`: дать новым уровням `chapter:'<key>'` + добавить запись в `CHAPTERS`
|
||||
(`{ key, title, subtitle, accent }`). Карта группирует автоматически (`groupByChapter` сохраняет
|
||||
порядок появления глав). Узлы внутри главы сортируются по `order`. Никаких правок map.js не нужно.
|
||||
- **Фаза 3 (граф-уровни) = НОВАЯ глава** (напр. `chapter:'functions'`): добавить уровни-спеки с
|
||||
`objects:[{type:'plot',...}]` + `goal.when` по форме функции; `unlockStars` гейтит её за Динамику.
|
||||
Узел рисуется тем же `buildNode` (тип спеки карте безразличен — она читает только метаданные).
|
||||
|
||||
### Модуль логики прогресса (`frontend/js/game/progress-logic.js`, `window.QuantikProgress`)
|
||||
Чистые функции (без DOM/сети/eval) — переносимы на сервер, покрыты тестом:
|
||||
- `fromProgressList(list)` → карта `{level_id: row}` из ответа `/api/game/progress`.
|
||||
- `starsFor(id, map)` / `isCompleted(id, map)` / `totalStars(levels, map)`.
|
||||
- `isUnlocked(level, map, levels)` — уровень открыт, если Σ звёзд во ВСЕХ уровнях с меньшим `order`
|
||||
≥ `level.unlockStars` (порог в данных уровня). `unlockStars:0` (или нет) → всегда открыт.
|
||||
- `nodeStatus` / `starsToUnlock` — для карты.
|
||||
- `computeXp(levels, map)` = Σ(звёзды·`STAR_XP`=100 + `COMPLETE_XP`=40 за пройденный).
|
||||
- `playerLevel(xp)` → `{ level, xp, xpInto, xpForNext, progress01 }`. Шкала: `xpForLevel(L)=240·(L-1)L/2`.
|
||||
- `groupByChapter(levels)` → `[{ chapter, levels:[…sorted by order] }]`.
|
||||
- `nextPlayable(curId, levels, map)` → след. разблокированный уровень (для кнопки «Дальше») или null.
|
||||
|
||||
### Скины
|
||||
- localStorage ключ **`quantik-skin`** (экспортирован `QuantikGame.SKIN_KEY`). Значение = `colorKey`
|
||||
из `PetSprite.PALETTES` (валидируется при чтении, иначе fallback `'cyan'`).
|
||||
- `QuantikGame.getSkin()/setSkin(key)/skinColor(key)`. Тинт героя — `tintHeroSpec(spec, key)`:
|
||||
глубокая копия спеки (JSON), переписывает `color/glowColor/trailColor` объекта с `id:'ball'`.
|
||||
Гейты скинов — массив `SKIN_GATES` в map.js (needStars/needXp). 8 скинов.
|
||||
|
||||
### Нарратор
|
||||
- `PetSprite.render(level, mood, [], colorKey, 0, 'none')` (DOM SVG-строка). Вызовы:
|
||||
- Карта-шапка: `QuantikMap.renderHeader` (mood по уровню игрока: ecstatic≥5 / happy≥2 / neutral).
|
||||
- Интро уровня: `QuantikGame.buildIntro(level, skin)` (mood `happy`).
|
||||
- Экран успеха: `QuantikGame.buildSuccessOverlay(state, {skin, hasNext})` — mood `ecstatic`, если все
|
||||
звёзды (got≥total и total≥2), иначе `happy`.
|
||||
|
||||
### Навигация (inline-bootstrap в quantik.html)
|
||||
- Два вида: `#qg-map-view` (карта) и `#qg-level-view` (`#qg-stage` под движок). Переключение
|
||||
классом `.show`. `showMap()` перезагружает прогресс (`LS.gameProgressList`) → `map.render`.
|
||||
`openLevel→интро→launchLevel→onGoal→успех→onNext(nextPlayable)|onMap`. При смене уровня
|
||||
ВСЕГДА `destroyLevel()` (= `inst.destroy()` + очистка `#qg-stage`) до нового mount (гоча Ф1).
|
||||
- Deep-link `?level=<id>` открывает уровень, если он разблокирован; иначе карта.
|
||||
|
||||
### Решения/гочи (для ревью и Ф3+)
|
||||
- **XP/прогресс игрока — чисто клиентская агрегация** из `game_progress` (Ф1). Новых таблиц/роутов НЕТ
|
||||
→ lint:routes baseline 0 не тронут, бэкенд-тесты не изменились (259, 251 pass / 8 baseline fail).
|
||||
- Уровни 3/5/6 имеют «лёгкий» выигрышный путь, попутно дающий обе звезды; «честная» механика
|
||||
(отскок/орбита/колодец) присутствует, но не единственно-возможна — НЕ блокер MVP (см. winnability).
|
||||
- На сервер агрегацию XP перенести легко: те же чистые функции в `progress-logic.js` (без DOM).
|
||||
|
||||
Reference in New Issue
Block a user