From 34060c90c385830270a69473bad23cf111effa9a Mon Sep 17 00:00:00 2001 From: min Date: Tue, 2 Jun 2026 22:00:26 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(12):=20=D0=B2=D0=BD=D1=83=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=B8=D0=B3=D1=80=D0=BE=D0=B2=D0=BE=D0=B9=20Loading=20Sc?= =?UTF-8?q?reen=20(game.loading)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Программный экран загрузки для перехода между мирами: - game.loading.show(opts) → хэндл (setProgress/setText/setCover/close/onSkip/onComplete) - game.loading.transition(opts) → Promise (фейковый прогресс за duration) - cover sceneSnapshot, прогресс-бар+процент, спиннер, кнопка Пропустить, логотип - blockInput + пауза симуляции, fadeIn/Out; tick независим от paused - настройки проекта «Экран загрузки» (логотип/акцент/дефолты) + 3 сниппета - LoadingScreenOverlay.js (новый DOM-оверлей), worker namespace loading, cmd loading.* + _ensureLoadingScreen, serialize/load конфига в scene - вики g5 #59 guide-taxi (карточка + урок), тест-игра «Такси-босс» id 2427 Co-Authored-By: Claude Opus 4.8 --- src/community/docsGames.js | 5 + src/community/docsLessons.jsx | 112 ++++++ src/editor/GameSettingsModal.jsx | 84 +++++ src/editor/KubikonEditor.jsx | 15 + src/editor/engine/BabylonScene.js | 46 +++ src/editor/engine/GameRuntime.js | 38 +++ src/editor/engine/LoadingScreenOverlay.js | 399 ++++++++++++++++++++++ src/editor/engine/ScriptSandboxWorker.js | 79 +++++ src/editor/engine/snippets.js | 41 +++ 9 files changed, 819 insertions(+) create mode 100644 src/editor/engine/LoadingScreenOverlay.js diff --git a/src/community/docsGames.js b/src/community/docsGames.js index 817b899..c035355 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -338,4 +338,9 @@ export const GAMES = [ desc: 'Tycoon-механика «купи → поставь»: магазин-инвентарь снизу, клик по слоту → полупрозрачная тень предмета летит за курсором, ЛКМ ставит на свой участок (можно стопкой), R/колесо — поворот, ПКМ/Esc — отмена. Денег мало → слот серый. Воксельный мир: трава, деревья, пруд.', mechanics: ['game.placement.start', 'game.inventoryUi (магазин-слоты)', 'onPlace/onCancel/onMove', 'тень-превью формой модели', 'снап к сетке + стопка', 'проверка баланса (не в минус)', 'воксельные модели + ландшафт'], previewShot: 'guide-zavod-scene.png', openProjectId: 2345, ready: true }, + { id: 'guide-taxi', num: 59, group: 'g5', stars: 2, icon: 'loader', + title: 'Такси-босс — экран загрузки между мирами', + desc: 'Программный экран загрузки (как в Roblox при телепорте): кликнул такси → весь экран плавно затемняется, в центре снимок сцены, жёлтый прогресс-бар заполняется за 4с, процент, кнопка «Пропустить», спиннер «Загрузка». Дальше — телепорт в город и закатное небо. В городе кнопка «Магазин» делает короткий переход.', + mechanics: ['game.loading.transition (Promise)', 'game.loading.show (хэндл setProgress/close)', 'cover: sceneSnapshot (снимок сцены)', 'прогресс-бар + процент + спиннер', 'кнопка «Пропустить» (onSkip)', 'blockInput + пауза симуляции', 'воксельный город (такси/небоскрёбы)'], + previewShot: 'guide-taxi-scene.png', openProjectId: 2427, ready: true }, ]; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 3682d17..13f6103 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8255,6 +8255,118 @@ game.placement.onCancel(() => game.ui.set('hint', '', {}));`} ), }, + 'guide-taxi': { + body: ( + <> +

Что получится

+

+ Игрок стоит в гараже рядом с жёлтым такси. Кликнул по + такси → весь экран плавно затемняется, в центре — снимок + сцены, под ним жёлтый прогресс-бар заполняется за 4 секунды, + крупно процент, кнопка «Пропустить» и спиннер + «Загрузка». Через 4 секунды экран исчезает — игрок уже + в городе, небо стало закатным. Это та самая «загрузка между + мирами», которую в больших играх показывают при телепорте на новый + уровень (Taxi Boss, Brookhaven, Jailbreak). +

+ + + +

Чему научишься

+
    +
  • game.loading.transition(opts) — готовый переход: показал + экран, заполнил бар за duration секунд, сам скрыл. + Возвращает Promise — пишешь await и + продолжаешь код уже «на новом уровне»;
  • +
  • game.loading.show(opts) — ручной режим: возвращает + хэндл с setProgress(0..1), setText(), + close() — для реальной загрузки ресурсов;
  • +
  • cover: {`{ sceneSnapshot: true }`} — кадр текущей сцены + автоматически становится картинкой-превью;
  • +
  • кнопка «Пропустить» (skipButton) и + спиннер (spinner) — включаются одной опцией;
  • +
  • blockInput + пауза — во время загрузки WASD/мышь не + работают, а симуляция замирает (NPC не двигаются), автоматически.
  • +
+ +

Шаг 1. Магазин по умолчанию — настройки игры

+

+ В Настройки → Экран загрузки один раз задаёшь логотип + игры, цвет акцента (бар и кнопка) и галочки «спиннер» / + «кнопка Пропустить» по умолчанию. Дальше любой game.loading + в этой игре берёт их сам — не повторяешь стиль в каждом вызове. +

+ +

Шаг 2. Клик по такси → переход в город

+

+ На самом такси висит маленький скрипт. game.self.onClick — + клик именно по этому объекту. Внутри — await + game.loading.transition(...): код «замирает», пока крутится + загрузка, и продолжается, когда она закончилась (или игрок нажал + «Пропустить»). +

+ + {`game.self.onClick(async () => { + await game.loading.transition({ + cover: { sceneSnapshot: true }, // снимок текущей сцены как картинка + duration: 4, // бар заполняется 4 секунды + text: 'Едем в центр города...', + skipButton: true, // можно пропустить ожидание + spinner: true, // спиннер «Загрузка» справа + }); + // Этот код выполнится ПОСЛЕ загрузки (экран уже скрыт): + game.player.teleport(100, 2, 100); // телепорт в город + game.scene.environment = 'sunset'; // закатное небо +});`} + + transition — это «фейковый» прогресс на заданное время + (для красивого перехода). Для реальной загрузки ресурсов есть + show + setProgress — см. Шаг 3. + + +

Шаг 3. Ручной прогресс (реальная загрузка)

+

+ Если грузишь много объектов и хочешь показать настоящий + прогресс — открой экран через show и двигай бар сам + через setProgress. Закрой через close(). +

+ + {`const lo = game.loading.show({ progressBar: true, spinner: true }); +const total = 10; +let i = 0; +const step = () => { + i++; + // ... подгрузить i-й кусок мира ... + lo.setProgress(i / total); // двигаем бар вручную + if (i < total) game.after(0.2, step); // следующий шаг через 0.2с + else lo.close(); // всё загружено — спрятать экран +}; +step();`} + + + +

Почему это удобно

+

+ Один проект — несколько миров (гараж, город, магазин), а + переключение между ними прячется за красивым экраном загрузки. + Игрок не видит «телепорт рывком» — видит плавную загрузку, как в + больших играх. А await делает код линейным: «показать + загрузку → дождаться → продолжить». +

+ + + В городе есть кнопка «Магазин» — она делает короткий переход + (1.5с) к зданию-магазину. Сделай по аналогии ещё одну точку: кнопку + «Гараж», которая через loading.transition на 1 секунду + возвращает игрока к такси (teleport(0, 2, 0)) и ставит + дневное небо (environment = 'day'). + + + ), + }, + }; /** Есть ли готовый текст урока для игры с таким id. */ diff --git a/src/editor/GameSettingsModal.jsx b/src/editor/GameSettingsModal.jsx index fdfa26c..e579b3d 100644 --- a/src/editor/GameSettingsModal.jsx +++ b/src/editor/GameSettingsModal.jsx @@ -45,8 +45,14 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot const [multiplayer, setMultiplayer] = useState(false); const [maxPlayers, setMaxPlayers] = useState(10); const [isTest, setIsTest] = useState(false); + // Задача 12: экран загрузки + const [loadingLogo, setLoadingLogo] = useState(''); + const [loadingAccent, setLoadingAccent] = useState('#ffc020'); + const [loadingSpinner, setLoadingSpinner] = useState(true); + const [loadingSkip, setLoadingSkip] = useState(false); const [error, setError] = useState(''); const fileInputRef = useRef(null); + const logoInputRef = useRef(null); // Заполняем поля ОДИН РАЗ при открытии модала. // Не зависим от `initial` — родитель часто передаёт литерал-объект, @@ -60,6 +66,11 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot setIsPublic(!!initial?.is_public); setMultiplayer(!!initial?.multiplayer); setIsTest(!!initial?.is_test); + const ls = initial?.loading_screen || {}; + setLoadingLogo(ls.logo || ''); + setLoadingAccent(ls.accentColor || '#ffc020'); + setLoadingSpinner(ls.defaultSpinner !== false); + setLoadingSkip(!!ls.defaultSkipButton); setMaxPlayers( typeof initial?.max_players === 'number' ? Math.max(2, Math.min(50, initial.max_players)) @@ -96,6 +107,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot reader.readAsDataURL(file); }; + const handleLogoSelect = (e) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Логотип: только PNG, JPG или WEBP'); return; } + if (file.size > MAX_THUMBNAIL_BYTES) { setError('Логотип слишком большой (макс. 500 КБ)'); return; } + const reader = new FileReader(); + reader.onload = (ev) => { setLoadingLogo(ev.target.result); setError(''); }; + reader.readAsDataURL(file); + }; + const handleSubmit = (e) => { e.preventDefault(); const trimmedTitle = title.trim(); @@ -120,6 +141,12 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot multiplayer, max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)), is_test: isTest, + loading_screen: { + logo: loadingLogo || null, + accentColor: loadingAccent || '#ffc020', + defaultSpinner: loadingSpinner, + defaultSkipButton: loadingSkip, + }, }); }; @@ -300,6 +327,63 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot )} + {/* Экран загрузки (задача 12) */} +
+
+ Экран загрузки +
+
+ Логотип и цвет акцента для экранов загрузки между мирами (game.loading). +
+
+
+ {loadingLogo + ? Логотип + : лого = обложка} +
+
+ + {loadingLogo && ( + + )} + +
+ +
+
+ + +
+
+ {error &&
{error}
} diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index ea8fc60..61efc12 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -1430,6 +1430,13 @@ const KubikonEditor = () => { max_players: typeof data.max_players === 'number' ? data.max_players : 10, is_test: !!data.is_test, + // Задача 12: конфиг экрана загрузки из scene-JSON (для модала настроек). + loading_screen: (data.project_data && (() => { + try { + const pd = typeof data.project_data === 'string' ? JSON.parse(data.project_data) : data.project_data; + return pd?.scene?.loadingScreen || null; + } catch { return null; } + })()) || null, }; // Состояние публикации (этап 3) setProjectStatus({ @@ -1444,6 +1451,9 @@ const KubikonEditor = () => { catch (e) { console.warn('[KubikonEditor] bad project_data JSON', e); } if (parsed) { await sceneRef.current.loadFromState(parsed); + // Задача 12: дефолтный логотип экрана загрузки = обложка + // проекта (thumbnail в meta, а не в scene-JSON). + try { sceneRef.current._projectThumbnail = data.thumbnail || null; } catch (e) { /* ignore */ } // Запоминаем сколько voxels было ЗАГРУЖЕНО — защита от // wipe в auto-save. Считаем из обоих источников. try { @@ -1693,6 +1703,11 @@ const KubikonEditor = () => { const handleSettingsSave = (data) => { metaRef.current = { ...metaRef.current, ...data }; setProjectName(data.title); + // Задача 12: конфиг экрана загрузки → в сцену (попадёт в project_data.scene + // через toJSON). Логотип-дефолт = обложка проекта. + try { + sceneRef.current?.setLoadingConfig?.(data.loading_screen || null, data.thumbnail); + } catch (e) { /* ignore */ } setSettingsModalOpen(false); setInitialModalOpen(false); if (autoSaveTimerRef.current) { diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index cfb0724..c1f8ad3 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -40,6 +40,7 @@ import { } from '@babylonjs/core'; import { PlacementManager } from './PlacementManager'; import { ShopInventoryUi } from './ShopInventoryUi'; +import { LoadingScreenOverlay } from './LoadingScreenOverlay'; import { BlockManager } from './BlockManager'; import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager'; // Этап 1 voxel-движка: новые классы chunks-based архитектуры (см. @@ -155,6 +156,11 @@ export class BabylonScene { this.shopInventoryUi = null; this._PlacementManagerClass = PlacementManager; this._ShopInventoryUiClass = ShopInventoryUi; + // Экран загрузки (задача 12) — DOM-оверлей + конфиг проекта. + this.loadingScreen = null; + this._LoadingScreenOverlayClass = LoadingScreenOverlay; + this._loadingConfig = null; // { logo, accentColor, defaultSpinner, defaultSkipButton } + this._projectThumbnail = null; // обложка проекта — дефолтный логотип this.spawnerManager = null; // спавнеры зомби this.environment = null; this.audioManager = null; @@ -1509,6 +1515,11 @@ export class BabylonScene { if (this._isPlaying && this.modalManager?.tick) { try { this.modalManager.tick(dt); } catch (e) {} } + // Задача 12: loadingScreen.tick — fade/auto-duration; тоже независимо + // от paused (иначе при pauseSimulation экран замрёт навсегда). + if (this._isPlaying && this.loadingScreen?.tick) { + try { this.loadingScreen.tick(dt); } catch (e) {} + } // Tick пользовательских скриптов: в Play-режиме или в solo-debug if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) { this.gameRuntime.tick(dt); @@ -5375,6 +5386,21 @@ export class BabylonScene { this._updateSpawnMarker(); } + /** Задача 12: конфиг экрана загрузки из настроек проекта (логотип/акцент/дефолты). */ + setLoadingConfig(cfg, thumbnail) { + if (cfg && typeof cfg === 'object') { + this._loadingConfig = { + logo: cfg.logo || null, + accentColor: cfg.accentColor || '#ffc020', + defaultSpinner: cfg.defaultSpinner !== false, + defaultSkipButton: !!cfg.defaultSkipButton, + }; + } else { + this._loadingConfig = null; + } + if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null; + } + /** Установить тип модели персонажа (для Play). */ setPlayerModelType(typeId) { if (!typeId) return; @@ -7000,6 +7026,13 @@ export class BabylonScene { coins: this._skinsConfig.coins || 0, customGlbs: this._skinsConfig.customGlbs || [], } : undefined, + // Задача 12: конфиг экрана загрузки (логотип/акцент/дефолты). + loadingScreen: this._loadingConfig ? { + logo: this._loadingConfig.logo || null, + accentColor: this._loadingConfig.accentColor || '#ffc020', + defaultSpinner: this._loadingConfig.defaultSpinner !== false, + defaultSkipButton: !!this._loadingConfig.defaultSkipButton, + } : undefined, worldSize: this._worldHalf * 2, floorEnabled: this._floorEnabled !== false, jumpPowerMul: this._jumpPowerMul ?? 1, @@ -7455,6 +7488,18 @@ export class BabylonScene { } else { this._skinsConfig = null; } + // Задача 12: конфиг экрана загрузки. + if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') { + const ls = state.scene.loadingScreen; + this._loadingConfig = { + logo: ls.logo || null, + accentColor: ls.accentColor || '#ffc020', + defaultSpinner: ls.defaultSpinner !== false, + defaultSkipButton: !!ls.defaultSkipButton, + }; + } else { + this._loadingConfig = null; + } // Пользовательские скрипты if (Array.isArray(state.scene.scripts)) { this._scripts = state.scene.scripts @@ -7577,6 +7622,7 @@ export class BabylonScene { // Placement mode (задача 11): сброс активной сессии + виджета магазина. if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) { /* ignore */ } this.placementManager = null; } if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) { /* ignore */ } this.shopInventoryUi = null; } + if (this.loadingScreen) { try { this.loadingScreen.dispose(); } catch (e) { /* ignore */ } this.loadingScreen = null; } // Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением) if (this.gdLevelManager) { diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index b8dca78..de23db9 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -1240,6 +1240,24 @@ export class GameRuntime { return this.scene3d.shopInventoryUi || null; } + /** Ленивая инициализация экрана загрузки (задача 12). */ + _ensureLoadingScreen() { + if (this.scene3d?.loadingScreen) return this.scene3d.loadingScreen; + if (!this.scene3d) return null; + try { + if (this.scene3d._LoadingScreenOverlayClass) { + const ls = new this.scene3d._LoadingScreenOverlayClass(this.scene3d); + // Мост колбэков → рассылаем в worker'ы как globalEvent. + ls.setBridge( + (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingSkip', loadingId: id }); }, + (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); }, + ); + this.scene3d.loadingScreen = ls; + } + } catch (e) { this._log('error', 'loadingScreen init: ' + (e?.message || e)); } + return this.scene3d.loadingScreen || null; + } + /** slug → _modelTypeId движка. Встроенные → 'skin_', character-* как есть. */ _resolveSkinTypeId(slug) { if (!slug) return 'character-a'; @@ -1767,6 +1785,26 @@ export class GameRuntime { return; } + // === Экран загрузки (задача 12) === + if (cmd === 'loading.show') { + const ls = this._ensureLoadingScreen(); + if (ls && payload) { + try { + const id = ls.show(payload.opts || {}); + // Вернуть worker'у real loadingId, чтобы хэндл (setProgress/close/колбэки) + // нашёл нужный экран по replyId → local→real маппингу. + if (payload.replyId != null) { + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id }); + } + } catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); } + } + return; + } + if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; } + if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; } + if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; } + if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; } + // === Beam / Trail — лучи и следы (Фаза 5.2) === if (cmd === 'fx.create') { // payload: { kind: 'beam'|'trail', localRef, ... } diff --git a/src/editor/engine/LoadingScreenOverlay.js b/src/editor/engine/LoadingScreenOverlay.js new file mode 100644 index 0000000..47f6b56 --- /dev/null +++ b/src/editor/engine/LoadingScreenOverlay.js @@ -0,0 +1,399 @@ +/** + * LoadingScreenOverlay — внутриигровой экран загрузки (задача 12). + * + * Программный mid-game transition: чёрный фон (fadeIn/Out), картинка-превью + * (cover) по центру, прогресс-бар (жёлтый по серому) + процент, спиннер + * «ЗАГРУЗКА» справа-снизу (CSS keyframes), кнопка «ПРОПУСТИТЬ» по центру-снизу + * (появляется через 0.5с — анти-accidental), логотип игры слева-снизу. + * + * Вызывается из скрипта через game.loading.show(opts) / game.loading.transition(opts). + * Покрывает и кейс задачи 05 (начальный экран при входе). + * + * Реализация — лёгкий DOM-оверлей поверх canvas (как ShopInventoryUi), а не + * Babylon-GUI: фиксированный layout с прогресс-баром/спиннером/кнопкой на HTML + * делается быстрее и доступнее. Класс самодостаточен: хранит state, рисует DOM, + * имеет tick(dt) для fade-фаз и авто-duration (в отличие от ShopInventoryUi, + * которому tick не нужен). + * + * Один активный экран одновременно: повторный show() мгновенно закрывает + * предыдущий (как ModalManager) — нет утечки overlay'ев при нескольких + * transition подряд. + * + * Фича-парность: идентичный модуль в rublox-player/src/engine/. + */ + +const EASE_OUT = (t) => 1 - Math.pow(1 - t, 3); + +// CSS спиннера вставляем один раз в (keyframes нельзя инлайнить в style). +let _spinCssInjected = false; +function injectSpinnerCss() { + if (_spinCssInjected) return; + _spinCssInjected = true; + try { + const style = document.createElement('style'); + style.id = 'kbn-loading-spin-css'; + style.textContent = + '@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' + + '.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' + + '@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}'; + document.head.appendChild(style); + } catch { /* ignore */ } +} + +export class LoadingScreenOverlay { + constructor(scene3d) { + this.s = scene3d; + this.root = null; + this._st = null; // state активного экрана или null + this._idSeq = 0; + // Мост наружу (GameRuntime подписывает) — id-based колбэки. + this._onSkipCb = null; // (id) => void + this._onCompleteCb = null; // (id) => void + // DOM-ссылки активного экрана: + this._els = null; + } + + /** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */ + setBridge(onSkip, onComplete) { + this._onSkipCb = onSkip; + this._onCompleteCb = onComplete; + } + + /** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */ + _cfg() { + return (this.s && this.s._loadingConfig) || {}; + } + + /** + * Показать экран загрузки. Возвращает числовой id (для матчинга команд). + * opts — см. 12_ingame_loading.md §2.2. + */ + show(opts) { + injectSpinnerCss(); + opts = opts && typeof opts === 'object' ? opts : {}; + // Один активный — мгновенно убрать предыдущий. + if (this._st) this._instantClose(); + + const cfg = this._cfg(); + const accent = opts.progressColor || cfg.accentColor || '#ffc020'; + const st = { + id: ++this._idSeq, + // Фон + bgColor: opts.bgColor || '#000', + bgOpacity: opts.bgOpacity != null ? Number(opts.bgOpacity) : 1, + fadeIn: opts.fadeIn != null ? Number(opts.fadeIn) : 0.3, + fadeOut: opts.fadeOut != null ? Number(opts.fadeOut) : 0.3, + // Прогресс + progressBar: opts.progressBar !== false, + progressColor: accent, + progressBgColor: opts.progressBgColor || '#444', + percentText: opts.percentText !== false, + progress: Math.max(0, Math.min(1, Number(opts.initialProgress) || 0)), + duration: Number.isFinite(opts.duration) && opts.duration > 0 ? Number(opts.duration) : null, + manualProgress: false, + // Спиннер + spinner: opts.spinner != null ? !!opts.spinner : (cfg.defaultSpinner !== false), + spinnerText: opts.spinnerText != null ? String(opts.spinnerText) : 'ЗАГРУЗКА', + // Кнопка Пропустить + skipButton: opts.skipButton != null ? !!opts.skipButton : !!cfg.defaultSkipButton, + skipButtonText: opts.skipButtonText != null ? String(opts.skipButtonText) : 'ПРОПУСТИТЬ', + skipButtonColor: opts.skipButtonColor || accent, + skipShown: false, + // Логотип + logo: opts.logo || cfg.logo || (this.s && this.s._projectThumbnail) || null, + logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12, + // Текст под картинкой + text: opts.text != null ? String(opts.text) : '', + // Поведение + blockInput: opts.blockInput !== false, + pauseSimulation: opts.pauseSimulation !== false, + // Жизненный цикл + phase: 'in', // 'in' | 'visible' | 'out' + alpha: 0, + elapsed: 0, // время с момента полного появления (для duration/skip) + fadeT: 0, + completed: false, // onComplete уже вызывался + }; + this._st = st; + this._build(st, opts.cover); + + // Блок ввода + пауза симуляции. + if (st.blockInput) { try { this.s.player?.setInputBlocked?.(true); } catch { /* ignore */ } } + if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = true; } catch { /* ignore */ } } + + return st.id; + } + + /** Резолв cover в URL/dataURL. */ + _resolveCover(cover) { + if (!cover) return null; + if (typeof cover === 'string') { + // asset:xxx → пробуем через AssetManager, иначе как прямой URL. + try { + const r = this.s.assetManager?.resolveUrl?.(cover); + if (r) return r; + } catch { /* ignore */ } + return cover; + } + if (typeof cover === 'object') { + if (cover.sceneSnapshot) { + try { + const canvas = this.s.engine?.getRenderingCanvas?.(); + if (canvas) return canvas.toDataURL('image/jpeg', 0.72); + } catch { /* ignore */ } + return null; + } + if (cover.url) return cover.url; + } + return null; + } + + _build(st, cover) { + const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body; + try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ } + + const root = document.createElement('div'); + root.className = 'kbn-loading'; + root.style.cssText = + 'position:absolute;inset:0;z-index:60;overflow:hidden;' + + 'display:flex;align-items:center;justify-content:center;' + + 'opacity:0;transition:none;font-family:system-ui,"Segoe UI",sans-serif;' + + `background:${st.bgColor};`; + // фон с настраиваемой непрозрачностью — отдельный слой, чтобы контент был непрозрачным + // (используем opacity всего root для fade, а bgOpacity — через rgba фон): + root.style.background = this._bgRgba(st.bgColor, st.bgOpacity); + + // --- Cover (картинка по центру) --- + const coverUrl = this._resolveCover(cover); + const coverImg = document.createElement('div'); + coverImg.style.cssText = + 'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' + + 'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' + + 'background-color:#1a1f2b;margin-bottom:140px;'; + if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`; + + // --- Текст под картинкой --- + const textEl = document.createElement('div'); + textEl.style.cssText = + 'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' + + 'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);'; + textEl.textContent = st.text || ''; + + // --- Прогресс-бар --- + const barWrap = document.createElement('div'); + barWrap.style.cssText = + 'position:absolute;left:50%;transform:translateX(-50%);bottom:120px;' + + `width:min(74vw,1180px);height:14px;border-radius:8px;background:${st.progressBgColor};` + + 'overflow:hidden;box-shadow:inset 0 1px 3px rgba(0,0,0,0.5);' + + (st.progressBar ? '' : 'display:none;'); + const bar = document.createElement('div'); + bar.style.cssText = + `height:100%;width:${(st.progress * 100).toFixed(1)}%;border-radius:8px;` + + `background:linear-gradient(90deg,${st.progressColor},${this._lighten(st.progressColor)});` + + 'transition:width 0.12s linear;box-shadow:0 0 8px rgba(255,200,40,0.4);'; + barWrap.appendChild(bar); + + // --- Процент --- + const percent = document.createElement('div'); + percent.style.cssText = + 'position:absolute;left:50%;transform:translateX(-50%);bottom:74px;' + + `color:${st.progressColor};font-size:30px;font-weight:800;text-shadow:0 2px 4px rgba(0,0,0,0.5);` + + (st.percentText ? '' : 'display:none;'); + percent.textContent = `${Math.round(st.progress * 100)}%`; + + // --- Кнопка Пропустить --- + const skipBtn = document.createElement('button'); + skipBtn.type = 'button'; + skipBtn.textContent = st.skipButtonText; + skipBtn.style.cssText = + 'position:absolute;left:50%;transform:translateX(-50%);bottom:18px;' + + 'min-width:260px;padding:14px 36px;border:none;border-radius:12px;cursor:pointer;' + + `background:linear-gradient(180deg,${this._lighten(st.skipButtonColor)},${st.skipButtonColor});` + + 'color:#3a2a00;font-size:18px;font-weight:800;letter-spacing:0.5px;' + + 'box-shadow:0 6px 16px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.4);' + + 'opacity:0;transition:opacity 0.25s,transform 0.1s;pointer-events:none;' + + (st.skipButton ? '' : 'display:none;'); + skipBtn.onmouseenter = () => { skipBtn.style.transform = 'translateX(-50%) translateY(-2px)'; }; + skipBtn.onmouseleave = () => { skipBtn.style.transform = 'translateX(-50%)'; }; + skipBtn.onclick = () => { + if (skipBtn.style.pointerEvents === 'none') return; + this._fireSkip(); + }; + + // --- Логотип (слева снизу) --- + const logo = document.createElement('div'); + logo.style.cssText = + 'position:absolute;left:28px;bottom:24px;max-width:200px;max-height:110px;' + + `border-radius:${st.logoCornerRadius}px;background-size:contain;background-repeat:no-repeat;` + + 'background-position:left bottom;width:200px;height:90px;'; + if (st.logo) logo.style.backgroundImage = `url("${st.logo}")`; + else logo.style.display = 'none'; + + // --- Спиннер + «ЗАГРУЗКА» (справа снизу) --- + const spinWrap = document.createElement('div'); + spinWrap.style.cssText = + 'position:absolute;right:32px;bottom:32px;display:flex;align-items:center;gap:14px;' + + 'color:#fff;font-size:20px;font-weight:700;letter-spacing:1px;' + + (st.spinner ? '' : 'display:none;'); + const spinTxt = document.createElement('span'); + spinTxt.textContent = st.spinnerText; + const spinCircle = document.createElement('span'); + spinCircle.className = 'kbn-ls-spinner'; + spinCircle.style.cssText = + `display:inline-block;width:28px;height:28px;border:3px solid rgba(255,255,255,0.25);` + + `border-top-color:${st.progressColor};border-radius:50%;`; + spinWrap.appendChild(spinTxt); + spinWrap.appendChild(spinCircle); + + root.appendChild(coverImg); + root.appendChild(textEl); + root.appendChild(barWrap); + root.appendChild(percent); + root.appendChild(skipBtn); + root.appendChild(logo); + root.appendChild(spinWrap); + parent.appendChild(root); + + this.root = root; + this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap }; + } + + /** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */ + tick(dt) { + const st = this._st; + if (!st || !this._els) return; + dt = Number(dt) || 0; + + if (st.phase === 'in') { + st.fadeT += dt; + const d = st.fadeIn > 0 ? st.fadeIn : 0.0001; + st.alpha = Math.min(1, EASE_OUT(st.fadeT / d)); + this._els.root.style.opacity = String(st.alpha); + if (st.fadeT >= d) { st.phase = 'visible'; st.alpha = 1; st.fadeT = 0; } + } else if (st.phase === 'visible') { + st.elapsed += dt; + // Кнопка Пропустить — появляется через 0.5с. + if (!st.skipShown && st.skipButton && st.elapsed >= 0.5) { + st.skipShown = true; + this._els.skipBtn.style.opacity = '1'; + this._els.skipBtn.style.pointerEvents = 'auto'; + } + // Авто-duration (если не было ручного setProgress). + if (st.duration && !st.manualProgress) { + st.progress = Math.min(1, st.elapsed / st.duration); + this._applyProgress(st); + if (st.progress >= 1 && !st.completed) { + st.completed = true; + this._fireComplete(); + this.close(); + } + } + } else if (st.phase === 'out') { + st.fadeT += dt; + const d = st.fadeOut > 0 ? st.fadeOut : 0.0001; + st.alpha = Math.max(0, 1 - EASE_OUT(st.fadeT / d)); + this._els.root.style.opacity = String(st.alpha); + if (st.fadeT >= d) this._teardown(); + } + } + + _applyProgress(st) { + if (!this._els) return; + this._els.bar.style.width = `${(st.progress * 100).toFixed(1)}%`; + this._els.percent.textContent = `${Math.round(st.progress * 100)}%`; + } + + setProgress(value) { + const st = this._st; + if (!st) return; + st.manualProgress = true; + st.progress = Math.max(0, Math.min(1, Number(value) || 0)); + this._applyProgress(st); + if (st.progress >= 1 && !st.completed) { + st.completed = true; + this._fireComplete(); + this.close(); + } + } + + setText(text) { + const st = this._st; + if (!st || !this._els) return; + st.text = String(text == null ? '' : text); + this._els.textEl.textContent = st.text; + } + + setCover(cover) { + if (!this._st || !this._els) return; + const url = this._resolveCover(cover); + if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`; + } + + /** Закрыть программно (с fadeOut). */ + close() { + const st = this._st; + if (!st) return; + if (st.phase !== 'out') { st.phase = 'out'; st.fadeT = 0; } + } + + _fireSkip() { + const st = this._st; + if (!st) return; + if (this._onSkipCb) { try { this._onSkipCb(st.id); } catch { /* ignore */ } } + this.close(); + } + + _fireComplete() { + const st = this._st; + if (!st) return; + if (this._onCompleteCb) { try { this._onCompleteCb(st.id); } catch { /* ignore */ } } + } + + /** Мгновенно убрать без fade (повторный show / выход из Play). */ + _instantClose() { + this._teardown(); + } + + _teardown() { + // Снять блок ввода / паузу. + const st = this._st; + if (st) { + if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } } + if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } } + } + if (this.root) { try { this.root.remove(); } catch { /* ignore */ } } + this.root = null; + this._els = null; + this._st = null; + } + + dispose() { + this._instantClose(); + this._onSkipCb = null; + this._onCompleteCb = null; + } + + // --- утилиты цвета --- + _lighten(hex) { + try { + const h = String(hex).replace('#', ''); + if (h.length !== 6) return hex; + const r = Math.min(255, parseInt(h.slice(0, 2), 16) + 40); + const g = Math.min(255, parseInt(h.slice(2, 4), 16) + 40); + const b = Math.min(255, parseInt(h.slice(4, 6), 16) + 40); + return `rgb(${r},${g},${b})`; + } catch { return hex; } + } + + _bgRgba(hex, opacity) { + try { + const h = String(hex).replace('#', ''); + if (h.length !== 6) return hex; + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + const a = opacity != null ? Math.max(0, Math.min(1, opacity)) : 1; + return `rgba(${r},${g},${b},${a})`; + } catch { return hex; } + } +} diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 99ac7fd..fd977f8 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -2731,6 +2731,55 @@ const game = { return m; }, }, + /** + * Экран загрузки (задача 12) — программный mid-game transition. + * const lo = game.loading.show({ progressBar:true, spinner:true }); + * lo.setProgress(0.5); lo.close(); + * await game.loading.transition({ cover:{sceneSnapshot:true}, duration:4 }); + * Хэндл возвращается синхронно (локальный id). Колбэки onSkip/onComplete + * приходят через globalEvent (loadingSkip/loadingComplete) — см. ниже. + */ + loading: { + _localSeq: 0, + _localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown) + _handlers: new Map(), // localId → { onSkip:[], onComplete:[] } + show(opts) { + opts = opts && typeof opts === 'object' ? opts : {}; + const localId = ++this._localSeq; + const replyId = '_lshow_' + localId; + const h = { onSkip: [], onComplete: [] }; + if (typeof opts.onSkip === 'function') h.onSkip.push(opts.onSkip); + if (typeof opts.onComplete === 'function') h.onComplete.push(opts.onComplete); + this._handlers.set(localId, h); + // Функции нельзя слать в main — вырезаем перед _send. + const safe = {}; + for (const k in opts) { if (typeof opts[k] !== 'function') safe[k] = opts[k]; } + _send('loading.show', { opts: safe, replyId }); + const self = this; + return { + _localId: localId, + setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); }, + setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); }, + setCover(c) { _send('loading.setCover', { localId, cover: c }); }, + close() { _send('loading.close', { localId }); }, + onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); }, + onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); }, + }; + }, + /** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */ + transition(opts) { + opts = opts && typeof opts === 'object' ? { ...opts } : {}; + if (!Number.isFinite(opts.duration) || opts.duration <= 0) opts.duration = 3; + const self = this; + return new Promise((resolve) => { + const h = self.show(opts); + let done = false; + const finish = () => { if (done) return; done = true; resolve(); }; + h.onComplete(finish); + h.onSkip(finish); + }); + }, + }, /** * Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar. * game.inventory.add({ name: 'Зелье', kind: 'item' }) @@ -4139,6 +4188,36 @@ self.onmessage = (e) => { for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose'); } } catch (e) {} + } else if (t === 'loadingShown') { + // Задача 12: реальный loadingId от runtime — маппим local→real, чтобы + // setProgress/close/колбэки нашли нужный экран. + try { + const lo = (typeof game !== 'undefined') && game.loading; + if (lo && payload && payload.replyId) { + const localId = Number(String(payload.replyId).replace(/^_lshow_/, '')); + if (Number.isFinite(localId) && payload.loadingId != null) { + lo._localToReal.set(localId, payload.loadingId); + } + } + } catch (e) {} + } else if (t === 'loadingSkip' || t === 'loadingComplete') { + // Игрок нажал «Пропустить» (skip) или прогресс достиг 100% (complete). + // Находим local по real loadingId и зовём соответствующие подписчики. + try { + const lo = (typeof game !== 'undefined') && game.loading; + const real = payload && payload.loadingId; + if (lo && real != null) { + for (const [local, r] of lo._localToReal) { + if (r === real) { + const h = lo._handlers.get(local); + if (h) { + const arr = t === 'loadingSkip' ? h.onSkip : h.onComplete; + for (const fn of arr) _safeCall(fn, undefined, 'loading.' + t); + } + } + } + } + } catch (e) {} } else if (t === 'skinChanged') { // Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков. const slug = payload && payload.slug; diff --git a/src/editor/engine/snippets.js b/src/editor/engine/snippets.js index f8b3949..df86bf6 100644 --- a/src/editor/engine/snippets.js +++ b/src/editor/engine/snippets.js @@ -437,6 +437,47 @@ export const SCRIPT_SNIPPETS = [ "});", ], }, + { + label: 'loading.transition', + detail: 'Экран загрузки: переход на новый уровень (фейковый прогресс)', + body: [ + "await game.loading.transition({", + "\tcover: { sceneSnapshot: true }, // снимок текущей сцены как превью", + "\tduration: ${1:4}, // секунд заполняется бар", + "\tskipButton: true,", + "\tspinner: true,", + "});", + "${2:game.player.teleport(100, 1, 100);} // после загрузки", + ], + }, + { + label: 'loading.realProgress', + detail: 'Экран загрузки: реальный прогресс подгрузки (ручной setProgress)', + body: [ + "const lo = game.loading.show({ progressBar: true, spinner: true });", + "const total = ${1:10};", + "let i = 0;", + "const step = () => {", + "\ti++;", + "\t${2:// подгрузить i-й ресурс}", + "\tlo.setProgress(i / total);", + "\tif (i < total) game.after(${3:0.2}, step); else lo.close();", + "};", + "step();", + ], + }, + { + label: 'loading.minigame', + detail: 'Экран загрузки: короткая пауза перед мини-игрой', + body: [ + "await game.loading.transition({", + "\ttext: '${1:Загружаем мини-игру...}',", + "\tduration: ${2:1.5},", + "\tskipButton: false,", + "});", + "${3:// запустить мини-игру}", + ], + }, ]; /** -- 2.47.2 From ef29c114734a9bf799310984ef951cefb74e13b7 Mon Sep 17 00:00:00 2001 From: min Date: Tue, 2 Jun 2026 22:53:51 +0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(12):=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=20=D0=B2=D0=B8=D0=BA=D0=B8=20=D0=A2=D0=B0=D0=BA=D1=81=D0=B8-?= =?UTF-8?q?=D0=B1=D0=BE=D1=81=D1=81=20(3=20=D0=BB=D0=BE=D0=BA=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8)=20+=20=D0=BA=D0=BE=D1=80=D0=BE=D1=82=D0=BA=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка guide-taxi: упоминание 3 локаций (гараж/город/магазин), описание сжато до 1-2 предложений (как у соседних). Статья: триггер через GUI-кнопку, интерьер магазина, Try на 4-ю локацию. Заодно сжато описание guide-zavod. Co-Authored-By: Claude Opus 4.8 --- src/community/docsGames.js | 6 +-- src/community/docsLessons.jsx | 70 +++++++++++++++++++++-------------- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/src/community/docsGames.js b/src/community/docsGames.js index c035355..678342c 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -335,12 +335,12 @@ export const GAMES = [ previewShot: 'guide-dynamic-label-scene.png', openProjectId: 2261, ready: true }, { id: 'guide-zavod', num: 58, group: 'g5', stars: 2, icon: 'cart', title: 'Мой завод — расстановка предметов (placement)', - desc: 'Tycoon-механика «купи → поставь»: магазин-инвентарь снизу, клик по слоту → полупрозрачная тень предмета летит за курсором, ЛКМ ставит на свой участок (можно стопкой), R/колесо — поворот, ПКМ/Esc — отмена. Денег мало → слот серый. Воксельный мир: трава, деревья, пруд.', + desc: 'Tycoon-механика «купи → поставь»: магазин-инвентарь снизу, тень предмета за курсором, ЛКМ ставит на участок (можно стопкой), нет денег → слот серый.', mechanics: ['game.placement.start', 'game.inventoryUi (магазин-слоты)', 'onPlace/onCancel/onMove', 'тень-превью формой модели', 'снап к сетке + стопка', 'проверка баланса (не в минус)', 'воксельные модели + ландшафт'], previewShot: 'guide-zavod-scene.png', openProjectId: 2345, ready: true }, { id: 'guide-taxi', num: 59, group: 'g5', stars: 2, icon: 'loader', title: 'Такси-босс — экран загрузки между мирами', - desc: 'Программный экран загрузки (как в Roblox при телепорте): кликнул такси → весь экран плавно затемняется, в центре снимок сцены, жёлтый прогресс-бар заполняется за 4с, процент, кнопка «Пропустить», спиннер «Загрузка». Дальше — телепорт в город и закатное небо. В городе кнопка «Магазин» делает короткий переход.', - mechanics: ['game.loading.transition (Promise)', 'game.loading.show (хэндл setProgress/close)', 'cover: sceneSnapshot (снимок сцены)', 'прогресс-бар + процент + спиннер', 'кнопка «Пропустить» (onSkip)', 'blockInput + пауза симуляции', 'воксельный город (такси/небоскрёбы)'], + 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 }, ]; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 13f6103..6722729 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8260,14 +8260,16 @@ game.placement.onCancel(() => game.ui.set('hint', '', {}));`} <>

Что получится

- Игрок стоит в гараже рядом с жёлтым такси. Кликнул по - такси → весь экран плавно затемняется, в центре — снимок - сцены, под ним жёлтый прогресс-бар заполняется за 4 секунды, - крупно процент, кнопка «Пропустить» и спиннер - «Загрузка». Через 4 секунды экран исчезает — игрок уже - в городе, небо стало закатным. Это та самая «загрузка между - мирами», которую в больших играх показывают при телепорте на новый - уровень (Taxi Boss, Brookhaven, Jailbreak). + Три локации в одном проекте, между ними — красивая загрузка. + Игрок в гараже у жёлтого такси жмёт «Поехать в город» → весь + экран плавно затемняется, в центре снимок сцены, жёлтый + прогресс-бар заполняется за 4 секунды, крупно процент, + кнопка «Пропустить» и спиннер «Загрузка». Через 4 секунды + экран исчезает — игрок уже в городе с высотками и закатным небом. + В городе кнопка «Магазин» делает короткий переход внутрь + закрытого магазина (ряды стеллажей, кассы), а «Назад» возвращает на + улицу. Это та самая «загрузка между мирами» из больших игр (Taxi Boss, + Brookhaven, Jailbreak) — несколько миров без отдельных «уровней».

game.ui.set('hint', '', {}));`} в этой игре берёт их сам — не повторяешь стиль в каждом вызове.

-

Шаг 2. Клик по такси → переход в город

+

Шаг 2. Кнопка «Поехать» → переход в город

- На самом такси висит маленький скрипт. game.self.onClick — - клик именно по этому объекту. Внутри — await - game.loading.transition(...): код «замирает», пока крутится + Делаем кнопку и вешаем на неё переход. await + game.loading.transition(...) «замораживает» код, пока крутится загрузка, и продолжается, когда она закончилась (или игрок нажал - «Пропустить»). + «Пропустить»). После — телепорт и смена окружения: игрок «оказался» в + новом мире.

- - {`game.self.onClick(async () => { + + {`game.gui.create('button', { + id: 'btn_go', x: 50, y: 92, w: 26, h: 9, anchor: 'center', + text: 'Поехать в город', textColor: '#3a2a00', textSize: 20, fontWeight: 800, + bgGradient: { stops: ['#ffd23a', '#e0a000'], angle: 90 }, borderRadius: 12, +}); +game.gui.onClick('btn_go', async () => { + game.gui.remove('btn_go'); await game.loading.transition({ cover: { sceneSnapshot: true }, // снимок текущей сцены как картинка duration: 4, // бар заполняется 4 секунды @@ -8320,8 +8328,11 @@ game.placement.onCancel(() => game.ui.set('hint', '', {}));`} game.scene.environment = 'sunset'; // закатное небо });`} - transition — это «фейковый» прогресс на заданное время - (для красивого перехода). Для реальной загрузки ресурсов есть + transition — «фейковый» прогресс на заданное время (для + красивого перехода). Точно так же сделана кнопка «Магазин»: + переход 1.5с → teleport внутрь закрытого + зала-магазина (отдельная локация со стеллажами и кассами) → кнопка + «Назад» возвращает в город. Для реальной загрузки ресурсов есть show + setProgress — см. Шаг 3. @@ -8345,23 +8356,26 @@ const step = () => { step();`} + caption="Город изнутри: перекрёсток с высотками по обеим сторонам и Макдональдс в конце улицы — одна из трёх локаций проекта (гараж · город · интерьер магазина)." />

Почему это удобно

- Один проект — несколько миров (гараж, город, магазин), а - переключение между ними прячется за красивым экраном загрузки. - Игрок не видит «телепорт рывком» — видит плавную загрузку, как в - больших играх. А await делает код линейным: «показать - загрузку → дождаться → продолжить». + Один проект — три мира (гараж · город · интерьер магазина), + собранные в разных углах сцены, а переключение между ними прячется за + красивым экраном загрузки. Игрок не видит «телепорт рывком» — видит + плавную загрузку, как в больших играх. А await делает код + линейным: «показать загрузку → дождаться → телепортировать → сменить + небо». Так из одного проекта получается целая игра с локациями, без + отдельных уровней-файлов.

- В городе есть кнопка «Магазин» — она делает короткий переход - (1.5с) к зданию-магазину. Сделай по аналогии ещё одну точку: кнопку - «Гараж», которая через loading.transition на 1 секунду - возвращает игрока к такси (teleport(0, 2, 0)) и ставит - дневное небо (environment = 'day'). + Добавь четвёртую локацию — например «парк». Поставь её в ещё + одном углу сцены (скажем, x = -100), сделай кнопку «В парк» + и переход на неё через loading.transition (свой текст и + duration). Не забудь кнопку «Назад», которая возвращает в + город. Подсказка: каждый переход = await transition → + teleport(...)environment = '...'. ), -- 2.47.2