Files
Learn_System/frontend/js/game/levels.js
Maxim Dolgolyov c780b6fd96 @
feat(quantik-game): фаза 5 — авторинг игровых уровней в sim-builder + раздача

Учитель собирает игровой уровень без кода: новая (аддитивная, сворачиваемая)
панель в sim-builder задаёт блок goal (when/title/hint/hold/fail) + до 3
звёзд + game-мету (chapter/order/par_ms); выражения проверяются inline через
SimExpr.compile (без eval). buildSpec/loadFromSim — round-trip без потерь
(goal/game пишутся только при включённом слое; обычная sim не меняется).
Кнопка «Играть» монтирует черновик в SimEngine-модалке (HUD цели из Ф0).
QuantikLevels стал async: подмешивает custom_sims cat=game (свои+
published) в реестр (custom:<dbid>), offline-safe, строки без goal
отбрасываются; deep-link /quantik?level=custom:<id> с серверной проверкой
доступа (own|published → иначе 403/404), мимо геймплейного гейта unlockStars.
Раздача классу — реюз share Ф6 (game-aware ссылка + durable pushNotif).
Правки sim-builder строго аддитивны (параллельная сессия). npm test 259/8
baseline; quantik-authoring 6/6; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-14 16:09:10 +03:00

1059 lines
64 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Квантик — Законы Мира · Реестр уровней (Фаза 2 — мир из 6 физ-уровней).
Уровень = СПЕКА SimForge (данные, не код) + блок `goal` (победа), который
движок (_sim_engine.js) умеет с Фазы 0. Игрок не управляет героем напрямую —
он «чинит закон мира»: крутит слайдеры params (угол/скорость/жёсткость…),
затем «Запуск», и симуляция проигрывается к цели.
── МЕТАДАННЫЕ УРОВНЯ (Фаза 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 для неё не нужен).
ИСТОЧНИК УРОВНЕЙ: встроенные данные здесь (window.QuantikLevels). Авторённые
уровни (custom_sims cat='game') подмешаются в Фазе 5 (реестр станет асинхронным).
⛔ Без eval/Function. Все «числовые» поля могут быть числом ИЛИ строкой-
выражением (их безопасно вычисляет SimExpr на клиенте).
════════════════════════════════════════════════════════════════════════ */
(function (global) {
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: 'Артиллерия',
chapter: 'kinematics',
order: 1,
unlockStars: 0,
par_ms: 1500,
subject: 'physics',
hint: 'Подбери угол и скорость, чтобы Квантик долетел до портала. Собери кристалл по дороге — это вторая звезда. Быстрый бросок даст третью.',
spec: {
specVersion: 1,
meta: { title: 'Артиллерия Квантика', desc: 'Закон движения: бросок под углом к горизонту.' },
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: 'м/с' }
],
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
objects: [
{ 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 - ' + L1_PX + ', ball.y - ' + L1_PY + ') < ' + L1_PR,
fail: 'ball.x > 11.5 || ball.y < -1.0',
stars: [
{ when: 'hypot(ball.x - ' + L1_CX + ', ball.y - ' + L1_CY + ') < ' + L1_CR, label: 'Собрал кристалл' },
{ when: 't*1000 <= 1500', label: 'Быстро (≤1.5 с)' }
]
}
}
};
/* Уровень 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 с)' }
]
}
}
};
/* ─────────────────────────────────────────────────────────────────────────
Глава III — «Функции» (созвездие): Квантик едет по кривой y=f(x), которую
СОБИРАЕТ игрок (слайдеры коэффициентов). Препятствия — зоны (Ф3 движка):
forbidden — задел → проигрыш (fail);
target — добрался → победа (goal);
collect — задел → звезда-бонус (sticky).
Реюз: plot+runner рисует кривую и ведёт по ней героя (одна скомпил. функция),
зоны дают булевы env-поля <id>.hit. Без eval — только SimExpr-выражения.
──────────────────────────────────────────────────────────────────────── */
var FORB = '#F87171'; // запретная зона (красный)
var TARG = '#34D399'; // целевая зона (зелёный)
var COIN = '#FBBF24'; // зона-сбор (золото)
var CURVE = '#67E8F9'; // цвет кривой-«дороги»
/* helper: герой-точка, едет по бегунку кривой (id 'curve'): x=curve.runX, y=curve.runY.
glow+trail (визуал P2). НЕ тело (кинематический проход) → не само-ссылка. */
function graphHero() {
return {
id: 'ball', type: 'point', r: 7, color: HERO,
x: 'curve.runX', y: 'curve.runY',
glow: true, glowColor: HERO, trail: true, trailColor: HERO, trailFade: true
};
}
/* helper: plot-«дорога» = кривая f(x) с бегунком. exprStr — выражение кривой. */
function road(exprStr, a, b, dur) {
return {
id: 'curve', type: 'plot', expr: exprStr, var: 'x',
range: [a, b], samples: 220, color: CURVE, width: 3, glow: true, glowColor: CURVE,
runner: { duration: dur, hold: true }
};
}
function rectZone(id, kind, x, y, w, h, label) {
var col = kind === 'target' ? TARG : kind === 'collect' ? COIN : FORB;
return { type: 'zone', id: id, kind: kind, shape: 'rect', x: x, y: y, w: w, h: h,
color: col, label: label || '' };
}
function circZone(id, kind, x, y, r, label) {
var col = kind === 'target' ? TARG : kind === 'collect' ? COIN : FORB;
return { type: 'zone', id: id, kind: kind, shape: 'circle', x: x, y: y, r: r,
color: col, label: label || '' };
}
function startMarker(x, y) {
return { type: 'circle', x: x, y: y, r: 0.16, color: '#94A3B8', width: 0, fill: '#94A3B8' };
}
/* Уровень 7: «Луч через ворота» — прямая f(x)=a·x+b. Сверху и снизу в центре —
запретные брусья; проведи луч между ними в зелёный портал справа. Монета по центру. */
var graphLine7 = {
id: 'graph-line-7',
title: 'Луч через ворота',
chapter: 'functions',
order: 7,
unlockStars: 9,
par_ms: 5200,
subject: 'algebra',
hint: 'Квантик поедет по прямой y = a·x + b. Подбери наклон a и сдвиг b, чтобы луч прошёл между брусьями в воротах и достиг зелёного портала. Монета — точно в центре прохода.',
spec: {
specVersion: 1,
meta: { title: 'Луч через ворота', desc: 'Линейная функция: y = a·x + b.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8, grid: true, axes: true, bg: BG },
time: { duration: 5, loop: false },
params: [
{ name: 'a', label: 'Наклон a', min: -0.5, max: 1.5, step: 0.05, value: 0.2 },
{ name: 'b', label: 'Сдвиг b', min: 0, max: 5, step: 0.1, value: 1 }
],
objects: [
// ворота в центре: верхний и нижний брус (проход 1..6 по y при x≈5)
rectZone('topbar', 'forbidden', 5, 7.0, 4, 2.4, 'стена'),
rectZone('botbar', 'forbidden', 5, -0.2, 4, 2.4, 'стена'),
// монета в центре прохода
circZone('coin', 'collect', 5, 3, 0.7, 'монета'),
// целевой портал справа
circZone('gate', 'target', 10, 5, 0.95, 'портал'),
startMarker(0, 1),
road('a*x + b', 0, 10, 5),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'b', expr: 'b', precision: 1 }
],
goal: {
title: 'Проведи луч в портал',
hint: 'Достигни зелёного портала, не задев красные брусья. Бонус: собери монету.',
when: 'gate.hit',
fail: 'topbar.hit || botbar.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && b < 1.6', label: 'Низкий старт (b < 1.6)' }
]
}
}
};
/* Уровень 8: «Синус сквозь врата» — f(x)=A·sin(k·x). Сверху/снизу — зубцы-стены
с просветами; подбери амплитуду и частоту, чтобы волна прошла сквозь все просветы. */
var graphSine8 = {
id: 'graph-sine-8',
title: 'Синус сквозь врата',
chapter: 'functions',
order: 8,
unlockStars: 11,
par_ms: 6000,
subject: 'algebra',
hint: 'Квантик едет по волне y = A·sin(k·x). Подбери амплитуду A и частоту k, чтобы волна прошла сквозь просветы между зубцами. Монеты — в гребне и впадине волны.',
spec: {
specVersion: 1,
meta: { title: 'Синус сквозь врата', desc: 'Синусоида: y = A·sin(k·x).' },
viewport: { xmin: -0.5, xmax: 12.5, ymin: -5, ymax: 5, grid: true, axes: true, bg: BG },
time: { duration: 6, loop: false },
params: [
{ name: 'A', label: 'Амплитуда A', min: 1, max: 4, step: 0.1, value: 2 },
{ name: 'k', label: 'Частота k', min: 0.3, max: 1.6, step: 0.05, value: 0.6 }
],
objects: [
// зубцы: верхний брус над гребнем (x≈π/2k) и нижний под впадиной (x≈3π/2k)
// при целевом k≈0.79 (π/4): гребень x≈2, впадина x≈6, гребень x≈10
rectZone('spikeTop', 'forbidden', 2, 4.4, 1.2, 2.4, ''),
rectZone('spikeBot', 'forbidden', 6, -4.4, 1.2, 2.4, ''),
rectZone('spikeTop2', 'forbidden', 10, 4.4, 1.2, 2.4, ''),
// монеты в гребне/впадине
circZone('coinTop', 'collect', 2, 3, 0.6, ''),
circZone('coinBot', 'collect', 6, -3, 0.6, ''),
// портал на третьем гребне
circZone('gate', 'target', 11.2, 0, 1.0, 'портал'),
startMarker(0, 0),
road('A*sin(k*x)', 0, 11.5, 6),
graphHero(),
{ type: 'readout', label: 'A', expr: 'A', precision: 1 },
{ type: 'readout', label: 'k', expr: 'k', precision: 2 }
],
goal: {
title: 'Проведи волну в портал',
hint: 'Подбери волну так, чтобы пройти все просветы и достичь портала. Бонус: собери монеты в гребне и впадине.',
when: 'gate.hit',
fail: 'spikeTop.hit || spikeBot.hit || spikeTop2.hit',
stars: [
{ when: 'coinTop.hit', label: 'Монета-гребень' },
{ when: 'coinBot.hit', label: 'Монета-впадина' }
]
}
}
};
/* Уровень 9: «Парабола над ямой» — f(x)=a·(x-3)^2+k (вершина в (3,k)... меняем).
В центре — запретная яма; перебрось параболу-дугу над ней в портал. */
var graphParab9 = {
id: 'graph-parab-9',
title: 'Парабола над ямой',
chapter: 'functions',
order: 9,
unlockStars: 13,
par_ms: 6000,
subject: 'algebra',
hint: 'Квантик едет по параболе y = a·(x − 5)² + k. Подбери раскрытие a (вниз — отрицательное) и высоту вершины k, чтобы дуга прошла над ямой в портал. Монета — на вершине дуги.',
spec: {
specVersion: 1,
meta: { title: 'Парабола над ямой', desc: 'Квадратичная: y = a·(x 5)² + k.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 9, grid: true, axes: true, bg: BG },
time: { duration: 6, loop: false },
params: [
{ name: 'a', label: 'Раскрытие a', min: -0.6, max: -0.1, step: 0.02, value: -0.3 },
{ name: 'k', label: 'Высота вершины k', min: 4, max: 8, step: 0.1, value: 6 }
],
objects: [
// яма в центре (запретная) — занимает низ по центру
rectZone('pit', 'forbidden', 5, 1.4, 5.0, 3.0, 'яма'),
// монета на вершине
circZone('coin', 'collect', 5, 6, 0.7, 'монета'),
// портал у правого края, низко (нужно спуститься на нисходящей ветви)
circZone('gate', 'target', 9.5, 4.4, 0.95, 'портал'),
startMarker(0.5, 0), // ориентир старта (фактический старт = f(0))
road('a*(x-5)^2 + k', 0, 9.5, 6),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'k', expr: 'k', precision: 1 }
],
goal: {
title: 'Дугой над ямой в портал',
hint: 'Перебрось дугу над ямой к порталу. Бонус: задень монету на вершине.',
when: 'gate.hit',
fail: 'pit.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && k >= 6.4', label: 'Высокая дуга (k ≥ 6.4)' }
]
}
}
};
/* Уровень 10: «Угол модуля» — f(x)=a·abs(x-h)+1. V-образная кривая: подбери
раскрытие a и положение вершины h, чтобы «галочка» обошла два бруса и попала в портал. */
var graphAbs10 = {
id: 'graph-abs-10',
title: 'Угол модуля',
chapter: 'functions',
order: 10,
unlockStars: 15,
par_ms: 6000,
subject: 'algebra',
hint: 'Квантик едет по «галочке» y = a·|x − m| + 1. Подбери крутизну a и положение вершины m, чтобы остриё прошло в проход и луч попал в портал. Монета — у самого острия.',
spec: {
specVersion: 1,
meta: { title: 'Угол модуля', desc: 'Модуль: y = a·|x m| + 1.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 9, grid: true, axes: true, bg: BG },
time: { duration: 6, loop: false },
params: [
// m = положение вершины (имя 'h' зарезервировано движком под высоту вьюпорта!)
{ name: 'a', label: 'Крутизна a', min: 0.6, max: 2.2, step: 0.05, value: 1.2 },
{ name: 'm', label: 'Вершина m', min: 3, max: 7, step: 0.1, value: 5 }
],
objects: [
// нижние брусья слева и справа от прохода-острия (вершина должна попасть в проход)
rectZone('floorL', 'forbidden', 2.4, -0.2, 2.4, 1.4, ''),
rectZone('floorR', 'forbidden', 7.6, -0.2, 2.4, 1.4, ''),
// монета у острия (низко, по центру)
circZone('coin', 'collect', 5, 1, 0.7, 'монета'),
// портал справа-вверху (на восходящей ветви)
circZone('gate', 'target', 9.6, 6.4, 1.0, 'портал'),
startMarker(0, 7),
road('a*abs(x-m) + 1', 0, 9.6, 6),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'm', expr: 'm', precision: 1 }
],
goal: {
title: 'Галочкой в портал',
hint: 'Проведи остриё галочки в проход между брусьями и попади в портал. Бонус: задень монету у острия.',
when: 'gate.hit',
fail: 'floorL.hit || floorR.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && m >= 4.6 && m <= 5.4', label: 'Вершина по центру' }
]
}
}
};
/* Уровень 11 (капстоун): «Экспонента-подъём» — f(x)=c·exp(r·x). Барьер-«потолок»
слева заставляет держать низкий старт; затем экспонента взмывает в высокий портал. */
var graphExp11 = {
id: 'graph-exp-11',
title: 'Экспонента-подъём',
chapter: 'functions',
order: 11,
unlockStars: 17,
par_ms: 6500,
subject: 'algebra',
hint: 'Квантик едет по экспоненте y = c·e^(r·x). Подбери начальную высоту c и скорость роста r, чтобы пройти под потолком слева, но взмыть в высокий портал справа. Монета — на разгоне.',
spec: {
specVersion: 1,
meta: { title: 'Экспонента-подъём', desc: 'Экспонента: y = c·e^(r·x).' },
viewport: { xmin: -0.5, xmax: 8.5, ymin: -0.5, ymax: 9, grid: true, axes: true, bg: BG },
time: { duration: 6.5, loop: false },
params: [
{ name: 'c', label: 'Старт c', min: 0.2, max: 1.5, step: 0.05, value: 0.6 },
{ name: 'r', label: 'Рост r', min: 0.2, max: 0.7, step: 0.02, value: 0.4 }
],
objects: [
// потолок слева (низкий старт): запретный брус над началом x∈[1,4], y от 2.5 вверх
rectZone('ceil', 'forbidden', 2.5, 4.0, 3.2, 3.0, 'потолок'),
// пол-яма в начале (нельзя слишком низко/нулём): брус снизу x∈[0,5]
rectZone('floor', 'forbidden', 2.4, -0.5, 5.0, 1.0, ''),
circZone('coin', 'collect', 5, 3.3, 0.7, 'монета'),
circZone('gate', 'target', 7.6, 7.2, 1.05, 'портал'),
startMarker(0, 0.6),
road('c*exp(r*x)', 0, 7.7, 6.5),
graphHero(),
{ type: 'readout', label: 'c', expr: 'c', precision: 2 },
{ type: 'readout', label: 'r', expr: 'r', precision: 2 }
],
goal: {
title: 'Взмой в портал',
hint: 'Пройди под потолком слева и взмой экспонентой в высокий портал. Бонус: задень монету на разгоне.',
when: 'gate.hit',
fail: 'ceil.hit || floor.hit',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && r >= 0.42', label: 'Крутой рост (r ≥ 0.42)' }
]
}
}
};
/* ─────────────────────────────────────────────────────────────────────────
Глава IV — «Квантовые законы» (созвездие): фирменные способности Квантика.
Всё выражено через БЕЗОПАСНУЮ модель спеки (params/зоны/plot), без новых
механик движка и без eval.
Суперпозиция — у уровня ДВА тела-копии Квантика (ball + ball2), один общий
«закон» (params) рулит обеими; победа — когда ОБЕ копии у своих порталов
(goal.when ссылается на ball.* и ball2.*). Реюз мульти-body физики.
Коллапс/прицел — на сцене всегда есть пунктирная «предсказанная траектория»
(plot выражения пути от текущего закона). Способность «Прицел» в игре
ставит паузу — игрок целится по предсказанной кривой до запуска.
Туннелирование — на пути стоит запретная стена-зона (tunnelable). Проигрыш
только когда стена задета И заряд не потрачен: fail:'wall.hit && tunnel < 1'.
Игровой слой тратит ЭНЕРГИЮ (заработанную в SR-комнате) и делает
inst.setParam('tunnel', 1) → стена временно проницаема. tunnel — НЕ слайдер
(управляется только способностью); по умолчанию отсутствует в env → 0
(неизвестная переменная SimExpr = 0) → стена сплошная.
──────────────────────────────────────────────────────────────────────── */
var PHANTOM = '#C4B5FD'; // полупрозрачный «фантом» — вторая копия (ball2)
var AIM = '#38BDF8'; // предсказанная траектория (пунктир)
var TUNNEL_WALL = '#F472B6'; // tunnelable-стена (магента — «квантовый барьер»)
/* helper: вторая копия-герой (ball2) — стартует из (sx,sy), полупрозрачный фантом. */
function hero2(sx, sy, vxExpr, vyExpr) {
return {
id: 'ball2', type: 'point', r: 7, color: PHANTOM, opacity: 0.78,
x: sx, y: sy,
glow: true, glowColor: PHANTOM, trail: true, trailColor: PHANTOM, trailFade: true,
body: { mass: 1, vx: vxExpr, vy: vyExpr }
};
}
/* helper: портал-кольцо «фантомного» цвета (цель второй копии). */
function portal2Objs(px, py, r) {
return [
{ type: 'circle', x: px, y: py, r: r, color: PHANTOM, width: 3, glow: true, glowColor: PHANTOM },
{ type: 'circle', x: px, y: py, r: r * 0.45, color: PHANTOM, width: 2, opacity: 0.7 },
{ type: 'label', x: px, y: py + r + 0.9, text: 'портал-2', color: PHANTOM, size: 12 }
];
}
/* helper: пунктирная предсказанная траектория (plot) — для «прицела». */
function aimPath(exprStr, a, b) {
return {
id: 'aim', type: 'plot', expr: exprStr, var: 'x',
range: [a, b], samples: 120, color: AIM, width: 1.6, lineStyle: 'dashed', opacity: 0.6
};
}
/* Уровень 12 (обучение суперпозиции): «Раздвоение» — две копии Квантика летят
симметрично (вправо и влево) под ОДНИМ законом. Один (θ,v) обязан попасть в ОБА
зеркальных портала. Симметрия гарантирует решение. */
var L12_PX = 6.5, L12_PY = 0.7, L12_PR = 0.95; // правый портал (для ball)
var L12_PX2 = -6.5; // левый портал (для ball2, зеркало)
var superpos12 = {
id: 'quantum-superpos-12',
title: 'Раздвоение',
chapter: 'quantum',
order: 12,
unlockStars: 19,
par_ms: 1700,
subject: 'physics',
hint: 'Квантик в суперпозиции — он сразу в двух местах! Обе копии летят под ОДНИМ законом, зеркально. Подбери угол и скорость так, чтобы каждая копия попала в свой портал.',
spec: {
specVersion: 1,
meta: { title: 'Суперпозиция: раздвоение', desc: 'Два тела, один закон. Симметричный бросок.' },
viewport: { xmin: -9, xmax: 9, ymin: -1.2, ymax: 7, grid: true, axes: true, bg: BG },
params: [
{ name: 'theta', label: 'Угол', min: 20, max: 80, step: 1, value: 50, unit: '°' },
{ name: 'v', label: 'Скорость', min: 5, max: 18, step: 0.5, value: 9, unit: 'м/с' }
],
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
objects: [
{ type: 'segment', x1: -9, y1: 0, x2: 9, y2: 0, color: GROUND, width: 2 }
].concat(portalObjs(L12_PX, L12_PY, L12_PR), portal2Objs(L12_PX2, L12_PY, L12_PR), [
// ball летит вправо, ball2 — зеркально влево (тот же закон)
hero(0, 0, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
hero2(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 - ' + L12_PX + ', ball.y - ' + L12_PY + ') < ' + L12_PR +
' && hypot(ball2.x - (' + L12_PX2 + '), ball2.y - ' + L12_PY + ') < ' + L12_PR,
fail: 't > 6',
stars: [
{ when: 't*1000 <= 1700', label: 'Быстро (≤1.7 с)' },
{ when: 'theta >= 55', label: 'Высокая дуга (θ ≥ 55°)' }
]
}
}
};
/* Уровень 13 (применение суперпозиции): «Две двери» — копии стартуют из РАЗНЫХ
точек, но скорости связаны одним законом (ball вправо-вверх, ball2 влево-вверх
одним законом). Обе зеркальные дуги должны перелететь центральную стену и
одновременно войти в свои порталы на вершинах дуг — нужен точный общий закон. */
var L13_PX = 5.0, L13_PY = 3.2, L13_PR = 0.85; // правый портал (вершина дуги ball)
var L13_PX2 = -5.0; // левый портал (зеркало, ball2)
var L13_WALLH = 2.0; // центральная стена — дуги обязаны её перелететь
var superpos13 = {
id: 'quantum-superpos-13',
title: 'Две двери',
chapter: 'quantum',
order: 13,
unlockStars: 20,
par_ms: 2000,
subject: 'physics',
hint: 'Две копии летят зеркально из центра — вправо и влево, под одним законом. Перебрось обе дуги через центральную стену так, чтобы вершина каждой дуги попала в свой портал.',
spec: {
specVersion: 1,
meta: { title: 'Суперпозиция: две двери', desc: 'Один закон ведёт обе зеркальные копии в свои двери.' },
viewport: { xmin: -7.5, xmax: 7.5, ymin: -1.2, ymax: 6, grid: true, axes: true, bg: BG },
params: [
{ name: 'theta', label: 'Угол', min: 25, max: 80, step: 1, value: 50, unit: '°' },
{ name: 'v', label: 'Скорость', min: 6, max: 16, step: 0.5, value: 10, unit: 'м/с' }
],
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
objects: [
{ type: 'segment', x1: -7.5, y1: 0, x2: 7.5, y2: 0, color: GROUND, width: 2 },
// центральная стена-препятствие (только визуал + ориентир; дуги должны быть выше)
{ type: 'segment', x1: 0, y1: 0, x2: 0, y2: L13_WALLH, color: '#475569', width: 6 },
{ type: 'label', x: 0, y: L13_WALLH + 0.5, text: 'стена', color: '#94A3B8', size: 11 }
].concat(portalObjs(L13_PX, L13_PY, L13_PR), portal2Objs(L13_PX2, L13_PY, L13_PR), [
// обе копии стартуют из центра (0, 0.2): ball вправо, ball2 зеркально влево
hero(0, 0.2, 'v*cos(theta*pi/180)', 'v*sin(theta*pi/180)'),
hero2(0, 0.2, '-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 - ' + L13_PX + ', ball.y - ' + L13_PY + ') < ' + L13_PR +
' && hypot(ball2.x - (' + L13_PX2 + '), ball2.y - ' + L13_PY + ') < ' + L13_PR,
fail: 't > 6',
stars: [
{ when: 't*1000 <= 2000', label: 'Быстро (≤2.0 с)' },
{ when: 'v <= 11', label: 'Экономный бросок (v ≤ 11)' }
]
}
}
};
/* Уровень 14 (обучение прицела/коллапса): «Прицел» — бросок под углом к порталу,
на сцене всегда видна предсказанная траектория (пунктир). Способность «Прицел»
(в игре) ставит паузу: целься по кривой до запуска. */
var L14_PX = 8.6, L14_PY = 4.4, L14_PR = 0.8;
var L14_CX = 5.0, L14_CY = 5.2, L14_CR = 0.7;
// предсказанная траектория: парабола броска y = tan(θ)·x g·x² / (2·v²·cos²θ)
var L14_AIM = 'tan(theta*pi/180)*x - 9.8*x^2 / (2*v^2*cos(theta*pi/180)^2)';
var aimer14 = {
id: 'quantum-aim-14',
title: 'Прицел',
chapter: 'quantum',
order: 14,
unlockStars: 22,
par_ms: 1600,
subject: 'physics',
hint: 'Пунктирная линия — предсказанная траектория Квантика для текущего закона. Способность «Прицел» ставит паузу: целься по кривой, затем коллапсируй (запусти) в портал.',
spec: {
specVersion: 1,
meta: { title: 'Коллапс: прицел', desc: 'Предсказанная траектория параболы броска.' },
viewport: { xmin: -1, xmax: 11, ymin: -1.2, 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: 6, max: 18, step: 0.5, value: 11, unit: 'м/с' }
],
physics: { enabled: true, gravity: { x: 0, y: -9.8 } },
objects: [
{ type: 'segment', x1: -1, y1: 0, x2: 11, y2: 0, color: GROUND, width: 2 },
aimPath(L14_AIM, 0, 10.5)
].concat(crystalObjs(L14_CX, L14_CY, L14_CR), portalObjs(L14_PX, L14_PY, L14_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 - ' + L14_PX + ', ball.y - ' + L14_PY + ') < ' + L14_PR,
fail: 'ball.x > 10.6 || ball.y < -1.0',
stars: [
{ when: 'hypot(ball.x - ' + L14_CX + ', ball.y - ' + L14_CY + ') < ' + L14_CR, label: 'Собрал кристалл' },
{ when: 't*1000 <= 1600', label: 'Быстро (≤1.6 с)' }
]
}
}
};
/* Уровень 15 (обучение туннелирования): «Квантовый барьер» — Квантик едет по
прямой к порталу, но на пути СТЕНА (tunnelable-зона). Без заряда стена сплошная
(задел → проигрыш). Способность «Туннель» (за энергию) делает стену проницаемой:
fail:'wall.hit && tunnel < 1'. */
var tunnel15 = {
id: 'quantum-tunnel-15',
title: 'Квантовый барьер',
chapter: 'quantum',
order: 15,
unlockStars: 24,
par_ms: 5200,
subject: 'physics',
hint: 'Стена-барьер преграждает путь напрямую, обойти её нельзя. Накопи энергию в комнате повторения и потрать заряд туннелирования — Квантик пройдёт СКВОЗЬ барьер.',
spec: {
specVersion: 1,
meta: { title: 'Туннелирование: барьер', desc: 'Прохождение сквозь барьер за квантовый заряд.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 8, grid: true, axes: true, bg: BG },
time: { duration: 5, loop: false },
params: [
{ name: 'a', label: 'Наклон a', min: -0.3, max: 0.5, step: 0.02, value: 0.1 },
{ name: 'b', label: 'Высота b', min: 2.5, max: 4.5, step: 0.1, value: 3.5 }
],
objects: [
// барьер ровно поперёк пути (вертикальный брус в центре, перекрывает весь коридор по y)
{ type: 'zone', id: 'wall', kind: 'forbidden', shape: 'rect', x: 5, y: 3.5, w: 0.8, h: 7.2,
color: TUNNEL_WALL, label: 'барьер' },
// целевой портал справа
circZone('gate', 'target', 9.5, 3.5, 0.95, 'портал'),
// бонус-монета сразу за барьером
circZone('coin', 'collect', 6.5, 3.5, 0.6, 'монета'),
startMarker(0, 3.5),
road('a*x + b', 0, 9.5, 5),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'b', expr: 'b', precision: 1 }
],
goal: {
title: 'Пройди сквозь барьер',
hint: 'Доберись до портала. Барьер задержит Квантика, пока не потрачен заряд туннелирования. Бонус: собери монету за барьером.',
when: 'gate.hit',
// проигрыш только если задел стену БЕЗ заряда туннеля (tunnel<1 ⇒ заряд не потрачен)
fail: 'wall.hit && tunnel < 1',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && abs(b - 3.5) < 0.4', label: 'Ровный путь (b ≈ 3.5)' }
]
}
}
};
/* Уровень 16 (капстоун главы): «Сквозь стену в дверь» — комбо: бегунок-парабола,
барьер-зона поперёк дуги (нужен туннель) и целевой портал за ним; монета на дуге. */
var tunnel16 = {
id: 'quantum-tunnel-16',
title: 'Сквозь стену',
chapter: 'quantum',
order: 16,
unlockStars: 26,
par_ms: 6000,
subject: 'physics',
hint: 'Дуга-парабола ведёт к высокому порталу, но её пересекает квантовый барьер. Туннелируй сквозь него (за энергию) и подгони дугу так, чтобы попасть в дверь.',
spec: {
specVersion: 1,
meta: { title: 'Туннель сквозь стену', desc: 'Парабола + барьер: туннель в портал.' },
viewport: { xmin: -1, xmax: 11, ymin: -1, ymax: 9, grid: true, axes: true, bg: BG },
time: { duration: 6, loop: false },
params: [
{ name: 'a', label: 'Раскрытие a', min: -0.5, max: -0.15, step: 0.02, value: -0.3 },
{ name: 'k', label: 'Высота вершины k', min: 5, max: 8, step: 0.1, value: 6.5 }
],
objects: [
// барьер поперёк восходящей дуги (вертикальный брус)
{ type: 'zone', id: 'wall', kind: 'forbidden', shape: 'rect', x: 3.2, y: 4.0, w: 0.7, h: 8.4,
color: TUNNEL_WALL, label: 'барьер' },
// монета у самой вершины высокой дуги (k≈7) — совместима со 2-й звездой k≥6.8
circZone('coin', 'collect', 5, 6.9, 0.85, 'монета'),
circZone('gate', 'target', 9.3, 4.6, 0.95, 'портал'),
startMarker(0, 0),
road('a*(x-5)^2 + k', 0, 9.3, 6),
graphHero(),
{ type: 'readout', label: 'a', expr: 'a', precision: 2 },
{ type: 'readout', label: 'k', expr: 'k', precision: 1 }
],
goal: {
title: 'Туннелем в дальний портал',
hint: 'Туннелируй сквозь барьер и проведи дугу в портал. Бонус: задень монету на вершине.',
when: 'gate.hit',
fail: 'wall.hit && tunnel < 1',
stars: [
{ when: 'coin.hit', label: 'Собрал монету' },
{ when: 'gate.hit && k >= 6.8', label: 'Высокая дуга (k ≥ 6.8)' }
]
}
}
};
var LEVELS = [
artillery1, arc2, bounce3, pendulum4, orbit5, slingshot6,
graphLine7, graphSine8, graphParab9, graphAbs10, graphExp11,
superpos12, superpos13, aimer14, tunnel15, tunnel16
];
/* Метаданные глав (созвездий) — для заголовков/оформления карты. */
var CHAPTERS = {
kinematics: { key: 'kinematics', title: 'Кинематика', subtitle: 'Полёт и гравитация', accent: '#22D3EE' },
dynamics: { key: 'dynamics', title: 'Динамика', subtitle: 'Силы, пружины, орбиты', accent: '#A78BFA' },
functions: { key: 'functions', title: 'Функции', subtitle: 'Едем по кривой y = f(x)', accent: '#67E8F9' },
quantum: { key: 'quantum', title: 'Квантовые законы', subtitle: 'Суперпозиция · прицел · туннель', accent: '#C4B5FD' },
// Авторённые учителями уровни (custom_sims cat='game') без явной главы — сюда.
custom: { key: 'custom', title: 'Уровни учителей', subtitle: 'Авторённые уровни сообщества', accent: '#F472B6' }
};
/* ── Авторённые уровни (Фаза 5): custom_sims с cat='game' ───────────────────
Реестр становится «асинхронным»: встроенные уровни доступны сразу (offline),
а опубликованные/свои игровые спеки подмешиваются после ensureCustom().
Запись уровня из строки custom_sims: id='custom:<dbid>', метаданные — из
spec.game (chapter/order/par_ms/unlockStars), spec — как есть. */
var CUSTOM = []; // смёрженные записи авторённых уровней
var _customPromise = null; // кэш промиса загрузки (грузим один раз)
/* Строка из LS.customSimsList/Get -> запись реестра уровня (или null). */
function customToLevel(row) {
if (!row || !row.spec || typeof row.spec !== 'object') return null;
var spec = row.spec;
if (!spec.goal) return null; // не игровой уровень — пропускаем
var gm = (spec.game && typeof spec.game === 'object') ? spec.game : {};
var dbid = row.id;
return {
id: 'custom:' + dbid,
dbid: dbid,
title: row.title || (spec.meta && spec.meta.title) || 'Уровень',
chapter: gm.chapter || 'custom',
order: (typeof gm.order === 'number') ? gm.order : (1000 + Number(dbid)),
unlockStars: (typeof gm.unlockStars === 'number') ? gm.unlockStars : 0,
par_ms: (typeof gm.par_ms === 'number') ? gm.par_ms : undefined,
subject: row.subject || (spec.goal && spec.goal.subject) || undefined,
hint: (spec.goal && spec.goal.hint) || '',
spec: spec,
_custom: true
};
}
/* Загрузить опубликованные + свои игровые custom_sims и смёржить.
Возвращает Promise (кэшируется). Тихо игнорирует ошибки/отсутствие LS. */
function ensureCustom() {
if (_customPromise) return _customPromise;
var LS = global.LS;
if (!LS || !LS.customSimsList || !LS.customSimGet) {
_customPromise = Promise.resolve([]);
return _customPromise;
}
_customPromise = LS.customSimsList().then(function (r) {
var sims = (r && r.sims) || [];
// только игровая категория (список не содержит spec — его берём отдельно)
var games = sims.filter(function (s) { return s && s.cat === 'game'; });
return Promise.all(games.map(function (s) {
return LS.customSimGet(s.id).then(function (g) {
return customToLevel(g && g.sim);
}).catch(function () { return null; });
}));
}).then(function (records) {
CUSTOM = records.filter(Boolean);
return CUSTOM.slice();
}).catch(function () { CUSTOM = []; return []; });
return _customPromise;
}
function list() { return LEVELS.concat(CUSTOM); }
function get(id) {
var all = list();
for (var i = 0; i < all.length; i++) if (all[i].id === id) return all[i];
return null;
}
/* Достать уровень по id асинхронно — для deep-link `custom:<dbid>`, когда он
может ещё не быть в смёрженном списке (напр. свой draft). Резолвит через
LS.customSimGet с проверкой доступа на сервере (own|published|admin). */
function getAsync(id) {
var found = get(id);
if (found) return Promise.resolve(found);
var m = /^custom:(\d+)$/.exec(String(id || ''));
var LS = global.LS;
if (!m || !LS || !LS.customSimGet) return Promise.resolve(null);
return LS.customSimGet(m[1]).then(function (g) {
var lvl = customToLevel(g && g.sim);
if (lvl) {
// подмешать в кэш, чтобы повторное открытие/«Дальше» нашло его синхронно
if (!CUSTOM.some(function (c) { return c.id === lvl.id; })) CUSTOM.push(lvl);
}
return lvl;
}).catch(function () { return null; });
}
function chapter(key) { return CHAPTERS[key] || { key: key, title: key, subtitle: '', accent: '#22D3EE' }; }
global.QuantikLevels = {
list: list, get: get, getAsync: getAsync, ensureCustom: ensureCustom, chapter: chapter,
LEVELS: LEVELS, CHAPTERS: CHAPTERS,
customToLevel: customToLevel
};
})(typeof window !== 'undefined' ? window : this);