feat(12): ������������� Loading Screen (game.loading) #24
@ -335,7 +335,12 @@ export const GAMES = [
|
|||||||
previewShot: 'guide-dynamic-label-scene.png', openProjectId: 2261, ready: true },
|
previewShot: 'guide-dynamic-label-scene.png', openProjectId: 2261, ready: true },
|
||||||
{ id: 'guide-zavod', num: 58, group: 'g5', stars: 2, icon: 'cart',
|
{ id: 'guide-zavod', num: 58, group: 'g5', stars: 2, icon: 'cart',
|
||||||
title: 'Мой завод — расстановка предметов (placement)',
|
title: 'Мой завод — расстановка предметов (placement)',
|
||||||
desc: 'Tycoon-механика «купи → поставь»: магазин-инвентарь снизу, клик по слоту → полупрозрачная тень предмета летит за курсором, ЛКМ ставит на свой участок (можно стопкой), R/колесо — поворот, ПКМ/Esc — отмена. Денег мало → слот серый. Воксельный мир: трава, деревья, пруд.',
|
desc: 'Tycoon-механика «купи → поставь»: магазин-инвентарь снизу, тень предмета за курсором, ЛКМ ставит на участок (можно стопкой), нет денег → слот серый.',
|
||||||
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 при телепорте.',
|
||||||
|
mechanics: ['game.loading.transition (Promise)', 'game.loading.show (хэндл setProgress/close)', 'cover: sceneSnapshot (снимок сцены)', 'прогресс-бар + процент + спиннер', 'кнопка «Пропустить» (onSkip)', 'blockInput + пауза симуляции', '3 локации = телепорт + смена окружения', 'воксельный город + интерьер магазина'],
|
||||||
|
previewShot: 'guide-taxi-scene.png', openProjectId: 2427, ready: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -8255,6 +8255,132 @@ game.placement.onCancel(() => game.ui.set('hint', '', {}));`}</Code>
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'guide-taxi': {
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<h3 className="lessonH">Что получится</h3>
|
||||||
|
<p>
|
||||||
|
<b>Три локации в одном проекте</b>, между ними — красивая загрузка.
|
||||||
|
Игрок в <b>гараже</b> у жёлтого такси жмёт «Поехать в город» → весь
|
||||||
|
экран <b>плавно затемняется</b>, в центре снимок сцены, <b>жёлтый
|
||||||
|
прогресс-бар</b> заполняется за 4 секунды, крупно <b>процент</b>,
|
||||||
|
кнопка <b>«Пропустить»</b> и спиннер <b>«Загрузка»</b>. Через 4 секунды
|
||||||
|
экран исчезает — игрок уже <b>в городе</b> с высотками и закатным небом.
|
||||||
|
В городе кнопка <b>«Магазин»</b> делает короткий переход <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>await
|
||||||
|
game.loading.transition(...)</code> «замораживает» код, пока крутится
|
||||||
|
загрузка, и продолжается, когда она закончилась (или игрок нажал
|
||||||
|
«Пропустить»). После — телепорт и смена окружения: игрок «оказался» в
|
||||||
|
новом мире.
|
||||||
|
</p>
|
||||||
|
<ScriptKind kind="global" />
|
||||||
|
<Code>{`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 секунды
|
||||||
|
text: 'Едем в центр города...',
|
||||||
|
skipButton: true, // можно пропустить ожидание
|
||||||
|
spinner: true, // спиннер «Загрузка» справа
|
||||||
|
});
|
||||||
|
// Этот код выполнится ПОСЛЕ загрузки (экран уже скрыт):
|
||||||
|
game.player.teleport(100, 2, 100); // телепорт в город
|
||||||
|
game.scene.environment = 'sunset'; // закатное небо
|
||||||
|
});`}</Code>
|
||||||
|
<Note>
|
||||||
|
<code>transition</code> — «фейковый» прогресс на заданное время (для
|
||||||
|
красивого перехода). Точно так же сделана кнопка <b>«Магазин»</b>:
|
||||||
|
переход <code>1.5</code>с → <code>teleport</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="Город изнутри: перекрёсток с высотками по обеим сторонам и Макдональдс в конце улицы — одна из трёх локаций проекта (гараж · город · интерьер магазина)." />
|
||||||
|
|
||||||
|
<h3 className="lessonH">Почему это удобно</h3>
|
||||||
|
<p>
|
||||||
|
Один проект — <b>три мира</b> (гараж · город · интерьер магазина),
|
||||||
|
собранные в разных углах сцены, а переключение между ними прячется за
|
||||||
|
красивым экраном загрузки. Игрок не видит «телепорт рывком» — видит
|
||||||
|
плавную загрузку, как в больших играх. А <code>await</code> делает код
|
||||||
|
линейным: «показать загрузку → дождаться → телепортировать → сменить
|
||||||
|
небо». Так из одного проекта получается целая игра с локациями, без
|
||||||
|
отдельных уровней-файлов.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Try>
|
||||||
|
Добавь <b>четвёртую локацию</b> — например «парк». Поставь её в ещё
|
||||||
|
одном углу сцены (скажем, <code>x = -100</code>), сделай кнопку «В парк»
|
||||||
|
и переход на неё через <code>loading.transition</code> (свой текст и
|
||||||
|
<code> duration</code>). Не забудь кнопку «Назад», которая возвращает в
|
||||||
|
город. Подсказка: каждый переход = <code>await transition</code> →
|
||||||
|
<code> teleport(...)</code> → <code>environment = '...'</code>.
|
||||||
|
</Try>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Есть ли готовый текст урока для игры с таким id. */
|
/** Есть ли готовый текст урока для игры с таким id. */
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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, ... }
|
||||||
|
|||||||
399
src/editor/engine/LoadingScreenOverlay.js
Normal file
399
src/editor/engine/LoadingScreenOverlay.js
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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:// запустить мини-игру}",
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user