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>
This commit is contained in:
min 2026-06-08 10:07:51 +03:00
parent 06df77cc97
commit 71d2f2db83
4 changed files with 475 additions and 9 deletions

193
src/editor/ConfirmModal.jsx Normal file
View File

@ -0,0 +1,193 @@
/**
* ConfirmModal кастомная модалка подтверждения вместо window.confirm.
*
* Использование:
* const [confirmState, setConfirmState] = useState(null);
* ...
* setConfirmState({
* title: 'Сменить язык?',
* message: '...',
* confirmLabel: 'Сменить',
* cancelLabel: 'Отмена',
* onConfirm: () => doSomething(),
* });
* ...
* {confirmState && <ConfirmModal {...confirmState} onClose={() => 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 (
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.55)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
animation: 'rbxConfirmFadeIn 140ms ease-out',
}}
>
<style>{`
@keyframes rbxConfirmFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes rbxConfirmPopIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
`}</style>
<div
onClick={(e) => 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 && (
<div style={{
fontSize: 16,
fontWeight: 800,
letterSpacing: -0.2,
marginBottom: 10,
color: '#fff',
}}>
{title}
</div>
)}
{message && (
<div style={{
fontSize: 13.5,
lineHeight: 1.55,
color: '#c8c8cc',
marginBottom: 20,
whiteSpace: 'pre-wrap',
}}>
{message}
</div>
)}
<div style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
}}>
<button
onClick={onClose}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid #3a3a40',
background: 'transparent',
color: '#c8c8cc',
fontSize: 13,
fontWeight: 700,
fontFamily: 'inherit',
cursor: 'pointer',
transition: 'all 120ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2e2e34';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#c8c8cc';
}}
>
{cancelLabel}
</button>
<button
ref={confirmBtnRef}
onClick={handleConfirm}
style={{
padding: '8px 18px',
borderRadius: 8,
border: 'none',
background: confirmTone === 'danger'
? 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)'
: 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)',
color: '#fff',
fontSize: 13,
fontWeight: 800,
fontFamily: 'inherit',
cursor: 'pointer',
letterSpacing: 0.2,
boxShadow: confirmTone === 'danger'
? '0 6px 16px rgba(192, 48, 63, 0.4)'
: '0 6px 16px rgba(79, 116, 255, 0.4)',
transition: 'all 120ms',
outline: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.filter = 'brightness(1.12)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.filter = 'brightness(1)';
e.currentTarget.style.transform = 'translateY(0)';
}}
onFocus={(e) => {
e.currentTarget.style.boxShadow = confirmTone === 'danger'
? '0 6px 16px rgba(192, 48, 63, 0.5), 0 0 0 3px rgba(192, 48, 63, 0.35)'
: '0 6px 16px rgba(79, 116, 255, 0.5), 0 0 0 3px rgba(79, 116, 255, 0.35)';
}}
onBlur={(e) => {
e.currentTarget.style.boxShadow = confirmTone === 'danger'
? '0 6px 16px rgba(192, 48, 63, 0.4)'
: '0 6px 16px rgba(79, 116, 255, 0.4)';
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@ -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
}}
/>
</div>
{confirmState && (
<ConfirmModal
{...confirmState}
onClose={() => setConfirmState(null)}
/>
)}
</div>
);
}

View File

@ -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 = {

View File

@ -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 },
],
};
},
});
}