studio/src/editor/GameSettingsModal.jsx
min 34060c90c3
All checks were successful
CI / Lint (pull_request) Successful in 1m13s
CI / Build (pull_request) Successful in 2m3s
CI / Secret scan (pull_request) Successful in 2m35s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(12): внутриигровой Loading Screen (game.loading)
Программный экран загрузки для перехода между мирами:
- 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>
2026-06-02 22:00:26 +03:00

405 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;