feat(studio): задача 05 — экран загрузки (Ken Burns + название места)

LoadingScreenOverlay: Ken-Burns фон (CSS pan+zoom) + 4 стиля (ken-burns/static/
parallax/particles) + карточка-композиция (cover/название места/автор/verified-SVG).
Стартовый экран при входе в Play (showStartupLoadingScreen из enterPlayMode +
поля проекта loadingScreen.* + serialize/deserialize). API game.loading.
setBackground/isVisible/onHide + расширенный show. UI редактора: секция
«Стартовый экран входа (Ken Burns)». Вики g5 #62 + статья. Тест-игра 2713.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-07 19:34:48 +03:00
parent c8a961815e
commit c31b1ed3d6
6 changed files with 420 additions and 30 deletions

View File

@ -373,4 +373,9 @@ export const GAMES = [
desc: 'Полный инвентарь как в Minecraft/RPG: сетка 8×5 + хотбар 9, стаки, 5 редкостей (цвет рамки), перетаскивание мышью, ПКМ-меню, tooltip, сортировка. Собираешь предметы — стаки растут.', desc: 'Полный инвентарь как в Minecraft/RPG: сетка 8×5 + хотбар 9, стаки, 5 редкостей (цвет рамки), перетаскивание мышью, ПКМ-меню, tooltip, сортировка. Собираешь предметы — стаки растут.',
mechanics: ['game.items.define([...]) — предметы (редкость/стак/иконка)', 'game.inventory.give / take', 'окно по I — сетка 8×5 + хотбар 9 (1-9)', 'drag-drop между слотами (swap + merge)', 'стаки с maxStack, 5 редкостей', 'ПКМ-меню: использовать / разделить / выбросить', 'tooltip + сортировка по редкости'], mechanics: ['game.items.define([...]) — предметы (редкость/стак/иконка)', 'game.inventory.give / take', 'окно по I — сетка 8×5 + хотбар 9 (1-9)', 'drag-drop между слотами (swap + merge)', 'стаки с maxStack, 5 редкостей', 'ПКМ-меню: использовать / разделить / выбросить', 'tooltip + сортировка по редкости'],
previewShot: 'guide-inventory-scene.png', openProjectId: 2685, ready: true }, previewShot: 'guide-inventory-scene.png', openProjectId: 2685, ready: true },
{ id: 'guide-loadingscreen', num: 66, group: 'g5', stars: 2, icon: 'loader',
title: 'Экран загрузки — Ken Burns и название места',
desc: 'Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор с verified-галочкой — как в Roblox. Автор настраивает экран во вкладке «Стартовый экран».',
mechanics: ['красивый экран загрузки игры в плеере (GameLoadingScreen)', 'Ken Burns / static / parallax / particles', 'карточка-витрина + название места + автор + verified', 'настройка во вкладке «Стартовый экран» (свойства проекта)', 'game.loading.show({ style, placeName, studioName, duration }) — переходы', 'game.loading.onHide() — продолжить после загрузки', 'game.loading.setBackground / setText / setProgress'],
previewShot: 'guide-loadingscreen-scene.png', openProjectId: 2713, ready: true },
]; ];

View File

@ -50,9 +50,21 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
const [loadingAccent, setLoadingAccent] = useState('#ffc020'); const [loadingAccent, setLoadingAccent] = useState('#ffc020');
const [loadingSpinner, setLoadingSpinner] = useState(true); const [loadingSpinner, setLoadingSpinner] = useState(true);
const [loadingSkip, setLoadingSkip] = useState(false); const [loadingSkip, setLoadingSkip] = useState(false);
// Задача 05: стартовый Ken-Burns экран
const [lsEnabled, setLsEnabled] = useState(true);
const [lsBackground, setLsBackground] = useState('');
const [lsCover, setLsCover] = useState('');
const [lsStyle, setLsStyle] = useState('ken-burns');
const [lsPlaceName, setLsPlaceName] = useState('');
const [lsStudioName, setLsStudioName] = useState('');
const [lsVerified, setLsVerified] = useState(false);
const [lsDuration, setLsDuration] = useState(2.5);
const [lsProgressBar, setLsProgressBar] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const logoInputRef = useRef(null); const logoInputRef = useRef(null);
const lsBgInputRef = useRef(null);
const lsCoverInputRef = useRef(null);
// Заполняем поля ОДИН РАЗ при открытии модала. // Заполняем поля ОДИН РАЗ при открытии модала.
// Не зависим от `initial` родитель часто передаёт литерал-объект, // Не зависим от `initial` родитель часто передаёт литерал-объект,
@ -71,6 +83,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
setLoadingAccent(ls.accentColor || '#ffc020'); setLoadingAccent(ls.accentColor || '#ffc020');
setLoadingSpinner(ls.defaultSpinner !== false); setLoadingSpinner(ls.defaultSpinner !== false);
setLoadingSkip(!!ls.defaultSkipButton); setLoadingSkip(!!ls.defaultSkipButton);
// Задача 05:
setLsEnabled(ls.enabled !== false);
setLsBackground(ls.background || '');
setLsCover(ls.cover || '');
setLsStyle(ls.style || 'ken-burns');
setLsPlaceName(ls.placeName || '');
setLsStudioName(ls.studioName || '');
setLsVerified(!!ls.verified);
setLsDuration(Number.isFinite(ls.duration) && ls.duration > 0 ? ls.duration : 2.5);
setLsProgressBar(ls.progressBar !== false);
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))
@ -117,6 +139,17 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
// Задача 05: универсальный загрузчик изображения (фон / cover-карточка).
const handleLsImage = (e, setter) => {
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) => { setter(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();
@ -146,6 +179,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
accentColor: loadingAccent || '#ffc020', accentColor: loadingAccent || '#ffc020',
defaultSpinner: loadingSpinner, defaultSpinner: loadingSpinner,
defaultSkipButton: loadingSkip, defaultSkipButton: loadingSkip,
// Задача 05:
enabled: lsEnabled,
background: lsBackground || null,
cover: lsCover || null,
style: lsStyle || 'ken-burns',
placeName: lsPlaceName.trim(),
studioName: lsStudioName.trim(),
verified: lsVerified,
duration: Math.max(1, Math.min(10, Number(lsDuration) || 2.5)),
progressBar: lsProgressBar,
}, },
}); });
}; };
@ -384,6 +427,115 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
</div> </div>
</div> </div>
{/* Стартовый экран — Ken Burns + название места (задача 05) */}
<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} /> Стартовый экран входа (Ken Burns)
</div>
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор.
</div>
<label className={cl.toggleRow} style={{ marginBottom: 10 }}>
<input type="checkbox" className={cl.toggle}
checked={lsEnabled} onChange={(e) => setLsEnabled(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Показывать стартовый экран</div>
<div className={cl.toggleHint}><span>Если выключено игрок сразу попадает в 3D-сцену</span></div>
</div>
</label>
{lsEnabled && (
<>
{/* Фон + карточка */}
<div style={{ display: 'flex', gap: 14, marginBottom: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
width: 130, height: 74, borderRadius: 8, background: '#15192a',
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundImage: lsBackground ? `url(${lsBackground})` : 'none',
backgroundSize: 'cover', backgroundPosition: 'center',
}}>
{!lsBackground && <span style={{ color: '#5a6178', fontSize: 11 }}>фон (размытый)</span>}
</div>
<button type="button" className={cl.actionBtn} onClick={() => lsBgInputRef.current?.click()}>
<Icon name="folder" size={14} /> Фон
</button>
{lsBackground && (
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsBackground('')}>
<Icon name="close" size={13} /> Убрать
</button>
)}
<input ref={lsBgInputRef} type="file" accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsBackground)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
width: 74, height: 74, borderRadius: 12, background: '#15192a',
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundImage: lsCover ? `url(${lsCover})` : 'none',
backgroundSize: 'cover', backgroundPosition: 'center',
}}>
{!lsCover && <span style={{ color: '#5a6178', fontSize: 10, textAlign: 'center' }}>карточка</span>}
</div>
<button type="button" className={cl.actionBtn} onClick={() => lsCoverInputRef.current?.click()}>
<Icon name="folder" size={14} /> Карточка
</button>
{lsCover && (
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsCover('')}>
<Icon name="close" size={13} /> Убрать
</button>
)}
<input ref={lsCoverInputRef} type="file" accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsCover)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minWidth: 180 }}>
<input type="text" className={cl.input} placeholder="Название места (по умолчанию = название игры)"
value={lsPlaceName} maxLength={40}
onChange={(e) => setLsPlaceName(e.target.value)} />
<input type="text" className={cl.input} placeholder="Имя автора"
value={lsStudioName} maxLength={40}
onChange={(e) => setLsStudioName(e.target.value)} />
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={lsVerified} onChange={(e) => setLsVerified(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Галочка verified</div>
</div>
</label>
</div>
</div>
{/* Стиль + длительность + прогресс */}
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12, color: '#aab' }}>Стиль анимации</span>
<select className={cl.input} value={lsStyle} onChange={(e) => setLsStyle(e.target.value)}>
<option value="ken-burns">Ken Burns (плавный pan+zoom)</option>
<option value="static">Статичный фон</option>
<option value="parallax">Параллакс (по мыши)</option>
<option value="particles">Частицы (искры)</option>
</select>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12, color: '#aab' }}>Длительность: {Number(lsDuration).toFixed(1)} с</span>
<input type="range" min="1" max="10" step="0.5" value={lsDuration}
onChange={(e) => setLsDuration(Number(e.target.value))}
style={{ width: 160 }} />
</label>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={lsProgressBar} onChange={(e) => setLsProgressBar(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Прогресс-бар</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

@ -5942,7 +5942,7 @@ export class BabylonScene {
this._updateSpawnMarker(); this._updateSpawnMarker();
} }
/** Задача 12: конфиг экрана загрузки из настроек проекта (логотип/акцент/дефолты). */ /** Задача 12+05: конфиг экрана загрузки из настроек проекта. */
setLoadingConfig(cfg, thumbnail) { setLoadingConfig(cfg, thumbnail) {
if (cfg && typeof cfg === 'object') { if (cfg && typeof cfg === 'object') {
this._loadingConfig = { this._loadingConfig = {
@ -5950,6 +5950,16 @@ export class BabylonScene {
accentColor: cfg.accentColor || '#ffc020', accentColor: cfg.accentColor || '#ffc020',
defaultSpinner: cfg.defaultSpinner !== false, defaultSpinner: cfg.defaultSpinner !== false,
defaultSkipButton: !!cfg.defaultSkipButton, defaultSkipButton: !!cfg.defaultSkipButton,
// --- Задача 05: стартовый экран при входе в Play ---
enabled: cfg.enabled !== false, // показывать ли стартовый экран
background: cfg.background || cfg.backgroundUrl || null,
cover: cfg.cover || cfg.coverUrl || null,
style: cfg.style || 'ken-burns',
placeName: cfg.placeName || '',
studioName: cfg.studioName || '',
verified: !!cfg.verified,
duration: Number.isFinite(cfg.duration) && cfg.duration > 0 ? Number(cfg.duration) : 2.5,
progressBar: cfg.progressBar !== false,
}; };
} else { } else {
this._loadingConfig = null; this._loadingConfig = null;
@ -5957,6 +5967,34 @@ export class BabylonScene {
if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null; if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null;
} }
/**
* Задача 05: показать СТАРТОВЫЙ экран загрузки при входе в Play.
* Зовётся из enterPlayMode; держится минимум `duration` сек либо до готовности сцены.
*/
showStartupLoadingScreen() {
const cfg = this._loadingConfig;
if (!cfg || cfg.enabled === false) return;
if (!this.gameRuntime) return;
try {
const ls = this.gameRuntime._ensureLoadingScreen?.();
if (!ls) return;
ls.show({
style: cfg.style,
background: cfg.background || cfg.cover || this._projectThumbnail,
cover: cfg.cover || this._projectThumbnail,
placeName: cfg.placeName || this._projectName || '',
studioName: cfg.studioName || '',
verified: cfg.verified,
duration: cfg.duration,
progressBar: cfg.progressBar,
spinner: true,
bgColor: '#070a14',
pauseSimulation: false, // стартовый — сцена грузится в фоне
blockInput: true,
});
} catch (e) { /* ignore */ }
}
/** Установить тип модели персонажа (для Play). */ /** Установить тип модели персонажа (для Play). */
setPlayerModelType(typeId) { setPlayerModelType(typeId) {
if (!typeId) return; if (!typeId) return;
@ -6074,6 +6112,9 @@ export class BabylonScene {
// поэтому скрипты стартуем в следующем кадре. // поэтому скрипты стартуем в следующем кадре.
this.gameRuntime = new GameRuntime(this); this.gameRuntime = new GameRuntime(this);
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
// Задача 05: стартовый экран загрузки (Ken-Burns + название места),
// если настроен в проекте. Показываем поверх старта сцены.
try { this.showStartupLoadingScreen(); } catch (e) {}
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
// this.audioManager (AudioManager — ambient/music для всех проектов). // this.audioManager (AudioManager — ambient/music для всех проектов).
@ -7646,12 +7687,22 @@ export class BabylonScene {
coins: this._skinsConfig.coins || 0, coins: this._skinsConfig.coins || 0,
customGlbs: this._skinsConfig.customGlbs || [], customGlbs: this._skinsConfig.customGlbs || [],
} : undefined, } : undefined,
// Задача 12: конфиг экрана загрузки (логотип/акцент/дефолты). // Задача 12+05: конфиг экрана загрузки (логотип/акцент/дефолты + стартовый Ken-Burns).
loadingScreen: this._loadingConfig ? { loadingScreen: this._loadingConfig ? {
logo: this._loadingConfig.logo || null, logo: this._loadingConfig.logo || null,
accentColor: this._loadingConfig.accentColor || '#ffc020', accentColor: this._loadingConfig.accentColor || '#ffc020',
defaultSpinner: this._loadingConfig.defaultSpinner !== false, defaultSpinner: this._loadingConfig.defaultSpinner !== false,
defaultSkipButton: !!this._loadingConfig.defaultSkipButton, defaultSkipButton: !!this._loadingConfig.defaultSkipButton,
// Задача 05:
enabled: this._loadingConfig.enabled !== false,
background: this._loadingConfig.background || null,
cover: this._loadingConfig.cover || null,
style: this._loadingConfig.style || 'ken-burns',
placeName: this._loadingConfig.placeName || '',
studioName: this._loadingConfig.studioName || '',
verified: !!this._loadingConfig.verified,
duration: this._loadingConfig.duration || 2.5,
progressBar: this._loadingConfig.progressBar !== false,
} : undefined, } : undefined,
// Задача 13: конфиг главного меню (passthrough — меню задаётся скриптом). // Задача 13: конфиг главного меню (passthrough — меню задаётся скриптом).
mainMenu: this._mainMenuConfig || undefined, mainMenu: this._mainMenuConfig || undefined,
@ -8120,15 +8171,9 @@ export class BabylonScene {
} else { } else {
this._skinsConfig = null; this._skinsConfig = null;
} }
// Задача 12: конфиг экрана загрузки. // Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг).
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') { if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
const ls = state.scene.loadingScreen; this.setLoadingConfig(state.scene.loadingScreen);
this._loadingConfig = {
logo: ls.logo || null,
accentColor: ls.accentColor || '#ffc020',
defaultSpinner: ls.defaultSpinner !== false,
defaultSkipButton: !!ls.defaultSkipButton,
};
} else { } else {
this._loadingConfig = null; this._loadingConfig = null;
} }

View File

@ -1326,6 +1326,8 @@ export class GameRuntime {
ls.setBridge( 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: 'loadingSkip', loadingId: id }); },
(id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); }, (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); },
// Задача 05: onHide — экран скрылся (любой).
() => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingHidden' }); },
); );
this.scene3d.loadingScreen = ls; this.scene3d.loadingScreen = ls;
} }
@ -1879,9 +1881,9 @@ export class GameRuntime {
const id = ls.show(payload.opts || {}); const id = ls.show(payload.opts || {});
// Вернуть worker'у real loadingId, чтобы хэндл (setProgress/close/колбэки) // Вернуть worker'у real loadingId, чтобы хэндл (setProgress/close/колбэки)
// нашёл нужный экран по replyId → local→real маппингу. // нашёл нужный экран по replyId → local→real маппингу.
if (payload.replyId != null) { // replyId может отсутствовать (стартовый экран) — всё равно шлём
for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id }); // loadingShown для game.loading.isVisible() (задача 05).
} 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)); } } catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); }
} }
return; return;
@ -1889,6 +1891,7 @@ export class GameRuntime {
if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); 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.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; }
if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; } if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; }
if (cmd === 'loading.setBackground') { this.scene3d?.loadingScreen?.setBackground?.(payload?.background); return; }
if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; } if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; }
// === Damage Floaters (задача 40) — всплывающие цифры урона === // === Damage Floaters (задача 40) — всплывающие цифры урона ===

View File

@ -35,7 +35,25 @@ function injectSpinnerCss() {
style.textContent = style.textContent =
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' + '@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' + '.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}'; // Ken Burns — медленный pan+zoom фона (задача 05).
'@keyframes kbn-ls-kenburns{' +
'0%{transform:scale(1.0) translate3d(0,0,0)}' +
'50%{transform:scale(1.10) translate3d(-3%,-2%,0)}' +
'100%{transform:scale(1.0) translate3d(-6%,0,0)}}' +
'.kbn-ls-kenburns{animation:kbn-ls-kenburns 24s ease-in-out infinite}' +
// particles — медленно всплывающие искры.
'@keyframes kbn-ls-rise{' +
'0%{transform:translateY(0) scale(1);opacity:0}' +
'10%{opacity:0.9}' +
'90%{opacity:0.7}' +
'100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
'.kbn-ls-particle{animation:kbn-ls-rise linear infinite}' +
// лёгкий «дыхательный» glow карточки-превью.
'@keyframes kbn-ls-cardglow{' +
'0%,100%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 0 rgba(120,160,255,0)}' +
'50%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 40px rgba(120,160,255,0.35)}}' +
'.kbn-ls-cardglow{animation:kbn-ls-cardglow 4s ease-in-out infinite}' +
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner,.kbn-ls-kenburns,.kbn-ls-particle,.kbn-ls-cardglow{animation:none}}';
document.head.appendChild(style); document.head.appendChild(style);
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@ -49,14 +67,17 @@ export class LoadingScreenOverlay {
// Мост наружу (GameRuntime подписывает) — id-based колбэки. // Мост наружу (GameRuntime подписывает) — id-based колбэки.
this._onSkipCb = null; // (id) => void this._onSkipCb = null; // (id) => void
this._onCompleteCb = null; // (id) => void this._onCompleteCb = null; // (id) => void
this._onHideCb = null; // () => void — задача 05 (game.loading.onHide)
this._parallaxHandler = null;
// DOM-ссылки активного экрана: // DOM-ссылки активного экрана:
this._els = null; this._els = null;
} }
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */ /** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
setBridge(onSkip, onComplete) { setBridge(onSkip, onComplete, onHide) {
this._onSkipCb = onSkip; this._onSkipCb = onSkip;
this._onCompleteCb = onComplete; this._onCompleteCb = onComplete;
if (onHide) this._onHideCb = onHide;
} }
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */ /** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
@ -104,6 +125,15 @@ export class LoadingScreenOverlay {
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12, logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
// Текст под картинкой // Текст под картинкой
text: opts.text != null ? String(opts.text) : '', text: opts.text != null ? String(opts.text) : '',
// --- Задача 05: Ken-Burns фон + карточка места ---
// style: 'ken-burns' | 'static' | 'parallax' | 'particles'
style: opts.style || cfg.style || 'ken-burns',
// фоновое размытое изображение (на весь экран); резолвится в _resolveCover.
background: opts.background != null ? opts.background : (cfg.background || null),
// карточка-витрина по центру (название места + автор), как в Roblox.
placeName: opts.placeName != null ? String(opts.placeName) : (cfg.placeName || ''),
studioName: opts.studioName != null ? String(opts.studioName) : (cfg.studioName || ''),
verified: opts.verified != null ? !!opts.verified : !!cfg.verified,
// Поведение // Поведение
blockInput: opts.blockInput !== false, blockInput: opts.blockInput !== false,
pauseSimulation: opts.pauseSimulation !== false, pauseSimulation: opts.pauseSimulation !== false,
@ -163,20 +193,107 @@ export class LoadingScreenOverlay {
// (используем opacity всего root для fade, а bgOpacity — через rgba фон): // (используем opacity всего root для fade, а bgOpacity — через rgba фон):
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity); root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
// --- Cover (картинка по центру) --- // --- Фоновый слой (Ken Burns / parallax / static) ---
const coverUrl = this._resolveCover(cover); // Размытое изображение игры на весь экран. Отдельный div под контентом,
const coverImg = document.createElement('div'); // чтобы blur/анимация не трогали карточку и текст.
coverImg.style.cssText = const bgUrl = this._resolveCover(st.background);
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' + const bgLayer = document.createElement('div');
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' + let bgClass = '';
'background-color:#1a1f2b;margin-bottom:140px;'; if (bgUrl) {
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`; if (st.style === 'ken-burns') bgClass = 'kbn-ls-kenburns';
bgLayer.className = bgClass;
bgLayer.style.cssText =
'position:absolute;inset:-8%;z-index:0;background-size:cover;background-position:center;' +
'filter:blur(8px) brightness(0.55);will-change:transform;' +
`background-image:url("${bgUrl}");`;
// parallax — лёгкий сдвиг по мыши (2 слоя имитируем одним + transform).
if (st.style === 'parallax') {
bgLayer.style.transition = 'transform 0.25s ease-out';
this._parallaxHandler = (e) => {
const cx = (e.clientX / window.innerWidth - 0.5) * 28;
const cy = (e.clientY / window.innerHeight - 0.5) * 18;
bgLayer.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.06)`;
};
window.addEventListener('mousemove', this._parallaxHandler);
}
root.appendChild(bgLayer);
}
// --- Текст под картинкой --- // --- particles слой (медленные искры) ---
if (st.style === 'particles') {
const pLayer = document.createElement('div');
pLayer.style.cssText = 'position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none;';
for (let i = 0; i < 26; i++) {
const sp = document.createElement('span');
sp.className = 'kbn-ls-particle';
const size = 2 + Math.round(Math.random() * 4);
const dur = 7 + Math.random() * 10;
sp.style.cssText =
`position:absolute;bottom:-10px;left:${(Math.random() * 100).toFixed(1)}%;` +
`width:${size}px;height:${size}px;border-radius:50%;` +
`background:rgba(${180 + Math.round(Math.random() * 70)},${190 + Math.round(Math.random() * 60)},255,0.85);` +
`box-shadow:0 0 ${size * 2}px rgba(140,170,255,0.7);` +
`animation-duration:${dur.toFixed(1)}s;animation-delay:${(-Math.random() * dur).toFixed(1)}s;`;
pLayer.appendChild(sp);
}
root.appendChild(pLayer);
}
// Обёртка контента (над фоном).
const content = document.createElement('div');
content.style.cssText =
'position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;';
// --- Cover (картинка-карточка по центру) ---
const coverUrl = this._resolveCover(cover);
// Режим карточки места (задача 05): квадрат + название + автор под ней.
const hasPlaceCard = !!(st.placeName || st.studioName);
const coverImg = document.createElement('div');
if (hasPlaceCard) {
coverImg.className = 'kbn-ls-cardglow';
coverImg.style.cssText =
'width:min(42vw,400px);aspect-ratio:1/1;border-radius:18px;' +
'background-size:cover;background-position:center;background-color:#1a1f2b;' +
'border:2px solid rgba(255,255,255,0.12);';
} else {
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}")`;
else if (bgUrl && hasPlaceCard) coverImg.style.backgroundImage = `url("${bgUrl}")`;
// --- Название места (крупный белый, под карточкой) ---
const placeEl = document.createElement('div');
placeEl.style.cssText =
'margin-top:22px;color:#fff;font-size:38px;font-weight:800;letter-spacing:0.5px;' +
'text-align:center;text-shadow:0 3px 14px rgba(0,0,0,0.7);' +
(st.placeName ? '' : 'display:none;');
placeEl.textContent = st.placeName || '';
// --- Автор + verified-галочка ---
const studioRow = document.createElement('div');
studioRow.style.cssText =
'margin-top:8px;display:flex;align-items:center;gap:7px;' +
'color:#cdd6e6;font-size:16px;font-weight:600;text-shadow:0 1px 4px rgba(0,0,0,0.6);' +
(st.studioName ? '' : 'display:none;');
const studioTxt = document.createElement('span');
studioTxt.textContent = st.studioName || '';
studioRow.appendChild(studioTxt);
if (st.verified) studioRow.appendChild(this._buildVerifiedBadge());
// --- Текст под картинкой (для не-карточного режима / mid-game) ---
const textEl = document.createElement('div'); const textEl = document.createElement('div');
textEl.style.cssText = if (hasPlaceCard) {
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' + textEl.style.cssText =
'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);'; 'margin-top:14px;color:#e8edf5;font-size:16px;font-weight:500;text-align:center;' +
'text-shadow:0 1px 3px rgba(0,0,0,0.6);' + (st.text ? '' : 'display:none;');
} else {
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 || ''; textEl.textContent = st.text || '';
// --- Прогресс-бар --- // --- Прогресс-бар ---
@ -245,8 +362,13 @@ export class LoadingScreenOverlay {
spinWrap.appendChild(spinTxt); spinWrap.appendChild(spinTxt);
spinWrap.appendChild(spinCircle); spinWrap.appendChild(spinCircle);
root.appendChild(coverImg); // Центральная композиция (карточка + название + автор + текст) — в content.
root.appendChild(textEl); content.appendChild(coverImg);
content.appendChild(placeEl);
content.appendChild(studioRow);
content.appendChild(textEl);
root.appendChild(content);
// Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content).
root.appendChild(barWrap); root.appendChild(barWrap);
root.appendChild(percent); root.appendChild(percent);
root.appendChild(skipBtn); root.appendChild(skipBtn);
@ -255,7 +377,19 @@ export class LoadingScreenOverlay {
parent.appendChild(root); parent.appendChild(root);
this.root = root; this.root = root;
this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap }; this._els = { root, content, coverImg, placeEl, studioRow, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
}
/** Verified-галочка (синий круг + белый чек) — inline SVG, без emoji-шрифта. */
_buildVerifiedBadge() {
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;align-items:center;';
wrap.innerHTML =
'<svg width="18" height="18" viewBox="0 0 24 24" aria-label="verified">' +
'<circle cx="12" cy="12" r="11" fill="#3897f0"/>' +
'<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" stroke-width="2.4" ' +
'stroke-linecap="round" stroke-linejoin="round"/></svg>';
return wrap;
} }
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */ /** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
@ -329,6 +463,23 @@ export class LoadingScreenOverlay {
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`; if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
} }
/** Задача 05: сменить фоновое (Ken-Burns) изображение на лету. */
setBackground(bg) {
if (!this._st || !this._els) return;
const url = this._resolveCover(bg);
if (!url) return;
this._st.background = bg;
// фоновый слой — первый ребёнок root с background-image; найдём его.
const layer = this._els.root.querySelector('.kbn-ls-kenburns')
|| this._els.root.firstElementChild;
if (layer && layer !== this._els.content) layer.style.backgroundImage = `url("${url}")`;
}
/** Задача 05: виден ли экран сейчас. */
isVisible() {
return !!(this._st && this._st.phase !== 'out');
}
/** Закрыть программно (с fadeOut). */ /** Закрыть программно (с fadeOut). */
close() { close() {
const st = this._st; const st = this._st;
@ -361,6 +512,13 @@ export class LoadingScreenOverlay {
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } } 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 (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } }
} }
// Снять parallax-listener (задача 05).
if (this._parallaxHandler) {
try { window.removeEventListener('mousemove', this._parallaxHandler); } catch { /* ignore */ }
this._parallaxHandler = null;
}
// onHide-мост (задача 05) — сообщаем скриптам что экран скрылся.
if (this._onHideCb) { try { this._onHideCb(); } catch { /* ignore */ } }
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } } if (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
this.root = null; this.root = null;
this._els = null; this._els = null;

View File

@ -70,6 +70,8 @@ let _toolUseHandlers = [];
// При toolUse-событии воркер сначала вызывает per-tool колбэк, потом глобальные. // При toolUse-событии воркер сначала вызывает per-tool колбэк, потом глобальные.
let _toolCallbacks = {}; // { 'custom:1': { activated: fn, equipped: fn, unequipped: fn } } let _toolCallbacks = {}; // { 'custom:1': { activated: fn, equipped: fn, unequipped: fn } }
let _toolSeq = 0; let _toolSeq = 0;
// Задача 05: зеркало видимости экрана загрузки (для game.loading.isVisible()).
let _loadingVisible = false;
// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. // Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }.
let _players = { me: null, list: [] }; let _players = { me: null, list: [] };
// Общее состояние комнаты game.room.get/set — зеркало из main thread. // Общее состояние комнаты game.room.get/set — зеркало из main thread.
@ -3032,6 +3034,7 @@ const game = {
_localSeq: 0, _localSeq: 0,
_localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown) _localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown)
_handlers: new Map(), // localId → { onSkip:[], onComplete:[] } _handlers: new Map(), // localId → { onSkip:[], onComplete:[] }
_onHide: [], // задача 05 — глобальные подписки на скрытие
show(opts) { show(opts) {
opts = opts && typeof opts === 'object' ? opts : {}; opts = opts && typeof opts === 'object' ? opts : {};
const localId = ++this._localSeq; const localId = ++this._localSeq;
@ -3050,11 +3053,27 @@ const game = {
setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); }, setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); },
setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); }, setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); },
setCover(c) { _send('loading.setCover', { localId, cover: c }); }, setCover(c) { _send('loading.setCover', { localId, cover: c }); },
setBackground(b) { _send('loading.setBackground', { localId, background: b }); },
close() { _send('loading.close', { localId }); }, close() { _send('loading.close', { localId }); },
onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); }, 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); }, onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); },
}; };
}, },
// --- Задача 05: управление активным экраном без хэндла (стартовый/любой текущий) ---
/** Подписаться на скрытие экрана загрузки. */
onHide(fn) { if (typeof fn === 'function') this._onHide.push(fn); },
/** Сменить фоновое изображение текущего экрана. */
setBackground(b) { _send('loading.setBackground', { background: b }); },
/** Сменить текст текущего экрана. */
setText(t) { _send('loading.setText', { text: String(t == null ? '' : t) }); },
/** Сменить cover текущего экрана. */
setCover(c) { _send('loading.setCover', { cover: c }); },
/** Ручной прогресс текущего экрана. */
setProgress(v) { _send('loading.setProgress', { value: Number(v) || 0 }); },
/** Скрыть текущий экран. */
hide() { _send('loading.close', {}); },
/** Виден ли экран сейчас (синхронно из локального зеркала). */
isVisible() { return !!_loadingVisible; },
/** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */ /** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */
transition(opts) { transition(opts) {
opts = opts && typeof opts === 'object' ? { ...opts } : {}; opts = opts && typeof opts === 'object' ? { ...opts } : {};
@ -4597,6 +4616,7 @@ self.onmessage = (e) => {
} else if (t === 'loadingShown') { } else if (t === 'loadingShown') {
// Задача 12: реальный loadingId от runtime — маппим local→real, чтобы // Задача 12: реальный loadingId от runtime — маппим local→real, чтобы
// setProgress/close/колбэки нашли нужный экран. // setProgress/close/колбэки нашли нужный экран.
_loadingVisible = true;
try { try {
const lo = (typeof game !== 'undefined') && game.loading; const lo = (typeof game !== 'undefined') && game.loading;
if (lo && payload && payload.replyId) { if (lo && payload && payload.replyId) {
@ -4606,6 +4626,13 @@ self.onmessage = (e) => {
} }
} }
} catch (e) {} } catch (e) {}
} else if (t === 'loadingHidden') {
// Задача 05: экран загрузки скрылся — обновляем зеркало + onHide-подписки.
_loadingVisible = false;
try {
const lo = (typeof game !== 'undefined') && game.loading;
if (lo) for (const fn of (lo._onHide || [])) _safeCall(fn, undefined, 'loading.onHide');
} catch (e) {}
} else if (t === 'loadingSkip' || t === 'loadingComplete') { } else if (t === 'loadingSkip' || t === 'loadingComplete') {
// Игрок нажал «Пропустить» (skip) или прогресс достиг 100% (complete). // Игрок нажал «Пропустить» (skip) или прогресс достиг 100% (complete).
// Находим local по real loadingId и зовём соответствующие подписчики. // Находим local по real loadingId и зовём соответствующие подписчики.