From f0e284878e02db7676c8c793ec29114b0cffaf84 Mon Sep 17 00:00:00 2001 From: min Date: Wed, 3 Jun 2026 00:07:33 +0300 Subject: [PATCH] =?UTF-8?q?feat(13):=20=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D1=8B=20(game.mainMenu)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живое 3D-меню как в топ-играх Roblox: cinematic-камера облетает витрину, патч-ноуты, логотип, кнопка ИГРАТЬ, музыка, блок управления. - game.mainMenu.show/hide/setCamera/setPatchNotes/onShow/onPlay/onHide - 4 режима камеры: cinematic(waypoints)/orbit/static/preset-cuts поверх camera.cutscene - зацикливание облёта через onCutsceneDone (камера не вылетает за сцену) - game.player.setInputBlocked экспортирован в worker + handler в runtime - passthrough scene.mainMenu в toJSON/load - вики g5 #60 guide-garage, тест-игра «Гараж Босса» id 2434 (GLB-машина Kenney) Co-Authored-By: Claude Opus 4.8 --- src/community/docsGames.js | 5 + src/community/docsLessons.jsx | 104 ++++++++++++++ src/editor/engine/BabylonScene.js | 6 + src/editor/engine/GameRuntime.js | 5 + src/editor/engine/ScriptSandboxWorker.js | 164 +++++++++++++++++++++++ 5 files changed, 284 insertions(+) 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'; // дневное небо +}`} + + + +

Почему это «лицо игры»

+

+ Меню — отдельная зона проекта (гараж-витрина), игра — другая + (поляна). До клика ИГРАТЬ человек видит живую кинематографичную + картинку, а не «прототип со спавном». Камера, патч-ноуты и музыка + создают ощущение большой игры. А весь переход — это просто + hidetransitionteleport. +

+ + + Поменяй режим камеры на 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.* и не хотят -- 2.47.2