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 (