studio/src/components/RbxlImportModal.jsx
min 16223e06ef feat(rbxl): выбор режима скриптов в модалке импорта
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.
2026-06-08 21:20:01 +03:00

332 lines
17 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.

/**
* 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,
};