@
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> @
This commit is contained in:
@@ -961,19 +961,98 @@
|
||||
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' }
|
||||
quantum: { key: 'quantum', title: 'Квантовые законы', subtitle: 'Суперпозиция · прицел · туннель', accent: '#C4B5FD' },
|
||||
// Авторённые учителями уровни (custom_sims cat='game') без явной главы — сюда.
|
||||
custom: { key: 'custom', title: 'Уровни учителей', subtitle: 'Авторённые уровни сообщества', accent: '#F472B6' }
|
||||
};
|
||||
|
||||
function list() { return LEVELS.slice(); }
|
||||
/* ── Авторённые уровни (Фаза 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) {
|
||||
for (var i = 0; i < LEVELS.length; i++) if (LEVELS[i].id === id) return LEVELS[i];
|
||||
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, chapter: chapter,
|
||||
LEVELS: LEVELS, CHAPTERS: CHAPTERS
|
||||
list: list, get: get, getAsync: getAsync, ensureCustom: ensureCustom, chapter: chapter,
|
||||
LEVELS: LEVELS, CHAPTERS: CHAPTERS,
|
||||
customToLevel: customToLevel
|
||||
};
|
||||
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
|
||||
+261
-3
@@ -74,7 +74,15 @@
|
||||
params: [],
|
||||
objects: [],
|
||||
plots: [], // храним plot-объекты отдельно для удобства UI, при сборке мерджим в objects
|
||||
physics: { enabled: false, gx: 0, gy: -9.8, friction: 0, restitution: 0.9, walls: [], springs: [] }
|
||||
physics: { enabled: false, gx: 0, gy: -9.8, friction: 0, restitution: 0.9, walls: [], springs: [] },
|
||||
// P5-Квантик: игровой слой (goal + игровые метаданные). enabled=false → goal/game
|
||||
// не попадают в спеку (обычная симуляция ведёт себя ровно как раньше).
|
||||
game: {
|
||||
enabled: false,
|
||||
when: '', title: '', hint: '', hold: '', fail: '',
|
||||
stars: [], // [{ when, label }], макс 3
|
||||
chapter: '', order: '', par_ms: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,7 +99,7 @@
|
||||
this._remountTimer = null;
|
||||
this._selObjId = null; // выбранный для drag-on-preview объект
|
||||
this._placing = false; // режим «поставить объект кликом»
|
||||
this._open = { meta: true, params: true, objects: true, plots: true };
|
||||
this._open = { meta: true, params: true, objects: true, plots: true, game: false };
|
||||
this._lastSpec = null;
|
||||
// P5: прямое манипулирование + история
|
||||
this._snap = false; // привязка к сетке при drag
|
||||
@@ -226,6 +234,8 @@
|
||||
walls: (Array.isArray(ph.walls) ? ph.walls : []).map(function (w) { return Object.assign({ _uid: uid('w') }, w); }),
|
||||
springs: (Array.isArray(ph.springs) ? ph.springs : []).map(function (s) { return Object.assign({ _uid: uid('s') }, s); })
|
||||
};
|
||||
// game/goal (P5-Квантик): раскладываем spec.goal + spec.game обратно в st.game.
|
||||
st.game = loadGame(spec.goal, spec.game);
|
||||
this.st = st;
|
||||
// свежая загрузка (открытие симуляции / шаблон) — история начинается заново
|
||||
this._undo.length = 0; this._redo.length = 0; this._fieldSnapTaken = false;
|
||||
@@ -272,6 +282,15 @@
|
||||
};
|
||||
spec.physics = ph;
|
||||
}
|
||||
|
||||
// game/goal (P5-Квантик): материализуем игровой слой, если он включён.
|
||||
// goal{when,title,hint,hold,fail,stars[]} и game{chapter,order,par_ms}.
|
||||
if (st.game && st.game.enabled) {
|
||||
var goal = buildGoal(st.game);
|
||||
if (goal) spec.goal = goal;
|
||||
var game = buildGameMeta(st.game);
|
||||
if (game) spec.game = game;
|
||||
}
|
||||
return spec;
|
||||
};
|
||||
|
||||
@@ -321,6 +340,71 @@
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ── Игровой слой (P5-Квантик): goal/game ⇄ UI-состояние st.game ───────────
|
||||
st.game = { enabled, when, title, hint, hold, fail, stars:[{when,label}],
|
||||
chapter, order, par_ms }. Хранит «как введено» (строки/числа) —
|
||||
материализация в spec.goal/spec.game на сборке, разбор обратно на загрузке. */
|
||||
|
||||
/* spec.goal + spec.game -> st.game (для loadFromSim). Включаем игровой режим,
|
||||
если в спеке присутствует goal ИЛИ game. */
|
||||
function loadGame(goal, game) {
|
||||
var g = {
|
||||
enabled: false,
|
||||
when: '', title: '', hint: '', hold: '', fail: '',
|
||||
stars: [], chapter: '', order: '', par_ms: ''
|
||||
};
|
||||
if (goal && typeof goal === 'object') {
|
||||
g.enabled = true;
|
||||
g.when = goal.when == null ? '' : String(goal.when);
|
||||
g.title = goal.title == null ? '' : String(goal.title);
|
||||
g.hint = goal.hint == null ? '' : String(goal.hint);
|
||||
g.fail = goal.fail == null ? '' : String(goal.fail);
|
||||
g.hold = (goal.hold == null || goal.hold === '') ? '' : goal.hold;
|
||||
g.stars = (Array.isArray(goal.stars) ? goal.stars : []).map(function (s) {
|
||||
s = s || {};
|
||||
return {
|
||||
_uid: uid('star'),
|
||||
when: s.when == null ? '' : String(s.when),
|
||||
label: s.label == null ? '' : String(s.label)
|
||||
};
|
||||
});
|
||||
}
|
||||
if (game && typeof game === 'object') {
|
||||
g.enabled = true;
|
||||
g.chapter = game.chapter == null ? '' : String(game.chapter);
|
||||
g.order = (game.order == null || game.order === '') ? '' : game.order;
|
||||
g.par_ms = (game.par_ms == null || game.par_ms === '') ? '' : game.par_ms;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/* st.game -> spec.goal (или null, если нет ни одного содержательного поля). */
|
||||
function buildGoal(gm) {
|
||||
var out = {};
|
||||
if (trimStr(gm.when)) out.when = trimStr(gm.when);
|
||||
if (trimStr(gm.title)) out.title = trimStr(gm.title);
|
||||
if (trimStr(gm.hint)) out.hint = trimStr(gm.hint);
|
||||
if (trimStr(gm.fail)) out.fail = trimStr(gm.fail);
|
||||
if (gm.hold !== '' && gm.hold != null && isFinite(parseFloat(gm.hold))) out.hold = parseFloat(gm.hold);
|
||||
var stars = (Array.isArray(gm.stars) ? gm.stars : []).map(function (s) {
|
||||
var os = {};
|
||||
if (trimStr(s.when)) os.when = trimStr(s.when);
|
||||
if (trimStr(s.label)) os.label = trimStr(s.label);
|
||||
return os;
|
||||
}).filter(function (s) { return s.when || s.label; }).slice(0, 3);
|
||||
if (stars.length) out.stars = stars;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/* st.game -> spec.game (метаданные уровня; null, если все пусты). */
|
||||
function buildGameMeta(gm) {
|
||||
var out = {};
|
||||
if (trimStr(gm.chapter)) out.chapter = trimStr(gm.chapter);
|
||||
if (gm.order !== '' && gm.order != null && isFinite(parseFloat(gm.order))) out.order = parseFloat(gm.order);
|
||||
if (gm.par_ms !== '' && gm.par_ms != null && isFinite(parseFloat(gm.par_ms))) out.par_ms = parseFloat(gm.par_ms);
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/* ════════════════════════ ЖИВОЕ ПРЕВЬЮ ════════════════════════ */
|
||||
|
||||
Builder.prototype.scheduleRemount = function (immediate) {
|
||||
@@ -609,6 +693,41 @@
|
||||
if (action === 'snap') { this.toggleSnap(); return; }
|
||||
};
|
||||
|
||||
/* «Играть»: открыть текущую (в работе) спеку в игровом режиме для теста уровня.
|
||||
Монтируем тот же SimEngine в модалке — слой цели (HUD/победа/звёзды) активируется
|
||||
САМ наличием блока goal (Фаза 0 движка), как и в /quantik. Без сохранения/сети —
|
||||
тестируем прямо черновик. Если goal не задан, подсказываем включить игровой слой. */
|
||||
Builder.prototype.playGame = function () {
|
||||
var self = this;
|
||||
var spec = this.buildSpec();
|
||||
if (!spec.goal || !spec.goal.when) {
|
||||
global.LS.toast('Задайте цель (поле «победа») и включите игровой уровень', 'warn', 2600);
|
||||
return;
|
||||
}
|
||||
if (!global.SimEngine) { global.LS.toast('Движок не загружен', 'error'); return; }
|
||||
// Модалка с хост-узлом сцены; SimEngine монтируется после открытия. Инстанс
|
||||
// уничтожается в onClose — он срабатывает на ЛЮБОЕ закрытие (X / оверлей / Escape /
|
||||
// кнопка «Закрыть»), поэтому отдельный destroy в onClick кнопки не нужен.
|
||||
var host = global.document.createElement('div');
|
||||
host.style.cssText = 'position:relative;width:100%;height:min(70vh,560px);background:#0D0D1A;border-radius:10px;overflow:hidden';
|
||||
var inst = null;
|
||||
var m = global.LS.modal({
|
||||
title: 'Тест уровня', size: 'lg', content: '',
|
||||
onClose: function () { if (inst) { try { inst.destroy(); } catch (e) {} inst = null; } },
|
||||
actions: [
|
||||
{ label: 'Сброс', onClick: function () { if (inst && inst.reset) { try { inst.reset(); } catch (e) {} } } },
|
||||
{ label: 'Закрыть', primary: true, onClick: function () { m.close(); } }
|
||||
]
|
||||
});
|
||||
m.body.appendChild(host);
|
||||
try {
|
||||
inst = global.SimEngine.mount(host, spec);
|
||||
if (inst && inst.play) inst.play();
|
||||
} catch (e) {
|
||||
host.innerHTML = '<div style="padding:30px;color:#ef4444">Ошибка запуска: ' + esc(e.message || e) + '</div>';
|
||||
}
|
||||
};
|
||||
|
||||
/* Переключить привязку к сетке (drag будет округлять к шагу сетки). */
|
||||
Builder.prototype.toggleSnap = function () {
|
||||
this._snap = !this._snap;
|
||||
@@ -764,6 +883,18 @@
|
||||
if (r < 0 || r > 1) errs.push('Упругость (restitution) должна быть в диапазоне 0..1.');
|
||||
}
|
||||
|
||||
// game/goal (P5-Квантик): проверяем выражения цели/проигрыша/звёзд
|
||||
if (st.game && st.game.enabled) {
|
||||
checkExpr(typeof st.game.when === 'string' ? st.game.when : '', 'Цель: условие победы (when)');
|
||||
checkExpr(typeof st.game.fail === 'string' ? st.game.fail : '', 'Цель: условие проигрыша (fail)');
|
||||
if (!trimStr(st.game.when)) errs.push('Игровой уровень: укажите условие победы (when).');
|
||||
var starList = Array.isArray(st.game.stars) ? st.game.stars : [];
|
||||
if (starList.length > 3) errs.push('Максимум 3 звезды.');
|
||||
starList.forEach(function (s, i) {
|
||||
checkExpr(typeof s.when === 'string' ? s.when : '', 'Звезда #' + (i + 1) + ', условие');
|
||||
});
|
||||
}
|
||||
|
||||
// размер JSON
|
||||
try {
|
||||
var bytes = new global.Blob([JSON.stringify(this.buildSpec())]).size;
|
||||
@@ -833,7 +964,8 @@
|
||||
this.sectionMeta() +
|
||||
this.sectionParams() +
|
||||
this.sectionObjects() +
|
||||
this.sectionPlotsPhysics();
|
||||
this.sectionPlotsPhysics() +
|
||||
this.sectionGame();
|
||||
this.wirePanels();
|
||||
};
|
||||
|
||||
@@ -1134,6 +1266,72 @@
|
||||
section('physics', 'Физика', physBody, !!ph.enabled);
|
||||
};
|
||||
|
||||
/* ── Игровой уровень (P5-Квантик) ─────────────────────────────────────────
|
||||
Панель «Цель» собирает блок goal (when/title/hint/hold/fail) + список звёзд
|
||||
(макс 3) + игровые метаданные (chapter/order/par_ms). Тумблер «Это игровой
|
||||
уровень» включает слой; выключенный — goal/game НЕ попадают в спеку.
|
||||
Выражения (when/fail/звёзды) проверяются inline через SimExpr.compile. */
|
||||
Builder.prototype.sectionGame = function () {
|
||||
var gm = this.st.game || {};
|
||||
var on = !!gm.enabled;
|
||||
// строка-выражение цели/проигрыша с inline-ошибкой
|
||||
function exprRow(key, label, val, ph) {
|
||||
var err = exprError(val);
|
||||
return '<div class="sbu-of' + (err ? ' has-err' : '') + '">' +
|
||||
'<label class="sbu-of-lbl">' + esc(label) +
|
||||
'<button class="sbu-fx" data-gfx="' + key + '" title="Палитра функций/параметров">fx</button>' +
|
||||
'</label>' +
|
||||
'<input class="sbu-in sbu-in-expr" data-gf="' + key + '" value="' + esc(val == null ? '' : val) + '" placeholder="' + esc(ph || 'условие (выражение)') + '" />' +
|
||||
(err ? '<span class="sbu-of-err">' + esc(err) + '</span>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
var stars = Array.isArray(gm.stars) ? gm.stars : [];
|
||||
var starRows = stars.map(function (s, i) {
|
||||
var err = exprError(s.when);
|
||||
return '<div class="sbu-star" data-si="' + i + '">' +
|
||||
'<div class="sbu-star-hdr">' +
|
||||
'<span class="sbu-obj-type">Звезда ' + (i + 1) + '</span>' +
|
||||
'<span style="flex:1"></span>' +
|
||||
'<button class="sbu-icon-btn sbu-del" data-stardel="' + i + '" title="Удалить звезду">' + ICON.trash + '</button>' +
|
||||
'</div>' +
|
||||
'<div class="sbu-of' + (err ? ' has-err' : '') + '">' +
|
||||
'<label class="sbu-of-lbl">условие' +
|
||||
'<button class="sbu-fx" data-sfx="' + i + '" title="Палитра">fx</button>' +
|
||||
'</label>' +
|
||||
'<input class="sbu-in sbu-in-expr" data-sf="when" value="' + esc(s.when == null ? '' : s.when) + '" placeholder="напр. coin.hit" />' +
|
||||
(err ? '<span class="sbu-of-err">' + esc(err) + '</span>' : '') +
|
||||
'</div>' +
|
||||
miniField('подпись', '<input class="sbu-in" data-sf="label" value="' + esc(s.label == null ? '' : s.label) + '" placeholder="Собрал кристалл" />') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
var inner =
|
||||
'<label class="sbu-of-check sbu-phys-toggle"><input type="checkbox" data-game="enabled"' + (on ? ' checked' : '') + '/> Это игровой уровень (Квантик)</label>' +
|
||||
'<div class="sbu-game-fields"' + (on ? '' : ' style="opacity:.45;pointer-events:none"') + '>' +
|
||||
'<div class="sbu-sub">Цель</div>' +
|
||||
exprRow('when', 'победа (when)', gm.when, 'напр. gate.hit или hypot(ball.x-8,ball.y-1)<0.8') +
|
||||
exprRow('fail', 'проигрыш (fail) — опц.', gm.fail, 'напр. ball.y < -1 || t > 8') +
|
||||
'<div class="sbu-row2">' +
|
||||
field('Заголовок цели', '<input class="sbu-in" data-gf="title" value="' + esc(gm.title || '') + '" placeholder="Попади в портал" />') +
|
||||
miniField('удержать, с (hold)', '<input class="sbu-in" type="number" step="0.1" min="0" data-gf="hold" value="' + esc(gm.hold == null ? '' : gm.hold) + '" placeholder="0" />') +
|
||||
'</div>' +
|
||||
field('Подсказка', '<textarea class="sbu-in" data-gf="hint" rows="2" placeholder="Краткая подсказка игроку">' + esc(gm.hint || '') + '</textarea>') +
|
||||
'<div class="sbu-sub">Звёзды (макс 3)</div>' +
|
||||
'<div class="sbu-stars-list">' + (starRows || '<div class="sbu-empty-sm">Нет звёзд-бонусов. Победа = 1-я звезда автоматически.</div>') + '</div>' +
|
||||
(stars.length < 3 ? '<button class="sbu-add sbu-add-sm" data-add="star">' + ICON.plus + ' Звезда</button>' : '') +
|
||||
'<div class="sbu-divider"></div>' +
|
||||
'<div class="sbu-sub">Метаданные уровня</div>' +
|
||||
'<div class="sbu-row4">' +
|
||||
miniField('глава', '<input class="sbu-in" data-gf="chapter" value="' + esc(gm.chapter || '') + '" placeholder="kinematics" />') +
|
||||
miniField('порядок', '<input class="sbu-in" type="number" data-gf="order" value="' + esc(gm.order == null ? '' : gm.order) + '" placeholder="1" />') +
|
||||
miniField('норматив, мс', '<input class="sbu-in" type="number" data-gf="par_ms" value="' + esc(gm.par_ms == null ? '' : gm.par_ms) + '" placeholder="1500" />') +
|
||||
'<span></span>' +
|
||||
'</div>' +
|
||||
'<button class="sbu-add sbu-add-sm" data-a2="play-game">' + ICON.play + ' Играть (тест уровня)</button>' +
|
||||
'</div>';
|
||||
return section('game', 'Игровой уровень (цель/звёзды)', inner, this._open.game);
|
||||
};
|
||||
|
||||
/* ════════════════════════ ПРИВЯЗКА СОБЫТИЙ ПАНЕЛЕЙ ════════════════════════ */
|
||||
|
||||
Builder.prototype.wirePanels = function () {
|
||||
@@ -1468,6 +1666,61 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ── Игровой слой (P5-Квантик) ──
|
||||
var gameOn = p.querySelector('[data-game="enabled"]');
|
||||
if (gameOn) gameOn.addEventListener('change', function () {
|
||||
self.pushHistory();
|
||||
self.st.game.enabled = gameOn.checked;
|
||||
self.renderPanels(); self.scheduleRemount(false);
|
||||
});
|
||||
// goal/game поля (when/fail/title/hint/hold/chapter/order/par_ms)
|
||||
p.querySelectorAll('[data-gf]').forEach(function (el) {
|
||||
el.addEventListener('input', function () {
|
||||
self.snapField();
|
||||
var k = el.getAttribute('data-gf');
|
||||
self.st.game[k] = el.value;
|
||||
self.updateFieldFeedback(el, null); // inline-ошибка выражения (when/fail)
|
||||
self.scheduleRemount(false);
|
||||
});
|
||||
});
|
||||
// звёзды: поля when/label
|
||||
p.querySelectorAll('.sbu-star').forEach(function (row) {
|
||||
var i = parseInt(row.getAttribute('data-si'), 10);
|
||||
row.querySelectorAll('[data-sf]').forEach(function (el) {
|
||||
el.addEventListener('input', function () {
|
||||
self.snapField();
|
||||
var k = el.getAttribute('data-sf');
|
||||
if (self.st.game.stars[i]) self.st.game.stars[i][k] = el.value;
|
||||
self.updateFieldFeedback(el, null);
|
||||
self.scheduleRemount(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
p.querySelectorAll('[data-stardel]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
self.pushHistory();
|
||||
self.st.game.stars.splice(parseInt(b.getAttribute('data-stardel'), 10), 1);
|
||||
self.renderPanels(); self.scheduleRemount(false);
|
||||
});
|
||||
});
|
||||
// fx-палитра для goal-выражений и условий звёзд
|
||||
p.querySelectorAll('[data-gfx]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var key = b.getAttribute('data-gfx');
|
||||
self.openPalette(p.querySelector('[data-gf="' + key + '"]'));
|
||||
});
|
||||
});
|
||||
p.querySelectorAll('[data-sfx]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var row = b.closest('.sbu-star');
|
||||
self.openPalette(row && row.querySelector('[data-sf="when"]'));
|
||||
});
|
||||
});
|
||||
// «Играть (тест уровня)» внутри панели
|
||||
p.querySelectorAll('[data-a2="play-game"]').forEach(function (b) {
|
||||
b.addEventListener('click', function () { self.playGame(); });
|
||||
});
|
||||
|
||||
// add buttons
|
||||
p.querySelectorAll('[data-add]').forEach(function (b) {
|
||||
b.addEventListener('click', function () { self.onAdd(b.getAttribute('data-add')); });
|
||||
@@ -1533,6 +1786,11 @@
|
||||
if (this.st.physics.springs.length >= LIMITS.springs) { global.LS.toast('Достигнут лимит пружин', 'warn'); return; }
|
||||
this.pushHistory();
|
||||
this.st.physics.springs.push({ _uid: uid('s'), a: '', b: '', k: 40, length: 2, damping: 0.5 });
|
||||
} else if (what === 'star') {
|
||||
this.st.game.stars = Array.isArray(this.st.game.stars) ? this.st.game.stars : [];
|
||||
if (this.st.game.stars.length >= 3) { global.LS.toast('Максимум 3 звезды', 'warn'); return; }
|
||||
this.pushHistory();
|
||||
this.st.game.stars.push({ _uid: uid('star'), when: '', label: '' });
|
||||
}
|
||||
this.renderPanels();
|
||||
this.scheduleRemount(false);
|
||||
|
||||
+26
-6
@@ -521,17 +521,37 @@
|
||||
|
||||
backBtn.addEventListener('click', showMap);
|
||||
|
||||
// Подмешать авторённые уровни (custom_sims cat='game') до рендера карты (Ф5).
|
||||
function ensureCustomLevels() {
|
||||
if (window.QuantikLevels.ensureCustom) {
|
||||
return window.QuantikLevels.ensureCustom().catch(function () {});
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Старт: если ?level=<id> в URL и уровень доступен — открыть его, иначе карта.
|
||||
loadProgress().then(function () {
|
||||
// Сначала грузим прогресс И авторённые уровни (параллельно), затем deep-link.
|
||||
Promise.all([loadProgress(), ensureCustomLevels()]).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;
|
||||
}
|
||||
// custom:<id> может быть свой draft (нет в списке) — резолвим асинхронно с
|
||||
// проверкой доступа на сервере (own|published|admin → иначе 404/403 → карта).
|
||||
var resolve = window.QuantikLevels.getAsync
|
||||
? window.QuantikLevels.getAsync(wantId)
|
||||
: Promise.resolve(window.QuantikLevels.get(wantId));
|
||||
resolve.then(function (lvl) {
|
||||
// Авторённый уровень (deep-link) — открываем без гейта unlockStars
|
||||
// (учитель/получатель ссылки заходит прямо в него). Встроенный — как раньше.
|
||||
var isCustom = /^custom:/.test(wantId);
|
||||
if (lvl && (isCustom || window.QuantikProgress.isUnlocked(lvl, progressMap, window.QuantikLevels.list()))) {
|
||||
openLevel(lvl);
|
||||
} else {
|
||||
showMapNoReload();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
showMapNoReload();
|
||||
});
|
||||
|
||||
@@ -133,6 +133,12 @@
|
||||
.sbu-phys-fields { display: flex; flex-direction: column; gap: 8px; }
|
||||
.sbu-wall { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
/* ── игровой уровень (P5-Квантик): цель + звёзды ── */
|
||||
.sbu-game-fields { display: flex; flex-direction: column; gap: 8px; }
|
||||
.sbu-stars-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.sbu-star { border: 1px solid var(--border); border-radius: 10px; padding: 9px; background: #fafbfd; display: flex; flex-direction: column; gap: 7px; }
|
||||
.sbu-star-hdr { display: flex; align-items: center; gap: 5px; }
|
||||
|
||||
/* ── палитра ── */
|
||||
.sbu-pal { display: flex; flex-direction: column; gap: 12px; max-height: 60vh; overflow-y: auto; }
|
||||
.sbu-pal-title { font-size: .72rem; font-weight: 700; color: var(--text-3); margin-bottom: 5px; }
|
||||
|
||||
Reference in New Issue
Block a user