diff --git a/src/community/docsGames.js b/src/community/docsGames.js
index 678342c..5a6aabd 100644
--- a/src/community/docsGames.js
+++ b/src/community/docsGames.js
@@ -343,4 +343,9 @@ export const GAMES = [
desc: 'Три локации (гараж · город · магазин), между ними — красивый экран загрузки с прогресс-баром, спиннером и кнопкой «Пропустить», как в Roblox при телепорте.',
mechanics: ['game.loading.transition (Promise)', 'game.loading.show (хэндл setProgress/close)', 'cover: sceneSnapshot (снимок сцены)', 'прогресс-бар + процент + спиннер', 'кнопка «Пропустить» (onSkip)', 'blockInput + пауза симуляции', '3 локации = телепорт + смена окружения', 'воксельный город + интерьер магазина'],
previewShot: 'guide-taxi-scene.png', openProjectId: 2427, ready: true },
+ { id: 'guide-garage', num: 60, group: 'g5', stars: 2, icon: 'camera',
+ title: 'Гараж Босса — главное меню с облётом камеры',
+ desc: 'Живое 3D-меню как в топ-играх: камера кинематографично облетает премиум-машину в гараже, справа патч-ноуты, снизу кнопка ИГРАТЬ → переход в саму игру.',
+ mechanics: ['game.mainMenu.show / hide', 'cinematic-камера (waypoints/orbit)', 'патч-ноуты + логотип + кнопка ИГРАТЬ', 'блок управления + фоновая музыка', 'onPlay → loading.transition → gameplay', 'GLB-модель машины (Kenney car-kit)'],
+ previewShot: 'guide-garage-scene.png', openProjectId: 2434, ready: true },
];
diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx
index 6722729..8b553c2 100644
--- a/src/community/docsLessons.jsx
+++ b/src/community/docsLessons.jsx
@@ -8381,6 +8381,110 @@ step();`}
),
},
+ 'guide-garage': {
+ body: (
+ <>
+
Что получится
+
+ Главное меню как в топ-играх Roblox. Игрок попадает в тёмный
+ гараж, где камера кинематографично облетает премиум-машину
+ (модель из набора Kenney). Сверху — логотип игры, справа — список
+ обновлений (патч-ноуты), снизу — большая жёлтая кнопка «ИГРАТЬ»,
+ играет фоновая музыка. Управление заблокировано — игрок только смотрит.
+ Клик «ИГРАТЬ» → плавная загрузка → ты в самой игре. Первые 5 секунд
+ решают, останется игрок или уйдёт — поэтому красивое меню = лицо игры.
+
+
+
+
+ Чему научишься
+
+ - game.mainMenu.show(opts) — одним вызовом строится всё меню:
+ камера-облёт, логотип, патч-ноуты, кнопка ИГРАТЬ, музыка, блок
+ управления;
+ - cinematic-камера — плавный пролёт по точкам (waypoints) или
+ облёт по кругу (orbit) вокруг витрины;
+ - патч-ноуты — карточка «версия + список изменений» одной
+ строкой
setPatchNotes;
+ - onPlay — что происходит по кнопке ИГРАТЬ: прячем меню →
+ загрузка (задача 12) → телепорт в игровую зону;
+ - меню — отдельная зона сцены (гараж), а игра — другая зона
+ (поляна); переключение прячется за загрузкой.
+
+
+ Шаг 1. Показать меню при старте
+
+ Весь экран строится одним game.mainMenu.show. Камера в
+ режиме cinematic летит по точкам waypoints
+ вокруг машины (центр target). Каждая точка — позиция
+ камеры + куда она смотрит.
+
+
+ {`game.mainMenu.show({
+ title: 'ГАРАЖ БОССА',
+ camera: {
+ mode: 'cinematic', duration: 16,
+ waypoints: [
+ { position: { x: 6, y: 2.6, z: 5.5 }, target: { x: 0, y: 1.3, z: 0 } },
+ { position: { x: 0, y: 1.6, z: 6 }, target: { x: 0, y: 1.3, z: 0 } },
+ { position: { x: -6, y: 3.2, z: -4.5 },target: { x: 0, y: 1.3, z: 0 } },
+ { position: { x: 6, y: 2.6, z: 5.5 }, target: { x: 0, y: 1.3, z: 0 } }, // петля
+ ],
+ },
+ patchNotes: {
+ version: '3.10', title: 'ГАРАЖ БОССА',
+ items: ['Новая система контрактов', '2 ремоделинга', '2 новых обвеса',
+ 'Улучшенные звуки', 'Багфиксы и многое другое...'],
+ },
+ music: 'epoch_01_main',
+ playButtonText: 'ИГРАТЬ',
+ onPlay: onPlay, // ← см. Шаг 2
+});`}
+
+ Камера НЕ должна вылетать за стены — держи точки внутри сцены меню.
+ Другие режимы: orbit (круговой облёт: center/radius/height),
+ static (одна точка), preset-cuts (резкие
+ смены ракурса, как в трейлере).
+
+
+ Шаг 2. Кнопка ИГРАТЬ → вход в игру
+
+ По кнопке прячем меню (это снимает блок управления, музыку и камеру),
+ показываем загрузку (задача 12) и телепортируем игрока в игровую зону.
+
+
+ {`async function onPlay() {
+ game.mainMenu.hide(); // убрать меню, вернуть управление
+ await game.loading.transition({ // красивая загрузка (задача 12)
+ cover: { sceneSnapshot: true }, duration: 1.5, text: 'Загружаем мир...',
+ });
+ game.player.teleport(0, 2, 100); // в игровую зону (поляна)
+ game.scene.environment = 'day'; // дневное небо
+}`}
+
+
+
+ Почему это «лицо игры»
+
+ Меню — отдельная зона проекта (гараж-витрина), игра — другая
+ (поляна). До клика ИГРАТЬ человек видит живую кинематографичную
+ картинку, а не «прототип со спавном». Камера, патч-ноуты и музыка
+ создают ощущение большой игры. А весь переход — это просто
+ hide → transition → teleport.
+
+
+
+ Поменяй режим камеры на orbit (круговой облёт):
+ camera: {`{ mode: 'orbit', center: { x:0, y:1, z:0 }, radius: 6, height: 2.5, duration: 12 }`}.
+ И обнови патч-ноуты под свою игру через
+ game.mainMenu.setPatchNotes(...).
+
+ >
+ ),
+ },
+
};
/** Есть ли готовый текст урока для игры с таким id. */
diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js
index c1f8ad3..57a0a01 100644
--- a/src/editor/engine/BabylonScene.js
+++ b/src/editor/engine/BabylonScene.js
@@ -160,6 +160,7 @@ export class BabylonScene {
this.loadingScreen = null;
this._LoadingScreenOverlayClass = LoadingScreenOverlay;
this._loadingConfig = null; // { logo, accentColor, defaultSpinner, defaultSkipButton }
+ this._mainMenuConfig = null; // задача 13: passthrough-конфиг главного меню
this._projectThumbnail = null; // обложка проекта — дефолтный логотип
this.spawnerManager = null; // спавнеры зомби
this.environment = null;
@@ -7033,6 +7034,8 @@ export class BabylonScene {
defaultSpinner: this._loadingConfig.defaultSpinner !== false,
defaultSkipButton: !!this._loadingConfig.defaultSkipButton,
} : undefined,
+ // Задача 13: конфиг главного меню (passthrough — меню задаётся скриптом).
+ mainMenu: this._mainMenuConfig || undefined,
worldSize: this._worldHalf * 2,
floorEnabled: this._floorEnabled !== false,
jumpPowerMul: this._jumpPowerMul ?? 1,
@@ -7500,6 +7503,9 @@ export class BabylonScene {
} else {
this._loadingConfig = null;
}
+ // Задача 13: конфиг главного меню (passthrough).
+ this._mainMenuConfig = (state.scene.mainMenu && typeof state.scene.mainMenu === 'object')
+ ? state.scene.mainMenu : null;
// Пользовательские скрипты
if (Array.isArray(state.scene.scripts)) {
this._scripts = state.scene.scripts
diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js
index de23db9..a725c95 100644
--- a/src/editor/engine/GameRuntime.js
+++ b/src/editor/engine/GameRuntime.js
@@ -2455,6 +2455,11 @@ export class GameRuntime {
this.scene3d?.player?.cameraReset?.();
return;
}
+ if (cmd === 'player.setInputBlocked') {
+ // Задача 13: блок управления (главное меню — игрок наблюдатель).
+ this.scene3d?.player?.setInputBlocked?.(!!payload?.blocked);
+ return;
+ }
if (cmd === 'player.setSkinVisible') {
const player = this.scene3d?.player;
if (player) {
diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js
index fd977f8..b82d650 100644
--- a/src/editor/engine/ScriptSandboxWorker.js
+++ b/src/editor/engine/ScriptSandboxWorker.js
@@ -804,6 +804,10 @@ const game = {
_send('player.teleport', { x: nx, y: ny, z: nz });
}
},
+ /** Заблокировать/разблокировать управление игроком (WASD/прыжок). */
+ setInputBlocked(blocked) {
+ _send('player.setInputBlocked', { blocked: !!blocked });
+ },
/** Сдвинуть игрока ТОЛЬКО по X (не трогая Z и Y). Для раннеров —
* смена полосы без отмены продвижения autorun. */
setLaneX(x) {
@@ -2443,6 +2447,166 @@ const game = {
_send('camera.reset', {});
},
},
+ /**
+ * Главное меню игры (задача 13) — оркестратор поверх camera.cutscene + gui
+ * + audio + loading. Показывает живую 3D-сцену с cinematic-камерой, GUI
+ * (логотип/патч-ноуты/кнопка ИГРАТЬ) и музыкой; клик ИГРАТЬ → переход в игру.
+ *
+ * game.mainMenu.show({
+ * camera: { mode:'orbit', center:{x:0,y:1,z:0}, radius:6, height:2, duration:12 },
+ * patchNotes: { version:'3.10', title:'DISPATCH CENTER', items:['NEW ...','2 ...'] },
+ * title: 'ГАРАЖ БОССА',
+ * music: 'epoch_01_main',
+ * playButtonText: 'ИГРАТЬ',
+ * onPlay: () => { game.mainMenu.hide(); game.player.teleport(0,2,0); },
+ * });
+ */
+ mainMenu: {
+ _onShow: [], _onPlay: [], _onHide: [],
+ _active: false,
+ _curMusic: null,
+ _opts: null,
+ _camCfg: null,
+ _loopArmed: false,
+ show(opts) {
+ opts = opts && typeof opts === 'object' ? opts : {};
+ this._opts = opts;
+ this._active = true;
+ // 1) Заблокировать управление игроком (наблюдатель).
+ _send('player.setInputBlocked', { blocked: true });
+ game.hud.setVisible(false);
+ // 2) Камера-катсцена по выбранному режиму (с зацикливанием).
+ this._camCfg = opts.camera || { mode: 'orbit', center: { x: 0, y: 1, z: 0 }, radius: 6, height: 2, duration: 12 };
+ this.setCamera(this._camCfg);
+ // Зацикливаем облёт: когда катсцена доиграла и меню ещё активно —
+ // запускаем её заново. Иначе камера отпускается к игроку (за стену).
+ if (!this._loopArmed) {
+ this._loopArmed = true;
+ const self = this;
+ game.onCutsceneDone(() => {
+ if (self._active && self._camCfg) self.setCamera(self._camCfg);
+ });
+ }
+ // 3) GUI меню: логотип + патч-ноуты + кнопка ИГРАТЬ.
+ this._buildGui(opts);
+ // 4) Музыка на лупе.
+ if (opts.music && typeof opts.music === 'string') {
+ this._curMusic = opts.music;
+ _send('audio.playMusic', { trackId: opts.music });
+ }
+ for (const fn of this._onShow) _safeCall(fn, undefined, 'mainMenu.onShow');
+ },
+ /** Перестроить камеру меню. mode: cinematic | orbit | static | preset-cuts. */
+ setCamera(cam) {
+ cam = cam && typeof cam === 'object' ? cam : {};
+ if (this._active) this._camCfg = cam; // запомнить для зацикливания
+ const mode = cam.mode || 'orbit';
+ const dur = Number.isFinite(Number(cam.duration)) ? Number(cam.duration) : 12;
+ if (mode === 'static') {
+ const p = cam.position || { x: 0, y: 5, z: 8 };
+ const t = cam.target || { x: 0, y: 1, z: 0 };
+ // Две одинаковые точки = неподвижная камера.
+ _send('camera.cutscene', { points: [p, p], lookAt: [t, t], segDuration: 9999 });
+ return;
+ }
+ if (mode === 'orbit') {
+ const c = cam.center || { x: 0, y: 1, z: 0 };
+ const r = Number.isFinite(Number(cam.radius)) ? Number(cam.radius) : 6;
+ const h = Number.isFinite(Number(cam.height)) ? Number(cam.height) : 2;
+ const N = 16;
+ const pts = [], looks = [];
+ for (let i = 0; i <= N; i++) {
+ const a = (i / N) * Math.PI * 2;
+ pts.push({ x: c.x + Math.cos(a) * r, y: c.y + h, z: c.z + Math.sin(a) * r });
+ looks.push({ x: c.x, y: c.y, z: c.z });
+ }
+ _send('camera.cutscene', { points: pts, lookAt: looks, segDuration: dur / N });
+ return;
+ }
+ if (mode === 'preset-cuts') {
+ const cuts = Array.isArray(cam.cuts) ? cam.cuts : [];
+ if (cuts.length < 1) return;
+ // Дублируем каждую точку (резкая «выдержка» segDuration), без плавного пролёта.
+ const pts = [], looks = [];
+ for (const c of cuts) {
+ const p = c.position || { x: 0, y: 3, z: 6 };
+ const t = c.target || { x: 0, y: 1, z: 0 };
+ pts.push(p, p); looks.push(t, t);
+ }
+ _send('camera.cutscene', { points: pts, lookAt: looks, segDuration: (cuts[0].duration || 2) });
+ return;
+ }
+ // cinematic — пролёт по waypoints с плавным lerp.
+ const wps = Array.isArray(cam.waypoints) ? cam.waypoints : [];
+ if (wps.length < 2) return;
+ const pts = wps.map(w => w.position || { x: 0, y: 2, z: 0 });
+ const looks = wps.map(w => w.target || { x: 0, y: 1, z: 0 });
+ _send('camera.cutscene', { points: pts, lookAt: looks, segDuration: dur / Math.max(1, pts.length - 1) });
+ },
+ /** Карточка патч-нот в меню (заголовок версии + bullet-список). */
+ setPatchNotes(pn) {
+ pn = pn && typeof pn === 'object' ? pn : {};
+ const title = (pn.title || '') + (pn.version ? ' (' + pn.version + '):' : ':');
+ const items = Array.isArray(pn.items) ? pn.items : [];
+ // Заголовок.
+ game.gui.create('text', {
+ id: '_mm_pn_title', x: 78, y: 28, w: 40, h: 6, anchor: 'center',
+ text: title, textColor: '#ffffff', textSize: 26, fontWeight: 900,
+ textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0,
+ });
+ // Пункты списком.
+ items.slice(0, 7).forEach((it, i) => {
+ game.gui.create('text', {
+ id: '_mm_pn_' + i, x: 78, y: 36 + i * 6, w: 40, h: 5, anchor: 'center',
+ text: '- ' + it, textColor: '#e8edf5', textSize: 20, fontWeight: 700,
+ textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0,
+ });
+ });
+ },
+ _buildGui(opts) {
+ // Логотип-заголовок сверху.
+ if (opts.title) {
+ game.gui.create('text', {
+ id: '_mm_logo', x: 30, y: 12, w: 56, h: 12, anchor: 'center',
+ text: String(opts.title), textColor: '#ffd23a', textSize: 48, fontWeight: 900,
+ textStroke: { color: '#000', width: 4 }, bgColor: 'transparent', bgOpacity: 0,
+ animationPreset: 'glow',
+ });
+ }
+ // Патч-ноуты справа.
+ if (opts.patchNotes) this.setPatchNotes(opts.patchNotes);
+ // Кнопка ИГРАТЬ снизу-справа.
+ game.gui.create('button', {
+ id: '_mm_play', x: 84, y: 90, w: 28, h: 11, anchor: 'center',
+ text: opts.playButtonText || 'ИГРАТЬ',
+ bgGradient: { stops: ['#ffe066', '#e0a000'], angle: 90 },
+ textColor: '#3a2a00', textSize: 30, fontWeight: 900, borderRadius: 14,
+ textStroke: { color: '#fff7d0', width: 1 },
+ hover: { scale: 1.06, brightness: 1.1 }, active: { scale: 0.96 },
+ });
+ const self = this;
+ game.gui.onClick('_mm_play', () => {
+ for (const fn of self._onPlay) _safeCall(fn, undefined, 'mainMenu.onPlay');
+ });
+ },
+ /** Скрыть меню: убрать GUI, остановить музыку, вернуть камеру и управление. */
+ hide() {
+ if (!this._active) return;
+ this._active = false;
+ this._camCfg = null; // остановить зацикливание облёта
+ const ids = ['_mm_logo', '_mm_play', '_mm_pn_title', '_mm_pn_0', '_mm_pn_1', '_mm_pn_2', '_mm_pn_3', '_mm_pn_4', '_mm_pn_5', '_mm_pn_6'];
+ for (const id of ids) { try { game.gui.remove(id); } catch (e) {} }
+ if (this._curMusic) { _send('audio.stopMusic', {}); this._curMusic = null; }
+ _send('camera.reset', {});
+ _send('player.setInputBlocked', { blocked: false });
+ game.hud.setVisible(true);
+ for (const fn of this._onHide) _safeCall(fn, undefined, 'mainMenu.onHide');
+ },
+ onShow(fn) { if (typeof fn === 'function') this._onShow.push(fn); },
+ onPlay(fn) { if (typeof fn === 'function') this._onPlay.push(fn); },
+ onHide(fn) { if (typeof fn === 'function') this._onHide.push(fn); },
+ isActive() { return this._active; },
+ },
/**
* Управление стандартным HUD движка (HP-бар, hotbar, кнопка меню, чат).
* Нужно для игр которые делают свой UI через game.gui.* и не хотят