Большой консолидирующий коммит после поднятия studio.rublox.pro (28 мая 2026). Содержит изменения которые делались в процессе подготовки прод-окружения: Фиксы импортов после выноса из minecraftia: - Массовая замена путей ../../components → ../components (40+ файлов в src/community/, src/admin-preview/) - Замена ../KubikonEditor/ → ../editor/, ../KubikonStudio/ → ../community/, ../AdminPreview/ → ../admin-preview/ - API.js скопирован из минки целиком (было 8 экспортов, стало 312) - Добавлены PLAYER_URL, MyButton_1, недостающие компоненты - Заменены require() на статические ES-imports в BabylonScene, PrimitiveManager, GameRuntime (Vite не поддерживает CJS require) Структура ассетов: - public/kubikon-templates/ → public/assets/kubikon-templates/ - public/kubikon-learn/ → public/assets/kubikon-learn/ - (код искал в /assets/, файлы лежали без /assets/) Навигация роутов внутри студии: - /kubikon-studio/docs → /docs (90+ навигационных вызовов sed-replaced) - /kubikon-editor/X → /edit/X, /kubikon/play/X → /play/X, /kubikon/gd/X → /gd/X UI: - Новый компонент StudioHeader (61px, как в минке) + копия favicon - WithHeader wrapper в App.jsx для всех страниц кроме fullscreen-редактора/плеера - SSO ticket-flow в AuthContext (auto-redeem #ticket= при загрузке) - Тёмная тема карточек игр в ВИКИ (фон #1c2231 вместо #fff, картинка впритык) Документация: - docs/ONBOARDING.md — путь нового контрибьютора от нуля до PR - docs/TUTORIAL_ADD_SCRIPT_API.md — как добавить game.* API - API_USAGE.md — список эндпоинтов backend - README в подпапках engine/, engine/terrain/, engine/voxel/, engine/robloxterrain/, engine/types/ .gitignore: - public/wiki/ исключён (73МБ PNG, будут на CDN отдельной задачей) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
457 lines
24 KiB
JavaScript
457 lines
24 KiB
JavaScript
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||
import Editor from '@monaco-editor/react';
|
||
import Icon from './Icon';
|
||
// Фаза 6.1: декларации game.* API теперь в отдельных файлах engine/types/*.d.ts,
|
||
// собираются скриптом _build_bundle.py в bundle.js (16 модулей, ~54КБ).
|
||
// Каждый модуль грузим в Monaco как отдельный extraLib — это позволяет
|
||
// при правке одного файла не перетряхивать все остальные.
|
||
import { GAME_TYPE_LIBS } from './engine/types/bundle';
|
||
import { registerSnippets } from './engine/snippets';
|
||
|
||
/**
|
||
* ScriptEditor — Monaco-редактор кода скрипта в табе.
|
||
*
|
||
* Особенности:
|
||
* - Дебаунс автосохранения 600мс: пока юзер печатает, не дёргаем onSave.
|
||
* Через 600мс тишины → onSave(code) → markDirty в KubikonEditor.
|
||
* - Темная тема (vs-dark).
|
||
* - Подсветка синтаксических ошибок JS (через настройки jsDefaults).
|
||
* - Автокомплит на game.* через TypeScript-декларацию (см. setupGameTypes).
|
||
* - Ctrl+S — мгновенный flush сохранения (без дебаунса).
|
||
* - Ctrl+/ — комментарий строки (встроенная фича Monaco).
|
||
*
|
||
* Props:
|
||
* value — начальный код
|
||
* onSave(code) — debounced сохранение
|
||
* onRunSolo(code) — запустить только этот скрипт (sandbox-режим)
|
||
* isSoloRunning — bool, активен ли solo-режим
|
||
* scriptId — для подсветки ошибок (один уникальный URI на скрипт)
|
||
* target — null | {kind, ...} — для отображения «привязка к: блок (1,2,3)»
|
||
* onClose — закрыть таб (передаётся в шапку)
|
||
*/
|
||
// Старый одностраничный TYPES_LIB удалён в Фазе 6.1 (2026-05-26).
|
||
// Декларации переехали в engine/types/*.d.ts → bundle.js → GAME_TYPE_LIBS (импорт сверху).
|
||
// Если нужен какой-то метод, которого нет в автокомплите — добавляйте его
|
||
// в соответствующий .d.ts (player.d.ts / scene.d.ts / ...) и пересобирайте
|
||
// командой `python _build_bundle.py` в той же папке.
|
||
function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, onClose, flushRef }) {
|
||
// Локальный буфер кода — то что в редакторе сейчас.
|
||
// Синхронизируется с external value только при смене scriptId.
|
||
const [localCode, setLocalCode] = useState(value || '');
|
||
const prevScriptIdRef = useRef(scriptId);
|
||
const debounceRef = useRef(null);
|
||
const editorRef = useRef(null);
|
||
// Актуальные значения для флаша из родителя (через flushRef).
|
||
// Без них замыкание поймает старый код/scriptId на момент монтирования.
|
||
const localCodeRef = useRef(localCode);
|
||
const onSaveRef = useRef(onSave);
|
||
const scriptIdRef = useRef(scriptId);
|
||
useEffect(() => { localCodeRef.current = localCode; }, [localCode]);
|
||
useEffect(() => { onSaveRef.current = onSave; }, [onSave]);
|
||
useEffect(() => { scriptIdRef.current = scriptId; }, [scriptId]);
|
||
|
||
// При смене скрипта — флашим pending-правки предыдущего, потом подгружаем код нового.
|
||
useEffect(() => {
|
||
if (prevScriptIdRef.current !== scriptId) {
|
||
// Если в предыдущем скрипте был активен дебаунс — сохраняем ПОСЛЕДНЮЮ
|
||
// версию кода для прошлого scriptId, иначе правки исчезнут.
|
||
if (debounceRef.current) {
|
||
clearTimeout(debounceRef.current);
|
||
debounceRef.current = null;
|
||
try { onSaveRef.current?.(localCodeRef.current); } catch (_) {}
|
||
}
|
||
setLocalCode(value || '');
|
||
prevScriptIdRef.current = scriptId;
|
||
}
|
||
}, [scriptId, value]);
|
||
|
||
// Если прилетело новое value (например после загрузки проекта) — синхронизируем.
|
||
useEffect(() => {
|
||
if (prevScriptIdRef.current === scriptId && value !== localCode) {
|
||
// Синхронизируем только если сильно отличается — иначе перетрут пользовательский ввод
|
||
if (!localCode || (value || '').length > 0 && (value || '') !== localCode && !document.activeElement?.closest?.('.monaco-editor')) {
|
||
setLocalCode(value || '');
|
||
}
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [value]);
|
||
|
||
// Дебаунс-сохранение
|
||
const scheduleSave = useCallback((code) => {
|
||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||
debounceRef.current = setTimeout(() => {
|
||
debounceRef.current = null;
|
||
onSave?.(code);
|
||
}, 600);
|
||
}, [onSave]);
|
||
|
||
const flushSave = useCallback(() => {
|
||
if (debounceRef.current) {
|
||
clearTimeout(debounceRef.current);
|
||
debounceRef.current = null;
|
||
}
|
||
// Берём актуальные ссылки из ref — без них Ctrl+S из Monaco
|
||
// мог поймать старое замыкание (binding регистрируется один раз в Mount).
|
||
onSaveRef.current?.(localCodeRef.current);
|
||
}, []);
|
||
|
||
// Прокидываем flush в родителя — KubikonEditor вызывает его перед каждым doSave,
|
||
// чтобы pending debounce не пропустил последние правки скрипта.
|
||
useEffect(() => {
|
||
if (!flushRef) return;
|
||
flushRef.current = () => {
|
||
if (debounceRef.current) {
|
||
clearTimeout(debounceRef.current);
|
||
debounceRef.current = null;
|
||
try { onSaveRef.current?.(localCodeRef.current); } catch (_) {}
|
||
}
|
||
};
|
||
return () => { if (flushRef) flushRef.current = null; };
|
||
}, [flushRef]);
|
||
|
||
// Очистка дебаунса и ResizeObserver при unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
if (debounceRef.current) {
|
||
clearTimeout(debounceRef.current);
|
||
try { onSaveRef.current?.(localCodeRef.current); } catch (_) {}
|
||
}
|
||
if (editorRef.current?._kubikonRO) {
|
||
try { editorRef.current._kubikonRO.disconnect(); } catch (_) {}
|
||
}
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const handleChange = (val) => {
|
||
const code = val ?? '';
|
||
setLocalCode(code);
|
||
scheduleSave(code);
|
||
};
|
||
|
||
/**
|
||
* Регистрируем декларации game.* в JS-режиме Monaco при mount.
|
||
* Вызывается ОДИН раз для всего приложения (если Monaco загружен повторно — повторно).
|
||
*/
|
||
const handleEditorWillMount = (monaco) => {
|
||
try {
|
||
// Подсветка синтаксических ошибок — да, но семантическая (полный TS-анализ)
|
||
// отключена: она тяжёлая и спавнит воркер для каждого файла.
|
||
// На скрипте 50-200 строк это даёт заметный лаг при каждом нажатии.
|
||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||
noSemanticValidation: true,
|
||
noSyntaxValidation: false,
|
||
noSuggestionDiagnostics: true,
|
||
});
|
||
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
||
allowNonTsExtensions: true,
|
||
strict: false,
|
||
noLib: true,
|
||
lib: ['es2020'],
|
||
});
|
||
// Регистрируем 16 typedef-модулей game.* (Фаза 6.1).
|
||
// Каждый файл — отдельный extraLib, чтобы при правке одного
|
||
// не пересоздавать всю кучу. existing проверяем по uri.
|
||
const existing = monaco.languages.typescript.javascriptDefaults.getExtraLibs();
|
||
for (const lib of GAME_TYPE_LIBS) {
|
||
if (!existing[lib.uri]) {
|
||
monaco.languages.typescript.javascriptDefaults.addExtraLib(lib.content, lib.uri);
|
||
}
|
||
}
|
||
// Сниппеты для быстрого старта (door/coin/portal/npc/quest/save и т.д.).
|
||
// Регистрируются один раз для всего Monaco, флаг хранится в monaco.__kubikonSnippetsRegistered.
|
||
registerSnippets(monaco);
|
||
} catch (e) {
|
||
// eslint-disable-next-line no-console
|
||
console.warn('[ScriptEditor] Monaco setup error', e);
|
||
}
|
||
};
|
||
|
||
const handleEditorMount = (editor, monaco) => {
|
||
editorRef.current = editor;
|
||
// Ctrl+S — flush сохранения
|
||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||
flushSave();
|
||
});
|
||
// Ctrl+Shift+I — вставить сниппет (вызов автокомплита).
|
||
// Ctrl+Shift+P уже занят встроенной палитрой команд Monaco (это и есть 6.1.5).
|
||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyI, () => {
|
||
editor.trigger('keyboard', 'editor.action.triggerSuggest', {});
|
||
});
|
||
// F8 — «Проверить скрипт» (включить полный TS-анализ на 4 сек,
|
||
// потом отключить чтобы не было лагов при печати). Все ошибки
|
||
// подсветятся squiggly-линиями.
|
||
editor.addCommand(monaco.KeyCode.F8, () => {
|
||
try {
|
||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||
noSemanticValidation: false,
|
||
noSyntaxValidation: false,
|
||
noSuggestionDiagnostics: false,
|
||
});
|
||
setTimeout(() => {
|
||
try {
|
||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||
noSemanticValidation: true,
|
||
noSyntaxValidation: false,
|
||
noSuggestionDiagnostics: true,
|
||
});
|
||
} catch (_) {}
|
||
}, 4000);
|
||
} catch (_) {}
|
||
});
|
||
// Ручной resize вместо automaticLayout (он внутри Monaco ставит RAF-цикл,
|
||
// что заметно тормозит на больших проектах). Слушаем ResizeObserver
|
||
// на ближайшем контейнере и вызываем editor.layout() только при изменении.
|
||
try {
|
||
const node = editor.getDomNode?.()?.parentElement;
|
||
if (node && typeof ResizeObserver !== 'undefined') {
|
||
let lastW = 0, lastH = 0;
|
||
const ro = new ResizeObserver((entries) => {
|
||
const e = entries[0];
|
||
if (!e) return;
|
||
const w = Math.round(e.contentRect.width);
|
||
const h = Math.round(e.contentRect.height);
|
||
if (w === lastW && h === lastH) return;
|
||
lastW = w; lastH = h;
|
||
try { editor.layout({ width: w, height: h }); } catch (_) {}
|
||
});
|
||
ro.observe(node);
|
||
editor._kubikonRO = ro;
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
|
||
// Подпись привязки скрипта
|
||
const targetLabel = (() => {
|
||
if (!target) return 'Глобальный скрипт';
|
||
if (target.kind === 'block') {
|
||
const r = target.ref || target;
|
||
return `Привязан к блоку (${r.x}, ${r.y}, ${r.z})`;
|
||
}
|
||
const id = target.id ?? target.ref;
|
||
if (target.kind === 'model') return `Привязан к модели #${id}`;
|
||
if (target.kind === 'primitive') return `Привязан к примитиву #${id}`;
|
||
return 'Привязан к объекту';
|
||
})();
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
background: '#1e1e1e',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
zIndex: 5,
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
}}>
|
||
{/* Шапка таба */}
|
||
<div style={{
|
||
padding: '10px 16px',
|
||
borderBottom: '1px solid #3a3a3a',
|
||
background: 'linear-gradient(180deg, #2e2e2e 0%, #252525 100%)',
|
||
color: '#e8e8ea',
|
||
fontSize: 13,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
fontFamily: 'inherit',
|
||
}}>
|
||
<div style={{
|
||
width: 28, height: 28, borderRadius: 8,
|
||
background: 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 14,
|
||
color: '#fff',
|
||
boxShadow: '0 4px 10px rgba(79, 116, 255, 0.45)',
|
||
}}><Icon name="script" size={14} /></div>
|
||
<span style={{
|
||
fontWeight: 800, color: '#e8e8ea',
|
||
letterSpacing: -0.2,
|
||
}}>
|
||
{scriptId === 'demo' ? 'Демо-скрипт' : scriptId}
|
||
</span>
|
||
{targetLabel && (
|
||
<span style={{
|
||
background: 'rgba(79, 116, 255, 0.18)',
|
||
color: '#6d8aff',
|
||
padding: '3px 10px',
|
||
borderRadius: 999,
|
||
fontSize: 11,
|
||
fontWeight: 700,
|
||
border: '1px solid rgba(79, 116, 255, 0.35)',
|
||
}}>{targetLabel}</span>
|
||
)}
|
||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 10, alignItems: 'center' }}>
|
||
{/* Фаза 6.1.4: кнопка «Проверить» — включает семантический анализ TS
|
||
на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.).
|
||
F8 — горячая клавиша. */}
|
||
<button
|
||
onClick={() => {
|
||
try {
|
||
const ed = editorRef.current;
|
||
if (!ed) return;
|
||
ed.trigger('keyboard', 'editor.action.marker.next', {});
|
||
ed.focus();
|
||
// Сам анализ запускается через F8 — здесь только триггер той же команды.
|
||
const monaco = window.monaco;
|
||
if (monaco && monaco.languages?.typescript?.javascriptDefaults) {
|
||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||
noSemanticValidation: false,
|
||
noSyntaxValidation: false,
|
||
noSuggestionDiagnostics: false,
|
||
});
|
||
setTimeout(() => {
|
||
try {
|
||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||
noSemanticValidation: true,
|
||
noSyntaxValidation: false,
|
||
noSuggestionDiagnostics: true,
|
||
});
|
||
} catch (_) {}
|
||
}, 4000);
|
||
}
|
||
} catch (_) {}
|
||
}}
|
||
title="Проверить скрипт на опечатки и несуществующие методы game.* (F8)"
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
padding: '6px 12px',
|
||
borderRadius: 8,
|
||
border: '1px solid rgba(255, 209, 102, 0.45)',
|
||
background: 'linear-gradient(135deg, rgba(255,209,102,0.18) 0%, rgba(255,158,71,0.18) 100%)',
|
||
color: '#ffd166',
|
||
fontSize: 12,
|
||
fontWeight: 700,
|
||
cursor: 'pointer',
|
||
fontFamily: 'inherit',
|
||
letterSpacing: 0.2,
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.filter = 'brightness(1.15)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.filter = 'brightness(1)';
|
||
}}
|
||
>
|
||
<span style={{ display: 'inline-flex' }}><Icon name="warning" size={13} /></span>
|
||
<span>Проверить</span>
|
||
</button>
|
||
<button
|
||
onClick={() => onRunSolo?.(localCode)}
|
||
title={isSoloRunning ? 'Остановить отладочный запуск' : 'Запустить только этот скрипт (для отладки)'}
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
padding: '6px 14px',
|
||
borderRadius: 8,
|
||
border: '1px solid transparent',
|
||
background: isSoloRunning
|
||
? 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)'
|
||
: 'linear-gradient(135deg, #22d97a 0%, #0f9d56 100%)',
|
||
color: '#fff',
|
||
fontSize: 12,
|
||
fontWeight: 800,
|
||
cursor: 'pointer',
|
||
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
fontFamily: 'inherit',
|
||
boxShadow: isSoloRunning
|
||
? '0 6px 16px rgba(239, 68, 68, 0.35)'
|
||
: '0 6px 16px rgba(34, 217, 122, 0.35)',
|
||
letterSpacing: 0.2,
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||
e.currentTarget.style.filter = 'brightness(1.08)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.transform = 'translateY(0)';
|
||
e.currentTarget.style.filter = 'brightness(1)';
|
||
}}
|
||
>
|
||
<span style={{ display: 'inline-flex' }}><Icon name={isSoloRunning ? 'stop' : 'play'} size={13} /></span>
|
||
<span>{isSoloRunning ? 'Стоп отладки' : 'Запустить только этот'}</span>
|
||
</button>
|
||
<span style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
color: '#9a9a9e', fontSize: 11, fontWeight: 700,
|
||
}}>
|
||
<kbd style={kbdStyle}>Ctrl</kbd>
|
||
<span>+</span>
|
||
<kbd style={kbdStyle}>S</kbd>
|
||
<span style={{ margin: '0 4px' }}>·</span>
|
||
<kbd style={kbdStyle}>Ctrl</kbd>
|
||
<span>+</span>
|
||
<kbd style={kbdStyle}>/</kbd>
|
||
</span>
|
||
</span>
|
||
</div>
|
||
|
||
{/* Редактор */}
|
||
<div style={{ flex: 1, minHeight: 0 }}>
|
||
<Editor
|
||
height="100%"
|
||
defaultLanguage="javascript"
|
||
theme="vs-dark"
|
||
value={localCode}
|
||
path={`script_${scriptId}.js`}
|
||
onChange={handleChange}
|
||
beforeMount={handleEditorWillMount}
|
||
onMount={handleEditorMount}
|
||
options={{
|
||
fontSize: 14,
|
||
minimap: { enabled: false },
|
||
scrollBeyondLastLine: false,
|
||
wordWrap: 'on',
|
||
// Вместо automaticLayout (rAF-цикл внутри Monaco — тормозит)
|
||
// используем явный ResizeObserver в onMount.
|
||
automaticLayout: false,
|
||
tabSize: 4,
|
||
insertSpaces: true,
|
||
// smoothScrolling и cursorBlinking тоже добавляют rAF-loop'ы
|
||
smoothScrolling: false,
|
||
cursorBlinking: 'blink',
|
||
roundedSelection: true,
|
||
padding: { top: 12, bottom: 12 },
|
||
guides: { indentation: true },
|
||
// bracketPairColorization — дорогая фича, на больших файлах лагает
|
||
bracketPairColorization: { enabled: false },
|
||
suggest: { showWords: false },
|
||
// Скрипты обычно небольшие — render не на каждый кадр
|
||
renderLineHighlight: 'line',
|
||
renderWhitespace: 'none',
|
||
// Mouse wheel: гарантируем что скролл попадает в Monaco
|
||
mouseWheelScrollSensitivity: 1,
|
||
scrollbar: {
|
||
useShadows: false,
|
||
verticalScrollbarSize: 10,
|
||
horizontalScrollbarSize: 10,
|
||
alwaysConsumeMouseWheel: true,
|
||
},
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const kbdStyle = {
|
||
background: '#1b1b1b',
|
||
border: '1px solid #4a4a4a',
|
||
borderRadius: 4,
|
||
padding: '1px 6px',
|
||
fontSize: 10,
|
||
fontFamily: 'inherit',
|
||
fontWeight: 800,
|
||
color: '#9a9a9e',
|
||
minWidth: 14,
|
||
textAlign: 'center',
|
||
display: 'inline-block',
|
||
boxShadow: '0 1px 0 rgba(0, 0, 0, 0.3)',
|
||
};
|
||
|
||
export default ScriptEditor;
|