Open-source веб-студия для создания игр Рублокса, двойная лицензия AGPL-3.0 + Коммерческая. Главное: - Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16 - Самодостаточный движок ~28к строк (66 файлов): BlockManager, TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов - Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco) - Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn) - Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt) - 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.) - Конфигурируемый бэкенд через VITE_API_BASE — работает со staging (dev-api.rublox.pro) без настройки - Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка - Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - ESLint + Prettier + EditorConfig - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Перед публикацией: - Все импорты из minecraftia заменены на локальные - Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env - Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо) - AdminKubikonModeration не публикуется (модерация — в team.rublox.pro) - 93 МБ ассетов public/kubikon-assets вынесены в .gitignore (раздаются через release artifact)
282 lines
12 KiB
JavaScript
282 lines
12 KiB
JavaScript
import React, { useEffect, useRef, useState } from 'react';
|
||
import Icon from './Icon';
|
||
|
||
/**
|
||
* ScriptConsole — нижняя выдвижная панель консоли скриптов (синий wow-стиль).
|
||
*
|
||
* Показывает game.log() и ошибки выполнения. Auto-scroll вниз при новых
|
||
* сообщениях. Кнопка «Очистить» сбрасывает буфер.
|
||
*
|
||
* Props:
|
||
* logs — массив { level, text, ts }
|
||
* onClear — () => void
|
||
* onClose — () => void
|
||
* visible — bool (если false — компонент не рендерится)
|
||
*/
|
||
const LEVEL_COLORS = {
|
||
info: '#6d8aff',
|
||
error: '#ff6b6b',
|
||
warn: '#f5b342',
|
||
};
|
||
|
||
const LEVEL_BG = {
|
||
info: 'rgba(79, 116, 255, 0.12)',
|
||
error: 'rgba(239, 68, 68, 0.14)',
|
||
warn: 'rgba(245, 158, 11, 0.12)',
|
||
};
|
||
|
||
const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => {
|
||
const listRef = useRef(null);
|
||
const [copyState, setCopyState] = useState('idle');
|
||
|
||
useEffect(() => {
|
||
if (!visible) return;
|
||
const el = listRef.current;
|
||
if (el) el.scrollTop = el.scrollHeight;
|
||
}, [logs, visible]);
|
||
|
||
const handleCopy = async () => {
|
||
const text = logs.map(l => {
|
||
const ts = new Date(l.ts || Date.now()).toLocaleTimeString().slice(0, 8);
|
||
const prefix = l.level === 'error' ? '[ERROR]' : l.level === 'warn' ? '[WARN]' : '[INFO]';
|
||
return `${ts} ${prefix} ${l.text || ''}`;
|
||
}).join('\n');
|
||
try {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(text);
|
||
} else {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.left = '-9999px';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
}
|
||
setCopyState('copied');
|
||
setTimeout(() => setCopyState('idle'), 1500);
|
||
} catch (e) {
|
||
setCopyState('error');
|
||
setTimeout(() => setCopyState('idle'), 1500);
|
||
}
|
||
};
|
||
|
||
if (!visible) return null;
|
||
|
||
const errorsCount = logs.filter(l => l.level === 'error').length;
|
||
const warnsCount = logs.filter(l => l.level === 'warn').length;
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'absolute',
|
||
left: 0, right: 0, bottom: 0,
|
||
height: 240,
|
||
background: '#1b1b1b',
|
||
backdropFilter: 'blur(20px)',
|
||
WebkitBackdropFilter: 'blur(20px)',
|
||
borderTop: '1px solid #3a3a3a',
|
||
color: '#e8e8ea',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
zIndex: 50,
|
||
fontFamily: 'Consolas, Menlo, Monaco, "Courier New", monospace',
|
||
fontSize: 12.5,
|
||
boxShadow: '0 -12px 32px rgba(0, 0, 0, 0.5)',
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{
|
||
padding: '10px 14px',
|
||
borderBottom: '1px solid #3a3a3a',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 10,
|
||
background: 'linear-gradient(180deg, #2e2e2e 0%, #252525 100%)',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
fontSize: 13,
|
||
fontWeight: 700,
|
||
color: '#e8e8ea',
|
||
}}>
|
||
<div style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||
}}>
|
||
<div style={{
|
||
width: 28, height: 28, borderRadius: 8,
|
||
background: 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 14,
|
||
boxShadow: '0 4px 10px rgba(79, 116, 255, 0.45)',
|
||
}}><Icon name="duplicate" size={14} /></div>
|
||
<span style={{ fontWeight: 800, letterSpacing: -0.2, color: '#e8e8ea' }}>
|
||
Консоль скриптов
|
||
</span>
|
||
<span style={{
|
||
background: '#1b1b1b',
|
||
padding: '2px 8px',
|
||
borderRadius: 999,
|
||
fontSize: 11,
|
||
fontWeight: 800,
|
||
color: '#9a9a9e',
|
||
}}>{logs.length}</span>
|
||
{errorsCount > 0 && (
|
||
<span style={{
|
||
background: 'rgba(239, 68, 68, 0.16)',
|
||
color: '#ff6b6b',
|
||
padding: '2px 8px',
|
||
borderRadius: 999,
|
||
fontSize: 11, fontWeight: 800,
|
||
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||
}}><Icon emoji="❌" size={11} /> {errorsCount}</span>
|
||
)}
|
||
{warnsCount > 0 && (
|
||
<span style={{
|
||
background: 'rgba(245, 158, 11, 0.16)',
|
||
color: '#f5b342',
|
||
padding: '2px 8px',
|
||
borderRadius: 999,
|
||
fontSize: 11, fontWeight: 800,
|
||
border: '1px solid rgba(245, 158, 11, 0.35)',
|
||
}}><Icon emoji="⚠" size={11} /> {warnsCount}</span>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ flex: 1 }} />
|
||
|
||
<button
|
||
onClick={handleCopy}
|
||
disabled={logs.length === 0}
|
||
style={{
|
||
background: copyState === 'copied'
|
||
? 'linear-gradient(135deg, #22d97a 0%, #0f9d56 100%)'
|
||
: '#2e2e2e',
|
||
border: '1px solid ' + (copyState === 'copied' ? 'transparent' : '#3a3a3a'),
|
||
color: copyState === 'copied' ? '#fff' : (logs.length === 0 ? '#7a7a7e' : '#e8e8ea'),
|
||
borderRadius: 8, padding: '5px 12px',
|
||
cursor: logs.length === 0 ? 'not-allowed' : 'pointer',
|
||
fontSize: 11, fontWeight: 700,
|
||
opacity: logs.length === 0 ? 0.55 : 1,
|
||
transition: 'all 200ms ease',
|
||
fontFamily: 'inherit',
|
||
boxShadow: copyState === 'copied' ? '0 4px 12px rgba(34, 217, 122, 0.40)' : 'none',
|
||
}}
|
||
title="Скопировать все логи в буфер"
|
||
>
|
||
{copyState === 'copied'
|
||
? <><Icon emoji="✓" size={12} /> Скопировано</>
|
||
: copyState === 'error'
|
||
? <><Icon emoji="✕" size={12} /> Ошибка</>
|
||
: <><Icon emoji="📋" size={12} /> Копировать</>}
|
||
</button>
|
||
<button
|
||
onClick={onClear}
|
||
style={{
|
||
background: '#2e2e2e',
|
||
border: '1px solid #3a3a3a',
|
||
color: '#e8e8ea',
|
||
borderRadius: 8, padding: '5px 12px',
|
||
cursor: 'pointer', fontSize: 11, fontWeight: 700,
|
||
transition: 'all 150ms ease',
|
||
fontFamily: 'inherit',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.16)';
|
||
e.currentTarget.style.borderColor = 'rgba(239, 68, 68, 0.4)';
|
||
e.currentTarget.style.color = '#ff6b6b';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = '#2e2e2e';
|
||
e.currentTarget.style.borderColor = '#3a3a3a';
|
||
e.currentTarget.style.color = '#e8e8ea';
|
||
}}
|
||
title="Очистить консоль"
|
||
>
|
||
<Icon name="trash" size={12} /> Очистить
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
width: 28, height: 28,
|
||
background: '#2e2e2e',
|
||
border: '1px solid #3a3a3a',
|
||
color: '#9a9a9e',
|
||
borderRadius: 8,
|
||
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
transition: 'all 150ms ease',
|
||
fontFamily: 'inherit',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = '#383838';
|
||
e.currentTarget.style.color = '#e8e8ea';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = '#2e2e2e';
|
||
e.currentTarget.style.color = '#9a9a9e';
|
||
}}
|
||
title="Закрыть"
|
||
><Icon name="close" size={13} /> </button>
|
||
</div>
|
||
|
||
{/* Лог-строки */}
|
||
<div
|
||
ref={listRef}
|
||
style={{
|
||
flex: 1,
|
||
overflowY: 'auto',
|
||
padding: '10px 14px',
|
||
lineHeight: 1.55,
|
||
userSelect: 'text',
|
||
WebkitUserSelect: 'text',
|
||
cursor: 'text',
|
||
}}
|
||
>
|
||
{logs.length === 0 ? (
|
||
<div style={{
|
||
color: '#9a9a9e',
|
||
fontStyle: 'italic',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
textAlign: 'center', padding: 20,
|
||
}}>
|
||
Здесь появятся логи <code style={{
|
||
background: 'rgba(79, 116, 255, 0.18)',
|
||
color: '#6d8aff',
|
||
padding: '1px 6px',
|
||
borderRadius: 4,
|
||
fontFamily: 'inherit',
|
||
fontSize: 12,
|
||
fontWeight: 700,
|
||
}}>game.log()</code> и ошибки скриптов.
|
||
</div>
|
||
) : (
|
||
logs.map((l, i) => (
|
||
<div key={i} style={{
|
||
color: LEVEL_COLORS[l.level] || '#e8e8ea',
|
||
whiteSpace: 'pre-wrap',
|
||
padding: '3px 8px',
|
||
margin: '1px -8px',
|
||
borderRadius: 6,
|
||
background: LEVEL_BG[l.level] || 'transparent',
|
||
borderLeft: l.level
|
||
? `3px solid ${LEVEL_COLORS[l.level]}`
|
||
: '3px solid transparent',
|
||
paddingLeft: 8,
|
||
}}>
|
||
<span style={{
|
||
color: '#94a3b8', marginRight: 10, fontWeight: 700,
|
||
}}>
|
||
{new Date(l.ts || Date.now()).toLocaleTimeString().slice(0, 8)}
|
||
</span>
|
||
{l.level === 'error' && <span><Icon name="error" size={14} /></span>}
|
||
{l.level === 'warn' && <span><Icon name="warning" size={14} /></span>}
|
||
{l.level === 'info' && <span style={{ opacity: 0.7 }}>● </span>}
|
||
{l.text}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ScriptConsole;
|