All checks were successful
Программный экран загрузки для перехода между мирами: - game.loading.show(opts) → хэндл (setProgress/setText/setCover/close/onSkip/onComplete) - game.loading.transition(opts) → Promise (фейковый прогресс за duration) - cover sceneSnapshot, прогресс-бар+процент, спиннер, кнопка Пропустить, логотип - blockInput + пауза симуляции, fadeIn/Out; tick независим от paused - настройки проекта «Экран загрузки» (логотип/акцент/дефолты) + 3 сниппета - LoadingScreenOverlay.js (новый DOM-оверлей), worker namespace loading, cmd loading.* + _ensureLoadingScreen, serialize/load конфига в scene - вики g5 #59 guide-taxi (карточка + урок), тест-игра «Такси-босс» id 2427 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
405 lines
22 KiB
JavaScript
405 lines
22 KiB
JavaScript
import React, { useState, useRef, useEffect } from 'react';
|
||
import cl from './GameSettingsModal.module.css';
|
||
import Icon from './Icon';
|
||
|
||
/**
|
||
* Модалка настроек игры (как Game Settings в Roblox Studio).
|
||
*
|
||
* Поля:
|
||
* - Название (обязательное, до 100 символов)
|
||
* - Описание (до 500 символов)
|
||
* - Иконка (data URL Base64) — превью + кнопка «Снять кадр сцены» + загрузка PNG/JPG
|
||
* - Жанр (селект)
|
||
* - Публикация — toggle is_public
|
||
*
|
||
* Props:
|
||
* open — открыта ли
|
||
* initial — { title, description, genre, thumbnail, is_public }
|
||
* onClose — закрыть без сохранения
|
||
* onSave(data) — сохранить (data — те же поля что initial)
|
||
* onCaptureScreenshot — функция возвращающая data URL текущей сцены
|
||
* mode — 'edit' | 'create' — текст заголовка/кнопки чуть отличается
|
||
*/
|
||
const GENRES = [
|
||
{ id: 'platformer', name: 'Платформер' },
|
||
{ id: 'racing', name: 'Гонки' },
|
||
{ id: 'shooter', name: 'Шутер' },
|
||
{ id: 'sandbox', name: 'Песочница' },
|
||
{ id: 'adventure', name: 'Приключение' },
|
||
{ id: 'puzzle', name: 'Головоломка' },
|
||
{ id: 'tycoon', name: 'Тайкун' },
|
||
{ id: 'rpg', name: 'РПГ' },
|
||
{ id: 'other', name: 'Другое' },
|
||
];
|
||
|
||
const MAX_TITLE_LEN = 100;
|
||
const MAX_DESC_LEN = 500;
|
||
const MAX_THUMBNAIL_BYTES = 500 * 1024; // 500 КБ — разумный потолок для base64
|
||
|
||
const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot, mode = 'edit' }) => {
|
||
const [title, setTitle] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [genre, setGenre] = useState('other');
|
||
const [thumbnail, setThumbnail] = useState('');
|
||
const [isPublic, setIsPublic] = useState(false);
|
||
const [multiplayer, setMultiplayer] = useState(false);
|
||
const [maxPlayers, setMaxPlayers] = useState(10);
|
||
const [isTest, setIsTest] = useState(false);
|
||
// Задача 12: экран загрузки
|
||
const [loadingLogo, setLoadingLogo] = useState('');
|
||
const [loadingAccent, setLoadingAccent] = useState('#ffc020');
|
||
const [loadingSpinner, setLoadingSpinner] = useState(true);
|
||
const [loadingSkip, setLoadingSkip] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const fileInputRef = useRef(null);
|
||
const logoInputRef = useRef(null);
|
||
|
||
// Заполняем поля ОДИН РАЗ при открытии модала.
|
||
// Не зависим от `initial` — родитель часто передаёт литерал-объект,
|
||
// и каждый его новый instance сбрасывал бы поля при ре-рендере.
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setTitle(initial?.title || '');
|
||
setDescription(initial?.description || '');
|
||
setGenre(initial?.genre || 'other');
|
||
setThumbnail(initial?.thumbnail || '');
|
||
setIsPublic(!!initial?.is_public);
|
||
setMultiplayer(!!initial?.multiplayer);
|
||
setIsTest(!!initial?.is_test);
|
||
const ls = initial?.loading_screen || {};
|
||
setLoadingLogo(ls.logo || '');
|
||
setLoadingAccent(ls.accentColor || '#ffc020');
|
||
setLoadingSpinner(ls.defaultSpinner !== false);
|
||
setLoadingSkip(!!ls.defaultSkipButton);
|
||
setMaxPlayers(
|
||
typeof initial?.max_players === 'number'
|
||
? Math.max(2, Math.min(50, initial.max_players))
|
||
: 10
|
||
);
|
||
setError('');
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open]);
|
||
|
||
if (!open) return null;
|
||
|
||
const handleScreenshot = () => {
|
||
if (!onCaptureScreenshot) return;
|
||
const data = onCaptureScreenshot();
|
||
if (data) setThumbnail(data);
|
||
};
|
||
|
||
const handleFileSelect = (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) => {
|
||
setThumbnail(ev.target.result);
|
||
setError('');
|
||
};
|
||
reader.readAsDataURL(file);
|
||
};
|
||
|
||
const handleLogoSelect = (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Логотип: только PNG, JPG или WEBP'); return; }
|
||
if (file.size > MAX_THUMBNAIL_BYTES) { setError('Логотип слишком большой (макс. 500 КБ)'); return; }
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => { setLoadingLogo(ev.target.result); setError(''); };
|
||
reader.readAsDataURL(file);
|
||
};
|
||
|
||
const handleSubmit = (e) => {
|
||
e.preventDefault();
|
||
const trimmedTitle = title.trim();
|
||
if (!trimmedTitle) {
|
||
setError('Название обязательно');
|
||
return;
|
||
}
|
||
if (trimmedTitle.length > MAX_TITLE_LEN) {
|
||
setError(`Название не длиннее ${MAX_TITLE_LEN} символов`);
|
||
return;
|
||
}
|
||
if (description.length > MAX_DESC_LEN) {
|
||
setError(`Описание не длиннее ${MAX_DESC_LEN} символов`);
|
||
return;
|
||
}
|
||
onSave({
|
||
title: trimmedTitle,
|
||
description: description.trim(),
|
||
genre,
|
||
thumbnail,
|
||
is_public: isPublic,
|
||
multiplayer,
|
||
max_players: Math.max(2, Math.min(50, Number(maxPlayers) || 10)),
|
||
is_test: isTest,
|
||
loading_screen: {
|
||
logo: loadingLogo || null,
|
||
accentColor: loadingAccent || '#ffc020',
|
||
defaultSpinner: loadingSpinner,
|
||
defaultSkipButton: loadingSkip,
|
||
},
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||
<div className={cl.modal}>
|
||
<header className={cl.header}>
|
||
<h2 className={cl.title}>
|
||
{mode === 'create' ? <><Icon name="sparkles" size={13} /> Новая игра</> : <><Icon name="settings" size={13} /> Настройки игры</>}
|
||
</h2>
|
||
<button className={cl.closeBtn} onClick={onClose}><Icon name="close" size={14} /></button>
|
||
</header>
|
||
|
||
<form onSubmit={handleSubmit} className={cl.form}>
|
||
<div className={cl.body}>
|
||
{/* Иконка */}
|
||
<div className={cl.iconRow}>
|
||
<div className={cl.iconPreview}>
|
||
{thumbnail ? (
|
||
<img src={thumbnail} alt="Иконка" />
|
||
) : (
|
||
<div className={cl.iconPlaceholder}><Icon name="gamepad" size={14} /></div>
|
||
)}
|
||
</div>
|
||
<div className={cl.iconActions}>
|
||
<div className={cl.fieldLabel}>Иконка</div>
|
||
<div className={cl.iconButtons}>
|
||
{onCaptureScreenshot && (
|
||
<button type="button" className={cl.actionBtn} onClick={handleScreenshot}>
|
||
<Icon name="camera" size={14} /> Снять кадр
|
||
</button>
|
||
)}
|
||
<button type="button" className={cl.actionBtn} onClick={() => fileInputRef.current?.click()}>
|
||
<Icon name="folder" size={14} /> Загрузить
|
||
</button>
|
||
{thumbnail && (
|
||
<button type="button" className={cl.removeBtn} onClick={() => setThumbnail('')}>
|
||
<Icon name="close" size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
style={{ display: 'none' }}
|
||
onChange={handleFileSelect}
|
||
/>
|
||
<div className={cl.fieldHint}>PNG/JPG/WEBP, до 500 КБ</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Название */}
|
||
<label className={cl.field}>
|
||
<div className={cl.fieldLabel}>
|
||
Название <span className={cl.required}>*</span>
|
||
<span className={cl.counter}>{title.length} / {MAX_TITLE_LEN}</span>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
className={cl.input}
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value.slice(0, MAX_TITLE_LEN))}
|
||
placeholder="Например, Гонки Стива по горам"
|
||
autoFocus
|
||
/>
|
||
</label>
|
||
|
||
{/* Описание */}
|
||
<label className={cl.field}>
|
||
<div className={cl.fieldLabel}>
|
||
Описание
|
||
<span className={cl.counter}>{description.length} / {MAX_DESC_LEN}</span>
|
||
</div>
|
||
<textarea
|
||
className={cl.textarea}
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value.slice(0, MAX_DESC_LEN))}
|
||
placeholder="Что это за игра, как в неё играть, чем она интересна..."
|
||
rows={3}
|
||
/>
|
||
</label>
|
||
|
||
{/* Жанр */}
|
||
<label className={cl.field}>
|
||
<div className={cl.fieldLabel}>Жанр</div>
|
||
<div className={cl.genreGrid}>
|
||
{GENRES.map(g => (
|
||
<button
|
||
key={g.id}
|
||
type="button"
|
||
className={`${cl.genreBtn} ${genre === g.id ? cl.genreBtnActive : ''}`}
|
||
onClick={() => setGenre(g.id)}
|
||
>
|
||
{g.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</label>
|
||
|
||
{/* Публикация + Мультиплеер — в одну строку 2 столбца */}
|
||
<div className={cl.togglesRow}>
|
||
<label className={cl.toggleRow}>
|
||
<input
|
||
type="checkbox"
|
||
className={cl.toggle}
|
||
checked={isPublic}
|
||
onChange={(e) => setIsPublic(e.target.checked)}
|
||
/>
|
||
<div className={cl.toggleText}>
|
||
<div className={cl.toggleTitle}>Опубликовать</div>
|
||
<div className={cl.toggleHint}>
|
||
<Icon name={isPublic ? 'globe' : 'locked'} size={12} />
|
||
<span>{isPublic ? 'Видна в ленте' : 'Приватная'}</span>
|
||
</div>
|
||
</div>
|
||
</label>
|
||
|
||
<label className={cl.toggleRow}>
|
||
<input
|
||
type="checkbox"
|
||
className={cl.toggle}
|
||
checked={multiplayer}
|
||
onChange={(e) => setMultiplayer(e.target.checked)}
|
||
/>
|
||
<div className={cl.toggleText}>
|
||
<div className={cl.toggleTitle}>Мультиплеер</div>
|
||
<div className={cl.toggleHint}>
|
||
<Icon name="globe" size={12} />
|
||
<span>{multiplayer ? 'Игроки видят друг друга' : 'Одиночная игра'}</span>
|
||
</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Тестовая игра — для разработки/проверки в плеере.
|
||
Не попадает в каталог (лента/поиск/профиль). */}
|
||
<label className={cl.toggleRow} style={{ marginTop: 8 }}>
|
||
<input
|
||
type="checkbox"
|
||
className={cl.toggle}
|
||
checked={isTest}
|
||
onChange={(e) => setIsTest(e.target.checked)}
|
||
/>
|
||
<div className={cl.toggleText}>
|
||
<div className={cl.toggleTitle}>Тестовая игра</div>
|
||
<div className={cl.toggleHint}>
|
||
<Icon name={isTest ? 'flask' : 'eye'} size={12} />
|
||
<span>{isTest
|
||
? 'Только для тебя: можно тестить в плеере, но НЕ видна в каталоге'
|
||
: 'Обычная игра — попадает в каталог при публикации'}</span>
|
||
</div>
|
||
</div>
|
||
</label>
|
||
|
||
{multiplayer && (
|
||
<label className={cl.field}>
|
||
<div className={cl.fieldLabel}>
|
||
Макс. игроков в комнате
|
||
<span className={cl.counter}>{maxPlayers}</span>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min="2"
|
||
max="50"
|
||
step="1"
|
||
value={maxPlayers}
|
||
onChange={(e) => setMaxPlayers(Number(e.target.value))}
|
||
style={{
|
||
width: '100%',
|
||
accentColor: '#3357ff',
|
||
cursor: 'pointer',
|
||
}}
|
||
/>
|
||
<div className={cl.fieldHint}>
|
||
От 2 до 50. Чем больше — тем выше нагрузка на сервер.
|
||
</div>
|
||
</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>}
|
||
</div>
|
||
|
||
<div className={cl.footer}>
|
||
<button type="button" className={cl.secondaryBtn} onClick={onClose}>
|
||
Отмена
|
||
</button>
|
||
<button type="submit" className={cl.primaryBtn}>
|
||
{mode === 'create' ? <><Icon name="sparkles" size={13} /> Создать игру</> : <><Icon name="save" size={13} /> Сохранить</>}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default GameSettingsModal;
|