studio/src/editor/ScriptEditor.jsx
МИН 61fba4e174
Some checks failed
CI / Lint + Format (push) Failing after 32s
CI / Build (push) Failing after 37s
CI / Secret scan (push) Failing after 37s
CI / PR size check (push) Has been skipped
fix: починка билда + studio.rublox.pro инфра
Большой консолидирующий коммит после поднятия 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>
2026-05-28 05:01:13 +03:00

457 lines
24 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, { 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;