3 опции в модалке (только если в карте есть скрипты): - 'disabled' (default) — скрипты импортируются с enabled=false в метадате → GameRuntime их не запускает, но видны в иерархии для чтения как референс при написании своих Lua-скриптов. - 'enabled' — скрипты активны (старое поведение). Может вешать игру на старых Roblox 2007-2010 паттернах. - 'skip' — scripts[] обнуляется, чистый импорт только геометрии. Реализация: - RbxlImportModal.jsx: state scriptsMode + radio-блок над названием игры, показывается только если report.scripts_total > 0. - rbxlImporterApi.js: передача scripts_mode в /import/rbxl/create. - app.py: _apply_scripts_mode() патчит JSON-метадату на 2-й строке packed-кода скрипта (или удаляет scripts[] для 'skip'). GameRuntime уже умеет уважать meta.enabled === false — пропускает скрипт. Deploy app.py на VM 130.
332 lines
17 KiB
JavaScript
332 lines
17 KiB
JavaScript
/**
|
||
* RbxlImportModal — модалка импорта .rbxl Roblox-карт в Rublox.
|
||
*
|
||
* Доступна ТОЛЬКО МИНу (user_id === 1) — это тест-фича.
|
||
*
|
||
* Поток:
|
||
* 1. Юзер дропает или выбирает .rbxl файл.
|
||
* 2. Кликает «Анализировать» → POST /import/rbxl/analyze.
|
||
* 3. Видит отчёт: число объектов, скриптов, ассетов, предупреждения.
|
||
* 4. Вводит название игры → кликает «Создать игру».
|
||
* 5. POST /import/rbxl/create → редирект на /edit/<new_id>.
|
||
*/
|
||
import React, { useState, useRef } from 'react';
|
||
import { analyzeRbxl, createRbxlProject } from '../api/rbxlImporterApi.js';
|
||
|
||
const ALLOWED_USER_ID = 1; // МИН
|
||
|
||
const MAX_SIZE = 50 * 1024 * 1024; // 50 MB
|
||
|
||
export default function RbxlImportModal({ open, onClose, currentUserId, onCreated }) {
|
||
const [file, setFile] = useState(null);
|
||
const [dragOver, setDragOver] = useState(false);
|
||
const [analyzing, setAnalyzing] = useState(false);
|
||
const [creating, setCreating] = useState(false);
|
||
const [report, setReport] = useState(null);
|
||
const [previewHash, setPreviewHash] = useState(null);
|
||
const [title, setTitle] = useState('');
|
||
const [error, setError] = useState(null);
|
||
// Режим скриптов: 'disabled' (импортнуть выключенными — для чтения),
|
||
// 'enabled' (попытаться запустить — может вешать карту), 'skip' (удалить).
|
||
const [scriptsMode, setScriptsMode] = useState('disabled');
|
||
const fileInputRef = useRef(null);
|
||
|
||
if (!open) return null;
|
||
|
||
if (currentUserId !== ALLOWED_USER_ID) {
|
||
return (
|
||
<div style={overlayStyle} onClick={onClose}>
|
||
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
|
||
<h2 style={{ marginTop: 0 }}>Импорт из Roblox</h2>
|
||
<p>Эта тест-функция доступна только администратору.</p>
|
||
<button style={btnStyle} onClick={onClose}>Закрыть</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const reset = () => {
|
||
setFile(null); setReport(null); setPreviewHash(null);
|
||
setTitle(''); setError(null); setAnalyzing(false); setCreating(false);
|
||
setScriptsMode('disabled');
|
||
};
|
||
|
||
const handleClose = () => { reset(); onClose?.(); };
|
||
|
||
const handleFile = (f) => {
|
||
if (!f) return;
|
||
if (!f.name.toLowerCase().endsWith('.rbxl')) {
|
||
setError('Нужен файл .rbxl (Roblox Binary Level)');
|
||
return;
|
||
}
|
||
if (f.size > MAX_SIZE) {
|
||
setError(`Файл больше ${MAX_SIZE / 1024 / 1024} MB`);
|
||
return;
|
||
}
|
||
setFile(f);
|
||
setError(null);
|
||
setReport(null);
|
||
setPreviewHash(null);
|
||
};
|
||
|
||
const handleAnalyze = async () => {
|
||
if (!file) return;
|
||
setAnalyzing(true);
|
||
setError(null);
|
||
try {
|
||
const result = await analyzeRbxl(file);
|
||
setPreviewHash(result.preview_hash);
|
||
setReport(result.report);
|
||
// дефолтный title — имя файла без .rbxl
|
||
const defTitle = (file.name || '').replace(/\.rbxl$/i, '');
|
||
setTitle(defTitle);
|
||
} catch (e) {
|
||
setError(e.message || String(e));
|
||
} finally {
|
||
setAnalyzing(false);
|
||
}
|
||
};
|
||
|
||
const handleCreate = async () => {
|
||
if (!previewHash) return;
|
||
setCreating(true);
|
||
setError(null);
|
||
try {
|
||
const result = await createRbxlProject(previewHash, title, { scriptsMode });
|
||
onCreated?.(result);
|
||
handleClose();
|
||
// редирект на редактор
|
||
if (result.redirect) window.location.href = result.redirect;
|
||
} catch (e) {
|
||
setError(e.message || String(e));
|
||
} finally {
|
||
setCreating(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div style={overlayStyle} onClick={handleClose}>
|
||
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||
<h2 style={{ marginTop: 0 }}>Импорт игры из Roblox</h2>
|
||
<button style={closeBtnStyle} onClick={handleClose}>✕</button>
|
||
</div>
|
||
<p style={{ color: '#888', fontSize: 13, marginTop: -8 }}>
|
||
Загрузи Roblox-карту в формате .rbxl. Геометрия и Lua-скрипты будут
|
||
сконвертированы в проект Rublox.
|
||
</p>
|
||
|
||
{!file && (
|
||
<div
|
||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||
onDragLeave={() => setDragOver(false)}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
setDragOver(false);
|
||
handleFile(e.dataTransfer.files?.[0]);
|
||
}}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
style={{
|
||
...dropZoneStyle,
|
||
borderColor: dragOver ? '#4a9eff' : '#444',
|
||
background: dragOver ? '#1a2a3a' : '#1a1a1a',
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 48, opacity: 0.5 }}>📦</div>
|
||
<div style={{ marginTop: 8 }}>
|
||
<strong>Перетащи .rbxl сюда</strong>
|
||
<div style={{ color: '#888', fontSize: 13, marginTop: 4 }}>
|
||
или кликни чтобы выбрать файл (макс {MAX_SIZE / 1024 / 1024} MB)
|
||
</div>
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".rbxl"
|
||
style={{ display: 'none' }}
|
||
onChange={(e) => handleFile(e.target.files?.[0])}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{file && !report && (
|
||
<div style={panelStyle}>
|
||
<div><strong>{file.name}</strong> ({(file.size / 1024).toFixed(1)} KB)</div>
|
||
<div style={{ marginTop: 16 }}>
|
||
<button style={btnStyle} onClick={handleAnalyze} disabled={analyzing}>
|
||
{analyzing ? 'Анализирую…' : 'Анализировать'}
|
||
</button>
|
||
<button style={{ ...btnStyle, marginLeft: 8, background: '#444' }} onClick={reset}>
|
||
Выбрать другой файл
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{report && (
|
||
<div style={panelStyle}>
|
||
<h3 style={{ marginTop: 0 }}>Отчёт</h3>
|
||
<table style={tableStyle}>
|
||
<tbody>
|
||
<tr><td>Файл:</td><td><strong>{report.filename}</strong></td></tr>
|
||
<tr><td>Размер:</td><td>{(report.size_bytes / 1024).toFixed(1)} KB</td></tr>
|
||
<tr><td>Объектов:</td><td>{report.instance_count}</td></tr>
|
||
<tr><td>Классов:</td><td>{report.class_count}</td></tr>
|
||
<tr><td>Создано Part'ов:</td><td><strong>{report.primitives_created}</strong></td></tr>
|
||
<tr><td>GLB-моделей:</td><td>{report.glb_models_created}</td></tr>
|
||
<tr><td>Lua-скриптов:</td><td>{report.scripts_total}</td></tr>
|
||
<tr><td>Ассетов для скачки:</td><td>{report.assets_to_download}</td></tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
{report.top_classes?.length > 0 && (
|
||
<details style={{ marginTop: 12 }}>
|
||
<summary style={{ cursor: 'pointer' }}>Что внутри (топ-25 классов)</summary>
|
||
<table style={tableStyle}>
|
||
<tbody>
|
||
{report.top_classes.slice(0, 25).map((c, i) => (
|
||
<tr key={i}><td>{c.class}</td><td>{c.count}</td></tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</details>
|
||
)}
|
||
|
||
{report.warnings?.length > 0 && (
|
||
<details style={{ marginTop: 12 }}>
|
||
<summary style={{ cursor: 'pointer', color: '#f8a' }}>
|
||
Предупреждения ({report.warnings.length})
|
||
</summary>
|
||
<ul style={{ fontSize: 13, color: '#aaa' }}>
|
||
{report.warnings.slice(0, 30).map((w, i) => <li key={i}>{w}</li>)}
|
||
</ul>
|
||
</details>
|
||
)}
|
||
|
||
<div style={{ marginTop: 16, padding: 12, background: '#2a2a2a', borderRadius: 6 }}>
|
||
<div style={{ fontSize: 13, color: '#fa8' }}>
|
||
⚠️ Загружая, ты подтверждаешь право использовать содержимое этой карты.
|
||
Не загружай чужие карты без разрешения автора.
|
||
</div>
|
||
</div>
|
||
|
||
{report.scripts_total > 0 && (
|
||
<div style={{ marginTop: 16, padding: 12, background: '#1f1f1f', borderRadius: 6, border: '1px solid #333' }}>
|
||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||
Что делать со скриптами ({report.scripts_total} шт.)?
|
||
</div>
|
||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 0', cursor: 'pointer' }}>
|
||
<input
|
||
type="radio" name="scriptsMode" value="disabled"
|
||
checked={scriptsMode === 'disabled'}
|
||
onChange={() => setScriptsMode('disabled')}
|
||
style={{ marginTop: 3 }}
|
||
/>
|
||
<div>
|
||
<div style={{ fontSize: 13 }}>Импортировать <b>выключенными</b> (рекомендуется)</div>
|
||
<div style={{ fontSize: 11, color: '#888' }}>
|
||
Скрипты видны в иерархии и редакторе, можно читать как референс,
|
||
но не исполняются. Карта не подвиснет.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 0', cursor: 'pointer' }}>
|
||
<input
|
||
type="radio" name="scriptsMode" value="enabled"
|
||
checked={scriptsMode === 'enabled'}
|
||
onChange={() => setScriptsMode('enabled')}
|
||
style={{ marginTop: 3 }}
|
||
/>
|
||
<div>
|
||
<div style={{ fontSize: 13 }}>Импортировать <b>активными</b></div>
|
||
<div style={{ fontSize: 11, color: '#888' }}>
|
||
Попытаться запустить. Старые Roblox-скрипты могут подвешивать игру —
|
||
тогда вернись и переимпортируй с другим режимом.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 0', cursor: 'pointer' }}>
|
||
<input
|
||
type="radio" name="scriptsMode" value="skip"
|
||
checked={scriptsMode === 'skip'}
|
||
onChange={() => setScriptsMode('skip')}
|
||
style={{ marginTop: 3 }}
|
||
/>
|
||
<div>
|
||
<div style={{ fontSize: 13 }}>Не импортировать совсем</div>
|
||
<div style={{ fontSize: 11, color: '#888' }}>
|
||
Только геометрия. Скрипты не попадут в проект — чистое начало.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ marginTop: 16 }}>
|
||
<label style={{ display: 'block', marginBottom: 6 }}>Название игры:</label>
|
||
<input
|
||
type="text"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
style={inputStyle}
|
||
placeholder="Например: Моя обби-карта"
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ marginTop: 16 }}>
|
||
<button
|
||
style={{ ...btnStyle, background: '#3a8' }}
|
||
onClick={handleCreate}
|
||
disabled={creating || !title.trim()}
|
||
>
|
||
{creating ? 'Создаю…' : '✨ Создать игру'}
|
||
</button>
|
||
<button style={{ ...btnStyle, marginLeft: 8, background: '#444' }} onClick={reset}>
|
||
Начать заново
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div style={{ marginTop: 12, padding: 12, background: '#3a1a1a', color: '#fa8', borderRadius: 6 }}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const overlayStyle = {
|
||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 9000,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
|
||
};
|
||
const dialogStyle = {
|
||
background: '#1f1f1f', color: '#eee', borderRadius: 10, padding: 20,
|
||
maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto',
|
||
boxShadow: '0 10px 40px rgba(0,0,0,0.5)',
|
||
};
|
||
const dropZoneStyle = {
|
||
border: '2px dashed #444', borderRadius: 8, padding: 40, textAlign: 'center',
|
||
cursor: 'pointer', transition: 'all 0.2s',
|
||
};
|
||
const panelStyle = {
|
||
background: '#262626', borderRadius: 8, padding: 16, marginTop: 12,
|
||
};
|
||
const btnStyle = {
|
||
background: '#4a8', color: '#fff', border: 'none', padding: '10px 20px',
|
||
borderRadius: 6, fontSize: 14, cursor: 'pointer',
|
||
};
|
||
const closeBtnStyle = {
|
||
background: 'transparent', border: 'none', color: '#888', fontSize: 20,
|
||
cursor: 'pointer', padding: 4,
|
||
};
|
||
const tableStyle = {
|
||
width: '100%', fontSize: 13, marginTop: 8,
|
||
};
|
||
const inputStyle = {
|
||
width: '100%', padding: 8, background: '#1a1a1a', color: '#eee',
|
||
border: '1px solid #444', borderRadius: 4, fontSize: 14,
|
||
};
|