studio/src/editor/lua-monaco-setup.js
min 71d2f2db83 fix(lua): tick() в LuaSharedSandbox + autocomplete/hover Lua + ConfirmModal
Критфикс:
- LuaSharedSandbox.tick(dt, state) — no-op (GameRuntime.tick крашил)
- LuaSharedSandbox.target (геттер) — null

Monaco IntelliSense для Lua:
- registerCompletionItemProvider('lua') — Vector3.new/Color3.fromRGB/UDim2/CFrame
  /Instance.new/game/workspace/script/task.*/print/wait/pcall/etc.
- registerHoverProvider('lua') — документация при наведении на API
- 6 готовых сниппетов: killbrick, teleportpad, coin, heartbeat, playeradded, spinpart

UI:
- ConfirmModal — кастомная модалка вместо window.confirm
- В шапке ScriptEditor при смене языка — наша модалка с правильным стилем
- Esc/Enter, автофокус на confirm-кнопке, blur-фон, поп-ин анимация

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:07:51 +03:00

250 lines
18 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.

/**
* lua-monaco-setup — регистрация Lua-фич в Monaco:
* 1) Подсветка через встроенный 'lua' language (Monaco поставляется с basic-languages/lua)
* 2) Автодополнение Roblox-API (Vector3.new, Color3.fromRGB, script.Parent, game.Players, ...)
* 3) Hover-документация (наведя на Vector3 — описание + пример)
* 4) Подсветка ошибок через luaparse (на этапе 7, опционально)
*
* Регистрируется ОДИН раз глобально через флаг monaco.__rbxLuaRegistered.
*/
const ROBLOX_LUA_API = [
// === Глобальные функции ===
{ kind: 'function', name: 'print', insertText: 'print($0)', doc: 'Выводит сообщения в Output-панель.\n```lua\nprint("Привет", x, y)\n```' },
{ kind: 'function', name: 'warn', insertText: 'warn($0)', doc: 'Выводит предупреждение (жёлтым).\n```lua\nwarn("Что-то не так")\n```' },
{ kind: 'function', name: 'error', insertText: 'error(${1:"сообщение"})', doc: 'Бросает ошибку, останавливая текущий скрипт.\n```lua\nerror("Здоровье < 0")\n```' },
{ kind: 'function', name: 'wait', insertText: 'wait(${1:1})', doc: 'Приостанавливает скрипт на N секунд (заменяется на `task.wait` в новом коде).' },
{ kind: 'function', name: 'tick', insertText: 'tick()', doc: 'Возвращает количество секунд с эпохи (как `os.time()`, но дробное).' },
{ kind: 'function', name: 'pcall', insertText: 'pcall(${1:fn}, $0)', doc: 'Защищённый вызов. Возвращает `success, result|error`.\n```lua\nlocal ok, err = pcall(function() risky() end)\nif not ok then warn(err) end\n```' },
{ kind: 'function', name: 'xpcall', insertText: 'xpcall(${1:fn}, ${2:handler})', doc: 'Защищённый вызов с кастомным обработчиком ошибки.' },
{ kind: 'function', name: 'tostring', insertText: 'tostring($0)', doc: 'Преобразует значение в строку.' },
{ kind: 'function', name: 'tonumber', insertText: 'tonumber($0)', doc: 'Преобразует строку в число. Возвращает nil если не число.' },
{ kind: 'function', name: 'type', insertText: 'type($0)', doc: 'Возвращает строку с типом: "nil", "number", "string", "boolean", "table", "function", "userdata".' },
{ kind: 'function', name: 'typeof', insertText: 'typeof($0)', doc: 'Расширенная версия type — для Roblox-типов вернёт "Vector3", "CFrame", "Color3", "Instance".' },
{ kind: 'function', name: 'ipairs', insertText: 'ipairs(${1:t})', doc: 'Итератор по числовым ключам массива.\n```lua\nfor i, v in ipairs(arr) do ... end\n```' },
{ kind: 'function', name: 'pairs', insertText: 'pairs(${1:t})', doc: 'Итератор по всем ключам таблицы.\n```lua\nfor k, v in pairs(t) do ... end\n```' },
{ kind: 'function', name: 'next', insertText: 'next(${1:t}, $0)', doc: 'Возвращает следующую пару ключ-значение в таблице.' },
{ kind: 'function', name: 'select', insertText: 'select(${1:1}, $0)', doc: 'select("#", ...) — количество аргументов. select(n, ...) — n-й и далее аргументы.' },
{ kind: 'function', name: 'unpack', insertText: 'unpack(${1:t})', doc: 'Распаковывает массив в значения. (В Lua 5.4 — `table.unpack`)' },
{ kind: 'function', name: 'setmetatable', insertText: 'setmetatable(${1:t}, ${2:mt})', doc: 'Устанавливает metatable для таблицы.' },
{ kind: 'function', name: 'getmetatable', insertText: 'getmetatable($0)', doc: 'Возвращает metatable или nil.' },
{ kind: 'function', name: 'rawget', insertText: 'rawget(${1:t}, ${2:key})', doc: 'Чтение без вызова __index metatable.' },
{ kind: 'function', name: 'rawset', insertText: 'rawset(${1:t}, ${2:key}, ${3:value})', doc: 'Запись без вызова __newindex metatable.' },
// === task.* ===
{ kind: 'module', name: 'task', insertText: 'task', doc: 'Современный API планировщика Roblox-Lua.\nЗаменяет `wait`, `spawn`, `delay`, `defer` из старого API.' },
{ kind: 'function', name: 'task.wait', insertText: 'task.wait(${1:1})', doc: 'Приостанавливает на N секунд.\nВозвращает фактическое время ожидания.\n```lua\nlocal dt = task.wait(0.5)\n```' },
{ kind: 'function', name: 'task.spawn', insertText: 'task.spawn(${1:function() end})', doc: 'Немедленно запускает функцию как coroutine.\n```lua\ntask.spawn(function() heavy() end)\n```' },
{ kind: 'function', name: 'task.delay', insertText: 'task.delay(${1:1}, ${2:function() end})', doc: 'Отложенный запуск функции через N секунд.\n```lua\ntask.delay(3, function() print("через 3 сек") end)\n```' },
{ kind: 'function', name: 'task.defer', insertText: 'task.defer(${1:function() end})', doc: 'Запуск в следующем кадре (после Heartbeat).' },
// === Vector3 ===
{ kind: 'class', name: 'Vector3', insertText: 'Vector3', doc: '3D-вектор в Roblox.\nКонструктор: `Vector3.new(x, y, z)`.\nКонстанты: `Vector3.zero`, `Vector3.one`, `Vector3.xAxis`, `Vector3.yAxis`, `Vector3.zAxis`.' },
{ kind: 'function', name: 'Vector3.new', insertText: 'Vector3.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт `Vector3(x, y, z)`.\n```lua\nlocal v = Vector3.new(10, 5, 0)\nprint(v.X, v.Y, v.Z, v.Magnitude)\n```' },
{ kind: 'function', name: 'Vector3.zero', insertText: 'Vector3.zero', doc: '`Vector3(0, 0, 0)`.' },
{ kind: 'function', name: 'Vector3.one', insertText: 'Vector3.one', doc: '`Vector3(1, 1, 1)`.' },
{ kind: 'function', name: 'Vector3.xAxis', insertText: 'Vector3.xAxis', doc: '`Vector3(1, 0, 0)`.' },
{ kind: 'function', name: 'Vector3.yAxis', insertText: 'Vector3.yAxis', doc: '`Vector3(0, 1, 0)`.' },
{ kind: 'function', name: 'Vector3.zAxis', insertText: 'Vector3.zAxis', doc: '`Vector3(0, 0, 1)`.' },
// === Color3 ===
{ kind: 'class', name: 'Color3', insertText: 'Color3', doc: 'Цвет RGB в Roblox.\nКомпоненты `R`, `G`, `B` в диапазоне [0, 1].' },
{ kind: 'function', name: 'Color3.new', insertText: 'Color3.new(${1:1}, ${2:1}, ${3:1})', doc: 'Создаёт `Color3(r, g, b)`, где компоненты в [0, 1].' },
{ kind: 'function', name: 'Color3.fromRGB', insertText: 'Color3.fromRGB(${1:255}, ${2:255}, ${3:255})', doc: 'Создаёт `Color3` из 0-255 RGB.\n```lua\nlocal red = Color3.fromRGB(255, 0, 0)\n```' },
{ kind: 'function', name: 'Color3.fromHSV', insertText: 'Color3.fromHSV(${1:0}, ${2:1}, ${3:1})', doc: 'Создаёт цвет из HSV-компонентов в [0, 1].' },
{ kind: 'function', name: 'Color3.fromHex', insertText: 'Color3.fromHex(${1:"#FF0000"})', doc: 'Создаёт цвет из hex-строки.' },
// === CFrame ===
{ kind: 'class', name: 'CFrame', insertText: 'CFrame', doc: 'Coordinate Frame — позиция + поворот в 3D.\nИспользуется для трансформаций Part.CFrame.' },
{ kind: 'function', name: 'CFrame.new', insertText: 'CFrame.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт CFrame в указанной позиции.' },
{ kind: 'function', name: 'CFrame.lookAt', insertText: 'CFrame.lookAt(${1:eye}, ${2:target})', doc: 'CFrame, направленный из eye на target.' },
{ kind: 'function', name: 'CFrame.Angles', insertText: 'CFrame.Angles(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame только с поворотом (в радианах).' },
{ kind: 'function', name: 'CFrame.fromEulerAnglesXYZ', insertText: 'CFrame.fromEulerAnglesXYZ(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame с поворотом по эйлеровым углам.' },
// === UDim2 / Vector2 ===
{ kind: 'class', name: 'UDim2', insertText: 'UDim2', doc: 'Размер/позиция GUI: процент + пиксели по обеим осям.' },
{ kind: 'function', name: 'UDim2.new', insertText: 'UDim2.new(${1:0}, ${2:0}, ${3:0}, ${4:0})', doc: '`UDim2.new(scaleX, offsetX, scaleY, offsetY)`.\n```lua\nframe.Position = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана\n```' },
{ kind: 'function', name: 'UDim2.fromScale', insertText: 'UDim2.fromScale(${1:0.5}, ${2:0.5})', doc: 'Только процентные размеры.' },
{ kind: 'function', name: 'UDim2.fromOffset', insertText: 'UDim2.fromOffset(${1:100}, ${2:100})', doc: 'Только пиксельные размеры.' },
{ kind: 'class', name: 'Vector2', insertText: 'Vector2', doc: '2D-вектор.' },
{ kind: 'function', name: 'Vector2.new', insertText: 'Vector2.new(${1:0}, ${2:0})', doc: '`Vector2(x, y)`.' },
{ kind: 'class', name: 'UDim', insertText: 'UDim', doc: 'Одномерная UDim (scale + offset).' },
{ kind: 'function', name: 'UDim.new', insertText: 'UDim.new(${1:0}, ${2:0})', doc: '`UDim.new(scale, offset)`.' },
// === Instance ===
{ kind: 'class', name: 'Instance', insertText: 'Instance', doc: 'Базовый класс всех объектов Roblox.' },
{ kind: 'function', name: 'Instance.new', insertText: 'Instance.new("${1:Part}", ${2:workspace})', doc: 'Создаёт новый объект указанного класса.\n```lua\nlocal part = Instance.new("Part", workspace)\npart.Size = Vector3.new(4, 1, 4)\npart.Position = Vector3.new(0, 10, 0)\n```' },
// === game / services ===
{ kind: 'variable', name: 'game', insertText: 'game', doc: 'Корень DataModel. `game:GetService("Players")` — доступ к сервисам.' },
{ kind: 'variable', name: 'workspace', insertText: 'workspace', doc: 'Сокращение для `game.Workspace`. Содержит все Part-объекты сцены.' },
{ kind: 'variable', name: 'script', insertText: 'script', doc: 'Текущий скрипт. `script.Parent` — объект-носитель.\n```lua\nlocal part = script.Parent\npart.Touched:Connect(function(hit) ... end)\n```' },
// === Enum ===
{ kind: 'enum', name: 'Enum', insertText: 'Enum', doc: 'Перечисления Roblox: KeyCode, Material, UserInputType, EasingStyle, EasingDirection, HumanoidStateType.' },
{ kind: 'enum', name: 'Enum.KeyCode', insertText: 'Enum.KeyCode.${1:W}', doc: 'Клавиши клавиатуры: W, A, S, D, Space, LeftShift, Q, E, F, R, T, ..., One, Two, ..., Up, Down.' },
{ kind: 'enum', name: 'Enum.UserInputType', insertText: 'Enum.UserInputType.${1:MouseButton1}', doc: 'Типы ввода: MouseButton1/2/3, Keyboard, Touch, MouseMovement, MouseWheel.' },
{ kind: 'enum', name: 'Enum.Material', insertText: 'Enum.Material.${1:Plastic}', doc: 'Материалы: Plastic, Wood, Metal, Neon, Glass, Sand, Ice, Grass, Concrete.' },
{ kind: 'enum', name: 'Enum.HumanoidStateType', insertText: 'Enum.HumanoidStateType.${1:Running}', doc: 'Состояния Humanoid: Running, Jumping, Freefall, Landed, Dead, Climbing, Swimming, Seated.' },
];
// === Сниппеты быстрого старта (готовые шаблоны) ===
const ROBLOX_LUA_SNIPPETS = [
{
label: 'killbrick',
documentation: 'KillBrick — убивает игрока при касании.',
insertText: [
'local part = script.Parent',
'part.Touched:Connect(function(hit)',
'\tlocal humanoid = hit.Parent:FindFirstChildOfClass("Humanoid")',
'\tif humanoid then',
'\t\thumanoid.Health = 0',
'\tend',
'end)',
].join('\n'),
},
{
label: 'teleportpad',
documentation: 'TeleportPad — телепортирует игрока в указанную точку.',
insertText: [
'local destination = Vector3.new(${1:0}, ${2:50}, ${3:0})',
'local pad = script.Parent',
'pad.Touched:Connect(function(hit)',
'\tlocal root = hit.Parent:FindFirstChild("HumanoidRootPart")',
'\tif root then',
'\t\troot.CFrame = CFrame.new(destination)',
'\tend',
'end)',
].join('\n'),
},
{
label: 'coin',
documentation: 'Coin — даёт игроку монету при касании, потом исчезает.',
insertText: [
'local coin = script.Parent',
'local collected = false',
'coin.Touched:Connect(function(hit)',
'\tif collected then return end',
'\tif hit.Parent:FindFirstChildOfClass("Humanoid") then',
'\t\tcollected = true',
'\t\tprint("Монета собрана!")',
'\t\tcoin:Destroy()',
'\tend',
'end)',
].join('\n'),
},
{
label: 'heartbeat',
documentation: 'RunService.Heartbeat — кадровый callback.',
insertText: [
'local RunService = game:GetService("RunService")',
'RunService.Heartbeat:Connect(function(dt)',
'\t${0:-- код, выполняется каждый кадр}',
'end)',
].join('\n'),
},
{
label: 'playeradded',
documentation: 'PlayerAdded — реакция на захождение игрока.',
insertText: [
'local Players = game:GetService("Players")',
'Players.PlayerAdded:Connect(function(player)',
'\tprint("Игрок зашёл:", player.Name)',
'\t${0:}',
'end)',
].join('\n'),
},
{
label: 'spinpart',
documentation: 'SpinPart — вращающаяся платформа.',
insertText: [
'local RunService = game:GetService("RunService")',
'local part = script.Parent',
'local speed = ${1:2} -- радиан/сек',
'RunService.Heartbeat:Connect(function(dt)',
'\tpart.CFrame = part.CFrame * CFrame.Angles(0, speed * dt, 0)',
'end)',
].join('\n'),
},
];
export function registerLuaInMonaco(monaco) {
if (monaco.__rbxLuaRegistered) return;
monaco.__rbxLuaRegistered = true;
// 1. CompletionProvider — автодополнение
monaco.languages.registerCompletionItemProvider('lua', {
triggerCharacters: ['.', ':', '"', "'"],
provideCompletionItems: (model, position) => {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const suggestions = [];
const kindMap = {
'function': monaco.languages.CompletionItemKind.Function,
'class': monaco.languages.CompletionItemKind.Class,
'module': monaco.languages.CompletionItemKind.Module,
'enum': monaco.languages.CompletionItemKind.Enum,
'variable': monaco.languages.CompletionItemKind.Variable,
};
for (const item of ROBLOX_LUA_API) {
suggestions.push({
label: item.name,
kind: kindMap[item.kind] || monaco.languages.CompletionItemKind.Text,
insertText: item.insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: { value: item.doc, isTrusted: true },
range,
});
}
for (const snip of ROBLOX_LUA_SNIPPETS) {
suggestions.push({
label: snip.label,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: snip.insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: { value: snip.documentation, isTrusted: true },
detail: 'Сниппет Roblox-Lua',
range,
});
}
return { suggestions };
},
});
// 2. HoverProvider — подсказки при наведении
const lookupTable = new Map();
for (const item of ROBLOX_LUA_API) lookupTable.set(item.name, item);
monaco.languages.registerHoverProvider('lua', {
provideHover: (model, position) => {
const word = model.getWordAtPosition(position);
if (!word) return null;
// Пробуем найти точное совпадение или с префиксом (Vector3.new)
let found = lookupTable.get(word.word);
if (!found) {
// Возможно курсор на середине A.B — попробуем собрать всю цепочку
const line = model.getLineContent(position.lineNumber);
// Ищем имя.имя.имя на позиции
const left = line.slice(0, word.endColumn - 1);
const m = left.match(/[A-Za-z_][\w.]*$/);
if (m) found = lookupTable.get(m[0]);
}
if (!found) return null;
return {
range: new monaco.Range(
position.lineNumber, word.startColumn,
position.lineNumber, word.endColumn,
),
contents: [
{ value: `**${found.name}**` },
{ value: found.doc },
],
};
},
});
}