studio/src/editor/ScriptEditor.jsx
min 0805da0708 fix(lua): PlayerAdded фейрится для уже существующих игроков
Roblox-конвенция: Players.PlayerAdded не срабатывает для игроков уже
на сервере к моменту подключения хендлера. Юзер пишет:
  Players.PlayerAdded:Connect(function(p) print(p.Name) end)
и удивляется почему лог пустой — игрок-то уже есть.

В реальном Roblox делают:
  for _, p in ipairs(Players:GetPlayers()) do print(p.Name) end
  Players.PlayerAdded:Connect(...)
Но мало кто помнит про этот workaround.

Решение: после kickoff всех скриптов (когда все Connect'ы установлены)
из LuaSharedSandbox шлём через api.fireExistingPlayers() →
PlayerAdded.Fire(localPlayer) + CharacterAdded.Fire(character).

Также:
- Добавлены localPlayer.CharacterAdded/CharacterRemoving/AppearanceLoaded
  signals (раньше не было).
- Шаблон LUA_TEMPLATE_GLOBAL обновлён: для всех Players:GetPlayers()
  делаем print, плюс PlayerAdded:Connect для будущих. Юзер видит
  результат сразу при первом Запустить.
- Шаблон LUA_TEMPLATE_PART сразу пишет 'Скрипт детали X запущен'.
2026-06-09 02:09:55 +03:00

569 lines
30 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';
import { registerLuaInMonaco } from './lua-monaco-setup';
import ConfirmModal from './ConfirmModal';
/**
* 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` в той же папке.
// Дефолтный шаблон Lua-скрипта для нового скрипта (на Part или глобальный).
// Используется при смене языка JS→Lua когда текущий код выглядит «пустым».
export const LUA_TEMPLATE_PART = `-- Скрипт привязан к Part. script.Parent = эта часть.
local part = script.Parent
print("Скрипт детали", part.Name, "запущен")
part.Touched:Connect(function(hit)
print("Касание:", hit.Name)
end)
`;
export const LUA_TEMPLATE_GLOBAL = `-- Глобальный Lua-скрипт. Доступ к game.* API через Roblox-обёртку.
local Players = game:GetService("Players")
print("Привет, Рублокс! Lua-скрипты работают.")
-- Здороваемся со всеми кто уже в игре + кто заходит позже
for _, player in ipairs(Players:GetPlayers()) do
print("Игрок в игре:", player.Name)
end
Players.PlayerAdded:Connect(function(player)
print("Зашёл игрок:", player.Name)
end)
`;
export const JS_TEMPLATE_GLOBAL = `// Глобальный JS-скрипт. Подробнее: см. game.* API в /справочник.
game.onPlayerJoined((player) => {
game.chat.say('Привет, ' + player.name + '!');
});
`;
function isCodeLikelyEmptyTemplate(code) {
if (!code) return true;
const trimmed = code.trim();
if (trimmed.length === 0) return true;
// Содержит ТОЛЬКО комментарии и пустые строки
const lines = trimmed.split('\n').map(l => l.trim()).filter(Boolean);
return lines.every(l =>
l.startsWith('//') || l.startsWith('--') ||
l.startsWith('/*') || l.startsWith('*/') || l.startsWith('*')
);
}
function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, language, onLanguageChange, onClose, flushRef }) {
const currentLanguage = language === 'lua' ? 'lua' : 'js';
// Кастомная модалка подтверждения смены языка (вместо window.confirm)
const [confirmState, setConfirmState] = useState(null);
// Локальный буфер кода — то что в редакторе сейчас.
// Синхронизируется с 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]);
// При смене языка — принудительно синхронизируем код со слотом нового языка.
// (родитель swap'нул code_js ↔ code_lua и прислал свежий value.)
useEffect(() => {
if (value !== undefined && value !== localCodeRef.current) {
setLocalCode(value || '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [language]);
// Дебаунс-сохранение
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);
// Lua: completionProvider (Vector3.new/Color3.fromRGB/script.Parent/...)
// + hoverProvider (документация при наведении)
registerLuaInMonaco(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>
)}
{/* Переключатель языка JS / Lua */}
<span style={{
display: 'inline-flex',
background: '#1a1a1c',
border: '1px solid #3a3a3a',
borderRadius: 8,
padding: 2,
}}>
{['js', 'lua'].map((lang) => {
const active = currentLanguage === lang;
return (
<button
key={lang}
onClick={() => {
if (active) return;
if (!onLanguageChange) return;
// Логика двух слотов (code_js / code_lua) живёт в родителе.
// Здесь только сигналим: «переключи на lang».
// Текущий код отдаём чтобы родитель сохранил в слот.
onLanguageChange(lang, localCodeRef.current);
}}
style={{
padding: '4px 12px',
fontSize: 11,
fontWeight: 800,
fontFamily: 'inherit',
border: 'none',
borderRadius: 6,
cursor: active ? 'default' : 'pointer',
background: active
? (lang === 'lua'
? 'linear-gradient(135deg, #2196f3 0%, #1565c0 100%)'
: 'linear-gradient(135deg, #f7df1e 0%, #d4b500 100%)')
: 'transparent',
color: active
? (lang === 'lua' ? '#fff' : '#1a1a1c')
: '#9a9a9e',
letterSpacing: 0.3,
}}
title={lang === 'lua'
? 'Lua с Roblox-совместимым API (Vector3, CFrame, Instance)'
: 'JavaScript с game.* API Рублокса'}
>
{lang === 'lua' ? 'Lua' : 'JS'}
</button>
);
})}
</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={currentLanguage === 'lua' ? 'lua' : 'javascript'}
language={currentLanguage === 'lua' ? 'lua' : 'javascript'}
theme="vs-dark"
value={localCode}
path={`script_${scriptId}.${currentLanguage === 'lua' ? 'lua' : '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>
{confirmState && (
<ConfirmModal
{...confirmState}
onClose={() => setConfirmState(null)}
/>
)}
</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;