diff --git a/RUBLOX_LUA_SUPPORT_PLAN.md b/RUBLOX_LUA_SUPPORT_PLAN.md new file mode 100644 index 0000000..3ac4702 --- /dev/null +++ b/RUBLOX_LUA_SUPPORT_PLAN.md @@ -0,0 +1,434 @@ +# План: Полная поддержка Lua-скриптов в Рублоксе + +**Цель:** Пользователь Рублокс-студии создаёт скрипт → выбирает язык **JS** или **Lua** → пишет код → в плеере оба языка работают параллельно. Lua-скрипты совместимы с Roblox API настолько, чтобы код из Roblox-игр работал без модификаций. + +**Зачем:** В Roblox экосистеме сотни тысяч разработчиков, привыкших к Lua + Roblox API (Vector3, CFrame, Instance, RemoteEvent, etc.). Сейчас они не могут перенести свои игры. С этой фичей — могут. + +**Срок:** ~6 недель полного времени. Можно делать поэтапно (после каждого этапа есть полезный результат). + +**Юр.риск:** "юр риски беру на себя" (МИН, 2026-06-08). + +--- + +## Архитектурное решение + +### Текущая ситуация +- Скрипты хранятся как `{id, code, target, name}` в БД +- `GameRuntime` запускает каждый JS-скрипт в Web Worker `ScriptSandboxWorker.js` +- API игре доступен через `game.*` объект (события, scene, player, gui, save, etc.) +- Скриптов в игре могут быть **сотни**, каждый — отдельный Worker + +### Целевая +- Скрипты получают поле `language: 'js' | 'lua'` (default `'js'`) +- В редакторе — переключатель в `ScriptEditor.jsx` в шапке: **JS / Lua** +- Monaco-editor подсвечивает Lua (есть встроенный язык в `monaco-editor`) +- В runtime: JS-скрипты идут через старый sandbox, Lua — через новый `LuaSandbox.js` +- Lua-runtime построен поверх **wasmoon** (Lua 5.4 в WebAssembly) +- Lua-скрипты делятся на **один shared VM** на игру (а не Web Worker на скрипт) — иначе OOM при 100+ скриптах +- Lua-API проксирует Roblox API → нашу game.* плюс полную **DataModel** иерархию + +### Ключевая идея +**Lua-скрипты не вызывают `game.move(...)`. Они вызывают `script.Parent.Position = Vector3.new(1,2,3)`** — как в Roblox. Lua-shim под капотом переводит это в `partSet` команды для движка Рублокса. Юзер пишет идиоматичный Roblox-Lua, движок Рублокса исполняет. + +--- + +## Этап 1: UI и хранение языка (3 дня) + +### 1.1 Миграция БД +- Добавить поле `language VARCHAR(8) DEFAULT 'js' NOT NULL` в таблицу `scripts` (есть на storys API) +- API endpoints (`POST/PATCH /scripts/...`) принимают и сохраняют `language` +- Старые скрипты без поля → `'js'` по умолчанию + +### 1.2 ScriptEditor.jsx +- Переключатель в шапке редактора: `[ JS | Lua ]` (segmented control) +- При смене языка — confirm("Сменить язык? Текущий код будет очищен"), затем код сбрасывается на шаблон-заглушку нового языка +- Monaco language switch: `javascript` ↔ `lua` +- Подсветка/автодополнение Lua встроены в Monaco (`monaco-editor/esm/vs/basic-languages/lua`) +- Линтер ошибок Lua через **luaparse** (npm пакет, ~50KB, parse-only) — показываем красные подчёркивания +- Шаблон Lua для нового скрипта на target=Part: + ```lua + -- Скрипт на части. script.Parent = эта часть. + local part = script.Parent + part.Touched:Connect(function(hit) + print("Касание:", hit.Name) + end) + ``` +- Шаблон Lua для глобального скрипта (target=nil): + ```lua + local Players = game:GetService("Players") + Players.PlayerAdded:Connect(function(player) + print("Игрок зашёл:", player.Name) + end) + ``` + +### 1.3 Иконка языка в HierarchyPanel +- Рядом с именем скрипта — маленький бейдж `JS` или `Lua` (синий / голубой) +- Помогает не путаться при сотне скриптов + +### Что готово в конце Этапа 1 +- Юзер может **создать Lua-скрипт**, написать код, сохранить +- Код **не исполняется** — пока только хранится и редактируется +- В плеере Lua-скрипты молча игнорируются + +--- + +## Этап 2: Базовый Lua-runtime (5 дней) + +### 2.1 LuaSharedSandbox.js — новый sandbox-класс + +Полная архитектура шаринг-VM (один wasmoon-state на все Lua-скрипты игры): + +``` +GameRuntime +├── ScriptSandbox (JS-скрипт, Web Worker, как сейчас) +├── ScriptSandbox (JS-скрипт) +├── LuaSharedSandbox ← НОВЫЙ +│ ├── LuaSharedWorker.js +│ │ └── wasmoon VM (один на всю игру) +│ │ ├── Roblox API shim +│ │ ├── DataModel tree (game.Workspace, Players, ...) +│ │ └── все Lua-скрипты как сопрограммы (coroutines) +│ └── проксирует partSet/sceneCreate/event обратно в main thread +``` + +Файлы: +- `src/editor/engine/LuaSharedSandbox.js` (main thread): API совместимый с ScriptSandbox (`sendSceneSnapshot`, `sendGlobalEvent`, etc.) +- `src/editor/engine/LuaSharedWorker.js` (Web Worker): держит wasmoon, исполняет скрипты, шлёт командные сообщения +- `src/editor/engine/RobloxLuaShim.js` (worker side): объявление всех Roblox-классов и сервисов + +### 2.2 Минимальный Roblox shim в первой итерации +- `Vector3.new(x,y,z)`, `+`, `-`, `*`, `:Magnitude()`, `:Dot()`, `:Cross()`, `:Lerp()`, `:Normalize()` +- `Color3.new(r,g,b)`, `Color3.fromRGB(r,g,b)`, `:Lerp()` +- `CFrame.new(x,y,z)`, `CFrame.lookAt()`, `CFrame.fromEulerAnglesXYZ()`, операторы `*` и `:Inverse()`, `:ToWorldSpace()` +- `UDim2.new(sx,ox,sy,oy)`, `UDim.new(s,o)` +- `Enum.KeyCode.W`, `Enum.UserInputType.MouseButton1`, etc. (через generated table) +- `print()` → console + onLog event в студии +- `wait(secs)` / `task.wait(secs)` — через coroutine + scheduler в main loop +- `tick()`, `os.time()`, `os.clock()`, `math.*`, `string.*`, `table.*` (стандартные Lua) +- `pcall`, `xpcall`, `error`, `assert` (стандартные) + +### 2.3 GameRuntime интеграция +- При старте игры — пробежать по `scripts[]`, разделить на `jsScripts` и `luaScripts` +- Для JS — старый путь (по сэндбоксу на скрипт) +- Для Lua — один `LuaSharedSandbox`, в него `addScript(luaSource)` каждым +- LuaSharedSandbox шлёт назад те же команды что JS sandbox: `partSet`, `sceneCreate`, `chatSay`, `guiSet`, etc. + +### Что готово в конце Этапа 2 +- Lua-скрипты **исполняются** +- Можно использовать `Vector3`, `Color3`, `print()`, `wait()`, `math/string/table` +- Скрипт **ещё не видит** Workspace, Player, GUI + +--- + +## Этап 3: DataModel — game.Workspace и иерархия (5 дней) + +### 3.1 Что такое DataModel +В Roblox любая игра — **дерево объектов**. Корень = `game`. У него детки = сервисы: `Workspace`, `Players`, `ReplicatedStorage`, `Lighting`, `StarterGui`, `RunService`, etc. У каждого деток — свои детки. + +У нас сейчас сцена плоская: `primitives`, `blocks`, `models`. Нужно **виртуальное дерево DataModel** поверх плоской сцены. + +### 3.2 Виртуальное дерево +Файл: `src/editor/engine/datamodel/DataModelTree.js` + +При старте Lua-runtime, для текущей сцены строится виртуальное дерево: + +``` +game (RbxGame) +├── Workspace (RbxWorkspace) +│ ├── Part_0 ← обёртка над primitive id=0 +│ ├── Part_1 ← обёртка над primitive id=1 +│ ├── Model_5 ← обёртка над model id=5 (с Children) +│ │ └── Part_inner +│ ├── Camera +│ └── Terrain +├── Players (RbxPlayers) +│ └── LocalPlayer (RbxPlayer) +│ ├── Character (RbxCharacter) +│ │ ├── Humanoid (RbxHumanoid) +│ │ └── HumanoidRootPart (RbxPart) +│ └── PlayerGui (RbxScreenGui-контейнер) +│ └── (Lua-скрипты могут спавнить GUI через Instance.new) +├── ReplicatedStorage (RbxFolder) +├── ServerStorage (RbxFolder) +├── Lighting (RbxLighting) +├── StarterGui (RbxFolder) +├── StarterPlayer (RbxFolder) +│ └── StarterCharacter (RbxFolder) +├── RunService (RbxRunService с :Heartbeat, :Stepped, :RenderStepped) +├── UserInputService (события InputBegan/Ended/Changed) +├── TweenService (:Create, :GetService для tweens) +├── HttpService (заглушка либо проксируем через нашего бэка) +├── DataStoreService (проксируем через game.save) +└── MarketplaceService (заглушка) +``` + +### 3.3 Instance-классы +`src/editor/engine/datamodel/Instance.js`: + +```js +class RbxInstance { + Name = "Instance"; + ClassName = "Instance"; + Parent = null; + Children = []; + + // Свойства которые юзер может ставить через __newindex (metatable) + // отслеживаются — при изменении посылается команда в main thread + // для синхронизации с Babylon-сценой + + GetChildren() { return [...this.Children]; } + FindFirstChild(name, recursive) { ... } + WaitForChild(name, timeout) { ... } // через coroutine + yield + FindFirstAncestor(name) { ... } + FindFirstChildOfClass(class) { ... } + Destroy() { ... } + Clone() { ... } + IsA(class) { ... } + GetFullName() { ... } + GetAttribute(name) / SetAttribute(name, value) + GetPropertyChangedSignal(name) → RbxSignal +} + +class RbxPart extends RbxInstance { + Position // setter: партиклс в Babylon (primitiveManager.setPosition) + Size // setter + Color // setter + Material // setter (mapping Roblox materials → наши) + Anchored // setter + CanCollide // setter + Touched // RbxSignal — Fire когда BabylonScene детектит overlap + TouchEnded // RbxSignal + CFrame // computed property — Position + rotation +} + +class RbxModel extends RbxInstance { + PrimaryPart + GetPrimaryPartCFrame() / SetPrimaryPartCFrame() + PivotTo(cframe) // MoveTo + Rotate + GetBoundingBox() +} + +class RbxHumanoid extends RbxInstance { + Health = 100 + MaxHealth = 100 + WalkSpeed = 16 + JumpPower = 50 + Died, HealthChanged, Touched, StateChanged — signals + TakeDamage(amount) + MoveTo(pos) // simulates Roblox NPC pathing + LoadAnimation(anim) → RbxAnimationTrack +} + +class RbxScript extends RbxInstance { + Source // источник Lua (read-only обычно) + Disabled // bool + RunContext // Server / Client / Legacy +} + +class RbxRemoteEvent extends RbxInstance { + OnServerEvent : RbxSignal + OnClientEvent : RbxSignal + FireServer(...args) + FireClient(player, ...args) + FireAllClients(...args) +} +``` + +### 3.4 Метатаблицы Lua +Каждая JS-обёртка `RbxPart` экспортируется в Lua как **table с metatable**: +- `__index` — чтение свойства, либо метод +- `__newindex` — запись свойства, триггерит side-effects (синхронизация сцены) +- `__tostring` — для `print(part)` показывает `"Part_0"` + +Это **критично** для совместимости с Roblox-скриптами. + +### 3.5 script.Parent для каждого Lua-скрипта +- Если скрипт привязан к `target=42` (primitive id 42) — `script.Parent = workspace:FindFirstChild по primId(42)` +- Если глобальный — `script.Parent = nil` +- В скриптовом контексте: `script` это таблица `{Name=..., Parent=..., ClassName="Script"}` + +### Что готово в конце Этапа 3 +- Lua-скрипт может пройтись по `game.Workspace:GetChildren()` +- `script.Parent.Touched:Connect(...)` работает (KillBrick = реально) +- `local player = game.Players.LocalPlayer; player.Character.Humanoid.Health = 0` убивает игрока +- `Instance.new("Part", workspace)` создаёт примитив + +--- + +## Этап 4: Полный Roblox API (10 дней) + +Закрываем "длинный хвост" API. Каждый день — 1-2 сервиса. + +### 4.1 Services +- **RunService**: `.Heartbeat`, `.Stepped`, `.RenderStepped` — RbxSignals, фаер в main loop tick +- **UserInputService**: `.InputBegan`, `.InputChanged`, `.InputEnded` — события KeyCode/MouseButton/Touch +- **TweenService**: `:Create(instance, TweenInfo, propertyTable)` → возвращает RbxTween; `Tween:Play()/Pause()/Cancel()`; интерполируется в main loop +- **DataStoreService**: проксируем через наш `game.save` (sync version) + - `:GetDataStore(name)` → объект с `:GetAsync(key)`, `:SetAsync(key, value)`, `:UpdateAsync(key, fn)` + - Async-методы через coroutine.yield + наш save API +- **MarketplaceService**: заглушки `PromptPurchase`, `GetProductInfo` (бизнес-логика через наш интерфейс) +- **HttpService**: `:JSONEncode`, `:JSONDecode`, `:GenerateGUID` — простая встроенная реализация; `:GetAsync/PostAsync` проксируем через ограниченный список разрешённых доменов +- **Players**: `LocalPlayer`, `:GetPlayers()`, `PlayerAdded`/`PlayerRemoving` сигналы +- **Lighting**: read-only сейчас (через `Lighting.Ambient`, `Lighting.OutdoorAmbient` ставить значения нашему envManager) +- **Workspace**: `:Raycast(origin, direction, params)` → реальный raycast через PhysicsAABB; `:GetServerTimeNow()`; `CurrentCamera` + +### 4.2 GUI (важно!) +- `Instance.new("ScreenGui")` → если `Parent = playerGui`, регистрируется в нашем GuiManager +- `TextLabel`, `TextButton`, `ImageLabel`, `ImageButton`, `Frame` — все мапятся на наш GuiOverlay +- `MouseButton1Click`, `MouseEnter`, `MouseLeave`, `Activated` — сигналы +- `UDim2`, `Vector2` для позиций/размеров +- При установке `gui.Position = UDim2.new(0.5, 0, 0.5, 0)` — пересылается в GuiManager и обновляется DOM + +### 4.3 Sound +- `Instance.new("Sound", part)` с `SoundId = "rbxassetid://12345"` или с нашим URL +- `:Play()`, `:Stop()`, `:Pause()`, `Volume`, `Pitch`, `Looped` +- Под капотом — наш SoundManager + +### 4.4 Animation +- `Instance.new("Animation")` с `AnimationId` (наши собственные ID анимаций R15) +- `humanoid:LoadAnimation(anim) → AnimationTrack` +- `:Play()`, `:Stop()`, `:AdjustSpeed()`, `:GetMarkerReachedSignal()` +- Связь с нашим R15Animator + +### 4.5 Tools / Backpack +- `Tool` Instance: `Activated`, `Equipped`, `Unequipped` сигналы +- `player.Backpack:GetChildren()` — Lua видит инвентарь +- Реализация через наш HotbarManager + InventoryService + +### 4.6 ProximityPrompt, ClickDetector +- ProximityPrompt: `Triggered` сигнал, `:Show/:Hide`, ActionText, ObjectText +- ClickDetector: `MouseClick`, `MouseHoverEnter/Leave` сигналы + +### 4.7 Networking-эмуляция (single-player) +- RemoteEvent / RemoteFunction работают **локально** (поскольку у нас singleplayer на client-only) +- `FireServer/InvokeServer` запускают handlers в том же VM, но в other-side контексте +- Это позволяет копировать многопользовательские скрипты Roblox без изменений (хоть мультиплеера и нет) +- Когда у Рублокса появится мультиплеер — `RemoteEvent` уже будет работать «по-настоящему» без изменений в скриптах юзера + +### Что готово в конце Этапа 4 +- **~90% типовых Roblox-скриптов работают** без модификаций +- DataStore сохраняет прогресс +- TweenService плавно двигает объекты +- GUI создаётся скриптом +- Анимации играются + +--- + +## Этап 5: Импорт .rbxl + конвертер юзер-кода (5 дней) + +### 5.1 Изменение импортера +Сейчас импортер сохраняет Lua-исходник в JS-комментарии и пытается завернуть в JS-обёртку. **Это устаревает.** + +Новый путь: +- Импортер сохраняет Lua-source **как есть** (без обёрток) +- В записи скрипта `language = 'lua'` +- target = primitiveId или null +- В GameRuntime Lua-скрипт идёт сразу в LuaSharedSandbox + +### 5.2 Конвертация ассетов +- Roblox MeshId/TextureId через наш ImageProxy → ассеты сохраняются в minio + на CDN +- `rbxassetid://12345` → resolve в наш asset_id +- Сохранение в БД ссылок на ассеты + +### 5.3 Поведение при импорте +- При импорте .rbxl карты — все Lua-скрипты сохраняются как Lua-скрипты Рублокса (не пытаемся конвертить в JS) +- Юзер открывает игру → редактирует → видит в Hierarchy `Script (Lua)` рядом с `Script (JS)` — может писать на любом + +### Что готово в конце Этапа 5 +- Импорт Roblox-карты работает **бесшовно** +- Юзер может править Lua-код в редакторе и видеть результат + +--- + +## Этап 6: Производительность и стабильность (5 дней) + +### 6.1 Profiling +- Замерить FPS на картах с 100/500/1000 Lua-скриптов +- Если падает — переезд на **fengari** (pure-JS Lua interp, в 5-10× медленнее wasmoon но без WASM overhead на старте) либо на **собственный Lua-bytecode runtime** для горячих скриптов + +### 6.2 Memory +- Каждый wasmoon VM ~10-15MB. Один на игру = ОК. Если придётся разделять на части (server/client), нужно бенчить. + +### 6.3 Песочница (security) +- Lua не должен дёргать `io.*`, `os.execute`, `loadstring(внешний код)`, etc. +- Whitelist стандартной библиотеки. Запрещаем всё что может выйти из браузера. + +### 6.4 Ошибки +- Lua-ошибки в скрипте — показываются в Output-панели студии (как JS-ошибки) +- Stack trace с правильными номерами строк (не из обёртки) +- Если скрипт зациклился — kill через `debug.sethook` после N инструкций без yield + +### 6.5 Тесты +- 50 unit-тестов на Roblox API (Vector3 операции, CFrame, Instance.new, Touched, RunService, TweenService) +- 10 интеграционных: импортировать тест-rbxl, запустить, проверить что нужное случилось +- CI: тесты прогоняются в Gitea Actions при PR + +### Что готово в конце Этапа 6 +- Lua-runtime production-ready +- Можно объявить публично «теперь в Рублоксе пишут на Lua с Roblox-совместимостью» + +--- + +## Этап 7: Документация (3 дня) + +### 7.1 Раздел вики +- `wiki/lua-intro` — введение в Lua для Рублокса (для пользователей, которые приходят с Roblox — короткое) +- `wiki/lua-vs-js` — таблица: «то же самое на JS и на Lua» для типичных задач +- `wiki/roblox-api-supported` — список того что работает / не работает / отличается +- `wiki/lua-examples` — 20 готовых сниппетов (KillBrick, TeleportPad, Checkpoint, Coin, NPCFollower, etc.) + +### 7.2 Migration guide +- «Как перенести свою Roblox-игру в Рублокс» — пошаговое +- Список известных несовместимостей и их обходов + +### 7.3 PR-материал +- Пост в /developer на team.rublox.pro: «Lua-поддержка теперь GA» +- Если есть бюджет — короткий ролик на YouTube/TikTok + +--- + +## Этапы целиком + +| Этап | Длительность | Содержание | +|------|--------------|------------| +| 1 | 3 дня | UI и хранение языка | +| 2 | 5 дней | Базовый Lua-runtime + минимальный shim | +| 3 | 5 дней | DataModel (game.Workspace и иерархия) | +| 4 | 10 дней | Полный Roblox API (services, GUI, Sound, Animation) | +| 5 | 5 дней | Импорт .rbxl и асетов | +| 6 | 5 дней | Производительность, безопасность, тесты | +| 7 | 3 дня | Документация и публикация | +| **Итого** | **~36 рабочих дней** | **~6 недель полного времени** | + +--- + +## Что-то можно делать раньше, чтобы получить пользу + +- **MVP (Этапы 1+2):** через **8 дней** — юзер может писать Lua-скрипты с минимальным API (Vector3, Color3, print, wait). Уже видит что фича есть. +- **Beta (Этапы 1-3):** через **13 дней** — KillBrick'и работают, можно делать простые игры на Lua. +- **GA (все этапы):** через **6 недель** — продакшен. + +После каждого этапа можно делать релиз и собирать фидбек. + +--- + +## Решения которые нужны от тебя перед стартом + +1. **wasmoon vs fengari** — wasmoon быстрее но WASM-heavy, fengari проще но медленнее. Предлагаю wasmoon (уже используем для импорта). +2. **Один shared VM на игру** — согласен или разделять server/client? Предлагаю один в singleplayer-фазе, разделение — позже когда будет мультиплеер. +3. **Бэкенд изменения** — нужна миграция БД (поле `language`). У нас сейчас S2 + S1 + auto-backup, ничего страшного, но согласовать момент апдейта. +4. **Roblox API trademark/copyright** — мы делаем API-compatible runtime. Названия классов `Workspace`, `Humanoid`, etc. это API names. Юр.риск есть. Ты сказал берёшь — фиксируем. +5. **Приоритет** — этот план делать **вместо** других задач (тогда параллельные фичи стопаются) или **после** текущего бэклога? + +--- + +## Связанные документы + +- `RUBLOX_PROJECT.md` — общий план Рублокса +- `RUBLOX_EDITOR_ROADMAP.md` — куда движется редактор +- `INFO_PROCESS.md` — лог реализации (будет апдейтиться по ходу) + +--- + +**Создано:** 2026-06-08, Claude (Opus 4.7) совместно с МИНом. +**Статус:** план готов, ждём решения по 5 вопросам перед стартом. diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index 531aaed..357c573 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -37,7 +37,7 @@ const renderRowIcon = (val) => { const ItemRow = ({ icon, label, title, depth = 0, selected, plusItems, onClick, onDoubleClick, onContextMenu, onDragStart, draggable, - extraStyle, selId, + extraStyle, selId, badge, }) => { const [hovered, setHovered] = useState(false); const rowRef = React.useRef(null); @@ -84,6 +84,9 @@ const ItemRow = ({ > {renderRowIcon(icon)} {label} + {badge && ( + {badge} + )} {plusItems && plusItems.length > 0 && ( )} @@ -129,15 +132,35 @@ const GroupRow = ({ icon, label, open, onToggle, plusItems }) => { /** Строка скрипта внутри иерархии. */ const ScriptRow = ({ script, depth, selected, onSelect, onDelete, onRename, onContextMenu, onStartRename }) => { const displayName = script.name || (script.id === 'demo' ? 'Демо-скрипт' : script.id); + const isLua = script.language === 'lua'; + const badge = ( + + {isLua ? 'LUA' : 'JS'} + + ); return ( { scriptId={sc.id} value={sc.code} target={sc.target} + language={sc.language || 'js'} flushRef={scriptEditorFlushRef} isSoloRunning={soloScriptId === sc.id} + onLanguageChange={(lang, newCode) => { + sceneRef.current?.upsertScript(sc.id, newCode, undefined, undefined, lang); + setScriptsList(sceneRef.current?.getScripts?.() || []); + markDirty(); + }} onSave={(code) => { sceneRef.current?.upsertScript(sc.id, code, sc.target); setScriptsList(sceneRef.current?.getScripts?.() || []); diff --git a/src/editor/ScriptEditor.jsx b/src/editor/ScriptEditor.jsx index faeb2a1..cd09e7d 100644 --- a/src/editor/ScriptEditor.jsx +++ b/src/editor/ScriptEditor.jsx @@ -34,7 +34,42 @@ import { registerSnippets } from './engine/snippets'; // Если нужен какой-то метод, которого нет в автокомплите — добавляйте его // в соответствующий .d.ts (player.d.ts / scene.d.ts / ...) и пересобирайте // командой `python _build_bundle.py` в той же папке. -function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, onClose, flushRef }) { +// Дефолтный шаблон Lua-скрипта для нового скрипта (на Part или глобальный). +// Используется при смене языка JS→Lua когда текущий код выглядит «пустым». +const LUA_TEMPLATE_PART = `-- Скрипт привязан к Part. script.Parent = эта часть. +local part = script.Parent + +part.Touched:Connect(function(hit) + print("Касание:", hit.Name) +end) +`; +const LUA_TEMPLATE_GLOBAL = `-- Глобальный Lua-скрипт. Работает на стороне сервера/клиента. +local Players = game:GetService("Players") + +Players.PlayerAdded:Connect(function(player) + print("Игрок зашёл:", player.Name) +end) +`; +const JS_TEMPLATE_GLOBAL = `// Глобальный JS-скрипт. Подробнее: см. game.* API в /справочник. +game.onPlayerJoined((player) => { + game.chat.say('Привет, ' + player.name + '!'); +}); +`; + +function isCodeLikelyEmptyTemplate(code) { + if (!code) return true; + const trimmed = code.trim(); + if (trimmed.length === 0) return true; + // Содержит ТОЛЬКО комментарии и пустые строки + const lines = trimmed.split('\n').map(l => l.trim()).filter(Boolean); + return lines.every(l => + l.startsWith('//') || l.startsWith('--') || + l.startsWith('/*') || l.startsWith('*/') || l.startsWith('*') + ); +} + +function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, target, language, onLanguageChange, onClose, flushRef }) { + const currentLanguage = language === 'lua' ? 'lua' : 'js'; // Локальный буфер кода — то что в редакторе сейчас. // Синхронизируется с external value только при смене scriptId. const [localCode, setLocalCode] = useState(value || ''); @@ -282,6 +317,64 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe border: '1px solid rgba(79, 116, 255, 0.35)', }}>{targetLabel} )} + {/* Переключатель языка JS / Lua */} + + {['js', 'lua'].map((lang) => { + const active = currentLanguage === lang; + return ( + + ); + })} + {/* Фаза 6.1.4: кнопка «Проверить» — включает семантический анализ TS на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.). @@ -394,10 +487,11 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
1000) { + this._touchDbgT0 = _nowDbg; + console.warn(`[TouchDbg] pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)}) scripts=${scripts.length}`); + } // 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId) + let _firedThisFrame = 0; for (const s of scripts) { if (!s.target) continue; - const key = 's:' + s.id; - seen.add(key); - const aabb = this._targetAABB(s.target); - if (!aabb) continue; - const overlap = - px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && - py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && - pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; - const wasTouching = this._touchState.get(key); - if (overlap && !wasTouching) { - this._touchState.set(key, true); - rt.routeEvent(s.target, 'touch', {}); - rt.routeGlobalEvent('playerTouch', { target: s.target }); - } else if (!overlap && wasTouching) { - this._touchState.set(key, false); - rt.routeEvent(s.target, 'untouch', {}); + try { + const key = 's:' + s.id; + seen.add(key); + const aabb = this._targetAABB(s.target); + if (!aabb) continue; + const overlap = + px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && + py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && + pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; + const wasTouching = this._touchState.get(key); + if (overlap && !wasTouching) { + this._touchState.set(key, true); + rt.routeEvent(s.target, 'touch', {}); + rt.routeGlobalEvent('playerTouch', { target: s.target }); + _firedThisFrame++; + if (_firedThisFrame === 1) { + console.warn(`[Touch FIRE] scriptId=${s.id} target=${s.target} pos=(${px.toFixed(2)},${py.toFixed(2)},${pz.toFixed(2)})`); + } + } else if (!overlap && wasTouching) { + this._touchState.set(key, false); + rt.routeEvent(s.target, 'untouch', {}); + } + } catch (e) { + if (!this._touchDetectErrored) { + this._touchDetectErrored = true; + console.error('[TouchDetect] error', e, 'on script', s); + } } } @@ -3161,6 +3180,17 @@ export class BabylonScene { _targetAABB(target) { if (!target) return null; try { + // Импортированные Roblox-скрипты имеют target = число (primitiveId). + if (typeof target === 'number') { + const data = this.primitiveManager?.instances?.get(target); + if (!data || data.sx == null || data.x == null) return null; + const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2; + return { + minX: data.x - hx, maxX: data.x + hx, + minY: data.y - hy, maxY: data.y + hy, + minZ: data.z - hz, maxZ: data.z + hz, + }; + } if (target.kind === 'block') { const r = target.ref || target; return { @@ -5364,6 +5394,7 @@ export class BabylonScene { code: s.code, name: s.name || null, target: newTarget, + language: s.language || 'js', }); } if (srcScripts.length > 0) { @@ -5521,7 +5552,7 @@ export class BabylonScene { const target = kind === 'block' ? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } } : { kind, id: dstRef }; - this._scripts.push({ id: newId, code: s.code, name: s.name || null, target }); + this._scripts.push({ id: newId, code: s.code, name: s.name || null, target, language: s.language || 'js' }); } this.history?.markChange(); if (this._onSceneChange) this._onSceneChange(); @@ -6677,7 +6708,7 @@ export class BabylonScene { } /** Установить код одного скрипта по id. Если id нет — создать новый. */ - upsertScript(id, code, target = undefined, name = undefined) { + upsertScript(id, code, target = undefined, name = undefined, language = undefined) { const i = this._scripts.findIndex(s => s.id === id); if (i >= 0) { this._scripts[i] = { @@ -6685,6 +6716,7 @@ export class BabylonScene { code, ...(target !== undefined ? { target } : {}), ...(name !== undefined ? { name } : {}), + ...(language !== undefined ? { language } : {}), }; } else { this._scripts.push({ @@ -6692,6 +6724,7 @@ export class BabylonScene { code, target: target !== undefined ? target : null, name: name || null, + language: language || 'js', }); } // Скрипты — часть сцены: фиксируем в истории, иначе undo откатит @@ -8193,6 +8226,7 @@ export class BabylonScene { code: s.code, target: s.target || null, name: s.name || null, + language: s.language === 'lua' ? 'lua' : 'js', })); } // Окружение (время суток, скайбокс, туман) diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 16dfde9..6f45e01 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -20,6 +20,7 @@ import { STORYS_addres } from '../../api/API'; import { PhysicsWorld } from './PhysicsWorld'; import { LabelManager } from './LabelManager'; import { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js'; +import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js'; export class GameRuntime { constructor(scene3d) { @@ -117,11 +118,19 @@ export class GameRuntime { // на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит. const rbxlBatch = []; const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || []; + // НОВОЕ (Этап 2): Lua-скрипты с language='lua' идут через LuaSharedSandbox + // (один shared VM на всю игру). Это user-written Lua + Roblox API совместимость. + // Отличается от rbxl-import batch: тут код юзер написал в редакторе сам. + const luaUserBatch = []; for (const s of scripts) { if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) { rbxlBatch.push(s); continue; } + if (s && s.language === 'lua') { + if (typeof s.code === 'string' && s.code.trim()) luaUserBatch.push(s); + continue; + } if (!s || typeof s.code !== 'string' || !s.code.trim()) { // eslint-disable-next-line no-console console.warn('[GameRuntime] skipping invalid script entry', s); @@ -167,9 +176,32 @@ export class GameRuntime { rbxlCount = result.count; } } - this._log('info', `Запущено скриптов: ${this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0)}`); + // НОВОЕ (Этап 2): user-written Lua-скрипты с language='lua' через LuaSharedSandbox + let luaUserCount = 0; + if (luaUserBatch.length > 0) { + try { + const sb = new LuaSharedSandbox(); + sb.setOnCommand(({ cmd, payload }) => { + this._handleCommand(null, cmd, payload); + }); + for (const s of luaUserBatch) sb.addScript(s.id, s.code, s.target); + sb.start(); + this.sandboxes.push(sb); + this._luaUserSandbox = sb; + luaUserCount = luaUserBatch.length; + } catch (e) { + // eslint-disable-next-line no-console + console.error('[GameRuntime] Lua user runtime failed to init', e); + this._log('error', `Lua-runtime ошибка: ${e?.message || e}`); + } + } + const jsOnly = this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0) - (this._luaUserSandbox ? 1 : 0); + this._log('info', `Запущено JS-скриптов: ${jsOnly}`); if (rbxlCount > 0) { - this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`); + this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlCount}`); + } + if (luaUserCount > 0) { + this._log('info', `Запущено Lua-скриптов юзера: ${luaUserCount}`); } // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' // во все sandbox'ы. Не перезаписываем существующий обработчик — diff --git a/src/editor/engine/RobloxLuaSharedSandbox.js b/src/editor/engine/RobloxLuaSharedSandbox.js index 84cda46..65cd699 100644 --- a/src/editor/engine/RobloxLuaSharedSandbox.js +++ b/src/editor/engine/RobloxLuaSharedSandbox.js @@ -114,9 +114,20 @@ export class RobloxLuaSharedSandbox { if (!payload || typeof payload !== 'object') return; const type = payload.type; // playerTouch: BabylonScene уже детектит касания → Touched на Part - if (type === 'playerTouch' && payload.target) { - const m = /^primitive:(\d+)$/.exec(String(payload.target)); - if (m) { this.fireEvent('touched', { primId: +m[1], isPlayer: true }); return; } + if (type === 'playerTouch' && payload.target != null) { + const t = payload.target; + // target может быть: число (импортированный rbxl), {id|ref}, 'primitive:' + let primId = null; + if (typeof t === 'number') primId = t; + else if (typeof t === 'object') primId = t.id ?? t.ref ?? null; + else if (typeof t === 'string') { + const m = /^primitive:(\d+)$/.exec(t); + if (m) primId = +m[1]; + } + if (primId != null) { + this.fireEvent('touched', { primId, isPlayer: true }); + return; + } } // GUI click — Rublox GuiOverlay шлёт guiClick с id if (type === 'guiClick' && (payload.id || payload.localId)) { diff --git a/src/editor/engine/RobloxLuaSharedWorker.js b/src/editor/engine/RobloxLuaSharedWorker.js index 60c16d6..55837fb 100644 --- a/src/editor/engine/RobloxLuaSharedWorker.js +++ b/src/editor/engine/RobloxLuaSharedWorker.js @@ -344,11 +344,12 @@ function handleEvent(payload) { } else if (kind === 'touched') { const primId = payload.primId; const part = state.api.part_by_id?.get(primId); + const hasFire = !!part?.Touched?.Fire; + const connCount = part?.Touched?.connections?.length ?? 0; + log('info', `[Touched] primId=${primId} hasPart=${!!part} hasFire=${hasFire} connectedHandlers=${connCount}`); if (part?.Touched?.Fire) { - // hit = HumanoidRootPart part.Touched.Fire(state.api.character?.HumanoidRootPart || part); } - // также Humanoid.Touched на самом игроке if (payload.isPlayer) { state.api.humanoid?.Touched?.Fire?.(part); } diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js new file mode 100644 index 0000000..b12f7ca --- /dev/null +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -0,0 +1,215 @@ +/** + * LuaSharedSandbox — обёртка над одним wasmoon-Worker для ВСЕХ Lua-скриптов игры. + * + * Идея: + * - Создаётся ОДИН экземпляр на всю игру (а не на скрипт, как ScriptSandbox) + * - Все Lua-скрипты добавляются через addScript(id, code, target) + * - Worker внутри держит ОДИН wasmoon Lua-state, в котором живут: + * * полный Roblox API shim (Vector3, CFrame, Color3, Instance, ...) + * * виртуальное DataModel дерево (game.Workspace, Players, ...) + * * все скрипты как coroutines (потому что Roblox-Lua так работает) + * - При партии команд (partSet/sceneCreate/event/log) — пересылка в main + * с тем же интерфейсом что у ScriptSandbox + * + * Совместимость с GameRuntime: + * методы sendEvent / sendGlobalEvent / sendSceneSnapshot / sendGuiSnapshot / + * sendDataSnapshot / sendSkinsSnapshot / sendTerrainHeightmap / stop / + * setOnCommand — поведение совпадает с ScriptSandbox. + * + * Отличия: + * - addScript(id, code, target) можно вызывать много раз ДО start() — все + * скрипты добавляются батчем и потом запускаются вместе. + * - После start() можно вызывать addScript() для live-добавления (например, + * Instance.new("Script", workspace) с переданным Source). + */ + +import LuaSharedWorker from './LuaSharedWorker.js?worker'; + +let _ipcId = 0; + +export class LuaSharedSandbox { + constructor() { + this.worker = null; + this._onCommand = null; + this._isReady = false; + this._isStopped = false; + // Скрипты добавленные до start() — буферизуются, отправляются батчем при start() + this._pendingScripts = []; + // Снапшоты пришедшие до ready — отправляются после ready + this._pendingSceneSnapshot = null; + this._pendingGuiSnapshot = null; + this._pendingDataSnapshot = null; + this._pendingSkinsSnapshot = null; + this._pendingTerrainHM = null; + } + + setOnCommand(cb) { this._onCommand = cb; } + + /** Добавить Lua-скрипт. Можно вызывать как ДО start() (буфер), так и ПОСЛЕ (live). */ + addScript(id, code, target) { + const entry = { + id: String(id), + source: String(code || ''), + target: target == null ? null : target, + }; + if (!this.worker) { + this._pendingScripts.push(entry); + return; + } + // Live-добавление после start() + try { + this.worker.postMessage({ cmd: 'addScript', payload: entry }); + } catch (_) {} + } + + /** Удалить Lua-скрипт по id (для случая когда в студии его удалили в Play-mode редко). */ + removeScript(id) { + if (!this.worker) { + this._pendingScripts = this._pendingScripts.filter(s => s.id !== String(id)); + return; + } + try { + this.worker.postMessage({ cmd: 'removeScript', payload: { id: String(id) } }); + } catch (_) {} + } + + /** + * Запустить worker и инициализировать VM. + * После start() Lua-runtime готов принимать события и снапшоты. + */ + start() { + if (this.worker) return; + // eslint-disable-next-line no-console + console.log('[LuaSharedSandbox] starting Lua VM, pending scripts:', this._pendingScripts.length); + this.worker = new LuaSharedWorker(); + this.worker.onmessage = (e) => this._handleMessage(e); + this.worker.onerror = (err) => { + // eslint-disable-next-line no-console + console.error('[LuaSharedSandbox] Worker error', err); + this._emit('log', { + level: 'error', + text: `Lua-runtime error: ${err.message || err}`, + }); + }; + this.worker.postMessage({ + cmd: 'init', + payload: { ipcId: ++_ipcId }, + }); + } + + _handleMessage(e) { + if (this._isStopped) return; + const { cmd, payload } = e.data || {}; + if (cmd === 'boot') return; + if (cmd === 'ready') { + this._isReady = true; + // Отправляем накопленные скрипты батчем + if (this._pendingScripts.length > 0) { + try { + this.worker.postMessage({ cmd: 'addScriptsBatch', payload: { scripts: this._pendingScripts } }); + } catch (_) {} + this._pendingScripts = []; + } + // Отправляем snapshot'ы + if (this._pendingSceneSnapshot) { + try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: this._pendingSceneSnapshot }); } catch (_) {} + this._pendingSceneSnapshot = null; + } + if (this._pendingGuiSnapshot) { + try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (_) {} + this._pendingGuiSnapshot = null; + } + if (this._pendingDataSnapshot) { + try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (_) {} + this._pendingDataSnapshot = null; + } + if (this._pendingSkinsSnapshot) { + try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (_) {} + this._pendingSkinsSnapshot = null; + } + if (this._pendingTerrainHM) { + try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (_) {} + this._pendingTerrainHM = null; + } + // Запустить главный loop (фаер RunService.Heartbeat/Stepped + резюм coroutines) + try { this.worker.postMessage({ cmd: 'kickoff' }); } catch (_) {} + return; + } + // Любая другая команда — прокинуть наружу как partSet/sceneCreate/log/etc + // _onCommand обработчик в GameRuntime разруливает их так же как от ScriptSandbox + this._emit(cmd, payload); + } + + _emit(cmd, payload) { + if (typeof this._onCommand === 'function') { + try { this._onCommand({ cmd, payload }); } catch (_) {} + } + } + + /** Событие target-attached скрипта (touch/untouch/click/etc). */ + sendEvent(payload) { + if (!this.worker) return; + if (!this._isReady) return; + try { this.worker.postMessage({ cmd: 'event', payload }); } catch (_) {} + } + + /** Глобальное событие игры (playerTouch/guiClick/keydown/etc). */ + sendGlobalEvent(payload) { + if (!this.worker) return; + if (!this._isReady) return; + try { this.worker.postMessage({ cmd: 'globalEvent', payload }); } catch (_) {} + } + + sendSceneSnapshot(snapshot) { + if (!this.worker) { + this._pendingSceneSnapshot = snapshot; + return; + } + if (!this._isReady) { + this._pendingSceneSnapshot = snapshot; + return; + } + try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: snapshot }); } catch (_) {} + } + + sendGuiSnapshot(snapshot) { + if (!this.worker || !this._isReady) { + this._pendingGuiSnapshot = snapshot; + return; + } + try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (_) {} + } + + sendDataSnapshot(snapshot) { + if (!this.worker || !this._isReady) { + this._pendingDataSnapshot = snapshot; + return; + } + try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: snapshot }); } catch (_) {} + } + + sendSkinsSnapshot(snapshot) { + if (!this.worker || !this._isReady) { + this._pendingSkinsSnapshot = snapshot; + return; + } + try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: snapshot }); } catch (_) {} + } + + sendTerrainHeightmap(hm) { + if (!this.worker || !this._isReady) { + this._pendingTerrainHM = hm; + return; + } + try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: hm }); } catch (_) {} + } + + stop() { + this._isStopped = true; + try { this.worker?.terminate(); } catch (_) {} + this.worker = null; + this._isReady = false; + } +} + +export default LuaSharedSandbox; diff --git a/src/editor/engine/lua/LuaSharedWorker.js b/src/editor/engine/lua/LuaSharedWorker.js new file mode 100644 index 0000000..aff4a94 --- /dev/null +++ b/src/editor/engine/lua/LuaSharedWorker.js @@ -0,0 +1,242 @@ +/* eslint-disable no-restricted-globals */ +/** + * LuaSharedWorker — Web Worker, держит ОДИН wasmoon Lua-state на всю игру. + * + * Жизненный цикл: + * 1. init {ipcId} → загружаем wasmoon, готовим VM, регистрируем shim, отвечаем 'ready' + * 2. addScriptsBatch {scripts} → добавляем все скрипты сразу (но НЕ запускаем — ждём kickoff) + * 3. sceneSnapshot/guiSnapshot → накопить состояние сцены до запуска + * 4. kickoff → запустить main loop (RunService.Heartbeat фейерится из main loop) + * и стартануть каждый скрипт как coroutine + * 5. event/globalEvent → проксировать в Lua-signal (RbxSignal.Fire) + * 6. addScript {entry} → live-добавление одного скрипта после kickoff + * + * Архитектура VM: + * - один wasmoon Lua state (createWasmoonVM) + * - registerRobloxShim(vm) — экспортирует Vector3.new, Color3.new, print, wait, + * game (с минимальным DataModel), Instance.new и проч. + * - state.scripts = Map + * - state.scheduler — список «спящих» coroutines с timeUntilResume, рекурзится в _tick + * - state.signals — RbxSignal-объекты для events; Worker слушает 'event' от main + * и вызывает Lua-side Fire по соответствующему signal'у + */ + +let _wasmoon = null; + +// Главное состояние VM (на весь life-cycle Worker'а) +const state = { + ipcId: null, + vm: null, + api: null, // объект который вернул registerRobloxShim + isReady: false, + isKickedOff: false, + pendingScripts: [], // скрипты которые ждут kickoff + scriptsById: new Map(), // id → {coroutine, target, source, name} + scenes: { primitives: null, blocks: null, models: null }, + guiTree: null, + skins: null, + data: null, + terrainHM: null, + // tick clock + lastTickAt: 0, +}; + +self.onmessage = async (e) => { + const { cmd, payload } = e.data || {}; + try { + if (cmd === 'init') await handleInit(payload); + else if (cmd === 'addScript') handleAddScript(payload); + else if (cmd === 'addScriptsBatch') handleAddScriptsBatch(payload); + else if (cmd === 'removeScript') handleRemoveScript(payload); + else if (cmd === 'sceneSnapshot') handleSceneSnapshot(payload); + else if (cmd === 'guiSnapshot') handleGuiSnapshot(payload); + else if (cmd === 'dataSnapshot') handleDataSnapshot(payload); + else if (cmd === 'skinsSnapshot') handleSkinsSnapshot(payload); + else if (cmd === 'terrainHeightmap') handleTerrainHeightmap(payload); + else if (cmd === 'event') handleTargetEvent(payload); + else if (cmd === 'globalEvent') handleGlobalEvent(payload); + else if (cmd === 'kickoff') handleKickoff(); + } catch (err) { + logToMain('error', `[LuaWorker] ${cmd} error: ${err?.message || err}`); + } +}; + +function send(cmd, payload) { + try { self.postMessage({ cmd, payload }); } catch (_) {} +} + +function logToMain(level, text) { + send('log', { level, text }); +} + +async function handleInit(payload) { + state.ipcId = payload?.ipcId || 0; + send('boot', { ipcId: state.ipcId }); + // Загрузить wasmoon + _wasmoon = await import(/* @vite-ignore */ 'wasmoon'); + const { LuaFactory } = _wasmoon; + const factory = new LuaFactory(); + state.vm = await factory.createEngine({ openStandardLibs: true }); + + // Регистрируем минимальный Roblox shim + const { registerRobloxShim } = await import('./RobloxShim.js'); + state.api = registerRobloxShim(state.vm, { + send, + getSceneSnapshot: () => state.scenes, + getGuiTree: () => state.guiTree, + scheduleWait: (sec) => scheduleWait(sec), + }); + + state.isReady = true; + send('ready', {}); +} + +function handleAddScriptsBatch(payload) { + const arr = Array.isArray(payload?.scripts) ? payload.scripts : []; + for (const s of arr) handleAddScript(s); +} + +function handleAddScript(entry) { + if (!entry || typeof entry.source !== 'string') return; + const id = String(entry.id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`); + state.scriptsById.set(id, { + id, + source: entry.source, + target: entry.target == null ? null : entry.target, + coroutine: null, + name: entry.name || null, + }); + // Если мы уже kickoff'нулись — стартанём сразу + if (state.isKickedOff) startSingleScript(id); + else state.pendingScripts.push(id); +} + +function handleRemoveScript(payload) { + const id = String(payload?.id || ''); + if (!id) return; + state.scriptsById.delete(id); + // (coroutine просто перестанет резюмиться) +} + +function handleSceneSnapshot(snap) { + state.scenes = snap || { primitives: null, blocks: null, models: null }; + // Обновим DataModel-дерево (Workspace children) — это сделает api при следующем GetChildren() + if (state.api?.onSceneSnapshot) { + try { state.api.onSceneSnapshot(state.scenes); } catch (_) {} + } +} + +function handleGuiSnapshot(g) { + state.guiTree = g || null; + if (state.api?.onGuiSnapshot) { + try { state.api.onGuiSnapshot(state.guiTree); } catch (_) {} + } +} + +function handleDataSnapshot(d) { + state.data = d || null; + if (state.api?.onDataSnapshot) { + try { state.api.onDataSnapshot(state.data); } catch (_) {} + } +} + +function handleSkinsSnapshot(s) { + state.skins = s || null; +} + +function handleTerrainHeightmap(hm) { + state.terrainHM = hm || null; +} + +function handleTargetEvent(payload) { + // События привязанные к конкретному скрипту (touch/untouch/click) + // payload: { scriptId, kind, ... } + if (!state.api?.fireTargetEvent) return; + try { state.api.fireTargetEvent(payload); } catch (e) { + logToMain('error', `[LuaWorker] fireTargetEvent: ${e.message || e}`); + } +} + +function handleGlobalEvent(payload) { + if (!state.api?.fireGlobalEvent) return; + try { state.api.fireGlobalEvent(payload); } catch (e) { + logToMain('error', `[LuaWorker] fireGlobalEvent: ${e.message || e}`); + } +} + +function handleKickoff() { + if (state.isKickedOff) return; + state.isKickedOff = true; + // Стартанём все накопленные скрипты как coroutines + for (const id of state.pendingScripts) startSingleScript(id); + state.pendingScripts = []; + // Главный loop — RunService Heartbeat + scheduler resume + state.lastTickAt = performance.now(); + startMainLoop(); +} + +function startSingleScript(id) { + const entry = state.scriptsById.get(id); + if (!entry) return; + // Каждый скрипт — coroutine. В нём script — это таблица {Name, Parent, ClassName="Script"}. + // Создаём в Lua wrapped chunk: + // coroutine.create(function() local script = ...; end) + const safeId = id.replace(/[^a-zA-Z0-9_]/g, '_'); + const targetLuaExpr = entry.target == null + ? 'nil' + : (typeof entry.target === 'number' + ? `__rbxl_get_part_by_id(${entry.target})` + : 'nil'); // для object-target (на будущее) + const name = entry.name || `Script_${safeId}`; + const wrapped = ` + local co = coroutine.create(function() + local script = { + Name = ${JSON.stringify(name)}, + Parent = ${targetLuaExpr}, + ClassName = "Script", + Disabled = false, + Source = nil, + } + __rbxl_script_run(${JSON.stringify(id)}, script, function() + ${entry.source} + end) + end) + __rbxl_register_coroutine(${JSON.stringify(id)}, co) + coroutine.resume(co) + `; + try { + state.vm.doStringSync(wrapped); + } catch (err) { + logToMain('error', `[Lua ${id}] init error: ${err.message || err}`); + } +} + +/** + * Главный loop: + * - вызывается раз в ~16мс (60 Гц), резюмит спящие coroutines у которых истёк wait + * - фейерит RunService.Heartbeat (dt секундах) + * - фейерит RunService.Stepped + */ +function startMainLoop() { + const tick = () => { + if (!state.isKickedOff) return; + try { + const now = performance.now(); + const dt = Math.min(0.1, (now - state.lastTickAt) / 1000); + state.lastTickAt = now; + // 1) Резюм coroutines, которым подошёл срок wait + if (state.api?.tickScheduler) state.api.tickScheduler(dt); + // 2) Heartbeat и Stepped сигналы + if (state.api?.fireHeartbeat) state.api.fireHeartbeat(dt); + } catch (e) { + logToMain('error', `[Lua tick] ${e.message || e}`); + } + setTimeout(tick, 16); + }; + setTimeout(tick, 16); +} + +function scheduleWait(_sec) { + // вызывается из Lua-side через api.scheduleWait. Реальная реализация — + // в RobloxShim.js (он держит scheduler). +} diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js new file mode 100644 index 0000000..1d449fe --- /dev/null +++ b/src/editor/engine/lua/RobloxShim.js @@ -0,0 +1,447 @@ +/** + * RobloxShim — экспорт минимально-достаточного Roblox API в wasmoon-VM. + * + * Этап 2 (текущий): базовый shim без DataModel-дерева. + * - Vector3, Color3, UDim2, UDim, Vector2 (с операторами) + * - print, warn, error, wait, task.wait, task.spawn, task.delay + * - RbxSignal (Connect/connect, Disconnect, Wait, Fire/fire) + * - scheduler для wait через coroutines (NB: используется внешний tick из Worker) + * - примитивная game-table с workspace, Players (заглушки) — расширится в Этапе 3 + * + * Возвращает объект api с методами: + * onSceneSnapshot(snap) — обновить понимание сцены (для DataModel в Этапе 3) + * onGuiSnapshot(g) — обновить GUI tree + * onDataSnapshot(d) — обновить data (save) + * tickScheduler(dt) — резюм coroutines с истёкшим wait + * fireHeartbeat(dt) — фейр RunService.Heartbeat + * fireTargetEvent(p) — событие для target-скрипта (touch/click) + * fireGlobalEvent(p) — playerTouch / guiClick / keydown + * + * Дизайн RbxSignal: хранится JS-сторона как {connections: [fn,...]}. + * Lua видит обёртку {Connect=fn, connect=fn, Wait=fn, Fire=fn}. + */ + +const SCHEDULER = { + sleeping: [], // [{coroutine, wakeAt}], wakeAt = performance.now()+ms + now: () => performance.now(), +}; + +const HEARTBEAT_SIGNAL = makeSignal(); +const STEPPED_SIGNAL = makeSignal(); + +function makeSignal() { + const connections = []; + return { + __isSignal: true, + connections, + Fire(...args) { for (const fn of [...connections]) { try { fn(...args); } catch (_) {} } }, + Connect(fn) { + if (typeof fn !== 'function') return { Disconnect() {}, disconnect() {} }; + connections.push(fn); + return { + Disconnect() { const i = connections.indexOf(fn); if (i >= 0) connections.splice(i, 1); }, + disconnect() { const i = connections.indexOf(fn); if (i >= 0) connections.splice(i, 1); }, + Connected: true, + }; + }, + Wait() { + // в реальной реализации — coroutine.yield пока не fire'нется + return null; + }, + }; +} + +// --- Vector3 --- +class RbxVector3 { + constructor(x = 0, y = 0, z = 0) { + this.X = +x; this.Y = +y; this.Z = +z; + } + static new(x, y, z) { return new RbxVector3(x, y, z); } + Magnitude() { return Math.hypot(this.X, this.Y, this.Z); } + get magnitude() { return Math.hypot(this.X, this.Y, this.Z); } + Unit() { + const m = this.Magnitude() || 1; + return new RbxVector3(this.X / m, this.Y / m, this.Z / m); + } + Normalize() { return this.Unit(); } + Dot(b) { return this.X * b.X + this.Y * b.Y + this.Z * b.Z; } + Cross(b) { + return new RbxVector3( + this.Y * b.Z - this.Z * b.Y, + this.Z * b.X - this.X * b.Z, + this.X * b.Y - this.Y * b.X, + ); + } + Lerp(b, t) { + return new RbxVector3( + this.X + (b.X - this.X) * t, + this.Y + (b.Y - this.Y) * t, + this.Z + (b.Z - this.Z) * t, + ); + } + // Lua operators реализуются через __add/__sub/__mul (metatable установит shim ниже) +} +RbxVector3.zero = new RbxVector3(0, 0, 0); +RbxVector3.one = new RbxVector3(1, 1, 1); +RbxVector3.xAxis = new RbxVector3(1, 0, 0); +RbxVector3.yAxis = new RbxVector3(0, 1, 0); +RbxVector3.zAxis = new RbxVector3(0, 0, 1); + +// --- Color3 --- +class RbxColor3 { + constructor(r = 0, g = 0, b = 0) { this.R = +r; this.G = +g; this.B = +b; } + static new(r, g, b) { return new RbxColor3(r, g, b); } + static fromRGB(r, g, b) { return new RbxColor3((r || 0) / 255, (g || 0) / 255, (b || 0) / 255); } + static fromHSV(h, s, v) { + const i = Math.floor(h * 6); const f = h * 6 - i; + const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s); + const [r, g, b] = [ + [v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q], + ][i % 6]; + return new RbxColor3(r, g, b); + } + Lerp(b, t) { + return new RbxColor3( + this.R + (b.R - this.R) * t, + this.G + (b.G - this.G) * t, + this.B + (b.B - this.B) * t, + ); + } +} + +// --- UDim / UDim2 / Vector2 --- +class RbxUDim { + constructor(s = 0, o = 0) { this.Scale = +s; this.Offset = +o; } + static new(s, o) { return new RbxUDim(s, o); } +} +class RbxUDim2 { + constructor(sx = 0, ox = 0, sy = 0, oy = 0) { + this.X = new RbxUDim(sx, ox); + this.Y = new RbxUDim(sy, oy); + } + static new(sx, ox, sy, oy) { return new RbxUDim2(sx, ox, sy, oy); } + static fromScale(sx, sy) { return new RbxUDim2(sx, 0, sy, 0); } + static fromOffset(ox, oy) { return new RbxUDim2(0, ox, 0, oy); } +} +class RbxVector2 { + constructor(x = 0, y = 0) { this.X = +x; this.Y = +y; } + static new(x, y) { return new RbxVector2(x, y); } +} + +// --- CFrame (минимум) --- +class RbxCFrame { + constructor(x = 0, y = 0, z = 0) { + this.X = +x; this.Y = +y; this.Z = +z; + this.Position = new RbxVector3(x, y, z); + // Полная матрица 3×3 на этапе 3 + } + static new(x, y, z) { return new RbxCFrame(x || 0, y || 0, z || 0); } + static lookAt(eye, target) { + // упрощение — возвращаем cframe в позиции eye + const cf = new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0); + cf._lookAt = target; + return cf; + } + static Angles(_rx, _ry, _rz) { return new RbxCFrame(); } + static fromEulerAnglesXYZ(_rx, _ry, _rz) { return new RbxCFrame(); } +} + +/** + * Главная регистрация. Возвращает api-объект используемый Worker'ом. + */ +export function registerRobloxShim(lua, opts) { + const { send, getSceneSnapshot, getGuiTree, scheduleWait } = opts; + const global = lua.global; + + // ------ Vector3 ------ + // Lua: local v = Vector3.new(1,2,3); v.X; v + v; v.Magnitude + const Vector3Table = { + new: (x, y, z) => new RbxVector3(x, y, z), + zero: RbxVector3.zero, + one: RbxVector3.one, + xAxis: RbxVector3.xAxis, + yAxis: RbxVector3.yAxis, + zAxis: RbxVector3.zAxis, + FromNormalId: () => new RbxVector3(), + }; + global.set('Vector3', Vector3Table); + + // ------ Color3 ------ + global.set('Color3', { + new: (r, g, b) => new RbxColor3(r, g, b), + fromRGB: (r, g, b) => RbxColor3.fromRGB(r, g, b), + fromHSV: (h, s, v) => RbxColor3.fromHSV(h, s, v), + fromHex: (hex) => { + const s = String(hex || '').replace('#', ''); + if (s.length !== 6) return new RbxColor3(); + return new RbxColor3( + parseInt(s.slice(0, 2), 16) / 255, + parseInt(s.slice(2, 4), 16) / 255, + parseInt(s.slice(4, 6), 16) / 255, + ); + }, + }); + + // ------ UDim / UDim2 / Vector2 / CFrame ------ + global.set('UDim', { new: (s, o) => new RbxUDim(s, o) }); + global.set('UDim2', { + new: (sx, ox, sy, oy) => new RbxUDim2(sx, ox, sy, oy), + fromScale: (sx, sy) => RbxUDim2.fromScale(sx, sy), + fromOffset: (ox, oy) => RbxUDim2.fromOffset(ox, oy), + }); + global.set('Vector2', { new: (x, y) => new RbxVector2(x, y) }); + global.set('CFrame', { + new: (x, y, z) => RbxCFrame.new(x, y, z), + lookAt: (e, t) => RbxCFrame.lookAt(e, t), + Angles: RbxCFrame.Angles, + fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ, + }); + + // ------ Enum (минимум) ------ + global.set('Enum', { + KeyCode: Object.fromEntries([ + 'W', 'A', 'S', 'D', 'Space', 'LeftShift', 'LeftControl', 'F', 'E', 'Q', + 'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'G', 'H', 'J', 'K', 'L', 'Z', 'X', + 'C', 'V', 'B', 'N', 'M', 'Tab', 'Return', 'Escape', 'Backspace', + 'Up', 'Down', 'Left', 'Right', + 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Zero', + ].map(k => [k, { Name: k, Value: k }])), + UserInputType: Object.fromEntries([ + 'MouseButton1', 'MouseButton2', 'MouseButton3', 'MouseMovement', + 'MouseWheel', 'Touch', 'Keyboard', + ].map(k => [k, { Name: k, Value: k }])), + Material: Object.fromEntries([ + 'Plastic', 'Wood', 'Metal', 'Neon', 'Glass', 'Sand', 'Ice', 'Grass', 'Concrete', + ].map(k => [k, { Name: k, Value: k }])), + HumanoidStateType: Object.fromEntries([ + 'Running', 'Jumping', 'Freefall', 'Landed', 'Dead', 'Climbing', 'Swimming', 'Seated', + ].map(k => [k, { Name: k, Value: k }])), + EasingStyle: Object.fromEntries([ + 'Linear', 'Sine', 'Quad', 'Cubic', 'Quart', 'Quint', 'Bounce', 'Elastic', + ].map(k => [k, { Name: k, Value: k }])), + EasingDirection: Object.fromEntries([ + 'In', 'Out', 'InOut', + ].map(k => [k, { Name: k, Value: k }])), + }); + + // ------ print / warn / error логируются в студию ------ + global.set('print', (...args) => { + const text = args.map(luaTostring).join('\t'); + send('log', { level: 'info', text }); + }); + global.set('warn', (...args) => { + const text = args.map(luaTostring).join('\t'); + send('log', { level: 'warn', text }); + }); + // Stdlib error — оставлен (бросает Lua-error). Дополнительно — наш logToMain в pcall. + + // ------ task.* и wait() ------ + // wait(sec) — приостанавливает текущую coroutine на sec секунд через scheduler. + // task.wait, task.spawn, task.delay — современные эквиваленты. + const taskTable = { + wait: (sec) => luaWait(sec), + spawn: (fn) => { + // task.spawn(fn) — стартует функцию как «корутину», немедленно резюмит + // у нас работает через прямой вызов pcall (упрощение, без честных coroutines) + try { if (typeof fn === 'function') fn(); } catch (_) {} + }, + delay: (sec, fn) => { + // task.delay(sec, fn) — отложенный спавн + if (typeof fn !== 'function') return; + // Добавляем в scheduler + const wakeAt = SCHEDULER.now() + (Number(sec) || 0) * 1000; + SCHEDULER.sleeping.push({ wakeAt, run: () => { try { fn(); } catch (_) {} } }); + }, + defer: (fn) => { + if (typeof fn === 'function') { + const wakeAt = SCHEDULER.now() + 0; + SCHEDULER.sleeping.push({ wakeAt, run: () => { try { fn(); } catch (_) {} } }); + } + }, + synchronize: () => {}, + desynchronize: () => {}, + }; + global.set('task', taskTable); + global.set('wait', (sec) => luaWait(sec)); + + /** + * luaWait — блокировка текущего coroutine на sec секунд. + * NB: использует lua-side coroutine.yield + Worker scheduler. + * Здесь упрощение: возвращаем как обычный вызов (без honest yield) для MVP. + * Honest реализация прийдёт когда intgrate с DataModel в Этапе 3. + */ + function luaWait(_sec) { + return null; + } + + // ------ game (минимум) ------ + // На Этапе 2 это пустой стаб — реальный DataModel будет на Этапе 3. + // Но для совместимости с скриптами которые делают `game:GetService(...)` + // возвращаем заглушку которая на всё отвечает безопасными no-op. + const stubService = (name) => ({ + __isService: true, + Name: name, + ClassName: name, + GetChildren: () => [], + GetDescendants: () => [], + FindFirstChild: () => null, + FindFirstChildOfClass: () => null, + WaitForChild: () => null, + IsA: () => false, + GetService: (n) => stubService(n), + ChildAdded: makeSignal(), + ChildRemoved: makeSignal(), + DescendantAdded: makeSignal(), + DescendantRemoving: makeSignal(), + }); + const runService = stubService('RunService'); + runService.Heartbeat = HEARTBEAT_SIGNAL; + runService.Stepped = STEPPED_SIGNAL; + runService.RenderStepped = HEARTBEAT_SIGNAL; // упрощённо + + const gameTable = { + __isGame: true, + Name: 'game', + ClassName: 'DataModel', + GetService(name) { + if (name === 'RunService') return runService; + return stubService(name); + }, + FindService(name) { + if (name === 'RunService') return runService; + return null; + }, + Workspace: stubService('Workspace'), + Players: stubService('Players'), + ReplicatedStorage: stubService('ReplicatedStorage'), + ServerStorage: stubService('ServerStorage'), + Lighting: stubService('Lighting'), + StarterGui: stubService('StarterGui'), + StarterPack: stubService('StarterPack'), + StarterPlayer: stubService('StarterPlayer'), + RunService: runService, + UserInputService: stubService('UserInputService'), + TweenService: stubService('TweenService'), + HttpService: stubService('HttpService'), + DataStoreService: stubService('DataStoreService'), + MarketplaceService: stubService('MarketplaceService'), + Chat: stubService('Chat'), + SoundService: stubService('SoundService'), + PathfindingService: stubService('PathfindingService'), + PhysicsService: stubService('PhysicsService'), + TeleportService: stubService('TeleportService'), + CollectionService: stubService('CollectionService'), + ContextActionService: stubService('ContextActionService'), + ContentProvider: stubService('ContentProvider'), + LocalizationService: stubService('LocalizationService'), + }; + global.set('game', gameTable); + global.set('workspace', gameTable.Workspace); + global.set('Workspace', gameTable.Workspace); + + // ------ Instance.new ------ + // Возвращает «pseudo-instance» — на Этапе 2 это просто object с пропсами. + // На Этапе 3 будет полноценный класс с metatable и Parent setter. + global.set('Instance', { + new: (className, parent) => { + const inst = { + ClassName: String(className || 'Instance'), + Name: String(className || 'Instance'), + Parent: parent || null, + Children: [], + Destroyed: false, + Touched: makeSignal(), + Activated: makeSignal(), + MouseButton1Click: makeSignal(), + Changed: makeSignal(), + AncestryChanged: makeSignal(), + ChildAdded: makeSignal(), + ChildRemoved: makeSignal(), + GetChildren() { return [...this.Children]; }, + FindFirstChild() { return null; }, + WaitForChild() { return null; }, + IsA() { return false; }, + Destroy() { this.Destroyed = true; }, + Clone() { return null; }, + GetFullName() { return this.Name; }, + GetAttribute() { return null; }, + SetAttribute() {}, + }; + return inst; + }, + }); + + // ------ Helpers для Worker'а ------ + // __rbxl_register_coroutine(id, co) — мы её отдадим, чтобы зарегистрировать в JS + const coroutinesById = new Map(); + global.set('__rbxl_register_coroutine', (id, co) => { + coroutinesById.set(String(id), co); + }); + global.set('__rbxl_get_part_by_id', (_id) => { + // На Этапе 3 будет lookup в DataModel. Пока nil (script.Parent = nil) + return null; + }); + global.set('__rbxl_script_run', (id, scriptObj, body) => { + // Запускает body() с обработкой ошибок. id и scriptObj прокидываются + // только для будущего использования (например, регистрации в DataModel). + try { + if (typeof body === 'function') body(); + } catch (err) { + send('log', { + level: 'error', + text: `[Lua ${id}] ${err?.message || err}`, + }); + } + }); + + // ------ Возвращаем api для Worker'а ------ + return { + // обновление снапшотов (будет использовано на Этапе 3 для DataModel) + onSceneSnapshot() {}, + onGuiSnapshot() {}, + onDataSnapshot() {}, + // tick scheduler — резюм ожидающих task.delay/defer + tickScheduler(_dt) { + const now = SCHEDULER.now(); + if (SCHEDULER.sleeping.length === 0) return; + const ready = []; + const rest = []; + for (const t of SCHEDULER.sleeping) { + if (t.wakeAt <= now) ready.push(t); + else rest.push(t); + } + SCHEDULER.sleeping = rest; + for (const t of ready) { + try { t.run(); } catch (_) {} + } + }, + fireHeartbeat(dt) { + try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {} + try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {} + }, + fireTargetEvent(p) { + // На Этапе 3 — найти Part в DataModel и фейернуть его Touched + // Сейчас — no-op (но не падаем) + // Возможные kind: 'touch', 'untouch', 'click' + if (!p) return; + }, + fireGlobalEvent(_p) { + // playerTouch / guiClick / keydown — также на Этапе 3 + }, + }; +} + +// --- Утилиты --- +function luaTostring(v) { + if (v == null) return 'nil'; + if (typeof v === 'string') return v; + if (typeof v === 'number') return String(v); + if (typeof v === 'boolean') return v ? 'true' : 'false'; + if (v instanceof RbxVector3) return `${v.X}, ${v.Y}, ${v.Z}`; + if (v instanceof RbxColor3) return `${v.R}, ${v.G}, ${v.B}`; + if (typeof v === 'object') { + if (v.Name) return String(v.Name); + return '[object]'; + } + try { return String(v); } catch (_) { return '?'; } +}