From 71d2f2db83f9df14f4626c22d9f5063ce9d06255 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 10:07:51 +0300 Subject: [PATCH] =?UTF-8?q?fix(lua):=20tick()=20=D0=B2=20LuaSharedSandbox?= =?UTF-8?q?=20+=20autocomplete/hover=20Lua=20+=20ConfirmModal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Критфикс: - 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 --- src/editor/ConfirmModal.jsx | 193 +++++++++++++++++ src/editor/ScriptEditor.jsx | 33 ++- src/editor/engine/lua/LuaSharedSandbox.js | 9 + src/editor/lua-monaco-setup.js | 249 ++++++++++++++++++++++ 4 files changed, 475 insertions(+), 9 deletions(-) create mode 100644 src/editor/ConfirmModal.jsx create mode 100644 src/editor/lua-monaco-setup.js diff --git a/src/editor/ConfirmModal.jsx b/src/editor/ConfirmModal.jsx new file mode 100644 index 0000000..e1e12b3 --- /dev/null +++ b/src/editor/ConfirmModal.jsx @@ -0,0 +1,193 @@ +/** + * ConfirmModal — кастомная модалка подтверждения вместо window.confirm. + * + * Использование: + * const [confirmState, setConfirmState] = useState(null); + * ... + * setConfirmState({ + * title: 'Сменить язык?', + * message: '...', + * confirmLabel: 'Сменить', + * cancelLabel: 'Отмена', + * onConfirm: () => doSomething(), + * }); + * ... + * {confirmState && setConfirmState(null)} />} + * + * Стиль — тёмная тема Рублокс-студии, кнопка confirm заметная. + */ +import React, { useEffect, useRef } from 'react'; + +export default function ConfirmModal({ + title, + message, + confirmLabel = 'OK', + cancelLabel = 'Отмена', + confirmTone = 'primary', // 'primary' | 'danger' + onConfirm, + onClose, +}) { + const confirmBtnRef = useRef(null); + + useEffect(() => { + // Автофокус на кнопке подтверждения + const t = setTimeout(() => confirmBtnRef.current?.focus(), 50); + const onKey = (e) => { + if (e.key === 'Escape') { e.preventDefault(); onClose?.(); } + else if (e.key === 'Enter') { + // Enter — confirm только если кнопка в фокусе или ничего не в фокусе + if (document.activeElement === confirmBtnRef.current || document.activeElement?.tagName === 'BODY') { + e.preventDefault(); + handleConfirm(); + } + } + }; + window.addEventListener('keydown', onKey); + return () => { clearTimeout(t); window.removeEventListener('keydown', onKey); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleConfirm = () => { + try { onConfirm?.(); } finally { onClose?.(); } + }; + + return ( +
+ +
e.stopPropagation()} + style={{ + background: 'linear-gradient(180deg, #2a2a2e 0%, #1f1f22 100%)', + border: '1px solid #3a3a40', + borderRadius: 14, + padding: '22px 26px 18px', + minWidth: 380, + maxWidth: 480, + color: '#e8e8ea', + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.04)', + animation: 'rbxConfirmPopIn 160ms cubic-bezier(0.34, 1.56, 0.64, 1)', + }} + > + {title && ( +
+ {title} +
+ )} + {message && ( +
+ {message} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/editor/ScriptEditor.jsx b/src/editor/ScriptEditor.jsx index cd09e7d..57cb0de 100644 --- a/src/editor/ScriptEditor.jsx +++ b/src/editor/ScriptEditor.jsx @@ -7,6 +7,8 @@ import Icon from './Icon'; // при правке одного файла не перетряхивать все остальные. 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-редактор кода скрипта в табе. @@ -70,6 +72,8 @@ function isCodeLikelyEmptyTemplate(code) { 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 || ''); @@ -197,6 +201,9 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe // Сниппеты для быстрого старта (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); @@ -333,20 +340,22 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe onClick={() => { if (active) return; if (!onLanguageChange) return; - let nextCode = localCode; if (isCodeLikelyEmptyTemplate(localCode)) { - nextCode = lang === 'lua' + const nextCode = lang === 'lua' ? (target ? LUA_TEMPLATE_PART : LUA_TEMPLATE_GLOBAL) : JS_TEMPLATE_GLOBAL; setLocalCode(nextCode); - } else { - const ok = window.confirm( - 'Сменить язык скрипта на ' + (lang === 'lua' ? 'Lua' : 'JavaScript') + - '?\n\nКод сохранится как есть — синтаксис прежнего языка перестанет подсвечиваться. Можно переключиться обратно.' - ); - if (!ok) return; + onLanguageChange(lang, nextCode); + return; } - onLanguageChange(lang, nextCode); + // Код не пустой — показываем кастомную модалку + setConfirmState({ + title: `Сменить язык на ${lang === 'lua' ? 'Lua' : 'JavaScript'}?`, + message: `Код останется как есть — синтаксис прежнего языка перестанет подсвечиваться, но текст не исчезнет. Можно переключиться обратно в любой момент.`, + confirmLabel: `Сменить на ${lang === 'lua' ? 'Lua' : 'JS'}`, + cancelLabel: 'Отмена', + onConfirm: () => onLanguageChange(lang, localCode), + }); }} style={{ padding: '4px 12px', @@ -528,6 +537,12 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe }} /> + {confirmState && ( + setConfirmState(null)} + /> + )} ); } diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js index b12f7ca..d73c411 100644 --- a/src/editor/engine/lua/LuaSharedSandbox.js +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -45,6 +45,15 @@ export class LuaSharedSandbox { setOnCommand(cb) { this._onCommand = cb; } + /** + * GameRuntime вызывает sb.tick(dt, state) каждый кадр. + * Для JS-sandbox tick шлёт snapshot в worker. Для нас snapshot ходит через + * sendSceneSnapshot отдельно — здесь no-op. + * NB: target=null, потому что наш sandbox общий, не на конкретный объект. + */ + get target() { return null; } + tick(_dt, _state) { /* no-op: main-loop fires Heartbeat внутри worker */ } + /** Добавить Lua-скрипт. Можно вызывать как ДО start() (буфер), так и ПОСЛЕ (live). */ addScript(id, code, target) { const entry = { diff --git a/src/editor/lua-monaco-setup.js b/src/editor/lua-monaco-setup.js new file mode 100644 index 0000000..ece634d --- /dev/null +++ b/src/editor/lua-monaco-setup.js @@ -0,0 +1,249 @@ +/** + * 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 }, + ], + }; + }, + }); +}