Критфикс:
- 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>
250 lines
18 KiB
JavaScript
250 lines
18 KiB
JavaScript
/**
|
||
* 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 },
|
||
],
|
||
};
|
||
},
|
||
});
|
||
}
|