feat: 50 игр на Lua + импорт Roblox для всех + поддержка Lua в плеере #39
193
src/editor/ConfirmModal.jsx
Normal file
193
src/editor/ConfirmModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
249
src/editor/lua-monaco-setup.js
Normal file
249
src/editor/lua-monaco-setup.js
Normal 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 },
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user