feat(12): ������������� Loading Screen (game.loading) #24

Merged
min merged 2 commits from feat/loading-screen-task12 into main 2026-06-02 20:10:50 +00:00
9 changed files with 819 additions and 0 deletions
Showing only changes of commit 34060c90c3 - Show all commits

View File

@ -338,4 +338,9 @@ export const GAMES = [
desc: 'Tycoon-механика «купи → поставь»: магазин-инвентарь снизу, клик по слоту → полупрозрачная тень предмета летит за курсором, ЛКМ ставит на свой участок (можно стопкой), R/колесо — поворот, ПКМ/Esc — отмена. Денег мало → слот серый. Воксельный мир: трава, деревья, пруд.', desc: 'Tycoon-механика «купи → поставь»: магазин-инвентарь снизу, клик по слоту → полупрозрачная тень предмета летит за курсором, ЛКМ ставит на свой участок (можно стопкой), R/колесо — поворот, ПКМ/Esc — отмена. Денег мало → слот серый. Воксельный мир: трава, деревья, пруд.',
mechanics: ['game.placement.start', 'game.inventoryUi (магазин-слоты)', 'onPlace/onCancel/onMove', 'тень-превью формой модели', 'снап к сетке + стопка', 'проверка баланса (не в минус)', 'воксельные модели + ландшафт'], mechanics: ['game.placement.start', 'game.inventoryUi (магазин-слоты)', 'onPlace/onCancel/onMove', 'тень-превью формой модели', 'снап к сетке + стопка', 'проверка баланса (не в минус)', 'воксельные модели + ландшафт'],
previewShot: 'guide-zavod-scene.png', openProjectId: 2345, ready: true }, 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 },
]; ];

View File

@ -8255,6 +8255,118 @@ game.placement.onCancel(() => game.ui.set('hint', '', {}));`}</Code>
), ),
}, },
'guide-taxi': {
body: (
<>
<h3 className="lessonH">Что получится</h3>
<p>
Игрок стоит в <b>гараже</b> рядом с жёлтым такси. Кликнул по
такси весь экран <b>плавно затемняется</b>, в центре снимок
сцены, под ним <b>жёлтый прогресс-бар</b> заполняется за 4 секунды,
крупно <b>процент</b>, кнопка <b>«Пропустить»</b> и спиннер
<b> «Загрузка»</b>. Через 4 секунды экран исчезает игрок уже
в <b>городе</b>, небо стало закатным. Это та самая «загрузка между
мирами», которую в больших играх показывают при телепорте на новый
уровень (Taxi Boss, Brookhaven, Jailbreak).
</p>
<Shot src="guide-taxi-play.png" wide
caption="Экран загрузки: затемнение, снимок сцены, прогресс-бар с процентом, кнопка «Пропустить» и спиннер «Загрузка» — как в больших играх при телепорте." />
<h3 className="lessonH">Чему научишься</h3>
<ul>
<li><b>game.loading.transition(opts)</b> готовый переход: показал
экран, заполнил бар за <code>duration</code> секунд, сам скрыл.
Возвращает <code>Promise</code> пишешь <code>await</code> и
продолжаешь код уже «на новом уровне»;</li>
<li><b>game.loading.show(opts)</b> ручной режим: возвращает
хэндл с <code>setProgress(0..1)</code>, <code>setText()</code>,
<code> close()</code> для реальной загрузки ресурсов;</li>
<li><b>cover: {`{ sceneSnapshot: true }`}</b> кадр текущей сцены
автоматически становится картинкой-превью;</li>
<li><b>кнопка «Пропустить»</b> (<code>skipButton</code>) и
<b> спиннер</b> (<code>spinner</code>) включаются одной опцией;</li>
<li><b>blockInput + пауза</b> во время загрузки WASD/мышь не
работают, а симуляция замирает (NPC не двигаются), автоматически.</li>
</ul>
<h3 className="lessonH">Шаг 1. Магазин по умолчанию настройки игры</h3>
<p>
В <b>Настройки Экран загрузки</b> один раз задаёшь <b>логотип
игры</b>, <b>цвет акцента</b> (бар и кнопка) и галочки «спиннер» /
«кнопка Пропустить» по умолчанию. Дальше любой <code>game.loading</code>
в этой игре берёт их сам не повторяешь стиль в каждом вызове.
</p>
<h3 className="lessonH">Шаг 2. Клик по такси переход в город</h3>
<p>
На самом такси висит маленький скрипт. <code>game.self.onClick</code>
клик именно по этому объекту. Внутри <code>await
game.loading.transition(...)</code>: код «замирает», пока крутится
загрузка, и продолжается, когда она закончилась (или игрок нажал
«Пропустить»).
</p>
<ScriptKind kind="object" />
<Code>{`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'; // закатное небо
});`}</Code>
<Note>
<code>transition</code> это «фейковый» прогресс на заданное время
(для красивого перехода). Для <b>реальной</b> загрузки ресурсов есть
<code> show</code> + <code>setProgress</code> см. Шаг 3.
</Note>
<h3 className="lessonH">Шаг 3. Ручной прогресс (реальная загрузка)</h3>
<p>
Если грузишь много объектов и хочешь показать <b>настоящий</b>
прогресс открой экран через <code>show</code> и двигай бар сам
через <code>setProgress</code>. Закрой через <code>close()</code>.
</p>
<ScriptKind kind="object" />
<Code>{`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();`}</Code>
<Shot src="guide-taxi-scene.png" wide
caption="Сцена в редакторе: гараж-двор с такси (по центру) и город из небоскрёбов поодаль — две локации в одном проекте, между ними переход через loading." />
<h3 className="lessonH">Почему это удобно</h3>
<p>
Один проект <b>несколько миров</b> (гараж, город, магазин), а
переключение между ними прячется за красивым экраном загрузки.
Игрок не видит «телепорт рывком» видит плавную загрузку, как в
больших играх. А <code>await</code> делает код линейным: «показать
загрузку дождаться продолжить».
</p>
<Try>
В городе есть кнопка <b>«Магазин»</b> она делает короткий переход
(1.5с) к зданию-магазину. Сделай по аналогии ещё одну точку: кнопку
«Гараж», которая через <code>loading.transition</code> на 1 секунду
возвращает игрока к такси (<code>teleport(0, 2, 0)</code>) и ставит
дневное небо (<code>environment = 'day'</code>).
</Try>
</>
),
},
}; };
/** Есть ли готовый текст урока для игры с таким id. */ /** Есть ли готовый текст урока для игры с таким id. */

View File

@ -45,8 +45,14 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
const [multiplayer, setMultiplayer] = useState(false); const [multiplayer, setMultiplayer] = useState(false);
const [maxPlayers, setMaxPlayers] = useState(10); const [maxPlayers, setMaxPlayers] = useState(10);
const [isTest, setIsTest] = useState(false); 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 [error, setError] = useState('');
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const logoInputRef = useRef(null);
// Заполняем поля ОДИН РАЗ при открытии модала. // Заполняем поля ОДИН РАЗ при открытии модала.
// Не зависим от `initial` родитель часто передаёт литерал-объект, // Не зависим от `initial` родитель часто передаёт литерал-объект,
@ -60,6 +66,11 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
setIsPublic(!!initial?.is_public); setIsPublic(!!initial?.is_public);
setMultiplayer(!!initial?.multiplayer); setMultiplayer(!!initial?.multiplayer);
setIsTest(!!initial?.is_test); setIsTest(!!initial?.is_test);
const ls = initial?.loading_screen || {};
setLoadingLogo(ls.logo || '');
setLoadingAccent(ls.accentColor || '#ffc020');
setLoadingSpinner(ls.defaultSpinner !== false);
setLoadingSkip(!!ls.defaultSkipButton);
setMaxPlayers( setMaxPlayers(
typeof initial?.max_players === 'number' typeof initial?.max_players === 'number'
? Math.max(2, Math.min(50, initial.max_players)) ? Math.max(2, Math.min(50, initial.max_players))
@ -96,6 +107,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
reader.readAsDataURL(file); 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) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
const trimmedTitle = title.trim(); const trimmedTitle = title.trim();
@ -120,6 +141,12 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
multiplayer, multiplayer,
max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)), max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)),
is_test: isTest, 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
</label> </label>
)} )}
{/* Экран загрузки (задача 12) */}
<div className={cl.field} style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 14, marginTop: 4 }}>
<div className={cl.fieldLabel} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="loader" size={13} /> Экран загрузки
</div>
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
Логотип и цвет акцента для экранов загрузки между мирами (game.loading).
</div>
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 }}>
<div style={{
width: 96, height: 54, borderRadius: 8, background: '#15192a',
border: '1px solid rgba(255,255,255,0.12)', display: 'flex',
alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flex: '0 0 auto',
}}>
{loadingLogo
? <img src={loadingLogo} alt="Логотип" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
: <span style={{ color: '#5a6178', fontSize: 11 }}>лого = обложка</span>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<button type="button" className={cl.actionBtn} onClick={() => logoInputRef.current?.click()}>
<Icon name="folder" size={14} /> Логотип игры
</button>
{loadingLogo && (
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLoadingLogo('')}>
<Icon name="close" size={13} /> Убрать
</button>
)}
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }} onChange={handleLogoSelect} />
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginLeft: 'auto' }}>
<span style={{ fontSize: 12, color: '#aab' }}>Цвет акцента</span>
<input type="color" value={loadingAccent}
onChange={(e) => setLoadingAccent(e.target.value)}
style={{ width: 48, height: 32, border: 'none', background: 'none', cursor: 'pointer' }} />
</label>
</div>
<div className={cl.togglesRow}>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={loadingSpinner} onChange={(e) => setLoadingSpinner(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Спиннер</div>
<div className={cl.toggleHint}><span>Показывать «ЗАГРУЗКА» по умолчанию</span></div>
</div>
</label>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={loadingSkip} onChange={(e) => setLoadingSkip(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Кнопка «Пропустить»</div>
<div className={cl.toggleHint}><span>Показывать по умолчанию</span></div>
</div>
</label>
</div>
</div>
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>} {error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
</div> </div>

View File

@ -1430,6 +1430,13 @@ const KubikonEditor = () => {
max_players: typeof data.max_players === 'number' max_players: typeof data.max_players === 'number'
? data.max_players : 10, ? data.max_players : 10,
is_test: !!data.is_test, 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) // Состояние публикации (этап 3)
setProjectStatus({ setProjectStatus({
@ -1444,6 +1451,9 @@ const KubikonEditor = () => {
catch (e) { console.warn('[KubikonEditor] bad project_data JSON', e); } catch (e) { console.warn('[KubikonEditor] bad project_data JSON', e); }
if (parsed) { if (parsed) {
await sceneRef.current.loadFromState(parsed); await sceneRef.current.loadFromState(parsed);
// Задача 12: дефолтный логотип экрана загрузки = обложка
// проекта (thumbnail в meta, а не в scene-JSON).
try { sceneRef.current._projectThumbnail = data.thumbnail || null; } catch (e) { /* ignore */ }
// Запоминаем сколько voxels было ЗАГРУЖЕНО защита от // Запоминаем сколько voxels было ЗАГРУЖЕНО защита от
// wipe в auto-save. Считаем из обоих источников. // wipe в auto-save. Считаем из обоих источников.
try { try {
@ -1693,6 +1703,11 @@ const KubikonEditor = () => {
const handleSettingsSave = (data) => { const handleSettingsSave = (data) => {
metaRef.current = { ...metaRef.current, ...data }; metaRef.current = { ...metaRef.current, ...data };
setProjectName(data.title); setProjectName(data.title);
// Задача 12: конфиг экрана загрузки в сцену (попадёт в project_data.scene
// через toJSON). Логотип-дефолт = обложка проекта.
try {
sceneRef.current?.setLoadingConfig?.(data.loading_screen || null, data.thumbnail);
} catch (e) { /* ignore */ }
setSettingsModalOpen(false); setSettingsModalOpen(false);
setInitialModalOpen(false); setInitialModalOpen(false);
if (autoSaveTimerRef.current) { if (autoSaveTimerRef.current) {

View File

@ -40,6 +40,7 @@ import {
} from '@babylonjs/core'; } from '@babylonjs/core';
import { PlacementManager } from './PlacementManager'; import { PlacementManager } from './PlacementManager';
import { ShopInventoryUi } from './ShopInventoryUi'; import { ShopInventoryUi } from './ShopInventoryUi';
import { LoadingScreenOverlay } from './LoadingScreenOverlay';
import { BlockManager } from './BlockManager'; import { BlockManager } from './BlockManager';
import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager'; import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager';
// Этап 1 voxel-движка: новые классы chunks-based архитектуры (см. // Этап 1 voxel-движка: новые классы chunks-based архитектуры (см.
@ -155,6 +156,11 @@ export class BabylonScene {
this.shopInventoryUi = null; this.shopInventoryUi = null;
this._PlacementManagerClass = PlacementManager; this._PlacementManagerClass = PlacementManager;
this._ShopInventoryUiClass = ShopInventoryUi; 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.spawnerManager = null; // спавнеры зомби
this.environment = null; this.environment = null;
this.audioManager = null; this.audioManager = null;
@ -1509,6 +1515,11 @@ export class BabylonScene {
if (this._isPlaying && this.modalManager?.tick) { if (this._isPlaying && this.modalManager?.tick) {
try { this.modalManager.tick(dt); } catch (e) {} 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 // Tick пользовательских скриптов: в Play-режиме или в solo-debug
if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) { if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) {
this.gameRuntime.tick(dt); this.gameRuntime.tick(dt);
@ -5375,6 +5386,21 @@ export class BabylonScene {
this._updateSpawnMarker(); 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). */ /** Установить тип модели персонажа (для Play). */
setPlayerModelType(typeId) { setPlayerModelType(typeId) {
if (!typeId) return; if (!typeId) return;
@ -7000,6 +7026,13 @@ export class BabylonScene {
coins: this._skinsConfig.coins || 0, coins: this._skinsConfig.coins || 0,
customGlbs: this._skinsConfig.customGlbs || [], customGlbs: this._skinsConfig.customGlbs || [],
} : undefined, } : 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, worldSize: this._worldHalf * 2,
floorEnabled: this._floorEnabled !== false, floorEnabled: this._floorEnabled !== false,
jumpPowerMul: this._jumpPowerMul ?? 1, jumpPowerMul: this._jumpPowerMul ?? 1,
@ -7455,6 +7488,18 @@ export class BabylonScene {
} else { } else {
this._skinsConfig = null; 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)) { if (Array.isArray(state.scene.scripts)) {
this._scripts = state.scene.scripts this._scripts = state.scene.scripts
@ -7577,6 +7622,7 @@ export class BabylonScene {
// Placement mode (задача 11): сброс активной сессии + виджета магазина. // Placement mode (задача 11): сброс активной сессии + виджета магазина.
if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) { /* ignore */ } this.placementManager = null; } 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.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 перед его удалением) // Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением)
if (this.gdLevelManager) { if (this.gdLevelManager) {

View File

@ -1240,6 +1240,24 @@ export class GameRuntime {
return this.scene3d.shopInventoryUi || null; 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_<slug>', character-* как есть. */ /** slug → _modelTypeId движка. Встроенные → 'skin_<slug>', character-* как есть. */
_resolveSkinTypeId(slug) { _resolveSkinTypeId(slug) {
if (!slug) return 'character-a'; if (!slug) return 'character-a';
@ -1767,6 +1785,26 @@ export class GameRuntime {
return; 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) === // === Beam / Trail — лучи и следы (Фаза 5.2) ===
if (cmd === 'fx.create') { if (cmd === 'fx.create') {
// payload: { kind: 'beam'|'trail', localRef, ... } // payload: { kind: 'beam'|'trail', localRef, ... }

View File

@ -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 спиннера вставляем один раз в <head> (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; }
}
}

View File

@ -2731,6 +2731,55 @@ const game = {
return m; 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. * Инвентарь игрока (Фаза 4.2) 5 слотов hot-bar.
* game.inventory.add({ name: 'Зелье', kind: 'item' }) * 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'); for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose');
} }
} catch (e) {} } 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') { } else if (t === 'skinChanged') {
// Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков. // Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков.
const slug = payload && payload.slug; const slug = payload && payload.slug;

View File

@ -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:// запустить мини-игру}",
],
},
]; ];
/** /**