diff --git a/RBXL_SOURCES.md b/RBXL_SOURCES.md new file mode 100644 index 0000000..e0e132e --- /dev/null +++ b/RBXL_SOURCES.md @@ -0,0 +1,124 @@ +# Реестр источников .rbxl / .rbxlx для портирования в Рублокс + +Цель: легально добыть Roblox Place-файлы (.rbxl бинарный / .rbxlx XML) по жанрам +для портирования и публикации на Рублоксе. + +**Форматы:** `.rbxlx` (XML — предпочтителен, читаемый, легко парсить геометрию/CFrame) +· `.rbxl` (бинарный, конвертировать) · `.rbxm`/`.rbxmx` (модели, не целые места). + +> ⚠️ **Главное про публикацию:** «uncopylocked» ≠ свободная лицензия. Для ПУБЛИКАЦИИ +> порта на Рублоксе безопасны только: репо с явной **MIT/Apache/MPL/CC0/CC-BY** + +> официальные ассеты Roblox с разрешением. Архивы чужих игр — только для +> обучения/прототипа парсера, НЕ для публикации. Lua-скрипты не портируются +> автоматом — логику переписываешь сам (это и снижает юр.риски). + +--- + +## ИНСТРУМЕНТЫ (распаковка/парсинг) + +| Инструмент | URL | Назначение | Лицензия | +|---|---|---|---| +| Rojo | https://github.com/rojo-rbx/rojo | place ↔ файлы | MPL-2.0 | +| rbxlx-to-rojo | https://github.com/rojo-rbx/rbxlx-to-rojo | .rbxl/.rbxlx → проект | MPL/MIT (проверить) | +| rbxfile (Go) | https://github.com/robloxapi/rbxfile | парсинг rbxl/rbxlx/rbxm | MIT (проверить) | +| remodel | https://github.com/rojo-rbx/remodel | скриптовая обработка | MPL-2.0 | +| RobloxAPI/spec | https://github.com/RobloxAPI/spec/blob/master/formats/rbxl.md | спека бинарного формата | docs | + +--- + +## (А) ОФИЦИАЛЬНЫЕ — самые надёжные + +### A1. Roblox/Old-Open-Source-Levels — классика от самой Roblox Corp ⭐ +- https://github.com/Roblox/Old-Open-Source-Levels +- Каталог: https://github.com/Roblox/Old-Open-Source-Levels/blob/master/catalog.md +- ~30+ мест 2007-2013 (.rbxl). Жанры: Crossroads (арена/PvP), Castle Warfare, + ROBLOX Battle (бой), Sword Fight in the Dark (PvP), Haunted Mansion (хоррор), + Glass Houses, Pinball Wizards, Happy Home in Robloxia (песочница/мини). +- Лицензия: «free to manipulate however you wish» — **проверь файл LICENSE вручную** перед публикацией. + +### A2. Встроенные шаблоны Roblox Studio +- Список: https://create.roblox.com/docs/resources/templates +- В Studio: открыть шаблон → File → Save to File → .rbxlx +- Baseplate, Castle, Suburban, Village, Racing, Classic Obby, Team Deathmatch/Combat, + Capture the Flag, Line Runner, Pirate Island, Modern City и др. +- Серая зона для публикации «как есть» — используй как базу/учёбу, геометрию делай своей. + +### A3. creator-docs (документация Roblox, open) +- https://github.com/Roblox/creator-docs + +### A4. Internet Archive — Crossroads (все версии 2007-2017) +- https://archive.org/details/roblox_crossroads +- https://archive.org/details/classic-crossroads_202408 + +--- + +## (Б) РЕПОЗИТОРИИ С КОДОМ/МЕСТАМИ (URL из поиска) + +### С подтверждённой свободной лицензией (можно публиковать) +| Репо | URL | Лицензия | Жанр | +|---|---|---|---| +| Vigilant | https://github.com/IsoLogicGames/Vigilant | **MIT** ✅ | co-op horde-survival (шутер) | +| crossroads-rojo | https://github.com/Dekkonot/crossroads-rojo | наследует Crossroads | арена | + +### Open-source игры (лицензию проверить у каждого — файл LICENSE) +| Репо | URL | Жанр | +|---|---|---| +| Miner's Haven | https://github.com/berezaa/minershaven | tycoon/симулятор | +| roblox-gym-tycoon | https://github.com/jason-lee88/roblox-gym-tycoon | tycoon | +| Racing-Kit-Roblox | https://github.com/Astrophsica/Racing-Kit-Roblox | гонки | +| RENTED_old_rbx | https://github.com/ReRand/RENTED_old_rbx | хоррор | +| roblox-rpg | https://github.com/mobyrblx/roblox-rpg | RPG/демо | +| RobloxGames (dwmk) | https://github.com/dwmk/RobloxGames | разное | +| recsObby | https://github.com/Nimblz/recsObby | obby | +| WavyRobloxObby | https://github.com/sammy0127/WavyRobloxObby | obby (.rbxlx) | +| Sight-Obby | https://github.com/TeoJJss/Sight-Obby | obby | +| fps (Anninzy) | https://github.com/Anninzy/fps | FPS | +| roblox-game-example | https://github.com/areshaistg/roblox-game-example | демо-каркас | + +### Архивы чужих игр (ТОЛЬКО обучение/прототип, НЕ публикация — смешанные права) +| Репо | URL | +|---|---| +| uncopylocked-game-collection | https://github.com/Kitaske/uncopylocked-game-collection | +| robloxplacearchive | https://github.com/tropicalbananas/robloxplacearchive | +| RobloxRBXLArchive | https://github.com/LuaGunsX/RobloxRBXLArchive | +| Biggest Uncopylocked Library | https://github.com/KH0DIN/Biggest_Uncopylocked_Roblox_Games_Library | +| GitHub topics | https://github.com/topics/rbxlx · /rbxl · /rbxm · /rojo · /uncopylocked | + +--- + +## (В) САЙТЫ ДЛЯ САМОСТОЯТЕЛЬНОГО СКАЧИВАНИЯ + +### Прямое скачивание .rbxl/.rbxlx +- **GitHub code search** (вход обязателен): `extension:rbxlx`, `extension:rbxl`, + `filename:default.project.json` (корень Rojo-проекта рядом с местом) + https://github.com/search?q=extension%3Arbxlx&type=code +- **GitHub Topics:** https://github.com/topics/rbxlx · https://github.com/topics/rojo +- **Internet Archive:** https://archive.org/ — поиск «roblox place», «rbxl», «crossroads» + +### CC0/CC-BY геометрия для воссоздания (юридически чистейший путь, не .rbxl но low-poly близко к Roblox) +- **Kenney** (CC0): https://kenney.nl/assets — Platformer/Nature/Car/Pirate/City/Prototype Kit, Blocky Characters +- **OpenGameArt** (CC0/CC-BY): https://opengameart.org/ — voxel/low-poly паки +- **itch.io** (фильтр assets+CC0): https://itch.io/game-assets/free/tag-low-poly +- **Poly Pizza** (CC0/CC-BY low-poly): https://poly.pizza/ +- **Quaternius** (CC0 low-poly паки): https://quaternius.com/ + +### Сообщества с открытыми играми (часто прямые ссылки + лицензия) +- DevForum «free & open-sourced games»: https://devforum.roblox.com/t/lots-of-free-open-sourced-games/525670 +- DevForum «Open Source Arena FPS»: https://devforum.roblox.com/t/open-source-arena-fps/1034576 +- Uplift Games open source: https://www.uplift.games/open-source + +--- + +## ЮРИДИЧЕСКИЕ ПРАВИЛА (коротко) + +- ✅ Публиковать можно: **MIT / Apache-2.0 / MPL-2.0 / CC0 / CC-BY** (CC-BY — с атрибуцией). +- ❌ Нельзя: **GPL/AGPL** (заразные), **CC-BY-NC** (некоммерч.), **без лицензии** (= all rights reserved), + чужие игры через game-savers/декомпиляторы (нарушение DMCA/ToS). +- ⚠️ «Uncopylocked» = только разрешение копировать в Studio, НЕ передача прав. +- ⚠️ Официальные шаблоны Studio — учиться ОК, публиковать «как есть» — серая зона. + +**Рекомендация для наполнения Рублокса легально:** +1. Геометрия под чистую публикацию → Kenney/OpenGameArt CC0. +2. Классика Roblox-стиля → Roblox/Old-Open-Source-Levels (проверить LICENSE) + Crossroads. +3. Полная игра с кодом → Vigilant (MIT). +4. Масса .rbxl для теста парсера → архивы из (Б) + GitHub topics. diff --git a/RUBLOX_LUA_API.md b/RUBLOX_LUA_API.md new file mode 100644 index 0000000..84c95dc --- /dev/null +++ b/RUBLOX_LUA_API.md @@ -0,0 +1,504 @@ +# Lua API Рублокса (справочник для скриптеров) + +Этот документ — полный список того, что работает в Lua-скриптах Рублокса. +API максимально приближен к Roblox, чтобы можно было переносить чужие +скрипты с минимальными правками. + +> **Как переключить скрипт на Lua:** в шапке вкладки редактора кода кликни +> по переключателю **JS / Lua**. Подсветка синтаксиса и автодополнение +> автоматически переключатся. + +--- + +## Содержание + +1. [Базовые типы](#базовые-типы) +2. [DataModel: game, workspace, Players](#datamodel) +3. [Part — куб на сцене](#part) +4. [Создание и удаление](#создание-и-удаление) +5. [События: Touched, Heartbeat, RemoteEvent](#события) +6. [Таймеры: task.wait, task.delay](#таймеры) +7. [GUI: TextLabel, TextButton, Frame](#gui) +8. [Звук: Sound](#звук) +9. [Анимации: TweenService](#tweenservice) +10. [Игрок: Humanoid, LocalPlayer](#игрок) +11. [Чего пока нет](#чего-пока-нет) + +--- + +## Базовые типы + +### `Vector3` + +```lua +local v = Vector3.new(1, 2, 3) +print(v.X, v.Y, v.Z) -- 1 2 3 +print(v.Magnitude) -- 3.7416... (длина) +print(v.Unit) -- нормализованный +print(v:Dot(otherVec)) -- скалярное произведение +print(v:Cross(otherVec)) -- векторное произведение +local mid = v:Lerp(otherVec, 0.5) -- линейная интерполяция + +-- Константы: +Vector3.zero -- (0,0,0) +Vector3.one -- (1,1,1) +Vector3.xAxis -- (1,0,0) +Vector3.yAxis, Vector3.zAxis +``` + +Поддержаны операторы: `+`, `-`, `*` (на число), `/`, унарный `-`. + +### `Color3` + +```lua +local c = Color3.new(0.5, 0.2, 0.8) -- 0..1 каждый +local c2 = Color3.fromRGB(255, 128, 0) -- 0..255 +local c3 = Color3.fromHSV(0.1, 0.8, 1) +local c4 = Color3.fromHex("#FF8000") +local mid = c:Lerp(c2, 0.5) +print(c:ToHex()) -- "#7F33CC" +``` + +### `UDim2` / `UDim` / `Vector2` + +Для GUI-координат: + +```lua +local pos = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана (scale/offset) +local pos2 = UDim2.fromScale(0.2, 0.1) +local pos3 = UDim2.fromOffset(100, 50) -- в пикселях +``` + +### `CFrame` + +```lua +local cf = CFrame.new(0, 10, 0) -- позиция +local cf2 = CFrame.lookAt(eye, target) -- упрощённый +print(cf.Position) -- Vector3 +``` + +### `Enum` + +```lua +Enum.KeyCode.W +Enum.KeyCode.Space +Enum.Material.Plastic, Enum.Material.Neon, Enum.Material.Wood +Enum.UserInputType.MouseButton1 +Enum.HumanoidStateType.Running +``` + +--- + +## DataModel + +Виртуальное дерево, как в Roblox: + +```lua +game -- корневой DataModel +game.Workspace -- = workspace (короче) +game.Players -- сервис игроков +game.Players.LocalPlayer -- локальный игрок +game.ReplicatedStorage -- хранилище общих ресурсов +game.StarterGui -- стартовое GUI +game.Lighting -- свет +``` + +Методы: + +```lua +local svc = game:GetService("RunService") +local part = workspace:FindFirstChild("Coin") +local part2 = workspace:FindFirstChildOfClass("Part") +local all = workspace:GetChildren() -- массив всех детей +local descendants = workspace:GetDescendants() +local sib = workspace.Coin:FindFirstAncestorOfClass("Workspace") +print(workspace:IsA("Workspace")) -- true +``` + +--- + +## Part + +`Part` — куб/сфера/цилиндр на сцене. **Это обёртка над примитивом Рублокса.** +Скрипт привязанный к кубу получает его через `script.Parent`: + +```lua +-- script.Parent — Part к которому прицеплен скрипт +print(script.Parent.Name) -- "Part_1" + +-- Чтение свойств +print(script.Parent.Position) -- Vector3 +print(script.Parent.Size) -- Vector3 +print(script.Parent.Color) -- Color3 +print(script.Parent.Anchored) -- bool +print(script.Parent.CanCollide) -- bool +print(script.Parent.Transparency) -- 0..1 + +-- Запись (двигает куб в реальном времени!) +script.Parent.Position = Vector3.new(0, 10, 0) +script.Parent.Size = Vector3.new(5, 1, 5) +script.Parent.Color = Color3.fromRGB(255, 0, 0) +script.Parent.Anchored = false -- куб начнёт падать (физика) +script.Parent.Transparency = 0.5 -- полупрозрачный +script.Parent.CFrame = CFrame.new(0, 20, 0) +``` + +--- + +## Создание и удаление + +### `Instance.new` + +```lua +-- Создать Part на сцене +local p = Instance.new("Part") +p.Position = Vector3.new(0, 5, 0) +p.Size = Vector3.new(2, 2, 2) +p.Color = Color3.fromRGB(255, 100, 0) +p.Anchored = true +p.Parent = workspace + +-- Удалить через 3 секунды +task.delay(3, function() + p:Destroy() +end) +``` + +Поддержанные классы: +- **Сцена:** `Part`, `WedgePart`, `MeshPart` +- **События:** `RemoteEvent`, `BindableEvent` +- **GUI:** `ScreenGui`, `Frame`, `TextLabel`, `TextButton`, `ImageLabel`, + `ImageButton`, `TextBox`, `ScrollingFrame` +- **Звук:** `Sound` +- **Прочее:** `Folder`, `Humanoid`, `Configuration`, любой `ClassName` + +--- + +## События + +### `script.Parent.Touched` — касание игрока + +```lua +script.Parent.Touched:Connect(function(hit) + print("Игрок коснулся!", hit.Name) + local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid") + if h then + h:TakeDamage(100) -- KillBrick + end +end) +``` + +### `RunService.Heartbeat` — каждый кадр + +```lua +local RunService = game:GetService("RunService") +RunService.Heartbeat:Connect(function(dt) + -- dt — время с прошлого кадра (~0.016) + script.Parent.Position = script.Parent.Position + Vector3.new(0, 0.1, 0) +end) +``` + +### `BindableEvent` / `RemoteEvent` — общение между скриптами + +```lua +-- Скрипт A создаёт событие в общем месте +local event = Instance.new("BindableEvent") +event.Name = "MyEvent" +event.Parent = game.ReplicatedStorage + +-- Скрипт B подписывается +local event = game.ReplicatedStorage:WaitForChild("MyEvent") +event.Event:Connect(function(msg, num) + print("Получено:", msg, num) +end) + +-- Скрипт A триггерит +event:Fire("привет", 42) +``` + +### `Humanoid.Died` + +```lua +local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid") +h.Died:Connect(function() + print("игрок умер") +end) +h.HealthChanged:Connect(function(newHp) + print("здоровье:", newHp) +end) +``` + +--- + +## Таймеры + +### `task.wait(сек)` — приостановить скрипт + +```lua +print("сейчас") +task.wait(1) +print("через секунду") +``` + +`task.wait` **не блокирует** другие скрипты — это yield через coroutines. +Можно использовать в `while true do ... task.wait(0.1) end` без проблем. + +### `task.delay(сек, fn)` — выполнить через + +```lua +task.delay(2, function() + print("через 2 секунды") +end) +``` + +### `task.spawn(fn)` — асинхронно + +```lua +task.spawn(function() + print("параллельно с основным потоком") +end) +``` + +--- + +## GUI + +### Базовая иерархия + +```lua +-- ScreenGui — корень всех GUI +local sg = Instance.new("ScreenGui") +sg.Parent = game.Players.LocalPlayer.PlayerGui + +-- TextLabel — статичный текст +local label = Instance.new("TextLabel") +label.Parent = sg +label.Text = "Привет!" +label.TextColor3 = Color3.fromRGB(255, 255, 0) +label.BackgroundColor3 = Color3.fromRGB(50, 30, 20) +label.Position = UDim2.new(0.4, 0, 0.1, 0) -- 40% от ширины, 10% от высоты +label.Size = UDim2.new(0.2, 0, 0.05, 0) +label.TextSize = 24 + +-- TextButton — кликабельная кнопка +local btn = Instance.new("TextButton") +btn.Parent = sg +btn.Text = "Нажми" +btn.Position = UDim2.new(0.4, 0, 0.5, 0) +btn.Size = UDim2.new(0.2, 0, 0.08, 0) +btn.MouseButton1Click:Connect(function() + print("Клик!") + label.Text = "Нажата!" +end) +``` + +### Свойства + +| Свойство | Тип | Описание | +|----------------------|-----------|-----------------------------------| +| `Text` | string | Видимый текст | +| `TextColor3` | Color3 | Цвет текста | +| `TextSize` | number | Размер шрифта | +| `BackgroundColor3` | Color3 | Цвет фона | +| `BackgroundTransparency` | 0..1 | 0=сплошной, 1=прозрачный | +| `Position` | UDim2 | Позиция (scale=%, offset=px/10) | +| `Size` | UDim2 | Размер | +| `Visible` | bool | Виден или нет | + +### События кнопок + +```lua +btn.MouseButton1Click:Connect(fn) -- ЛКМ клик +btn.MouseEnter:Connect(fn) -- наведение +btn.MouseLeave:Connect(fn) -- увод +btn.Activated:Connect(fn) -- = MouseButton1Click +``` + +--- + +## Звук + +```lua +local sound = Instance.new("Sound") +sound.SoundId = "coin" -- или "jump", "win", "lose", "hit", "click", "pickup" +sound.Volume = 1 -- 0..2 +sound.PlaybackSpeed = 1 -- pitch +sound:Play() +``` + +Также Roblox-AssetID работает с эвристикой: + +```lua +sound.SoundId = "rbxassetid://1234567890" -- автоподбор по имени переменной +``` + +Поддержанные звуки (процедурные, не из файлов): +- `jump` — прыжок +- `pickup` — подбор +- `coin` — звон монеты +- `win` — победа +- `lose` — поражение +- `click` — клик +- `hit` — удар + +Зацикливание: + +```lua +sound.Looped = true +sound:Play() -- играет до sound:Stop() +``` + +--- + +## TweenService + +Плавная анимация свойств: + +```lua +local TweenService = game:GetService("TweenService") + +local part = script.Parent +local tween = TweenService:Create( + part, + { Time = 2 }, -- длительность 2 сек + { Position = Vector3.new(0, 20, 0), + Color = Color3.fromRGB(255, 0, 0) } -- цели +) +tween:Play() + +tween.Completed:Connect(function() + print("Анимация завершилась!") +end) +``` + +Работает с `Position`, `Size`, `Color` (Vector3/Color3) и числовыми +свойствами (`Transparency`, `TextSize`, и т.д.). + +--- + +## Игрок + +### `game.Players.LocalPlayer` + +```lua +local plr = game.Players.LocalPlayer +print(plr.Name, plr.UserId, plr.DisplayName) +print(plr.Character) -- Model +``` + +### `Humanoid` + +```lua +local char = game.Players.LocalPlayer.Character +local h = char:FindFirstChildOfClass("Humanoid") + +print(h.Health, h.MaxHealth) +print(h.WalkSpeed) -- скорость ходьбы +print(h.JumpPower) -- сила прыжка + +h.Health = 0 -- мгновенная смерть → респавн +h:TakeDamage(50) -- урон с учётом invulnerability + +h.Died:Connect(function() + print("Помер") +end) +h.HealthChanged:Connect(function(newHp) + if newHp < 30 then + print("Здоровье низкое!") + end +end) +``` + +### `HumanoidRootPart` + +```lua +local hrp = char:FindFirstChild("HumanoidRootPart") +print(hrp.Position) +``` + +--- + +## Чего пока нет + +Не работает (пока): + +- **Скрипты не делятся на Server/LocalScript** — все скрипты client-side. +- **DataStoreService** — методы есть, но возвращают nil/no-op. +- **`workspace:Raycast`** / **`game.Lighting.ClockTime`** — заглушки. +- **`Players.PlayerAdded`** — никогда не фейерится (только один игрок). +- **3D-анимации (`Animation` instance + `AnimationController`)** — + `LoadAnimation` возвращает заглушку. +- **`Sound` из файлов** — только встроенные процедурные. +- **`SurfaceGui` / `BillboardGui`** — нет, только `ScreenGui`. +- **`Model:MoveTo` / `:SetPrimaryPartCFrame`** — нет. +- **Networking (`RemoteFunction:InvokeServer`)** — RemoteEvent работает + только в пределах одного клиента. + +Если что-то из этого критично — открой issue в репо. + +--- + +## Пример: KillBrick + монета + GUI-счётчик + +Положи 1 куб и 1 шарик на сцене. К каждому привяжи скрипт: + +**На кубе (KillBrick):** +```lua +script.Parent.Color = Color3.fromRGB(200, 30, 30) +script.Parent.Touched:Connect(function() + local h = game.Players.LocalPlayer.Character:FindFirstChildOfClass("Humanoid") + if h then h:TakeDamage(100) end +end) +``` + +**На шарике (Coin):** +```lua +script.Parent.Color = Color3.fromRGB(255, 215, 0) +script.Parent.Touched:Connect(function() + -- Запускаем событие на ReplicatedStorage + local re = game.ReplicatedStorage:FindFirstChild("CoinPicked") + if not re then + re = Instance.new("BindableEvent") + re.Name = "CoinPicked" + re.Parent = game.ReplicatedStorage + end + re:Fire() + script.Parent:Destroy() +end) +``` + +**Глобальный скрипт (GUI):** +```lua +local sg = Instance.new("ScreenGui") +sg.Parent = game.Players.LocalPlayer.PlayerGui + +local label = Instance.new("TextLabel") +label.Parent = sg +label.Text = "Монет: 0" +label.Position = UDim2.new(0.05, 0, 0.05, 0) +label.Size = UDim2.new(0.1, 0, 0.05, 0) +label.TextSize = 20 +label.TextColor3 = Color3.fromRGB(255, 215, 0) + +local count = 0 +task.spawn(function() + while not game.ReplicatedStorage:FindFirstChild("CoinPicked") do + task.wait(0.1) + end + game.ReplicatedStorage.CoinPicked.Event:Connect(function() + count = count + 1 + label.Text = "Монет: " .. count + local sound = Instance.new("Sound") + sound.SoundId = "coin" + sound:Play() + end) +end) +``` + +Получится: красный куб убивает, золотая монета даёт +1 к счётчику со +звуком. + +--- + +**Версия документации:** Этап 7 (готово после реализации Этапов 1-6). +Если что-то описанное здесь не работает — это баг, репортуй. diff --git a/RUBLOX_LUA_API_CHANGELOG.md b/RUBLOX_LUA_API_CHANGELOG.md new file mode 100644 index 0000000..16f8e36 --- /dev/null +++ b/RUBLOX_LUA_API_CHANGELOG.md @@ -0,0 +1,148 @@ +# Lua API — журнал изменений + +Файл фиксирует **что было добавлено в Lua-runtime** при работе с реальными +Roblox-играми. Цель — потом продублировать тот же API для **JS-движка** +(на будущее, сейчас работаем только с Lua). + +Формат: дата + что и почему + куда добавлено + надо ли портировать в JS. + +--- + +## 2026-06-08 — Итерация 1: RayGun (проект 2792, 9 скриптов) + +**Контекст:** Roblox-Tool пушка-стрелялка, использует Tool-API, Lighting, +Mouse, Welds, BodyForce, BrickColor, IntValue для leaderboard. + +### Добавлено в `RobloxShim.js` + +**Глобалы:** +- `BrickColor.new("Bright red")` + ~25 цветов (White, Black, Bright red/blue/green, + Pink, Brown, Reddish brown, Cyan, Magenta и др.). Возвращает `{Color, Name, R, G, B}`. +- `Ray.new(origin, direction)` — для raycast (заглушка структуры). +- `Region3.new(min, max)` — куб (заглушка). +- `TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime)` +- `NumberSequence`, `ColorSequence`, `NumberRange`, `Rect` — конструкторы-стабы. + +**Enum расширения:** InfoType, SortOrder, FillDirection, Font, +TextXAlignment/TextYAlignment, ScaleType, AspectType, PartType, SurfaceType, +ContextActionResult, UserInputState, BorderMode, FormFactor. + +**`game` методы:** +- `game:service(name)` (lowercase alias на GetService) — старый Roblox API. +- `game.GetServiceFromName` = alias. +- `game.JobId/PlaceId/GameId/CreatorId/CreatorType` — stub fields. + +**Lighting:** +- `Brightness`, `ClockTime`, `TimeOfDay`, `OutdoorAmbient`, `FogStart/End/Color`. +- `GetMinutesAfterMidnight()`, `SetMinutesAfterMidnight(m)`. +- `GetMoonDirection()`, `GetSunDirection()`. + +**Players:** +- `GetPlayers()`, `GetPlayerFromCharacter(char)`, `playerFromCharacter` alias. +- `PlayerAdded`, `PlayerRemoving`, `ChildAdded` signals. + +**Instance.new новые типы:** +- `Tool` / `HopperBin` — Equipped/Unequipped/Activated/Deactivated signals, + GripForward/Right/Up/Pos, CanBeDropped, RequiresHandle, ToolTip. +- `IntValue` / `NumberValue` / `BoolValue` / `StringValue` / `ObjectValue` / + `CFrameValue` / `Vector3Value` / `Color3Value` / `BrickColorValue` / `RayValue` + — `.Value` + `.Changed` сигнал. +- `BodyForce` / `BodyVelocity` / `BodyPosition` / `BodyGyro` / `BodyAngularVelocity` + / `BodyThrust` — `.force`, `.Velocity`, `.MaxForce`, `.P/.D`. +- `Weld` / `WeldConstraint` / `Motor6D` / `Snap` / `HingeConstraint` / + `BallSocketConstraint` / `RopeConstraint` / `SpringConstraint` — Part0/Part1/C0/C1/Enabled. +- `Sparkles` / `ParticleEmitter` / `Smoke` / `Fire` / `Trail` / `Beam` / + `PointLight` / `SurfaceLight` / `SpotLight` — Enabled/Color/Rate/Lifetime/Brightness/Range. +- `Mouse` — Button1Down/Up, Button2Down/Up, Move, KeyDown/Up, WheelForward/Backward, + Icon, Hit (Position), Target, TargetSurface, X/Y, ViewSizeX/Y. + +### Исправлено + +- `rbx_wait(sec)`: минимум 0.016с (1 кадр). `while true do wait() end` без + аргумента раньше делал tight loop без yield → WASM stack overflow + ("memory access out of bounds"). + +- **Уважаем `enabled: false`** в Roblox-метадате. Roblox-скрипты с + `Disabled = true` — это шаблоны для клонирования (`script.Clean:Clone()`), + не должны запускаться при старте. `parseRobloxLuaMeta()` парсит JSON + из второй строки packed-кода, при `enabled=false` скрипт идёт в `rbxlSkipped`. + +### Tool/Backpack/Mouse flow (Шаг 1) + +Контекст: Roblox-Tool это объект который попадает в Backpack игрока, +при экипировке (клавиша 1-9) фейерит Tool.Equipped с настоящим Mouse, +скрипты внутри Tool слушают MouseButton1Down/KeyDown. + +**В `RobloxShim.js`:** +- `localPlayer.Backpack` — инвентарь. +- `localPlayer:GetMouse()` → playerMouse с Button1Down/KeyDown/Hit.Position. +- Внутренний `allTools[]` registry + `equippedTool` слот. +- `Instance.new('Tool')` теперь: + - создаёт виртуальный `Handle` (Part внутри Tool); + - регистрирует в `allTools[]`; + - шлёт `toolRegistered {index, name}` в GameRuntime. +- `fireGlobalEvent` обрабатывает: `equipTool`, `unequipTool`, + `toolActivated`, `toolDeactivated`, `mouseButton1Down`/`Up`, `keyDown`/`Up`. +- `__rbxl_get_tool_by_name(name)` — для script.Parent резолва. + +**В `LuaSharedSandbox.js`:** +- `addScript(id, code, target, name, {toolName})` — расширенная сигнатура. +- В `_startSingleScript` если есть `toolName` — `script.Parent` = виртуальный Tool. + +**В `GameRuntime.js`:** +- Эвристика: скрипты с `target=null` и содержащие + `(script.Parent|Tool).(Equipped|Unequipped|Activated|Deactivated)` → + получают `toolName='Tool'`, группируются в один общий Tool. +- `_registerRbxlTool(payload)` — кладёт item в InventoryUI.hotbar, + слушает `slot` event → шлёт `equipTool`/`unequipTool`. +- `canvas.mousedown` → `mouseButton1Down` + `toolActivated` с raycast Hit. +- `_raycastFromCamera()` — простой ray из камеры на 50 unit вперёд. + +**Надо ли в JS?** ✅ Да — Tool/Backpack/Mouse это базовый Roblox-game-loop. + +### Импорт изменений в converter.py (не задеплоено) + +Файл изменён локально, но importer на VM 130 — не обновлён. Когда придёт +время деплоя, ключевые правки: +- `_collect_tool(inst)` — собирает `scene['tools'][]` из Tool/HopperBin; +- `_find_ancestor_tool(inst)` — определяет в каком Tool лежит Script; +- В `_convert_script` добавлено поле `tool_id` в метадату. + +Это уберёт необходимость эвристики на стороне studio. + +### Надо ли портировать в JS-движок? + +✅ **Да, всё** — это базовый Roblox-совместимый API, который должен работать +независимо от языка скриптов. + +**JS-эквивалент будет такой же структурой:** +- `BrickColor.new("Bright red")` → `new BrickColor("Bright red")` +- `Tool` Equipped/Unequipped → JS-EventEmitter методы +- BodyForce/Weld/Sparkles → JS-классы с теми же полями +- Mouse — глобальный объект `game.mouse` или через `player:GetMouse()`. + +--- + +## Куда добавляется API + +| Источник | Файл | Что туда идёт | +|----------|------|---------------| +| Глобалы (Vector3, Color3, BrickColor, Enum) | `RobloxShim.js` через `global.set` | Конструкторы, Enum-таблицы | +| Instance.new типы | `RobloxShim.js` в ветке `global.set('Instance', {new: ...})` | Tool, BodyForce, Weld, Sparkles и т.д. | +| Сервисы | `RobloxShim.js` через `makeService(name)` | Lighting, Players, RunService и т.д. | +| Wait/Task | `RobloxShim.js` в Lua-prelude (`lua.doStringSync`) | rbx_wait, task.wait | +| Setter Part-свойств | `newPart()` через `Object.defineProperty` | Position, Color, Anchored шлют partSet | +| Команды от Lua к Babylon | `rbxl-lua-integration.js` `handleLuaCommand` | partSet, sceneCreate, sceneDelete | + +--- + +## Принципы расширения API + +1. **No-op > Падение.** Лучше пустой stub-метод чем `nil error`. +2. **Сигналы (`Connect`/`Fire`) всегда есть на любом объекте.** +3. **Coloncall совместимость.** Если есть `Foo.Bar`, обычно делаем и `Foo:Bar` + (lowercase) как alias. +4. **При добавлении нового Instance-типа** — давай ему **все типичные поля** + сразу, не только те что нужны прямо сейчас (Equipped + Unequipped + Activated + вместе, даже если скрипт юзает только Equipped). +5. **Логировать сюда после каждой итерации** — что было добавлено и из какой игры. 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/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc new file mode 100644 index 0000000..6f7af91 Binary files /dev/null and b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc new file mode 100644 index 0000000..946eaaf Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc new file mode 100644 index 0000000..6d971cf Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc new file mode 100644 index 0000000..44b6b88 Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc differ diff --git a/rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc b/rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc new file mode 100644 index 0000000..c20812b Binary files /dev/null and b/rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc differ diff --git a/rbxl-importer/src/app.py b/rbxl-importer/src/app.py index 00a113c..b9788db 100644 --- a/rbxl-importer/src/app.py +++ b/rbxl-importer/src/app.py @@ -122,12 +122,23 @@ def analyze(): blob = upload.read() if len(blob) > MAX_RBXL_SIZE: return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413 - if not blob.startswith(b'... + stripped = blob.lstrip() + is_binary = stripped.startswith(b' и содержит дерево .... + +Возвращает тот же `RobloxModel` что и rbxl_parser.parse — чтобы converter.py +работал без изменений. + +Пример входного файла: + + + + Workspace + + + + + 0100 + 1...1 + + 412 + 4286611584 + 21 + + + + + +Поддерживает все типичные property-теги: string, bool, int, float, double, +token, Vector3, Vector2, CoordinateFrame, Color3, Color3uint8, BrickColor, +Content, ProtectedString, Ref, BinaryString, UDim, UDim2, Rect2D. +""" +from __future__ import annotations +from typing import Dict, List, Any, Optional, Tuple +import xml.etree.ElementTree as ET +import re +import base64 +import struct + +from rbxl_parser import Instance, RobloxModel +from rbxl_types import ( + Vector3, Vector2, Color3, CFrame, BrickColor, + EnumValue, PhysicalProperties, OptionalCFrame, +) + + +# Magic для XML-формата +XML_MAGIC = b' bool: + """Проверяет XML это или нет. Бинарный начинается с str: + """Текст элемента (None → default).""" + return (el.text if el.text is not None else default).strip() + + +def _f(el: ET.Element, default: float = 0.0) -> float: + """Float из text.""" + try: + return float(_text(el, '0')) + except (ValueError, TypeError): + return default + + +def _i(el: ET.Element, default: int = 0) -> int: + """Int из text.""" + try: + s = _text(el, '0') + # Roblox иногда пишет '1.0' где ожидается int + return int(float(s)) if '.' in s else int(s) + except (ValueError, TypeError): + return default + + +def _parse_vector3(el: ET.Element) -> Vector3: + x = _f(el.find('X')) + y = _f(el.find('Y')) + z = _f(el.find('Z')) + return Vector3(x, y, z) + + +def _parse_vector2(el: ET.Element) -> Vector2: + x = _f(el.find('X')) + y = _f(el.find('Y')) + return Vector2(x, y) + + +def _parse_cframe(el: ET.Element) -> CFrame: + """CoordinateFrame: 3 позиции + 9 элементов матрицы ротации.""" + pos = Vector3(_f(el.find('X')), _f(el.find('Y')), _f(el.find('Z'))) + matrix = tuple(_f(el.find(f'R{i}{j}'), 1.0 if i == j else 0.0) + for i in range(3) for j in range(3)) + return CFrame(position=pos, matrix=matrix) + + +def _parse_color3(el: ET.Element) -> Color3: + """.........""" + r = _f(el.find('R')) + g = _f(el.find('G')) + b = _f(el.find('B')) + return Color3(r, g, b) + + +def _parse_color3uint8(el: ET.Element) -> Color3: + """4286611584 — packed RGB как uint32.""" + val = _i(el, 0) + # uint32 = 0xFFRRGGBB (alpha=FF). r=byte2, g=byte1, b=byte0 + b = (val & 0xff) / 255.0 + g = ((val >> 8) & 0xff) / 255.0 + r = ((val >> 16) & 0xff) / 255.0 + return Color3(r, g, b) + + +def _parse_property(prop_el: ET.Element) -> Tuple[str, Any]: + """Парсит один <тип name="имя">значение. Возвращает (name, value).""" + tag = prop_el.tag + name = prop_el.attrib.get('name', '') + + if tag == 'string' or tag == 'ProtectedString' or tag == 'Content': + return name, _text(prop_el) + + if tag == 'bool': + return name, _text(prop_el).lower() == 'true' + + if tag in ('int', 'int64'): + val = _i(prop_el) + # В старом XML цвет хранится как 21, + # а converter ожидает BrickColor-объект с .code. + if name == 'BrickColor': + return name, BrickColor(code=val) + return name, val + + if tag in ('float', 'double'): + return name, _f(prop_el) + + if tag == 'token': + # token — int-значение enum + return name, EnumValue(value=_i(prop_el)) + + if tag == 'Vector3': + return name, _parse_vector3(prop_el) + + if tag == 'Vector2': + return name, _parse_vector2(prop_el) + + if tag == 'CoordinateFrame': + return name, _parse_cframe(prop_el) + + if tag == 'Color3': + return name, _parse_color3(prop_el) + + if tag == 'Color3uint8': + return name, _parse_color3uint8(prop_el) + + if tag == 'BrickColor': + return name, BrickColor(code=_i(prop_el)) + + if tag == 'Ref': + # Ссылка на другой Item по referent (например "RBX42" или "null") + txt = _text(prop_el, 'null') + if txt in ('null', 'nil', ''): + return name, None + return name, txt # храним как строку-referent + + if tag == 'BinaryString': + # base64 → bytes + try: + return name, base64.b64decode(_text(prop_el)) + except Exception: + return name, b'' + + if tag == 'UDim': + scale = _f(prop_el.find('S')) + offset = _i(prop_el.find('O')) + return name, {'scale': scale, 'offset': offset} + + if tag == 'UDim2': + xs = _f(prop_el.find('XS')) + xo = _i(prop_el.find('XO')) + ys = _f(prop_el.find('YS')) + yo = _i(prop_el.find('YO')) + return name, {'x_scale': xs, 'x_offset': xo, 'y_scale': ys, 'y_offset': yo} + + if tag == 'Rect2D': + # min/max + min_el = prop_el.find('min') + max_el = prop_el.find('max') + return name, { + 'min': _parse_vector2(min_el) if min_el is not None else Vector2(0, 0), + 'max': _parse_vector2(max_el) if max_el is not None else Vector2(0, 0), + } + + if tag == 'OptionalCoordinateFrame': + cf_el = prop_el.find('CFrame') + return name, OptionalCFrame(cframe=_parse_cframe(cf_el)) if cf_el is not None else OptionalCFrame(cframe=None) + + if tag == 'PhysicalProperties': + cust = prop_el.find('CustomPhysics') + custom = cust is not None and _text(cust).lower() == 'true' + return name, PhysicalProperties( + custom_physics=custom, + density=_f(prop_el.find('Density'), 0.7), + friction=_f(prop_el.find('Friction'), 0.3), + elasticity=_f(prop_el.find('Elasticity'), 0.5), + friction_weight=_f(prop_el.find('FrictionWeight'), 1.0), + elasticity_weight=_f(prop_el.find('ElasticityWeight'), 1.0), + ) + + if tag == 'NumberRange': + return name, {'min': _f(prop_el.find('Min')), 'max': _f(prop_el.find('Max'))} + + # SharedString / Uri / другие незнакомые — оставляем как текст + return name, _text(prop_el) + + +# Регекс для извлечения referent из строк типа "RBX42" +_REF_RE = re.compile(r'^RBX(\d+)$') + + +def _ref_to_int(ref: Optional[str]) -> Optional[int]: + """RBX42 → 42, null → None. Если уникальной номер не найден — None.""" + if ref is None or ref == 'null': + return None + m = _REF_RE.match(str(ref)) + if m: + return int(m.group(1)) + return None + + +def parse_xml(blob: bytes) -> RobloxModel: + """Главный entry: bytes → RobloxModel.""" + try: + text = blob.decode('utf-8', errors='replace') + except Exception: + text = blob.decode('latin-1', errors='replace') + + # XML может иметь BOM или leading whitespace + text = text.lstrip('').lstrip() + + root = ET.fromstring(text) + + instances: List[Instance] = [] + by_referent: Dict[int, Instance] = {} + roots: List[Instance] = [] + + # Auto-increment id для Item'ов без referent (старые форматы) + next_id_counter = [100000] + + def _walk(item_el: ET.Element, parent_ref: Optional[int]) -> None: + """Рекурсивный обход элементов.""" + cls = item_el.attrib.get('class', 'Unknown') + + # Referent из атрибута (например referent="RBX42") + ref_attr = item_el.attrib.get('referent') or item_el.attrib.get('Referent') + ref_int = _ref_to_int(ref_attr) if ref_attr else None + if ref_int is None: + # Назначаем auto-id чтобы converter мог отслеживать parent_referent + ref_int = next_id_counter[0] + next_id_counter[0] += 1 + + # Парсим properties + props: Dict[str, Any] = {} + props_el = item_el.find('Properties') + if props_el is not None: + for prop_el in props_el: + try: + pname, pval = _parse_property(prop_el) + if pname: + props[pname] = pval + except Exception: + continue + + # Roblox в старых картах использовал имена с маленькой первой буквы: + # name → Name, size → Size, shape → Shape, и т.д. Converter ожидает + # PascalCase. Делаем алиасы (старое имя остаётся, новое добавляется). + _ALIAS_TO_PASCAL = { + 'name': 'Name', + 'size': 'Size', + 'shape': 'Shape', + 'archivable': 'Archivable', + 'shape3d': 'Shape', + } + for old, new in _ALIAS_TO_PASCAL.items(): + if old in props and new not in props: + props[new] = props[old] + + # Convert Ref-properties (string "RBX42") в parent_referent если нужно + # — пока оставляем как строки. + + inst = Instance( + referent=ref_int, + class_name=cls, + properties=props, + parent_referent=parent_ref, + children=[], + ) + instances.append(inst) + by_referent[ref_int] = inst + if parent_ref is None: + roots.append(inst) + + # Рекурсивно дочерние Item'ы + for child in item_el.findall('Item'): + _walk(child, ref_int) + + # Roblox-XML: top-level идут под + for item in root.findall('Item'): + _walk(item, None) + + # Заполняем children после полного прохода (для удобства converter'а) + for inst in instances: + if inst.parent_referent is not None: + parent = by_referent.get(inst.parent_referent) + if parent is not None: + parent.children.append(inst) + + # Версия из атрибута + version_attr = root.attrib.get('version', '4') + try: + version = int(version_attr) + except ValueError: + version = 4 + + return RobloxModel( + version=version, + class_count=len(set(i.class_name for i in instances)), + instance_count=len(instances), + instances=instances, + by_referent=by_referent, + roots=roots, + shared_strings=[], + meta={}, + warnings=[], + ) diff --git a/src/editor/ConfirmModal.jsx b/src/editor/ConfirmModal.jsx new file mode 100644 index 0000000..e1e12b3 --- /dev/null +++ b/src/editor/ConfirmModal.jsx @@ -0,0 +1,193 @@ +/** + * ConfirmModal — кастомная модалка подтверждения вместо window.confirm. + * + * Использование: + * const [confirmState, setConfirmState] = useState(null); + * ... + * setConfirmState({ + * title: 'Сменить язык?', + * message: '...', + * confirmLabel: 'Сменить', + * cancelLabel: 'Отмена', + * onConfirm: () => doSomething(), + * }); + * ... + * {confirmState && setConfirmState(null)} />} + * + * Стиль — тёмная тема Рублокс-студии, кнопка confirm заметная. + */ +import React, { useEffect, useRef } from 'react'; + +export default function ConfirmModal({ + title, + message, + confirmLabel = 'OK', + cancelLabel = 'Отмена', + confirmTone = 'primary', // 'primary' | 'danger' + onConfirm, + onClose, +}) { + const confirmBtnRef = useRef(null); + + useEffect(() => { + // Автофокус на кнопке подтверждения + const t = setTimeout(() => confirmBtnRef.current?.focus(), 50); + const onKey = (e) => { + if (e.key === 'Escape') { e.preventDefault(); onClose?.(); } + else if (e.key === 'Enter') { + // Enter — confirm только если кнопка в фокусе или ничего не в фокусе + if (document.activeElement === confirmBtnRef.current || document.activeElement?.tagName === 'BODY') { + e.preventDefault(); + handleConfirm(); + } + } + }; + window.addEventListener('keydown', onKey); + return () => { clearTimeout(t); window.removeEventListener('keydown', onKey); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleConfirm = () => { + try { onConfirm?.(); } finally { onClose?.(); } + }; + + return ( +
+ +
e.stopPropagation()} + style={{ + background: 'linear-gradient(180deg, #2a2a2e 0%, #1f1f22 100%)', + border: '1px solid #3a3a40', + borderRadius: 14, + padding: '22px 26px 18px', + minWidth: 380, + maxWidth: 480, + color: '#e8e8ea', + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.04)', + animation: 'rbxConfirmPopIn 160ms cubic-bezier(0.34, 1.56, 0.64, 1)', + }} + > + {title && ( +
+ {title} +
+ )} + {message && ( +
+ {message} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index 531aaed..a7c23d1 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,39 @@ 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); + // Lua — либо явно language='lua', либо импортированный .rbxl-скрипт + // (хранится с language='js' в БД но фактически Lua-код внутри обёртки). + const isRbxlImported = typeof script.code === 'string' + && script.code.startsWith('// @roblox-lua'); + const isLua = script.language === 'lua' || isRbxlImported; + 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..ce52d5d 100644 --- a/src/editor/ScriptEditor.jsx +++ b/src/editor/ScriptEditor.jsx @@ -7,6 +7,8 @@ import Icon from './Icon'; // при правке одного файла не перетряхивать все остальные. import { GAME_TYPE_LIBS } from './engine/types/bundle'; import { registerSnippets } from './engine/snippets'; +import { registerLuaInMonaco } from './lua-monaco-setup'; +import ConfirmModal from './ConfirmModal'; /** * ScriptEditor — Monaco-редактор кода скрипта в табе. @@ -34,7 +36,44 @@ 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'; + // Кастомная модалка подтверждения смены языка (вместо window.confirm) + const [confirmState, setConfirmState] = useState(null); // Локальный буфер кода — то что в редакторе сейчас. // Синхронизируется с external value только при смене scriptId. const [localCode, setLocalCode] = useState(value || ''); @@ -162,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); @@ -282,6 +324,72 @@ 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 +502,11 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
+ {confirmState && ( + setConfirmState(null)} + /> + )} ); } diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index e1feeeb..175c430 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -3036,24 +3036,36 @@ export class BabylonScene { const EPS = 0.25; // 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 +3173,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 +5387,7 @@ export class BabylonScene { code: s.code, name: s.name || null, target: newTarget, + language: s.language || 'js', }); } if (srcScripts.length > 0) { @@ -5506,7 +5530,7 @@ export class BabylonScene { }; clip.scripts = (this._scripts || []) .filter(s => matchTarget(s.target)) - .map(s => ({ code: s.code, name: s.name || null })); + .map(s => ({ code: s.code, name: s.name || null, language: s.language || 'js' })); } catch (e) { clip.scripts = []; } try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); } catch (e) { /* ignore — приватный режим / переполнение */ } @@ -5521,7 +5545,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 +6701,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 +6709,7 @@ export class BabylonScene { code, ...(target !== undefined ? { target } : {}), ...(name !== undefined ? { name } : {}), + ...(language !== undefined ? { language } : {}), }; } else { this._scripts.push({ @@ -6692,6 +6717,7 @@ export class BabylonScene { code, target: target !== undefined ? target : null, name: name || null, + language: language || 'js', }); } // Скрипты — часть сцены: фиксируем в истории, иначе undo откатит @@ -7736,6 +7762,7 @@ export class BabylonScene { code: s.code, target: s.target || null, name: s.name || null, + language: s.language === 'lua' ? 'lua' : 'js', })), }, editorCamera: this.camera ? { @@ -8193,6 +8220,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..6a0ae5a 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -19,7 +19,8 @@ import { ScriptSandbox } from './ScriptSandbox'; import { STORYS_addres } from '../../api/API'; import { PhysicsWorld } from './PhysicsWorld'; import { LabelManager } from './LabelManager'; -import { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js'; +import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js'; +import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js'; export class GameRuntime { constructor(scene3d) { @@ -115,11 +116,49 @@ export class GameRuntime { try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } // Roblox-Lua скрипты собираем для single-VM режима: один shared Worker // на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит. - const rbxlBatch = []; const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || []; + // Единый Lua-batch: и user-Lua (language='lua'), и импортированные .rbxl + // скрипты (с маркером // @roblox-lua) теперь идут через ОДИН LuaSharedSandbox. + // .rbxl-скрипты распаковываем из JS-комментария-обёртки в чистый Lua. + const luaUserBatch = []; + // Импортированные .rbxl-скрипты ВКЛЮЧЕНЫ — итеративно настраиваем API + // под реальные скрипты. Выключить временно: window.__RBXL_SKIP_IMPORTED=true. + const runImportedRbxl = !(typeof window !== 'undefined' && window.__RBXL_SKIP_IMPORTED === true); + let rbxlSkipped = 0; for (const s of scripts) { if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) { - rbxlBatch.push(s); + if (!runImportedRbxl) { rbxlSkipped++; continue; } + // Уважаем поле enabled=false из Roblox-метадаты: такие скрипты + // были disabled-шаблоны (для клонирования через :Clone()), их + // запуск немедленно крашит coroutine (WASM access out of bounds). + const meta = parseRobloxLuaMeta(s.code); + if (meta && meta.enabled === false) { rbxlSkipped++; continue; } + const luaSource = unpackRobloxLuaCode(s.code); + if (luaSource && luaSource.trim()) { + // Эвристика Tool: если скрипт ссылается на Equipped/Activated + // или Tool = script.Parent — он лежит в Tool. Все Tool-скрипты + // с target=null склеиваем в ОДИН виртуальный Tool, имя берём + // из самого "явного" скрипта (содержит RayGun/Sword/Gun/Weapon). + let toolName = null; + if (s.target == null && /(script\.Parent|Tool)\.(Equipped|Unequipped|Activated|Deactivated)/.test(luaSource)) { + // Все Tool-скрипты группируем в ОДИН виртуальный Tool с именем "Tool". + // Для Zapper-демки этого хватит. В будущем — парсинг StarterPack из converter. + toolName = 'Tool'; + } + luaUserBatch.push({ + id: s.id, + name: s.name, + target: s.target, + toolName, + language: 'lua', + code: luaSource, + _rbxlImported: true, + }); + } + 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()) { @@ -151,25 +190,95 @@ export class GameRuntime { // eslint-disable-next-line no-console console.log('[GameRuntime] sandbox started for script id=', s.id); } - // Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом. - let rbxlCount = 0; - if (rbxlBatch.length > 0) { - // GUI-дерево из projectData для pre-population - const guiElements = this.projectData?.scene?.gui || []; - const result = startRobloxLuaShared(rbxlBatch, { - primitives, - guiElements, - onCommand: (cmd, payload) => handleLuaCommand(null, cmd, payload, this), - }); - if (result && result.sandbox) { - this.sandboxes.push(result.sandbox); - this._rbxlSharedSandbox = result.sandbox; - rbxlCount = result.count; + // Импортированные .rbxl-скрипты теперь идут через тот же LuaSharedSandbox + // вместе с user-Lua (см. luaUserBatch выше). Отдельный Worker больше не нужен. + let luaUserCount = 0; + if (luaUserBatch.length > 0) { + try { + const sb = new LuaSharedSandbox(); + // partSet/sceneCreate — переиспользуем обработчик rbxl + sb.setOnCommand(({ cmd, payload }) => { + if (cmd === 'partSet' || cmd === 'partVel' || + cmd === 'sceneCreate' || cmd === 'sceneDelete') { + try { + handleLuaCommand(null, cmd, payload, this); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[GameRuntime] handleLuaCommand failed:', cmd, payload, e); + } + } else if (cmd === 'toolRegistered') { + // Lua-shim создал Tool — кладём в hotbar инвентаря. + try { this._registerRbxlTool(payload); } catch (e) { + console.warn('[GameRuntime] toolRegistered failed', e); + } + } else if (cmd === 'lightingTimeUpdate') { + // Roblox Lighting:SetMinutesAfterMidnight → Babylon небо. + // Ускоряем в 8x + меняем пресет skybox (clear/sunset/night). + try { + const baseHour = Number(payload?.hour); + if (baseHour >= 0 && baseHour < 24) { + if (this._lightBaseHour == null) { + this._lightBaseHour = baseHour; + this._lightStartReal = performance.now(); + } + const dGame = baseHour - this._lightBaseHour; + const accel = 8; + const hour = ((this._lightBaseHour + dGame * accel) % 24 + 24) % 24; + this.scene3d?.setTimeOfDay?.(hour); + // Skybox preset по фазе: + // 06-08 sunset, 08-17 clear, 17-19 sunset, 19-06 starry-night + let targetPreset; + if (hour >= 6 && hour < 8) targetPreset = 'sunset'; + else if (hour >= 8 && hour < 17) targetPreset = 'lowpoly-roblox'; + else if (hour >= 17 && hour < 19) targetPreset = 'sunset'; + else targetPreset = 'starry-night'; + if (this._lightPreset !== targetPreset) { + this._lightPreset = targetPreset; + try { + const sb = this.scene3d?.skybox; + if (sb?.fadeTo) sb.fadeTo({ preset: targetPreset }, 2); + else this.scene3d?.setSkybox?.({ preset: targetPreset }); + } catch (_) {} + } + } + } catch (_) {} + } else if (cmd === 'particleCreated') { + // Roblox Instance.new('Sparkles') — запомнили какие + // partlcle-эффекты есть у Tool. При equip покажем у руки. + this._rbxlPendingParticles = this._rbxlPendingParticles || []; + this._rbxlPendingParticles.push(payload); + } else { + this._handleCommand(null, cmd, payload); + } + }); + // Передаём snapshot ДО start чтобы Workspace.Children заполнились + try { + const snap = this._buildSceneSnapshot(); + sb.sendSceneSnapshot(snap); + } catch (_) {} + for (const s of luaUserBatch) sb.addScript(s.id, s.code, s.target, s.name, { toolName: s.toolName }); + 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}`); } } - this._log('info', `Запущено скриптов: ${this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0)}`); - if (rbxlCount > 0) { - this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`); + const rbxlImported = luaUserBatch.filter(s => s._rbxlImported).length; + const luaWritten = luaUserCount - rbxlImported; + const jsOnly = this.sandboxes.length - (this._luaUserSandbox ? 1 : 0); + this._log('info', `Запущено JS-скриптов: ${jsOnly}`); + if (rbxlImported > 0) { + this._log('info', `Запущено Roblox-Lua скриптов (импортированных): ${rbxlImported}`); + } + if (rbxlSkipped > 0) { + this._log('info', `Импортированных .rbxl-скриптов пропущено: ${rbxlSkipped} (Roblox-скрипты не поддерживаются — пиши свои Lua-скрипты под Этап 1-7 API)`); + } + if (luaWritten > 0) { + this._log('info', `Запущено Lua-скриптов юзера: ${luaWritten}`); } // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' // во все sandbox'ы. Не перезаписываем существующий обработчик — @@ -467,6 +576,137 @@ export class GameRuntime { return null; } + /** Регистрирует Roblox-Tool в InventoryUI как item в hotbar. + * Слушает смену активного слота → шлёт equipTool/unequipTool в Lua-shim. + * Слушает клики ЛКМ → шлёт mouseButton1Down (Tool.Activated fires там). */ + _registerRbxlTool(payload) { + if (!payload || payload.index == null) return; + // invUI — это новая drag-drop система с defineItem, а не inventory (старая) + const invUI = this.scene3d?.invUI; + if (!invUI || typeof invUI.defineItem !== 'function') { + console.warn('[GameRuntime] invUI not available for tool', payload); + return; + } + const itemId = `rbxlTool_${payload.index}`; + const toolName = String(payload.name || `Tool ${payload.index}`); + invUI.defineItem({ + id: itemId, + name: toolName, + emoji: '🔫', + rarity: 'uncommon', + maxStack: 1, + description: `Импортированный Roblox-Tool: ${toolName}`, + }); + // Кладём в конкретный hotbar-слот (index 1..9 → slot 0..8) + const slot = Math.max(0, Math.min(8, payload.index - 1)); + invUI.hotbar[slot] = { itemId, count: 1 }; + invUI._renderHotbar?.(); + // На первом Tool — навешиваем слушатели слотов и кликов мыши. + if (!this._rbxlToolHooks) { + this._rbxlToolHooks = true; + this._rbxlActiveSlot = -1; + // Авто-эквип первого Tool сразу при регистрации — иначе юзер + // не понимает что нажимать. В Roblox StarterPack тоже сразу + // в Backpack попадает и юзер жмёт 1 для эквипа. + setTimeout(() => { + if (this._rbxlActiveSlot < 0) { + invUI.setActiveHotbar?.(slot); + const sb = this._luaUserSandbox; + sb?.sendGlobalEvent?.({ type: 'equipTool', index: payload.index }); + this._rbxlActiveSlot = slot; + // Если у Tool были Sparkles — рисуем непрерывно у руки игрока + this._startRbxlToolParticles(); + } + }, 100); + invUI.on('slot', () => { + const sl = invUI.active; + const item = invUI.hotbar[sl]; + const sb = this._luaUserSandbox; + if (!sb) return; + if (item && item.itemId.startsWith('rbxlTool_')) { + const idx = +item.itemId.slice('rbxlTool_'.length); + sb.sendGlobalEvent?.({ type: 'equipTool', index: idx }); + this._rbxlActiveSlot = sl; + this._startRbxlToolParticles(); + } else if (this._rbxlActiveSlot >= 0) { + sb.sendGlobalEvent?.({ type: 'unequipTool' }); + this._rbxlActiveSlot = -1; + this._stopRbxlToolParticles(); + } + }); + // Клики мыши при экипированном Tool — Activated/mouseButton1Down + try { + const canvas = this.scene3d?.engine?.getRenderingCanvas?.(); + if (canvas) { + const sb = this._luaUserSandbox; + canvas.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + if (this._rbxlActiveSlot < 0) return; + // Hit-position: raycast от камеры в сцену + const hit = this._raycastFromCamera?.() || { x: 0, y: 5, z: 0 }; + sb?.sendGlobalEvent?.({ type: 'mouseButton1Down', hit }); + sb?.sendGlobalEvent?.({ type: 'toolActivated' }); + }); + canvas.addEventListener('mouseup', (e) => { + if (e.button !== 0) return; + if (this._rbxlActiveSlot < 0) return; + sb?.sendGlobalEvent?.({ type: 'mouseButton1Up' }); + }); + } + } catch (_) {} + } + } + + /** Запускает непрерывный эмиттер Sparkles у руки игрока, пока Tool экипирован. */ + _startRbxlToolParticles() { + if (this._rbxlSparkInterval) return; + const particles = this._rbxlPendingParticles || []; + if (particles.length === 0) return; + // RayGun Color3.new(0,0,1) → #0000ff. Берём цвет первой партиклы. + const p0 = particles[0] || {}; + const col = p0.color || [0, 0, 1]; + const hexCol = '#' + [col[0], col[1], col[2]].map(c => { + const v = Math.max(0, Math.min(255, Math.round((Number(c) || 0) * 255))); + return v.toString(16).padStart(2, '0'); + }).join(''); + // Каждые 200мс — короткий burst у руки игрока (приблизительно) + this._rbxlSparkInterval = setInterval(() => { + try { + const pl = this.scene3d?.player; + if (!pl || !pl._pos) return; + this.scene3d?._spawnParticleEffect?.({ + type: 'sparks', + position: { x: pl._pos.x + 0.3, y: pl._pos.y + 0.4, z: pl._pos.z + 0.3 }, + color: hexCol, + duration: 0.4, + count: 0.5, + }); + } catch (_) {} + }, 200); + } + + _stopRbxlToolParticles() { + if (this._rbxlSparkInterval) { + clearInterval(this._rbxlSparkInterval); + this._rbxlSparkInterval = null; + } + } + + /** Простой raycast от камеры — для mouse.Hit. */ + _raycastFromCamera() { + try { + const cam = this.scene3d?.scene?.activeCamera; + if (!cam) return { x: 0, y: 5, z: 0 }; + const forward = cam.getForwardRay?.()?.direction; + const pos = cam.position; + if (!pos || !forward) return { x: 0, y: 5, z: 0 }; + const t = 50; + return { x: pos.x + forward.x * t, y: pos.y + forward.y * t, z: pos.z + forward.z * t }; + } catch (_) { + return { x: 0, y: 5, z: 0 }; + } + } + stop() { if (this.sandboxes.length > 0) { this._log('info', 'Остановка скриптов'); @@ -474,6 +714,11 @@ export class GameRuntime { console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes'); for (const sb of this.sandboxes) sb.stop(); } + // Останавливаем эффекты импортированных Tools + this._stopRbxlToolParticles?.(); + this._rbxlToolHooks = false; + this._rbxlActiveSlot = -1; + this._rbxlPendingParticles = null; // Удаляем все объекты, которые скрипты наспавнили через // game.scene.spawn/clone — иначе после Stop они остаются на сцене // и накапливаются при повторных запусках. @@ -3935,6 +4180,25 @@ export class GameRuntime { } return; } + if (cmd === 'playerSet' && payload) { + // Из Lua-runtime: humanoid.Health = N → {prop:'health', value:N}. + // Используем PlayerController.takeDamage, который запускает полный + // death-flow: distance debris, _onDeath callback (respawn), звук. + // Сбрасываем _lastDamageTime чтобы invulnerability не блокировал. + const player = this.scene3d?.player; + if (!player) return; + if (payload.prop === 'health') { + const target = Math.max(0, Number(payload.value) || 0); + const damage = Math.max(0, (player.hp || 0) - target); + if (damage > 0 && typeof player.takeDamage === 'function') { + player._lastDamageTime = 0; + player.takeDamage(damage, 'lua'); + } else { + player.hp = target; + } + } + return; + } // eslint-disable-next-line no-console console.warn('[GameRuntime] unknown cmd', cmd); } @@ -4213,6 +4477,7 @@ export class GameRuntime { if (s?.primitiveManager) { for (const data of s.primitiveManager.instances.values()) { primitives.push({ + id: data.id, ref: 'primitive:' + data.id, type: data.type, x: data.x, y: data.y, z: data.z, @@ -4222,7 +4487,11 @@ export class GameRuntime { sz: data.sz != null ? data.sz : 1, rotationY: data.rotationY || 0, visible: data.visible !== false, - name: data.name || null, + name: data.name || undefined, + color: data.color || undefined, + anchored: data.anchored !== false, + canCollide: data.canCollide !== false, + opacity: data.opacity != null ? data.opacity : 1, }); } } @@ -4359,6 +4628,13 @@ export class GameRuntime { } _log(level, text, scriptId = null, scriptName = null) { + // Дублируем в DevTools Console — удобно для отладки скриптов + try { + const fn = level === 'error' ? console.error + : level === 'warn' ? console.warn + : console.log; + fn(`[script${scriptName ? ' ' + scriptName : ''}] ${text}`); + } catch (_) {} if (this._onLog) { try { this._onLog({ level, text, ts: Date.now(), scriptId, scriptName }); } catch (e) { /* ignore */ } } diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 8a50c0c..9e6a556 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -689,7 +689,16 @@ export class PrimitiveManager { const data = this.instances.get(id); if (!data) return; - // Позиция + // Позиция / поворот / размер — нужно расфризить world matrix, + // иначе freezeStaticPrimitives() сделает mesh.position.set бессмысленным. + const positionChanged = patch.x !== undefined || patch.y !== undefined || patch.z !== undefined; + const transformChanged = positionChanged + || patch.rotationX !== undefined || patch.rotationY !== undefined || patch.rotationZ !== undefined + || patch.sx !== undefined || patch.sy !== undefined || patch.sz !== undefined; + if (transformChanged && data._worldMatrixFrozen) { + try { data.mesh.unfreezeWorldMatrix?.(); } catch (_) {} + data._worldMatrixFrozen = false; + } if (patch.x !== undefined) data.x = patch.x; if (patch.y !== undefined) data.y = patch.y; if (patch.z !== undefined) data.z = patch.z; diff --git a/src/editor/engine/RobloxLuaSandbox.js b/src/editor/engine/RobloxLuaSandbox.js deleted file mode 100644 index 6a87e77..0000000 --- a/src/editor/engine/RobloxLuaSandbox.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * RobloxLuaSandbox — main-side обёртка над одним RobloxLuaWorker. - * - * Использование (по аналогии с ScriptSandbox): - * const sb = new RobloxLuaSandbox(luaSource, targetPrimitiveId); - * sb.setOnCommand((cmd, payload) => ...); - * sb.setInitialScene({primitives: {...}}); - * sb.start(); - * sb.tick(dt, sceneSnap); - * sb.fireEvent('touched', {primId, otherPrimId}); - * sb.stop(); - * - * Команды от Worker: - * { cmd: 'boot' } — Lua-VM запущена - * { cmd: 'ready' } — top-level код выполнен - * { cmd: 'log', payload: { level, text } } - * { cmd: 'partSet', payload: { primId, prop, value } } - * { cmd: 'partVel', payload: { primId, vx, vy, vz } } - * { cmd: 'playerCmd', payload: { method, args } } - * { cmd: 'tweenStart', payload: { ... } } - * { cmd: 'broadcast', payload: { msg, data } } - * { cmd: 'spawn', payload: { template, props, parentId } } - */ - -let _workerUrl = null; - -function getWorkerUrl() { - if (_workerUrl) return _workerUrl; - // Vite worker syntax — лучше через ?worker импорт; но мы можем - // динамически генерировать URL для ScriptSandboxWorker-style. - // Здесь упрощённо: загружаем worker как module через Vite ?worker&inline. - // Это будет настроено при интеграции в GameRuntime. - return null; -} - -export class RobloxLuaSandbox { - constructor(luaSource, targetPrimitiveId = null) { - this.luaSource = luaSource || ''; - this.targetPrimitiveId = targetPrimitiveId; - this.worker = null; - this._onCommand = null; - this._booted = false; - this._ready = false; - this._stopped = false; - this._pendingTicks = []; - this._pendingEvents = []; - this._initialScene = null; - } - - setOnCommand(cb) { this._onCommand = cb; } - setInitialScene(snap) { this._initialScene = snap; } - - /** - * @param {Worker} worker — экземпляр Worker'а (предоставляется снаружи, - * так как Vite требует new Worker(new URL(...)) syntax который надо - * прописать в месте импорта) - */ - start(worker) { - if (this.worker) return; - if (!worker) throw new Error('RobloxLuaSandbox.start(worker): worker is required'); - - this.worker = worker; - this.worker.onmessage = (e) => this._handle(e); - this.worker.onerror = (err) => { - this._emit('log', { level: 'error', text: `Worker error: ${err.message || err}` }); - }; - this.worker.postMessage({ - cmd: 'init', - payload: { - code: this.luaSource, - target: this.targetPrimitiveId, - sceneSnap: this._initialScene || { primitives: {} }, - }, - }); - } - - /** Передать кадр (snap сцены + dt). */ - tick(dt, sceneSnap) { - if (!this.worker) return; - if (!this._ready) { - this._pendingTicks.push({ dt, sceneSnap }); - return; - } - try { this.worker.postMessage({ cmd: 'tick', payload: { dt, sceneSnap } }); } catch (e) {} - } - - /** Передать событие. */ - fireEvent(kind, args, signalId) { - if (!this.worker) return; - if (!this._ready) { - this._pendingEvents.push({ kind, args, signalId }); - return; - } - try { this.worker.postMessage({ cmd: 'event', payload: { kind, args, signalId } }); } catch (e) {} - } - - stop() { - this._stopped = true; - try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {} - try { this.worker?.terminate(); } catch (e) {} - this.worker = null; - } - - // ── Совместимость с интерфейсом ScriptSandbox (GameRuntime ожидает эти методы) ── - // Все no-op либо мапятся на fireEvent — наш Worker сам держит state сцены. - sendSceneSnapshot(_snap) { /* no-op: ничего не делает, наш Worker не использует snapshot напрямую */ } - sendGuiSnapshot(_snap) { /* no-op */ } - sendSkinsSnapshot(_snap) { /* no-op */ } - sendInventorySnapshot(_snap) { /* no-op */ } - sendTerrainHeightmap(_payload) { /* no-op */ } - sendGlobalEvent(kind, payload) { - // Глобальные события (input, hpChange, broadcast) маршрутизируем в наш fireEvent. - try { this.fireEvent(kind, [payload]); } catch (e) {} - } - sendBroadcast(msg, data) { - try { this.fireEvent('broadcast', [msg, data]); } catch (e) {} - } - sendOnTouchEvent(payload) { - try { this.fireEvent('touched', [payload]); } catch (e) {} - } - sendOnTickEvent(dt) { - try { this.tick(dt, null); } catch (e) {} - } - sendTweenDone(payload) { - try { this.fireEvent('tweenDone', [payload]); } catch (e) {} - } - sendSpawnResolved(payload) { - try { this.fireEvent('spawnResolved', [payload]); } catch (e) {} - } - setInitialSelfPosition(_p) { /* no-op */ } - setModules(_modules) { /* no-op: rbxl Lua не использует наш game.require */ } - get scriptId() { return this._scriptId; } - set scriptId(v) { this._scriptId = v; } - - _handle(ev) { - if (this._stopped) return; - const { cmd, payload } = ev.data || {}; - if (cmd === 'boot') { - this._booted = true; - return; - } - if (cmd === 'ready') { - this._ready = true; - // флушим накопленное - for (const t of this._pendingTicks) { - try { this.worker.postMessage({ cmd: 'tick', payload: t }); } catch (e) {} - } - this._pendingTicks = []; - for (const e of this._pendingEvents) { - try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (e) {} - } - this._pendingEvents = []; - this._emit('ready', null); - return; - } - this._emit(cmd, payload); - } - - _emit(cmd, payload) { - if (this._onCommand) { - try { this._onCommand(cmd, payload); } catch (e) {} - } - } -} diff --git a/src/editor/engine/RobloxLuaSharedSandbox.js b/src/editor/engine/RobloxLuaSharedSandbox.js deleted file mode 100644 index 84cda46..0000000 --- a/src/editor/engine/RobloxLuaSharedSandbox.js +++ /dev/null @@ -1,150 +0,0 @@ -/** - * RobloxLuaSharedSandbox — main-side обёртка над одним shared Lua-worker'ом. - * - * v2 (после rewrite): - * - start(sceneSnap, guiTree, worker) → init с GUI-деревом - * - addScriptsBatch(scripts) → одной пачкой все скрипты регистрируются в VM - * - kickoff() → запускает event loop, fire'ит PlayerAdded - * - tick(dt) каждый кадр - * - fireEvent(kind, payload) — маршрутизирует в Worker.handleEvent - * - * GameRuntime пушит ОДИН экземпляр в this.sandboxes. - */ -export class RobloxLuaSharedSandbox { - constructor() { - this.worker = null; - this._onCommand = null; - this._booted = false; - this._scriptsLoaded = false; - this._stopped = false; - this._pendingTicks = []; - this._pendingEvents = []; - this._pendingScripts = null; - this._pendingKickoff = false; - this.scriptId = 'rbxl-shared'; - } - - setOnCommand(cb) { this._onCommand = cb; } - - start(sceneSnap, guiTree, worker) { - if (this.worker) return; - this.worker = worker; - this.worker.onmessage = (e) => this._handle(e); - this.worker.onerror = (err) => { - this._emit('log', { level: 'error', text: `SharedWorker error: ${err.message || err}` }); - }; - this.worker.postMessage({ cmd: 'init', payload: { sceneSnap, guiTree } }); - } - - addScriptsBatch(scripts) { - if (!this._booted) { this._pendingScripts = scripts; return; } - try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts } }); } catch (e) {} - } - - kickoff() { - if (!this._scriptsLoaded) { this._pendingKickoff = true; return; } - try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {} - } - - tick(dt) { - if (!this.worker) return; - if (!this._booted) { this._pendingTicks.push(dt); return; } - try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {} - } - - fireEvent(kind, payload) { - if (!this.worker) return; - const ev = { kind, ...(payload || {}) }; - if (!this._booted) { this._pendingEvents.push(ev); return; } - try { this.worker.postMessage({ cmd: 'event', payload: ev }); } catch (e) {} - } - - stop() { - this._stopped = true; - try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {} - try { this.worker?.terminate(); } catch (e) {} - this.worker = null; - } - - _handle(ev) { - if (this._stopped) return; - const { cmd, payload } = ev.data || {}; - if (cmd === 'boot') { - this._booted = true; - // флушим pending scripts - if (this._pendingScripts) { - try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts: this._pendingScripts } }); } catch (e) {} - this._pendingScripts = null; - } - // ticks накопленные до boot - for (const dt of this._pendingTicks) { - try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {} - } - this._pendingTicks = []; - return; - } - if (cmd === 'ready') { - this._scriptsLoaded = true; - this._emit('ready', payload); - if (this._pendingKickoff) { - try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {} - this._pendingKickoff = false; - } - // флушим pending events - for (const e of this._pendingEvents) { - try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (er) {} - } - this._pendingEvents = []; - return; - } - this._emit(cmd, payload); - } - - _emit(cmd, payload) { - if (this._onCommand) { try { this._onCommand(cmd, payload); } catch (e) {} } - } - - // ── Совместимость с ScriptSandbox (GameRuntime ожидает эти методы) ── - sendSceneSnapshot(_snap) {} - sendGuiSnapshot(_snap) {} - sendSkinsSnapshot(_snap) {} - sendInventorySnapshot(_snap) {} - sendTerrainHeightmap(_payload) {} - sendGlobalEvent(payload) { - 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; } - } - // GUI click — Rublox GuiOverlay шлёт guiClick с id - if (type === 'guiClick' && (payload.id || payload.localId)) { - this.fireEvent('guiClick', { guiId: payload.id || payload.localId }); - return; - } - // keyboard - if (type === 'keydown' || type === 'keyup') { - this.fireEvent(type, { key: payload.key }); - return; - } - // hp/death - if (type === 'hpChange' || type === 'humanoidHealth') { - this.fireEvent('humanoidHealth', { health: payload.hp || payload.health || 0 }); - return; - } - if (type === 'died' || type === 'humanoidDied') { - this.fireEvent('humanoidDied', {}); - return; - } - // default: пробрасываем как kind=type - this.fireEvent(type || 'unknown', payload); - } - sendBroadcast(msg, data) { this.fireEvent('broadcast', { msg, data }); } - sendOnTouchEvent(payload) { this.fireEvent('touched', { primId: payload?.primId, isPlayer: true }); } - sendOnTickEvent(dt) { this.tick(dt); } - sendTweenDone(payload) { this.fireEvent('tweenDone', payload); } - sendSpawnResolved(payload) { this.fireEvent('spawnResolved', payload); } - setInitialSelfPosition(_p) {} - setModules(_modules) {} -} diff --git a/src/editor/engine/RobloxLuaSharedWorker.js b/src/editor/engine/RobloxLuaSharedWorker.js deleted file mode 100644 index 60c16d6..0000000 --- a/src/editor/engine/RobloxLuaSharedWorker.js +++ /dev/null @@ -1,380 +0,0 @@ -/* eslint-disable no-restricted-globals */ -/** - * RobloxLuaSharedWorker.js — single-VM Lua-runtime для импортированных Roblox-скриптов. - * - * Архитектура v2 (после ITERATION 5-step rewrite): - * - * ФАЗА 1 (boot): создаём wasmoon-VM, регистрируем Roblox API без скриптов. - * - * ФАЗА 2 (populate): main шлёт snapshot сцены (primitives + GUI tree). - * Создаём workspace со всеми Part'ами и PlayerGui со всем GUI-деревом. - * На каждом TextButton — MouseButton1Click сигнал, на каждом Part — Touched. - * - * ФАЗА 3 (addScripts): main шлёт ВСЕ скрипты ОДНИМ батчем. Worker загружает - * их в Lua-VM как отдельные функции в pcall. Скрипты регистрируют свои - * Connect'ы (Touched, MouseButton1Click, Heartbeat, ...). top-level wait() - * yield'ится через coroutine — управление возвращается в worker. - * - * ФАЗА 4 (run): main шлёт 'startEvents'. Worker запускает scheduler-tick - * и начинает обрабатывать события (touched/guiClick/heartbeat). - * - * IPC: - * <- init { sceneSnap, guiTree } - * <- addScripts { scripts: [{id, target, luaSource}] } - * <- start - * <- tick { dt } - * <- event { kind, payload } - * <- stop - * -> boot - * -> ready - * -> log/partSet/partVel/playerCmd/broadcast/guiUpdate - */ - -import { LuaFactory } from 'wasmoon'; -import { registerRobloxApi, RbxSignal } from './roblox-shim.js'; - -const state = { - factory: null, - lua: null, - sceneSnap: { primitives: {} }, - guiTree: [], - isStopped: false, - initPromise: null, - eventsStarted: false, - pendingEvents: [], - scriptCount: 0, - coroutines: [], // активные ждущие корутины: { co, resumeAt } - nowSec: 0, - api: null, // результат registerRobloxApi: { workspace, game, part_by_id, gui_by_id, localPlayer, humanoid } -}; - -function send(cmd, payload) { - self.postMessage({ cmd, payload }); -} - -function log(level, text) { - send('log', { level, text }); -} - -const scheduler = { - now: () => state.nowSec, - schedule: (sec, fn) => { - state.coroutines.push({ resumeAt: state.nowSec + (sec || 0), fn }); - }, - spawn: (fn) => { - // spawn — fn запускается асинхронно (на следующем tick'е) - state.coroutines.push({ resumeAt: state.nowSec, fn }); - }, -}; - -self.addEventListener('message', async (ev) => { - const { cmd, payload } = ev.data || {}; - try { - if (cmd === 'init') await handleInit(payload); - else if (cmd === 'addScripts') await handleAddScripts(payload); - else if (cmd === 'start') handleStart(); - else if (cmd === 'tick') handleTick(payload); - else if (cmd === 'event') { - if (!state.eventsStarted) state.pendingEvents.push(payload); - else handleEvent(payload); - } - else if (cmd === 'stop') { - state.isStopped = true; - try { state.lua?.global?.close?.(); } catch (e) {} - } - } catch (err) { - log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`); - } -}); - -async function handleInit({ sceneSnap, guiTree }) { - if (state.initPromise) { await state.initPromise; return; } - state.initPromise = (async () => { - state.sceneSnap = sceneSnap || { primitives: {} }; - state.guiTree = guiTree || []; - state.factory = new LuaFactory(); - state.lua = await state.factory.createEngine({ - injectObjects: true, - enableProxy: true, - traceAllocations: false, - }); - state.api = registerRobloxApi(state.lua, { - getSceneSnap: () => state.sceneSnap, - getGuiTree: () => state.guiTree, - targetPrimitiveId: null, - send, - scheduler, - }); - // Передаём part_by_id в Lua как table {id → Instance} - // ВНИМАНИЕ: lua_table принимает string keys, ключи кладём как строки. - try { - const m = state.api?.part_by_id; - if (m) { - const obj = {}; - for (const [id, part] of m) obj[String(id)] = part; - state.lua.global.set('__rbxl_parts_by_id', obj); - } - } catch (e) {} - // null-stub builder: возвращает Instance-like объект который безопасно - // отвечает на .Parent, .Name, .WaitForChild и т.п. чтобы цепочки - // script.Parent.Parent.X не валили. - const makeNullStub = () => { - const stub = { - Name: 'NullStub', - ClassName: 'Nil', - Children: [], - __isNullStub: true, - }; - // Parent — самоссылающийся nullStub - stub.Parent = stub; - stub.FindFirstChild = () => stub; - stub.FindFirstChildOfClass = () => stub; - stub.FindFirstAncestor = () => stub; - stub.FindFirstAncestorOfClass = () => stub; - stub.WaitForChild = () => stub; - stub.GetChildren = () => []; - stub.GetDescendants = () => []; - stub.IsA = () => false; - stub.Clone = () => makeNullStub(); - stub.Destroy = () => {}; - stub.GetService = () => stub; - // Сигналы — пустой connector - const nullSig = { - Connect: () => ({ Disconnect: () => {}, Connected: false }), - Wait: () => null, - Fire: () => {}, - }; - // Любой каpitalized property — сигнал-stub - return new Proxy(stub, { - get(t, k) { - if (k in t) return t[k]; - if (typeof k === 'string' && /^[A-Z]/.test(k)) return nullSig; - return undefined; - }, - set(t, k, v) { t[k] = v; return true; }, - }); - }; - state.lua.global.set('__rbxl_make_null_stub', makeNullStub); - // ВАЖНО: создаём nullStub НА СТОРОНЕ LUA как настоящую table с - // metatable __index возвращающей сам stub. Это позволит цепочкам - // .Parent.X.Y:WaitForChild():Connect() корректно работать и обе - // нотации (. и :) сработают. - await state.lua.doString(` - __null_stub_mt = {} - function __make_null_stub() - local t = setmetatable({ - Name = "Nil", - ClassName = "Nil", - __isNullStub = true, - Visible = false, - Enabled = false, - Value = 0, - Text = "", - }, __null_stub_mt) - return t - end - __null_stub_singleton = __make_null_stub() - -- nullSignal с обоими Connect/connect: - local function null_sig_method() return { Disconnect = function() end, disconnect = function() end, Connected = false } end - __null_signal = setmetatable({ - Connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end, - connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end, - Wait = function() return nil end, - wait = function() return nil end, - Fire = function() end, - fire = function() end, - }, { __index = function() return function() return __null_stub_singleton end end }) - -- Любой index nullStub'а → возвращает либо null_signal (для уже известных - -- сигнальных имён) либо noop-функцию которая возвращает сам stub. - __null_stub_mt.__index = function(t, k) - -- известные сигнал-имена - local sig_names = {Touched=true,TouchEnded=true,Changed=true,Activated=true, - MouseButton1Click=true,MouseButton1Down=true,MouseButton1Up=true, - MouseEnter=true,MouseLeave=true,InputBegan=true,InputEnded=true, - PlayerAdded=true,PlayerRemoving=true,CharacterAdded=true,CharacterRemoving=true, - Heartbeat=true,Stepped=true,RenderStepped=true,Died=true,HealthChanged=true, - FocusLost=true,Focused=true,ChildAdded=true,ChildRemoved=true, - AncestryChanged=true,DescendantAdded=true,DescendantRemoving=true} - if sig_names[k] then return __null_signal end - -- любой метод → функция которая возвращает stub (поддерживает оба синтаксиса) - return function(...) return __null_stub_singleton end - end - __null_stub_mt.__newindex = function(t, k, v) rawset(t, k, v) end - __null_stub_mt.__call = function(t, ...) return __null_stub_singleton end - -- Сделаем __null_stub_singleton.Parent = сам себя (lazy) - rawset(__null_stub_singleton, "Parent", __null_stub_singleton) - `); - // Заменяем __rbxl_make_null_stub на Lua-side функцию - await state.lua.doString(` - function __rbxl_make_null_stub() return __null_stub_singleton end - `); - // КРИТИЧНО: расширенные metatable для nil + function + number чтобы - // любые цепочки nil.x.y:method() и func.x не валили скрипты. - await state.lua.doString(` - if debug and debug.setmetatable then - local _stub_mt = { - __index = function(t, k) return __null_stub_singleton end, - __newindex = function(t, k, v) end, - __call = function(t, ...) return __null_stub_singleton end, - __add = function(a, b) return 0 end, - __sub = function(a, b) return 0 end, - __mul = function(a, b) return 0 end, - __div = function(a, b) return 0 end, - __mod = function(a, b) return 0 end, - __pow = function(a, b) return 0 end, - __unm = function() return 0 end, - __concat = function(a, b) return "" end, - __len = function() return 0 end, - __eq = function(a, b) return false end, - __lt = function(a, b) return false end, - __le = function(a, b) return false end, - __tostring = function() return "nil" end, - } - debug.setmetatable(nil, _stub_mt) - debug.setmetatable(function() end, _stub_mt) - -- НЕ ставим на number/string/boolean — они должны работать нормально - end - `); - // helper: безопасный pcall с warn'ом при ошибке - await state.lua.doString(` - __rbxl_scripts = {} - function __rbxl_safe_run(id, fn) - local ok, err = pcall(fn) - if not ok then warn("[rbxl-lua " .. tostring(id) .. " err] " .. tostring(err)) end - end - -- Lookup Part по primitiveId. Используем __rbxl_parts_by_id из JS, - -- т.к. ipairs() на JS array не работает (0-indexed vs Lua 1-indexed). - function __rbxl_lookup_part(id) - if __rbxl_parts_by_id then - return __rbxl_parts_by_id[tostring(id)] or __rbxl_parts_by_id[id] - end - return nil - end - `); - send('boot', null); - })(); - await state.initPromise; -} - -async function handleAddScripts({ scripts }) { - if (!state.lua) { log('error', 'addScripts before init'); return; } - let ok = 0, fail = 0; - for (const s of scripts) { - const safeId = String(s.id).replace(/[^a-zA-Z0-9_]/g, '_'); - const targetExpr = s.target != null - ? `__rbxl_lookup_part(${JSON.stringify(s.target)}) or __rbxl_make_null_stub()` - : '__rbxl_make_null_stub()'; - // Оборачиваем в pcall. script — локальный, не конфликтует между скриптами. - // script.Parent НИКОГДА не nil — даём nullStub чтобы цепочки - // script.Parent.Parent.X не валили. - const wrapped = ` - do - local script = setmetatable({ - Name = "Script_${safeId}", - Parent = ${targetExpr}, - ClassName = "LocalScript", - }, { __index = function(t, k) return rawget(t, k) end }) - __rbxl_safe_run("${safeId}", function() - ${s.luaSource} - end) - end - `; - try { - await state.lua.doString(wrapped); - ok++; - } catch (e) { - fail++; - // ошибки парсинга/runtime, считаем но не валим всё - } - } - state.scriptCount = ok; - send('ready', { ok, fail }); -} - -function handleStart() { - state.eventsStarted = true; - // Эмитим Players.PlayerAdded + CharacterAdded чтобы скрипты которые - // делают game.Players.PlayerAdded:Connect(...) получили событие. - try { - const lp = state.api?.localPlayer; - const players = state.api?.services?.get('Players'); - if (lp && players?.PlayerAdded?.Fire) players.PlayerAdded.Fire(lp); - if (lp?.CharacterAdded?.Fire && lp.Character) lp.CharacterAdded.Fire(lp.Character); - } catch (e) {} - // Флушим накопленные события - for (const e of state.pendingEvents) handleEvent(e); - state.pendingEvents = []; -} - -function handleTick({ dt }) { - if (state.isStopped || !state.lua) return; - state.nowSec += dt || 0; - // Резолвим планированные корутины - if (state.coroutines.length > 0) { - const due = []; - const left = []; - for (const c of state.coroutines) { - if (c.resumeAt <= state.nowSec) due.push(c); else left.push(c); - } - state.coroutines = left; - for (const c of due) { - try { c.fn(); } catch (e) { log('warn', `coroutine err: ${e?.message || e}`); } - } - } - // RunService сигналы - try { - const rs = state.api?.services?.get('RunService'); - if (rs?.Heartbeat?.Fire) rs.Heartbeat.Fire(dt); - if (rs?.Stepped?.Fire) rs.Stepped.Fire(state.nowSec, dt); - if (rs?.RenderStepped?.Fire) rs.RenderStepped.Fire(dt); - } catch (e) {} -} - -function handleEvent(payload) { - if (state.isStopped || !state.lua || !state.api) return; - const { kind } = payload || {}; - try { - if (kind === 'guiClick' || kind === 'guiActivated') { - const guiId = payload.guiId; - const inst = state.api.gui_by_id?.get(guiId); - if (inst) { - if (kind === 'guiActivated') inst.Activated?.Fire?.(1); - else inst.MouseButton1Click?.Fire?.(); - } - } else if (kind === 'touched') { - const primId = payload.primId; - const part = state.api.part_by_id?.get(primId); - 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); - } - } else if (kind === 'keydown' || kind === 'keyup') { - // UserInputService.InputBegan/Ended - const uis = state.api.services?.get('UserInputService') || - (() => { - const s = new (state.lua.global.get('Instance')?.new ? Object : Object)(); - return null; - })(); - if (uis) { - if (kind === 'keydown') uis.InputBegan?.Fire?.({ KeyCode: { Name: payload.key } }); - else uis.InputEnded?.Fire?.({ KeyCode: { Name: payload.key } }); - } - } else if (kind === 'humanoidDied') { - state.api.humanoid?.Died?.Fire?.(); - } else if (kind === 'humanoidHealth') { - const h = state.api.humanoid; - if (h) { - h.Health = payload.health; - h.HealthChanged?.Fire?.(payload.health); - } - } - } catch (e) { - log('warn', `event ${kind} err: ${e?.message || e}`); - } -} - -self.__rbxlSharedState = state; diff --git a/src/editor/engine/RobloxLuaWorker.js b/src/editor/engine/RobloxLuaWorker.js deleted file mode 100644 index c58b6f7..0000000 --- a/src/editor/engine/RobloxLuaWorker.js +++ /dev/null @@ -1,180 +0,0 @@ -/* eslint-disable no-restricted-globals */ -/** - * RobloxLuaWorker.js — Web Worker, хостящий Lua 5.4 VM (wasmoon) для исполнения - * Roblox-Lua скриптов импортированных через rbxl-importer. - * - * Запускается из RobloxLuaSandbox.js (main thread). - * - * IPC (с main): - * <- init { code: string, target?: id, shim: 'full'|'mini', sceneSnap: object } - * <- tick { dt, sceneSnap } — каждый кадр - * <- event { kind: 'touched'|'changed'|..., args } — события сцены - * -> boot нет payload — Worker запустился, Lua-VM ready - * -> ready нет payload — top-level lua код исполнен - * -> log { level, text } - * -> partSet { primId, prop, value } — изменение свойства Part'а - * -> partVel { primId, vx, vy, vz } - * -> playerCmd { method, args } — методы game.player (teleport, damage, walkSpeed) - * -> tweenStart{ targetId, prop, from, to, durationSec, easing } - * -> broadcast { msg, data } — RemoteEvent аналог - * -> spawn { template, props, parentId } — Instance.new() - * - * Lua-runtime архитектура: - * - wasmoon = Lua 5.4 в WebAssembly, ~500KB, ~5x быстрее fengari. - * - Lua-VM глобалы: game, workspace, script, task, wait, print, warn, error. - * - Все Roblox-классы — JS-объекты-прокси (см. roblox-shim.js, регистрируемые - * через factory.setProxy). - * - * Безопасность: - * - Worker изолирован от DOM. - * - Memory limit ~50 MB на VM (через wasmoon options). - * - На каждые N=10000 инструкций Lua hook → возможность отменить (TODO). - * - * Воркер ДЕРЖИТ И ОБНОВЛЯЕТ snapshot сцены (зеркало того что в Babylon-сцене), - * чтобы Lua-код мог читать Position/Color без round-trip к main thread. - * Обновление от main: cmd='tick' с дельтой сцены. - * - * Это первый MVP-вариант. Полный shim API регистрируется в фазе 4.3-4.13. - */ - -import { LuaFactory } from 'wasmoon'; -import { registerRobloxApi } from './roblox-shim.js'; - -/** - * Worker-side state. Один Worker = один скрипт. - */ -const state = { - factory: null, - lua: null, - target: null, // id примитива к которому привязан script.Parent - sceneSnap: { primitives: {} },// зеркало - isStopped: false, - pendingEvents: [], // события до init - signals: new Map(), // signalId → [callbacks] - nextSignalId: 1, -}; - -/* ──────── IPC helpers ──────── */ - -function send(cmd, payload) { - self.postMessage({ cmd, payload }); -} - -function log(level, text) { - send('log', { level, text }); -} - -/* ──────── Worker entrypoint ──────── */ - -self.addEventListener('message', async (ev) => { - const { cmd, payload } = ev.data || {}; - try { - if (cmd === 'init') { - await handleInit(payload); - } else if (cmd === 'tick') { - handleTick(payload); - } else if (cmd === 'event') { - handleEvent(payload); - } else if (cmd === 'stop') { - state.isStopped = true; - try { state.lua?.global?.close?.(); } catch (e) {} - } - } catch (err) { - log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`); - } -}); - -async function handleInit({ code, target, sceneSnap }) { - state.target = target; - state.sceneSnap = sceneSnap || { primitives: {} }; - - state.factory = new LuaFactory(); - state.lua = await state.factory.createEngine({ - injectObjects: true, - enableProxy: true, - traceAllocations: false, - }); - - // Регистрируем Roblox API. - registerRobloxApi(state.lua, { - getSceneSnap: () => state.sceneSnap, - targetPrimitiveId: state.target, - send, - registerSignal: (callback) => { - const id = state.nextSignalId++; - const list = state.signals.get(id) || []; - list.push(callback); - state.signals.set(id, list); - return id; - }, - }); - - send('boot', null); - - try { - // Оборачиваем в pcall + ловим errors. Roblox-карты часто делают - // game.Players.LocalPlayer:WaitForChild("PlayerGui") который у нас - // даёт null — top-level код падает на первой такой строке. - // pcall ловит и даёт сигналам/Touched зарегистрироваться там где смогли. - const wrapped = ` - local _ok, _err = pcall(function() - ${code} - end) - if not _ok then - warn("[rbxl-lua partial fail] " .. tostring(_err)) - end - `; - await state.lua.doString(wrapped); - send('ready', null); - } catch (e) { - log('error', `Lua error: ${e && e.message ? e.message : e}`); - send('ready', null); - } - - // После ready доставляем events которые накопились - for (const ev of state.pendingEvents) handleEvent(ev); - state.pendingEvents = []; -} - -function handleTick({ dt, sceneSnap }) { - if (state.isStopped || !state.lua) return; - if (sceneSnap) state.sceneSnap = sceneSnap; - // Heartbeat — для всех подписанных - fireSignalByName('Heartbeat', [dt]); - // Stepped (старая API) — тоже даём - fireSignalByName('Stepped', [dt]); - // RenderStepped — отдельно (на клиенте между physics и render) - fireSignalByName('RenderStepped', [dt]); -} - -function handleEvent({ kind, args, signalId }) { - if (!state.lua) { - state.pendingEvents.push({ kind, args, signalId }); - return; - } - if (signalId != null) { - const list = state.signals.get(signalId) || []; - for (const cb of list) { - try { cb(...(args || [])); } catch (e) { log('error', `signal callback error: ${e}`); } - } - } else { - fireSignalByName(kind, args || []); - } -} - -function fireSignalByName(name, args) { - // namedSignals регистрируются в roblox-shim как сильные строки - // (например 'Heartbeat'). Все callback'и под этим именем в signals. - // Без отдельной мапы — ищем линейно. - for (const [id, list] of state.signals.entries()) { - if (list.__name === name) { - for (const cb of list) { - try { cb(...args); } catch (e) { log('error', `${name} cb error: ${e}`); } - } - } - } -} - -/* ──────── Helper export для тестов ──────── */ - -self.__rbxlState = state; diff --git a/src/editor/engine/StudioCollab.js b/src/editor/engine/StudioCollab.js index ccb4c74..f9f6166 100644 --- a/src/editor/engine/StudioCollab.js +++ b/src/editor/engine/StudioCollab.js @@ -170,8 +170,8 @@ export class StudioCollab { sc.__collabScriptsPatched = true; if (typeof sc.upsertScript === 'function') { const origUpsert = sc.upsertScript.bind(sc); - sc.upsertScript = function (id, code, target, name) { - const r = origUpsert(id, code, target, name); + sc.upsertScript = function (id, code, target, name, language) { + const r = origUpsert(id, code, target, name, language); if (!self._applyingRemote) { // id может быть сгенерён внутри upsertScript, если был null — // достаём фактический из _scripts (последний с этим code). @@ -188,6 +188,7 @@ export class StudioCollab { code: rec.code, target: rec.target ?? null, name: rec.name ?? null, + language: rec.language ?? 'js', }); } } @@ -433,7 +434,7 @@ export function applyRemoteOp(scene, op) { // Создание/редактирование скрипта у соавтора. _applyingRemote уже // выставлен (см. _applyRemoteOp) → обёртка upsertScript не зашлёт // эхо обратно. _onSceneChange внутри обновит React-панели. - scene.upsertScript?.(op.id, op.code, op.target ?? null, op.name ?? null); + scene.upsertScript?.(op.id, op.code, op.target ?? null, op.name ?? null, op.language ?? undefined); scene._onCollabScriptsChange?.(); return; case 'scriptRemove': diff --git a/src/editor/engine/lua/LuaSharedSandbox.js b/src/editor/engine/lua/LuaSharedSandbox.js new file mode 100644 index 0000000..6446408 --- /dev/null +++ b/src/editor/engine/lua/LuaSharedSandbox.js @@ -0,0 +1,298 @@ +/** + * LuaSharedSandbox (v3, main-thread) — wasmoon-VM работает в MAIN потоке, + * без Web Worker. Это позволяет: + * - Видеть точные Lua-ошибки в DevTools (через console.error) + * - Использовать debugger / breakpoints прямо в RobloxShim.js + * - Не возиться с молчаливыми Worker-падениями + * + * Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style + * скриптов это нестрашно — они быстрые. + * + * API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent / + * sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot / + * sendTerrainHeightmap / stop / tick / target. + * + * Что добавлено сверх ScriptSandbox: + * - addScript(id, code, target) — добавить скрипт в общий VM. Можно + * до или после start(). + * - start() — асинхронен (createEngine), но возвращает сразу. После init + * стартует main loop (Heartbeat + scheduler). + */ + +import { LuaFactory } from 'wasmoon'; +import { registerRobloxShim } from './RobloxShim.js'; + +export class LuaSharedSandbox { + constructor() { + this.vm = null; + this.api = null; + this._onCommand = null; + this._isReady = false; + this._isStopped = false; + this._isKickedOff = false; + this._pendingScripts = []; // [{id, code, target, name}] + this._scriptsById = new Map(); + this._scenes = null; + this._guiTree = null; + this._loopHandle = null; + this._lastTickAt = 0; + } + + setOnCommand(cb) { this._onCommand = cb; } + + get target() { return null; } + tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ } + + addScript(id, code, target, name, extra) { + const entry = { + id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`), + code: String(code || ''), + target: target == null ? null : target, + name: name || null, + toolName: extra?.toolName || null, + }; + this._scriptsById.set(entry.id, entry); + if (!this._isKickedOff) { + this._pendingScripts.push(entry); + } else { + this._startSingleScript(entry); + } + } + + removeScript(id) { + this._scriptsById.delete(String(id)); + } + + /** Стартует VM, регистрирует shim, запускает main-loop. */ + start() { + if (this.vm || this._isStopped) return; + // eslint-disable-next-line no-console + console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...'); + this._initAsync().catch((err) => { + // eslint-disable-next-line no-console + console.error('[LuaSharedSandbox] FATAL init error:', err); + this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` }); + }); + } + + async _initAsync() { + const factory = new LuaFactory(); + this.vm = await factory.createEngine({ openStandardLibs: true }); + // eslint-disable-next-line no-console + console.log('[LuaSharedSandbox] VM created. Registering Roblox shim...'); + + // Адаптер для shim'а: он ожидает send(cmd, payload), getSceneSnapshot, getGuiTree, scheduleWait. + const send = (cmd, payload) => this._emit(cmd, payload); + + this.api = registerRobloxShim(this.vm, { + send, + getSceneSnapshot: () => this._scenes, + getGuiTree: () => this._guiTree, + scheduleWait: () => null, + }); + + // eslint-disable-next-line no-console + console.log('[LuaSharedSandbox] Shim registered. api keys:', Object.keys(this.api || {})); + + // Применим snapshot если он есть + if (this._scenes && this.api?.onSceneSnapshot) { + try { this.api.onSceneSnapshot(this._scenes); } catch (e) { + console.error('[LuaSharedSandbox] onSceneSnapshot:', e); + } + } + + this._isReady = true; + this._kickoff(); + } + + _kickoff() { + if (this._isKickedOff || this._isStopped) return; + this._isKickedOff = true; + // eslint-disable-next-line no-console + console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`); + const pending = this._pendingScripts; + this._pendingScripts = []; + // Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines. + this._lastTickAt = performance.now(); + this._startMainLoop(); + // Init батчами по 20 с yield между ними, чтобы UI не подвисал на 700+ скриптах. + const BATCH_SIZE = 20; + let idx = 0; + const initBatch = () => { + if (this._isStopped) return; + const end = Math.min(idx + BATCH_SIZE, pending.length); + for (let i = idx; i < end; i++) { + try { this._startSingleScript(pending[i]); } + catch (e) { + // eslint-disable-next-line no-console + console.error('[LuaSharedSandbox] init batch err:', e); + } + } + idx = end; + if (idx < pending.length) { + setTimeout(initBatch, 0); + } else { + // eslint-disable-next-line no-console + console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`); + } + }; + setTimeout(initBatch, 0); + } + + _startSingleScript(entry) { + if (!this.vm || !entry || typeof entry.code !== 'string') return; + let primId = null; + if (typeof entry.target === 'number') primId = entry.target; + else if (entry.target && typeof entry.target === 'object') { + if (entry.target.kind === 'primitive') primId = entry.target.id ?? entry.target.ref; + } + const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_'); + const scriptName = entry.name || `Script_${safeId}`; + // Скрипт оборачиваем в coroutine — это позволяет task.wait через yield. + // Резюмим coroutine из main-loop когда наступило время. + // Регистрируем coroutine в __rbxl_coroutines с id для возобновления. + // Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает + // delay из resume → планируем следующий resume через scheduleResume. + // Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) — + // подсовываем виртуальный Tool как script.Parent. Иначе primitive по id, + // иначе workspace. + let parentExpr; + if (entry.toolName) { + // Tool создаётся в shim как Instance.new('Tool'). По имени достаём. + // Если не нашли — fallback на новый Tool того же имени. + const safeName = JSON.stringify(entry.toolName); + parentExpr = `(function() + local existing = __rbxl_get_tool_by_name(${safeName}) + if existing then return existing end + local t = Instance.new("Tool") + t.Name = ${safeName} + return t + end)()`; + } else if (primId != null) { + parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`; + } else { + parentExpr = 'workspace'; + } + const wrapped = ` + do + local script = { + Name = ${JSON.stringify(scriptName)}, + Parent = ${parentExpr}, + ClassName = "Script", + Disabled = false, + Source = nil, + } + local co = coroutine.create(function() + -- pcall защищает от runtime-ошибок которые иначе крашат + -- coroutine и могут повредить WASM-стейт. Возвраты + -- handler'а намеренно поглощаются. + local ok_, err_ = pcall(function() + ${entry.code} + end) + if not ok_ then + __rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err_)) + end + end) + __rbxl_register_coroutine(${JSON.stringify(entry.id)}, co) + local ok, ret = coroutine.resume(co) + if not ok then + __rbxl_send_error(${JSON.stringify(entry.id)}, tostring(ret)) + __rbxl_unregister_coroutine(${JSON.stringify(entry.id)}) + elseif type(ret) == 'number' then + -- скрипт yield'нул с delay (через task.wait) — планируем resume + __rbxl_schedule_resume(${JSON.stringify(entry.id)}, ret) + elseif coroutine.status(co) == 'dead' then + __rbxl_unregister_coroutine(${JSON.stringify(entry.id)}) + end + end + `; + try { + this.vm.doStringSync(wrapped); + // eslint-disable-next-line no-console + console.log(`[LuaSharedSandbox] script ${entry.id} initialized OK`); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[LuaSharedSandbox] script ${entry.id} init FAILED:`, err); + this._emit('log', { level: 'error', text: `[Lua ${entry.id}] ${err?.message || err}` }); + } + } + + _startMainLoop() { + const tick = () => { + if (this._isStopped) return; + try { + const now = performance.now(); + const dt = Math.min(0.1, (now - this._lastTickAt) / 1000); + this._lastTickAt = now; + if (this.api?.tickScheduler) this.api.tickScheduler(dt); + if (this.api?.fireHeartbeat) this.api.fireHeartbeat(dt); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[LuaSharedSandbox tick]', e); + } + this._loopHandle = setTimeout(tick, 16); + }; + this._loopHandle = setTimeout(tick, 16); + } + + _emit(cmd, payload) { + if (typeof this._onCommand === 'function') { + try { this._onCommand({ cmd, payload }); } catch (_) {} + } + } + + // ----- API совместимый с ScriptSandbox ----- + sendEvent(payload) { + if (!this.api?.fireTargetEvent || !this._isReady) return; + try { this.api.fireTargetEvent(payload); } catch (e) { + console.error('[LuaSharedSandbox] sendEvent:', e); + } + } + + sendGlobalEvent(payload) { + if (!this.api?.fireGlobalEvent || !this._isReady) return; + try { this.api.fireGlobalEvent(payload); } catch (e) { + console.error('[LuaSharedSandbox] sendGlobalEvent:', e); + } + } + + sendSceneSnapshot(snapshot) { + this._scenes = snapshot; + if (this.api?.onSceneSnapshot && this._isReady) { + try { this.api.onSceneSnapshot(snapshot); } catch (e) { + console.error('[LuaSharedSandbox] onSceneSnapshot:', e); + } + } + } + + sendGuiSnapshot(snapshot) { + this._guiTree = snapshot; + if (this.api?.onGuiSnapshot && this._isReady) { + try { this.api.onGuiSnapshot(snapshot); } catch (_) {} + } + } + + sendDataSnapshot(snapshot) { + if (this.api?.onDataSnapshot && this._isReady) { + try { this.api.onDataSnapshot(snapshot); } catch (_) {} + } + } + + sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ } + sendTerrainHeightmap(_) { /* no-op */ } + + stop() { + this._isStopped = true; + if (this._loopHandle) { + clearTimeout(this._loopHandle); + this._loopHandle = null; + } + if (this.vm) { + try { this.vm.global.close(); } catch (_) {} + this.vm = null; + } + this.api = null; + } +} + +export default LuaSharedSandbox; diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js new file mode 100644 index 0000000..5bfd169 --- /dev/null +++ b/src/editor/engine/lua/RobloxShim.js @@ -0,0 +1,1637 @@ +/** + * RobloxShim v3 (для main-thread sandbox) — Roblox API + DataModel. + * + * Этап 3: добавлено виртуальное дерево DataModel поверх плоской сцены. + * - game.Workspace.Children = массив RbxPart обёрток над примитивами + * - script.Parent для target-скриптов = реальный RbxPart + * - RbxPart.Touched — RbxSignal который фейерится из BabylonScene при overlap + * - RbxPart.Position/Size/Color/Anchored/CanCollide — пишутся через setProp(part, ...) + * методы, которые шлют partSet в main thread (применяется к Babylon-сцене) + * - Humanoid с Health setter → playerSet команда + * + * ВАЖНО про wasmoon: не используем Object.defineProperty getters на JS-объектах + * передаваемых в Lua — wasmoon их некорректно оборачивает (js_promise). Вместо + * этого — обычные поля, которые юзер читает напрямую. Запись свойств происходит + * через `__rbxl_part_set(part, prop, value)` — она шлёт partSet и обновляет поле. + */ + +// ---------- Scheduler (для task.delay/defer) ---------- +const SCHEDULER = { + sleeping: [], // [{wakeAt, run}] + now: () => performance.now(), +}; + +// ---------- Базовые сигналы ---------- +const HEARTBEAT_SIGNAL = makeSignal(); +const STEPPED_SIGNAL = makeSignal(); + +// Очередь handler'ов которые надо запустить на следующем tickScheduler. +// Этим мы выходим из C-boundary — wait() внутри handler'а становится +// безопасным yield в собственной coroutine, потому что handler стартует +// уже из main loop, а не из синхронного JS-callback. +const _pendingHandlerQueue = []; + +function makeSignal() { + const sig = { + __isSignal: true, + connections: [], + }; + sig.Connect = function (fn) { + if (typeof fn !== 'function') return { Disconnect() {}, disconnect() {}, Connected: false }; + sig.connections.push(fn); + const conn = { Connected: true }; + conn.Disconnect = function () { + const i = sig.connections.indexOf(fn); + if (i >= 0) sig.connections.splice(i, 1); + conn.Connected = false; + }; + conn.disconnect = conn.Disconnect; + return conn; + }; + sig.connect = sig.Connect; + sig.Fire = function (...args) { + for (const fn of [...sig.connections]) { + // Кладём в очередь, чтобы handler стартовал не в текущем + // JS-callback (откуда yield запрещён), а из tickScheduler + // в своей coroutine. Безопасно для wait() внутри. + _pendingHandlerQueue.push({ fn, args }); + } + }; + sig.fire = sig.Fire; + sig.Wait = () => undefined; + sig.wait = sig.Wait; + return sig; +} + +// ---------- Vector3 / Color3 / UDim2 / Vector2 / CFrame ---------- +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); } + get Magnitude() { return Math.hypot(this.X, this.Y, this.Z); } + get magnitude() { return Math.hypot(this.X, this.Y, this.Z); } + get Unit() { + const m = Math.hypot(this.X, this.Y, this.Z) || 1; + return new RbxVector3(this.X / m, this.Y / m, this.Z / m); + } + get unit() { return this.Unit; } + 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, + ); + } +} +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); + +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); + } + static 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, + ); + } + 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, + ); + } + toHex() { + const h = (n) => Math.round(Math.max(0, Math.min(1, n)) * 255).toString(16).padStart(2, '0'); + return '#' + h(this.R) + h(this.G) + h(this.B); + } +} + +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); } +} +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); + this.p = this.Position; + } + static new(x, y, z) { return new RbxCFrame(x || 0, y || 0, z || 0); } + static lookAt(eye, _t) { return new RbxCFrame(eye?.X || 0, eye?.Y || 0, eye?.Z || 0); } + static Angles() { return new RbxCFrame(); } + static fromEulerAnglesXYZ() { return new RbxCFrame(); } +} + +// ---------- Instance / Part ---------- +let _instanceMethods = null; +function makeInstanceMethods() { + if (_instanceMethods) return _instanceMethods; + _instanceMethods = { + GetChildren: function () { return [...(this.Children || [])]; }, + GetDescendants: function () { + const out = []; + const visit = (n) => { + for (const c of n.Children || []) { out.push(c); visit(c); } + }; + visit(this); + return out; + }, + FindFirstChild: function (name, recursive) { + for (const c of this.Children || []) { + if (c.Name === name) return c; + if (recursive) { + const f = c.FindFirstChild && c.FindFirstChild(name, true); + if (f) return f; + } + } + return undefined; + }, + FindFirstChildOfClass: function (cls) { + for (const c of this.Children || []) { + if (c.ClassName === cls) return c; + } + return undefined; + }, + FindFirstAncestor: function (name) { + let p = this.Parent; + while (p) { if (p.Name === name) return p; p = p.Parent; } + return undefined; + }, + FindFirstAncestorOfClass: function (cls) { + let p = this.Parent; + while (p) { if (p.ClassName === cls) return p; p = p.Parent; } + return undefined; + }, + WaitForChild: function (name) { + // В Roblox WaitForChild блокирует пока ребёнок не появится. У нас + // нет yield с произвольных JS-функций, поэтому возвращаем либо + // существующего ребёнка, либо ленивый stub-Folder чтобы избежать + // падений типа "attempt to index a nil value" в импортированных + // скриптах. Stub автоматически добавляется в Children. + const existing = this.FindFirstChild(name); + if (existing) return existing; + try { + const stub = newInstance('Folder', String(name)); + stub.Parent = this; + if (this.Children) this.Children.push(stub); + return stub; + } catch (_) { + return undefined; + } + }, + IsA: function (cls) { return this.ClassName === cls || cls === 'Instance'; }, + GetFullName: function () { + const parts = []; + let p = this; + while (p && p.ClassName !== 'DataModel') { + parts.unshift(p.Name); + p = p.Parent; + } + return parts.join('.'); + }, + Destroy: function () { + this.Destroyed = true; + // Если это Part с примитивом — шлём sceneDelete + if (this.__primId != null && this.__sendDestroy) { + try { this.__sendDestroy(this.__primId); } catch (_) {} + } + if (this.Parent && this.Parent.Children) { + const i = this.Parent.Children.indexOf(this); + if (i >= 0) this.Parent.Children.splice(i, 1); + this.Parent = undefined; + } + }, + Clone: function () { return undefined; }, + GetAttribute: function (n) { return (this.Attributes || {})[n]; }, + SetAttribute: function (n, v) { + if (!this.Attributes) this.Attributes = {}; + this.Attributes[n] = v; + }, + GetPropertyChangedSignal: function () { return this.Changed; }, + }; + return _instanceMethods; +} + +// Создаёт stub-signal который ничего не делает — для unknown свойств Instance +// которые скрипты пытаются использовать как сигнал (script.Parent.Selected:Connect). +function makeStubSignal() { + const sig = makeSignal(); + // Помечаем чтобы знать что это stub (для возможной отладки) + sig.__stub = true; + return sig; +} + +// Callable proxy: сам вызывается как function (ничего не делает), также имеет +// поля Connect/Disconnect и Fire/fire — то есть выглядит и как метод, и как +// сигнал, и как объект. Используется для unknown method-like свойств. +function makeStubCallable() { + const fn = function () { return undefined; }; + fn.__stub = true; + fn.Connect = function () { return { Disconnect: () => {}, disconnect: () => {}, Connected: false }; }; + fn.connect = fn.Connect; + fn.Fire = function () {}; + fn.fire = fn.Fire; + fn.Wait = function () { return undefined; }; + fn.wait = fn.Wait; + return fn; +} + +// Эвристика: какие имена свойств вероятно сигналы? +// В Roblox сигналы заканчиваются на: Changed, Added, Removed, Began, Ended, +// Clicked, Activated, Touched, Selected, Deselected, Equipped, Unequipped, и т.д. +function isProbablySignalName(prop) { + if (typeof prop !== 'string') return false; + return /^(Mouse|Touch|Input|Render|Step|Heart|Render|On|Char|Player|Selected|Deselect|Equipped|Unequipped|Activated|Click|Changed|Added|Removed|Began|Ended|Died|Spawned|Reached|Loaded|Hover)/.test(prop) + || /(Changed|Added|Removed|Began|Ended|Clicked|Activated|Touched|Died|Loaded|Hover|Connect|Event|Signal|Reached)$/.test(prop); +} + +// Универсальный object-stub: ведёт себя как сигнал, как Instance, как Tool/Folder. +// НЕ function — иначе wasmoon мапит в Lua-function и Lua-индексация `.field` +// падает с "attempt to index a function value". +function makeObjectStub(name) { + const target = { + __stubName: name || 'stub', + // Signal API + Connect() { return { Disconnect() {}, disconnect() {}, Connected: false }; }, + connect() { return this.Connect(); }, + Wait() { return undefined; }, + wait() { return undefined; }, + Fire() {}, + fire() {}, + Disconnect() {}, + disconnect() {}, + // Instance read-API + FindFirstChild() { return undefined; }, + FindFirstChildOfClass() { return undefined; }, + FindFirstAncestor() { return undefined; }, + FindFirstAncestorOfClass() { return undefined; }, + GetChildren() { return []; }, + GetDescendants() { return []; }, + IsA() { return false; }, + GetFullName() { return name || 'stub'; }, + Destroy() {}, + Clone() { return makeObjectStub(name); }, + GetAttribute() { return undefined; }, + SetAttribute() {}, + GetPropertyChangedSignal() { return makeObjectStub('Changed'); }, + // Tool/Animation/Sound — частые no-op методы + Activate() {}, Deactivate() {}, Equip() {}, Unequip() {}, + Play() {}, Stop() {}, Pause() {}, Resume() {}, + AdjustSpeed() {}, LoadAnimation() { return makeObjectStub('Animation'); }, + TakeDamage() {}, MoveTo() {}, + // Базовые поля + Parent: undefined, + Name: name || 'stub', + ClassName: 'Folder', + Children: [], + }; + target.WaitForChild = function (childName) { return makeObjectStub(childName); }; + return new Proxy(target, { + get(t, prop) { + if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) { + return t[prop]; + } + if (typeof prop !== 'string') return undefined; + if (prop === 'then' || prop === 'catch' || prop === 'finally' || + prop === 'toJSON' || prop === 'constructor' || prop === 'prototype' || + prop.startsWith('__') || prop.startsWith('Symbol')) { + return undefined; + } + const child = makeObjectStub(prop); + t[prop] = child; + return child; + }, + set(t, prop, value) { t[prop] = value; return true; }, + }); +} + +function newInstance(className, name) { + const m = makeInstanceMethods(); + const target = { + ClassName: className || 'Instance', + Name: name || className || 'Instance', + Parent: undefined, + Children: [], + Destroyed: false, + Attributes: {}, + ChildAdded: makeSignal(), + ChildRemoved: makeSignal(), + AncestryChanged: makeSignal(), + Changed: makeSignal(), + GetChildren: m.GetChildren, + GetDescendants: m.GetDescendants, + FindFirstChild: m.FindFirstChild, + FindFirstChildOfClass: m.FindFirstChildOfClass, + FindFirstAncestor: m.FindFirstAncestor, + FindFirstAncestorOfClass: m.FindFirstAncestorOfClass, + WaitForChild: m.WaitForChild, + IsA: m.IsA, + GetFullName: m.GetFullName, + Destroy: m.Destroy, + Clone: m.Clone, + GetAttribute: m.GetAttribute, + SetAttribute: m.SetAttribute, + GetPropertyChangedSignal: m.GetPropertyChangedSignal, + }; + // Proxy: для unknown свойств возвращаем stub чтобы скрипты не падали. + return new Proxy(target, { + get(t, prop) { + // Существующее свойство всегда возвращаем как есть (включая методы) + if (Object.prototype.hasOwnProperty.call(t, prop) || prop in t) { + return t[prop]; + } + // Не-строки и Symbol.* — undefined чтобы wasmoon не путался + if (typeof prop !== 'string') return undefined; + // wasmoon JS-internal ключи — undefined + if (prop === 'then' || prop === 'catch' || prop === 'finally' || + prop === 'toJSON' || prop === 'toString' || prop === 'valueOf' || + prop === 'constructor' || prop === 'prototype' || + prop.startsWith('__') || prop.startsWith('Symbol')) { + return undefined; + } + // Object-stub: ведёт себя как сигнал (Connect), как Instance + // (WaitForChild, GetChildren), как Tool (Activate). НЕ function — + // иначе Lua упадёт с "attempt to index a function value". + const stub = makeObjectStub(prop); + t[prop] = stub; + return stub; + }, + set(t, prop, value) { + t[prop] = value; + return true; + }, + has(t, prop) { + // Для in-проверок отвечаем true почти всегда (чтобы Lua не падал на + // условиях вроде if obj.SomeField then ...) + return true; + }, + }); +} + +/** + * Создать RbxPart-обёртку. Возвращает plain объект (без getter'ов) — + * запись свойств идёт через метод __SetProp, которое мы экспортируем + * глобально как `__rbxl_part_set(part, prop, value)`. + */ +function newPart(primData, sendFn) { + const p = newInstance('Part', primData.name || `Part_${primData.id}`); + p.__primId = primData.id; + p.__sendDestroy = (id) => sendFn('sceneDelete', { primId: id }); + p.Touched = makeSignal(); + p.TouchEnded = makeSignal(); + p.Material = 'Plastic'; + + // Внутренний state: реальные значения хранятся здесь, в Lua через getter/setter. + p._state = { + Position: new RbxVector3(primData.x || 0, primData.y || 0, primData.z || 0), + Size: new RbxVector3(primData.sx || 1, primData.sy || 1, primData.sz || 1), + Color: primData.color ? RbxColor3.fromHex(primData.color) : new RbxColor3(0.5, 0.5, 0.5), + Anchored: !!primData.anchored, + CanCollide: primData.canCollide !== false, + Transparency: primData.opacity != null ? (1 - primData.opacity) : 0, + }; + + // Setter'ы шлют partSet → BabylonScene.primitiveManager через handleLuaCommand. + const send = (prop, value) => { + try { sendFn('partSet', { primId: p.__primId, prop, value }); } catch (_) {} + }; + Object.defineProperty(p, 'Position', { + get() { return p._state.Position; }, + set(v) { + if (!v) return; + const nv = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0); + p._state.Position = nv; + send('position', { x: nv.X, y: nv.Y, z: nv.Z }); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Size', { + get() { return p._state.Size; }, + set(v) { + if (!v) return; + const nv = new RbxVector3(v.X || 1, v.Y || 1, v.Z || 1); + p._state.Size = nv; + send('size', { sx: nv.X, sy: nv.Y, sz: nv.Z }); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Color', { + get() { return p._state.Color; }, + set(v) { + if (!v) return; + const nv = v instanceof RbxColor3 ? v : new RbxColor3(v.R || 0, v.G || 0, v.B || 0); + p._state.Color = nv; + // handleLuaCommand ожидает строку для color + send('color', nv.toHex()); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'BrickColor', { + get() { return { Color: p._state.Color, Name: 'Custom' }; }, + set(v) { if (v && v.Color) p.Color = v.Color; }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Anchored', { + get() { return p._state.Anchored; }, + set(v) { + p._state.Anchored = !!v; + send('anchored', !!v); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'CanCollide', { + get() { return p._state.CanCollide; }, + set(v) { + p._state.CanCollide = !!v; + send('canCollide', !!v); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'Transparency', { + get() { return p._state.Transparency; }, + set(v) { + const nv = Math.max(0, Math.min(1, Number(v) || 0)); + p._state.Transparency = nv; + // handleLuaCommand ожидает number для opacity + send('opacity', 1 - nv); + }, + enumerable: true, configurable: true, + }); + Object.defineProperty(p, 'CFrame', { + get() { + const pos = p._state.Position; + return new RbxCFrame(pos.X, pos.Y, pos.Z); + }, + set(v) { + if (v && v.Position) p.Position = v.Position; + else if (v && v.X != null) p.Position = new RbxVector3(v.X, v.Y, v.Z); + }, + enumerable: true, configurable: true, + }); + return p; +} + +// ---------- Регистрация в Lua ---------- +export function registerRobloxShim(lua, opts) { + const { send } = opts; + const global = lua.global; + + // === Базовые типы === + global.set('Vector3', { + 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('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) => RbxColor3.fromHex(hex), + }); + // BrickColor — старая система цветов Roblox по имени + const BRICK_COLORS = { + 'White': [1, 1, 1], 'Black': [0.1, 0.1, 0.1], 'Grey': [0.6, 0.6, 0.6], + 'Bright red': [0.77, 0.2, 0.2], 'Bright blue': [0.05, 0.4, 0.7], + 'Bright green': [0.3, 0.8, 0.2], 'Bright yellow': [1, 0.85, 0.1], + 'Bright orange': [0.85, 0.5, 0.15], 'Bright violet': [0.45, 0.2, 0.65], + 'Dark blue': [0.05, 0.15, 0.4], 'Dark green': [0.15, 0.4, 0.2], + 'Dark red': [0.4, 0.1, 0.1], 'Lime green': [0.7, 0.95, 0.3], + 'Pink': [1, 0.55, 0.7], 'Brown': [0.4, 0.25, 0.15], + 'Reddish brown': [0.45, 0.2, 0.15], 'Sand red': [0.85, 0.6, 0.55], + 'Medium blue': [0.4, 0.65, 0.85], 'Cyan': [0, 0.8, 0.8], + 'Magenta': [0.85, 0, 0.85], 'Really red': [1, 0, 0], 'Really blue': [0, 0, 1], + 'Really black': [0, 0, 0], 'Really white': [1, 1, 1], + }; + function _brickToColor3(name) { + const rgb = BRICK_COLORS[name] || [0.5, 0.5, 0.5]; + return new RbxColor3(rgb[0], rgb[1], rgb[2]); + } + global.set('BrickColor', { + new(nameOrR, g, b) { + // BrickColor.new("Bright red") или BrickColor.new(r, g, b) + const name = typeof nameOrR === 'string' ? nameOrR : 'White'; + const c = typeof nameOrR === 'string' + ? _brickToColor3(nameOrR) + : new RbxColor3(nameOrR, g, b); + return { Color: c, Name: name, Number: 1, R: c.R, G: c.G, B: c.B, + r: c.R, g: c.G, b: c.B }; + }, + random() { return { Color: new RbxColor3(Math.random(), Math.random(), Math.random()), Name: 'Random' }; }, + White() { return this.new('White'); }, + Black() { return this.new('Black'); }, + Gray() { return this.new('Grey'); }, + Red() { return this.new('Bright red'); }, + Yellow() { return this.new('Bright yellow'); }, + Green() { return this.new('Bright green'); }, + Blue() { return this.new('Bright blue'); }, + DarkGray() { return this.new('Dark stone grey'); }, + palette(n) { return this.new('White'); }, + }); + // Ray — луч, используется в raycast + global.set('Ray', { + new(origin, direction) { return { Origin: origin, Direction: direction }; }, + }); + // Region3 — куб в пространстве + global.set('Region3', { + new(min, max) { return { Min: min, Max: max, CFrame: { Position: min }, Size: max }; }, + }); + 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 === + const mkE = (arr) => Object.fromEntries(arr.map(k => [k, { Name: k, Value: k }])); + global.set('Enum', { + KeyCode: mkE(['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']), + UserInputType: mkE(['MouseButton1','MouseButton2','MouseButton3','MouseMovement','MouseWheel','Touch','Keyboard']), + Material: mkE(['Plastic','Wood','Metal','Neon','Glass','Sand','Ice','Grass','Concrete']), + HumanoidStateType: mkE(['Running','Jumping','Freefall','Landed','Dead','Climbing','Swimming','Seated']), + EasingStyle: mkE(['Linear','Sine','Quad','Cubic','Quart','Quint','Bounce','Elastic']), + EasingDirection: mkE(['In','Out','InOut']), + // Часто используемые в туториалах + InfoType: mkE(['Asset','BundleDetails','Subscription','GamePass','UserProductsInExperience']), + SortOrder: mkE(['Name','Custom','LayoutOrder']), + FillDirection: mkE(['Horizontal','Vertical']), + HorizontalAlignment: mkE(['Left','Center','Right']), + VerticalAlignment: mkE(['Top','Center','Bottom']), + Font: mkE(['Legacy','Arial','SourceSans','Code','Highway','SciFi','Cartoon','Gotham','GothamBold']), + TextXAlignment: mkE(['Left','Center','Right']), + TextYAlignment: mkE(['Top','Center','Bottom']), + ScaleType: mkE(['Stretch','Slice','Tile','Fit','Crop']), + AspectType: mkE(['FitWithinMaxSize','ScaleWithParentSize']), + DominantAxis: mkE(['Width','Height']), + BorderMode: mkE(['Outline','Middle','Inset']), + FormFactor: mkE(['Symmetric','Brick','Plate','Custom']), + PartType: mkE(['Ball','Block','Cylinder','Wedge','CornerWedge']), + SurfaceType: mkE(['Smooth','Glue','Weld','Studs','Inlet','Universal']), + ContextActionResult: mkE(['Pass','Sink']), + UserInputState: mkE(['Begin','Change','End','Cancel','None']), + }); + + // TweenInfo — конструктор объекта с параметрами анимации + // Сигнатура: TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime) + global.set('TweenInfo', { + new(time, easingStyle, easingDirection, repeatCount, reverses, delayTime) { + return { + Time: time || 1, + EasingStyle: easingStyle, + EasingDirection: easingDirection, + RepeatCount: repeatCount || 0, + Reverses: !!reverses, + DelayTime: delayTime || 0, + }; + }, + }); + + // NumberSequence, ColorSequence — упрощённые конструкторы для GUI-эффектов + global.set('NumberSequence', { + new(...args) { return { Keypoints: [], __ns: true }; }, + }); + global.set('ColorSequence', { + new(...args) { return { Keypoints: [], __cs: true }; }, + }); + global.set('NumberRange', { + new(min, max) { return { Min: min, Max: max == null ? min : max }; }, + }); + global.set('Rect', { + new(minX, minY, maxX, maxY) { return { Min: { X: minX, Y: minY }, Max: { X: maxX, Y: maxY } }; }, + }); + + // === print / warn === + const stringify = (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 '?'; } + }; + global.set('print', (...args) => { + send('log', { level: 'info', text: args.map(stringify).join('\t') }); + }); + global.set('warn', (...args) => { + send('log', { level: 'warn', text: args.map(stringify).join('\t') }); + }); + + // require(ModuleScript) — в Roblox загружает модуль. У нас модулей нет — + // возвращаем undefined (Lua nil) чтобы скрипты типа local mod = require(...) + // не падали. require строкой (стандартный Lua) перехватывать не будем. + global.set('require', (mod) => { + // Если передали Instance-stub — возвращаем сам stub (чтобы хоть + // что-то можно было сделать с возвращённым значением). + if (mod && typeof mod === 'object') return mod; + return undefined; + }); + + // === task.* + wait === + // task.wait/wait — реальный yield через coroutines. Юзер пишет: + // while true do part.Position = ... ; task.wait(0.1) end + // Это работает потому что **скрипт сам запускается как coroutine** + // (см. LuaSharedSandbox._startSingleScript → мы оборачиваем код в pcall, + // НО для yield нам нужно завернуть в coroutine.create). Делаем это + // через Lua-prelude: глобальная функция `_run_in_coroutine(fn)`. + global.set('task', { + spawn: (fn) => { + try { if (typeof fn === 'function') fn(); } catch (e) { + send('log', { level: 'error', text: `[task.spawn] ${e?.message || e}` }); + } + }, + delay: (sec, fn) => { + if (typeof fn !== 'function') return; + SCHEDULER.sleeping.push({ + wakeAt: SCHEDULER.now() + (Number(sec) || 0) * 1000, + run: () => { try { fn(); } catch (e) { + send('log', { level: 'error', text: `[task.delay] ${e?.message || e}` }); + } }, + }); + }, + defer: (fn) => { + if (typeof fn !== 'function') return; + SCHEDULER.sleeping.push({ + wakeAt: SCHEDULER.now(), + run: () => { try { fn(); } catch (_) {} }, + }); + }, + synchronize: () => {}, + desynchronize: () => {}, + }); + // task.wait и wait определяются через Lua coroutine.yield в prelude (см. ниже) + + // === DataModel === + const game = newInstance('DataModel', 'game'); + const workspace = newInstance('Workspace', 'Workspace'); + workspace.Parent = game; + workspace.Gravity = 196.2; + workspace.CurrentCamera = newInstance('Camera', 'Camera'); + workspace.CurrentCamera.Parent = workspace; + workspace.Children.push(workspace.CurrentCamera); + workspace.Terrain = newInstance('Terrain', 'Terrain'); + workspace.Terrain.Parent = workspace; + workspace.Children.push(workspace.Terrain); + game.Children.push(workspace); + game.Workspace = workspace; + + const players = newInstance('Players', 'Players'); + players.Parent = game; + players.PlayerAdded = makeSignal(); + players.PlayerRemoving = makeSignal(); + game.Children.push(players); + game.Players = players; + + const localPlayer = newInstance('Player', 'Player'); + localPlayer.Parent = players; + localPlayer.UserId = 1; + // PlayerGui — контейнер для GUI принадлежащих игроку. В Rublox это no-op + // (overlay глобальный), но Roblox-скрипты часто делают gui.Parent = playerGui. + const playerGui = newInstance('PlayerGui', 'PlayerGui'); + playerGui.Parent = localPlayer; + localPlayer.Children.push(playerGui); + localPlayer.PlayerGui = playerGui; + localPlayer.DisplayName = 'Player'; + // Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически + // клонируется в Backpack каждого спавнящегося игрока. + const backpack = newInstance('Backpack', 'Backpack'); + backpack.Parent = localPlayer; + localPlayer.Children.push(backpack); + localPlayer.Backpack = backpack; + // Глобальный Mouse — единственный экземпляр на игрока, привязан к окну + // браузера. Реальные Button1Down/Hit фейерятся в GameRuntime. + const playerMouse = (function makePlayerMouse() { + const m = newInstance('Mouse', 'Mouse'); + m.Button1Down = makeSignal(); + m.Button1Up = makeSignal(); + m.Button2Down = makeSignal(); + m.Button2Up = makeSignal(); + m.Move = makeSignal(); + m.KeyDown = makeSignal(); + m.KeyUp = makeSignal(); + m.WheelForward = makeSignal(); + m.WheelBackward = makeSignal(); + m.Idle = makeSignal(); + m.Icon = ''; + m.X = 0; m.Y = 0; + m.ViewSizeX = 1920; m.ViewSizeY = 1080; + m.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0), + Lookvector: new RbxVector3(0, 0, -1) }; + m.Origin = { Position: new RbxVector3(0, 5, 0) }; + m.Target = undefined; + m.TargetFilter = undefined; + m.TargetSurface = 'Top'; + return m; + })(); + localPlayer.GetMouse = function () { return playerMouse; }; + localPlayer.playerMouse = playerMouse; + players.Children.push(localPlayer); + players.LocalPlayer = localPlayer; + + // === Tool registry === + // Tracks все Tool-инстансы — для UI (hotbar) и equip-flow. + // GameRuntime читает API equipTool/unequipTool на main-loop. + const allTools = []; // [Tool, ...] в порядке создания (для hotbar 1-9) + let equippedTool = null; + + const character = newInstance('Model', 'Player'); + character.Parent = localPlayer; + localPlayer.Children.push(character); + localPlayer.Character = character; + + const humanoid = newInstance('Humanoid', 'Humanoid'); + humanoid.Parent = character; + humanoid.Health = 100; + humanoid.MaxHealth = 100; + humanoid.WalkSpeed = 16; + humanoid.JumpPower = 50; + humanoid.Died = makeSignal(); + humanoid.HealthChanged = makeSignal(); + humanoid.Touched = makeSignal(); + humanoid.StateChanged = makeSignal(); + humanoid.TakeDamage = function (n) { + const v = Math.max(0, (this.Health || 100) - (Number(n) || 0)); + this.Health = v; + this.HealthChanged.Fire(v); + if (v === 0) this.Died.Fire(); + send('playerSet', { prop: 'health', value: v }); + }; + humanoid.MoveTo = function () {}; + humanoid.LoadAnimation = function () { return { Play: () => {}, Stop: () => {}, AdjustSpeed: () => {} }; }; + character.Children.push(humanoid); + character.Humanoid = humanoid; + + const hrp = newInstance('Part', 'HumanoidRootPart'); + hrp.Parent = character; + hrp.Position = new RbxVector3(0, 5, 0); + hrp.Size = new RbxVector3(2, 2, 1); + character.Children.push(hrp); + character.HumanoidRootPart = hrp; + character.PrimaryPart = hrp; + + // === Сервисы === + const services = {}; + const makeService = (name) => { + if (services[name]) return services[name]; + const s = newInstance(name, name); + s.Parent = game; + game.Children.push(s); + services[name] = s; + game[name] = s; + return s; + }; + makeService('ReplicatedStorage'); + makeService('ServerStorage'); + makeService('StarterGui'); + makeService('StarterPack'); + makeService('StarterPlayer'); + + const uis = makeService('UserInputService'); + uis.InputBegan = makeSignal(); + uis.InputChanged = makeSignal(); + uis.InputEnded = makeSignal(); + + // TweenService — реальная интерполяция через Heartbeat + const tw = makeService('TweenService'); + const activeTweens = []; // [{inst, props, duration, startAt, startVals, onDone}] + tw.Create = function (inst, info, propGoals) { + // info: TweenInfo (duration, EasingStyle, ...) — упрощённо берём только duration + const duration = (info && (info.Time || info.duration)) || 1; + const tween = { + __completed: makeSignal(), + Completed: undefined, + Play() { + if (!inst || !propGoals) return; + const startVals = {}; + for (const k of Object.keys(propGoals)) { + try { startVals[k] = inst[k]; } catch (_) {} + } + activeTweens.push({ + inst, props: propGoals, duration, + startAt: performance.now(), + startVals, + onDone: () => tween.__completed.Fire(), + }); + }, + Pause() {}, + Cancel() {}, + }; + tween.Completed = tween.__completed; + return tween; + }; + function _stepTweens(_dt) { + if (activeTweens.length === 0) return; + const now = performance.now(); + for (let i = activeTweens.length - 1; i >= 0; i--) { + const t = activeTweens[i]; + const elapsed = (now - t.startAt) / 1000; + const k = Math.min(1, elapsed / t.duration); + for (const prop of Object.keys(t.props)) { + const goal = t.props[prop]; + const start = t.startVals[prop]; + if (!start || !goal) continue; + if (start instanceof RbxVector3 && goal instanceof RbxVector3) { + try { t.inst[prop] = start.Lerp(goal, k); } catch (_) {} + } else if (start instanceof RbxColor3 && goal instanceof RbxColor3) { + try { t.inst[prop] = start.Lerp(goal, k); } catch (_) {} + } else if (typeof start === 'number' && typeof goal === 'number') { + try { t.inst[prop] = start + (goal - start) * k; } catch (_) {} + } + } + if (k >= 1) { + activeTweens.splice(i, 1); + try { t.onDone(); } catch (_) {} + } + } + } + + const http = makeService('HttpService'); + http.JSONEncode = function (v) { try { return JSON.stringify(v); } catch (_) { return '{}'; } }; + http.JSONDecode = function (s) { try { return JSON.parse(s); } catch (_) { return undefined; } }; + http.GenerateGUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 3 | 8)).toString(16); + }); + + const lighting = makeService('Lighting'); + lighting.Ambient = new RbxColor3(0.5, 0.5, 0.5); + lighting.Brightness = 1; + lighting.ClockTime = 14; + lighting.TimeOfDay = "14:00:00"; + lighting.OutdoorAmbient = new RbxColor3(0.5, 0.5, 0.5); + lighting.FogEnd = 100000; + lighting.FogStart = 0; + lighting.FogColor = new RbxColor3(0.75, 0.75, 0.75); + lighting._minutes = 14 * 60; + lighting.GetMinutesAfterMidnight = function () { return lighting._minutes; }; + let _lastLightSent = 0; + lighting.SetMinutesAfterMidnight = function (m) { + lighting._minutes = (Number(m) || 0) % 1440; + lighting.ClockTime = lighting._minutes / 60; + // Тротлинг: не чаще раза в 250мс. Скрипты Day/Night обновляют это + // каждый кадр (100+ Hz), это убивает WASM. + const now = performance.now(); + if (now - _lastLightSent < 250) return; + _lastLightSent = now; + send('lightingTimeUpdate', { hour: lighting.ClockTime }); + }; + lighting.GetMoonDirection = function () { return new RbxVector3(0, 1, 0); }; + lighting.GetSunDirection = function () { return new RbxVector3(0, 1, 0); }; + makeService('Chat'); + const soundService = makeService('SoundService'); + soundService.PlayLocalSound = function (sound) { + if (sound && typeof sound.Play === 'function') sound.Play(); + }; + makeService('PathfindingService'); + makeService('CollectionService'); + makeService('MarketplaceService'); + + const ds = makeService('DataStoreService'); + ds.GetDataStore = function () { + return { + GetAsync: () => undefined, SetAsync: () => {}, UpdateAsync: () => {}, + RemoveAsync: () => {}, IncrementAsync: () => {}, + }; + }; + + const ctx = makeService('ContextActionService'); + ctx.BindAction = () => {}; + ctx.UnbindAction = () => {}; + + const runService = makeService('RunService'); + runService.Heartbeat = HEARTBEAT_SIGNAL; + runService.Stepped = STEPPED_SIGNAL; + runService.RenderStepped = HEARTBEAT_SIGNAL; + runService.IsClient = () => true; + runService.IsServer = () => true; + runService.IsRunning = () => true; + runService.IsStudio = () => false; + + game.GetService = function (name) { + if (name === 'Workspace') return workspace; + if (name === 'Players') return players; + return services[name] || makeService(name); + }; + // Старый Roblox API: game:service(name) lowercase + game.service = game.GetService; + game.GetServiceFromName = game.GetService; + game.FindService = function (name) { return services[name] || null; }; + game.JobId = ''; + game.PlaceId = 0; + game.GameId = 0; + game.CreatorId = 0; + game.CreatorType = 'User'; + + // Players API extensions + players.GetPlayers = function () { return [players.LocalPlayer].filter(Boolean); }; + players.GetPlayerFromCharacter = function (character) { + if (character && players.LocalPlayer && players.LocalPlayer.Character === character) { + return players.LocalPlayer; + } + return undefined; + }; + players.playerFromCharacter = players.GetPlayerFromCharacter; + players.PlayerAdded = makeSignal(); + players.PlayerRemoving = makeSignal(); + players.ChildAdded = makeSignal(); + + global.set('game', game); + global.set('Game', game); + global.set('workspace', workspace); + global.set('Workspace', workspace); + + // === Instance.new === + // === Helper: создание GUI-элемента через game.gui.create === + // Roblox: Frame/TextLabel/TextButton/ImageLabel/TextBox/ScrollingFrame. + // Шлём gui.create команду в main thread → GuiManager создаёт элемент. + // Возвращаем Lua-объект с setter'ами для основных свойств. + let _nextGuiLocalRef = 0; + function newGuiInstance(robloxClass) { + const localRef = `_gui_lua_${_nextGuiLocalRef++}`; + const inst = newInstance(robloxClass, robloxClass); + inst.__guiLocalRef = localRef; + inst.__guiClass = robloxClass; + // Маппим Roblox-класс на тип в GuiManager + const guiType = ({ + Frame: 'frame', + TextLabel: 'text', + TextButton: 'button', + ImageLabel: 'image', + ImageButton: 'button', + TextBox: 'textbox', + ScrollingFrame: 'scroll', + })[robloxClass] || 'frame'; + // Внутренние стейты + inst._gui = { + type: guiType, + text: '', + bgColor: '#3a2820', + bgOpacity: 1, + textColor: '#f0e6d8', + textSize: 16, + x: 50, y: 50, w: 20, h: 10, + visible: true, + }; + // Шлём create при первом обращении (lazy) или сейчас — лучше сейчас, чтобы + // не было гонок при моментальной правке свойств после Instance.new. + send('gui.create', { + type: guiType, + opts: { ...inst._gui, _scriptCreated: true }, + localRef, + }); + // Сигналы (для кнопок) + if (robloxClass === 'TextButton' || robloxClass === 'ImageButton') { + inst.MouseButton1Click = makeSignal(); + inst.MouseEnter = makeSignal(); + inst.MouseLeave = makeSignal(); + inst.Activated = inst.MouseButton1Click; + } + // Setters + const updateField = (field, value) => { + inst._gui[field] = value; + send('gui.update', { id: localRef, patch: { [field]: value } }); + }; + Object.defineProperty(inst, 'Text', { + get() { return inst._gui.text; }, + set(v) { updateField('text', String(v ?? '')); }, + enumerable: true, + }); + Object.defineProperty(inst, 'Visible', { + get() { return inst._gui.visible; }, + set(v) { updateField('visible', !!v); }, + enumerable: true, + }); + Object.defineProperty(inst, 'BackgroundColor3', { + get() { return RbxColor3.fromHex(inst._gui.bgColor); }, + set(v) { + if (!v) return; + const hex = v.toHex ? v.toHex() : (v instanceof RbxColor3 ? v.toHex() : '#3a2820'); + updateField('bgColor', hex); + }, + enumerable: true, + }); + Object.defineProperty(inst, 'BackgroundTransparency', { + get() { return 1 - (inst._gui.bgOpacity ?? 1); }, + set(v) { updateField('bgOpacity', 1 - Math.max(0, Math.min(1, +v || 0))); }, + enumerable: true, + }); + Object.defineProperty(inst, 'TextColor3', { + get() { return RbxColor3.fromHex(inst._gui.textColor); }, + set(v) { + if (!v) return; + const hex = v.toHex ? v.toHex() : '#f0e6d8'; + updateField('textColor', hex); + }, + enumerable: true, + }); + Object.defineProperty(inst, 'TextSize', { + get() { return inst._gui.textSize; }, + set(v) { updateField('textSize', Math.max(8, Math.min(72, +v || 16))); }, + enumerable: true, + }); + // Position: UDim2 → x,y проценты (Roblox-style: scale=%, offset=px) + // Упрощённо берём scale*100 как x/y; offset игнорируем. + Object.defineProperty(inst, 'Position', { + get() { + return new RbxUDim2(inst._gui.x / 100, 0, inst._gui.y / 100, 0); + }, + set(v) { + if (!v) return; + const xPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10; + const yPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10; + inst._gui.x = xPct; + inst._gui.y = yPct; + send('gui.update', { id: localRef, patch: { x: xPct, y: yPct } }); + }, + enumerable: true, + }); + Object.defineProperty(inst, 'Size', { + get() { + return new RbxUDim2(inst._gui.w / 100, 0, inst._gui.h / 100, 0); + }, + set(v) { + if (!v) return; + const wPct = (v.X?.Scale || 0) * 100 + (v.X?.Offset || 0) / 10; + const hPct = (v.Y?.Scale || 0) * 100 + (v.Y?.Offset || 0) / 10; + inst._gui.w = wPct; + inst._gui.h = hPct; + send('gui.update', { id: localRef, patch: { w: wPct, h: hPct } }); + }, + enumerable: true, + }); + // Destroy — удаление GUI + const origDestroy = inst.Destroy; + inst.Destroy = function () { + try { send('gui.remove', { id: localRef }); } catch (_) {} + origDestroy.call(inst); + }; + return inst; + } + + // Регистрация в guiByLocalRef для дальнейшей маршрутизации событий клика + const guiByLocalRef = new Map(); + + // Счётчик для новых Part'ов, создаваемых через Instance.new("Part"): + // primitiveManager.addInstance даст уникальный id, мы используем временный + // отрицательный id пока не пришёл sceneCreate ACK. Но проще: даём свой + // негативный id, primitiveManager заменит на свой. Для простоты — даём + // высокий positive id (10000+ random) и primitiveManager его использует + // если не занят. + let _nextNewPartId = 100000 + Math.floor(Math.random() * 10000); + + global.set('Instance', { + new: (className, parent) => { + let inst; + if (className === 'Part' || className === 'WedgePart' || className === 'MeshPart') { + // Реальный примитив на сцене: шлём sceneCreate, регистрируем в partById + const newId = _nextNewPartId++; + const fakePrim = { + id: newId, + name: `Part_${newId}`, + x: 0, y: 0, z: 0, + sx: 4, sy: 1, sz: 2, + color: '#A0A0A0', + anchored: true, + canCollide: true, + }; + send('sceneCreate', { + primId: newId, + type: className === 'WedgePart' ? 'wedge' : 'cube', + x: 0, y: 0, z: 0, + sx: 4, sy: 1, sz: 2, + color: '#A0A0A0', + anchored: true, + canCollide: true, + }); + inst = newPart(fakePrim, send); + partById.set(newId, inst); + } else if (className === 'RemoteEvent') { + inst = newInstance('RemoteEvent', 'RemoteEvent'); + inst.OnServerEvent = makeSignal(); + inst.OnClientEvent = makeSignal(); + inst.FireServer = function (...a) { this.OnServerEvent.Fire(localPlayer, ...a); }; + inst.FireClient = function (_p, ...a) { this.OnClientEvent.Fire(...a); }; + inst.FireAllClients = function (...a) { this.OnClientEvent.Fire(...a); }; + } else if (className === 'BindableEvent') { + inst = newInstance('BindableEvent', 'BindableEvent'); + inst.Event = makeSignal(); + inst.Fire = function (...a) { this.Event.Fire(...a); }; + } else if (className === 'Humanoid') { + inst = newInstance('Humanoid', 'Humanoid'); + inst.Health = 100; inst.MaxHealth = 100; + inst.Died = makeSignal(); inst.HealthChanged = makeSignal(); + inst.TakeDamage = function (n) { this.Health = Math.max(0, this.Health - (n || 0)); }; + } else if (className === 'Sound') { + // Sound — процедурные звуки через _playSound. + // SoundId → имя процедурного звука (rbxassetid игнорится). + inst = newInstance('Sound', 'Sound'); + inst.SoundId = ''; + inst.Volume = 1; + inst.PlaybackSpeed = 1; + inst.Pitch = 1; + inst.Looped = false; + inst.IsPlaying = false; + inst.Played = makeSignal(); + inst.Ended = makeSignal(); + // Map SoundId/имя на встроенный звук (jump/pickup/win/lose/click/hit/coin). + const _mapSoundName = (idOrName) => { + if (!idOrName) return 'click'; + const s = String(idOrName).toLowerCase(); + // Прямые ключи имеют приоритет + if (['jump','pickup','win','lose','click','hit','coin'].indexOf(s) >= 0) return s; + // Эвристика по части строки (для Roblox AssetID) + if (s.includes('jump')) return 'jump'; + if (s.includes('pickup') || s.includes('collect')) return 'pickup'; + if (s.includes('win') || s.includes('victory')) return 'win'; + if (s.includes('lose') || s.includes('death')) return 'lose'; + if (s.includes('hit') || s.includes('damage')) return 'hit'; + if (s.includes('coin') || s.includes('gem')) return 'coin'; + return 'click'; + }; + inst.Play = function () { + const name = _mapSoundName(this.SoundId || this.Name); + const pitch = +this.PlaybackSpeed || +this.Pitch || 1; + const volume = +this.Volume || 1; + send('sound.play', { name, volume, pitch }); + this.IsPlaying = true; + this.Played.Fire(); + // Простая модель: считаем что звук длится 0.5с + SCHEDULER.sleeping.push({ + wakeAt: SCHEDULER.now() + 500, + run: () => { + this.IsPlaying = false; + this.Ended.Fire(); + if (this.Looped) this.Play(); + }, + }); + }; + inst.Stop = function () { this.IsPlaying = false; }; + inst.Pause = function () { this.IsPlaying = false; }; + inst.Resume = function () { if (!this.IsPlaying) this.Play(); }; + } else if (className === 'ScreenGui') { + // ScreenGui — логический корень GUI. В Rublox overlay глобальный, + // поэтому ScreenGui это просто контейнер-no-op (без gui.create). + inst = newInstance('ScreenGui', 'ScreenGui'); + inst.__isScreenGui = true; + inst.Enabled = true; + } else if (className === 'Frame' || className === 'TextLabel' + || className === 'TextButton' || className === 'ImageLabel' + || className === 'ImageButton' || className === 'TextBox' + || className === 'ScrollingFrame') { + inst = newGuiInstance(className); + guiByLocalRef.set(inst.__guiLocalRef, inst); + } else if (className === 'Tool' || className === 'HopperBin') { + inst = newInstance(className, 'Tool'); + inst.Equipped = makeSignal(); + inst.Unequipped = makeSignal(); + inst.Activated = makeSignal(); + inst.Deactivated = makeSignal(); + inst.GripForward = new RbxVector3(0, -1, 0); + inst.GripRight = new RbxVector3(1, 0, 0); + inst.GripUp = new RbxVector3(0, 1, 0); + inst.GripPos = new RbxVector3(0, 0, 0); + inst.CanBeDropped = true; + inst.Enabled = true; + inst.RequiresHandle = true; + inst.TextureId = ''; + inst.ToolTip = ''; + // Виртуальный Handle — Roblox-скрипты делают Tool.Handle.Position + const handle = newInstance('Part', 'Handle'); + handle.Parent = inst; + handle.Position = new RbxVector3(0, 5, 0); + handle.Size = new RbxVector3(1, 1, 1); + inst.Handle = handle; + inst.Children = inst.Children || []; + inst.Children.push(handle); + // Регистрируем Tool, чтобы плеер показал его в hotbar + allTools.push(inst); + inst.__toolIndex = allTools.length; + send('toolRegistered', { + index: inst.__toolIndex, + name: inst.Name || `Tool ${inst.__toolIndex}`, + }); + } else if (className === 'IntValue' || className === 'NumberValue' + || className === 'BoolValue' || className === 'StringValue' + || className === 'ObjectValue' || className === 'CFrameValue' + || className === 'Vector3Value' || className === 'Color3Value' + || className === 'BrickColorValue' || className === 'RayValue') { + inst = newInstance(className, className); + inst.Value = className === 'BoolValue' ? false + : className === 'StringValue' ? '' + : (className === 'IntValue' || className === 'NumberValue') ? 0 + : undefined; + inst.Changed = makeSignal(); + } else if (className === 'BodyForce' || className === 'BodyVelocity' + || className === 'BodyPosition' || className === 'BodyGyro' + || className === 'BodyAngularVelocity' || className === 'BodyThrust') { + inst = newInstance(className, className); + inst.force = new RbxVector3(0, 0, 0); + inst.Force = inst.force; + inst.Velocity = new RbxVector3(0, 0, 0); + inst.MaxForce = new RbxVector3(0, 0, 0); + inst.P = 1000; inst.D = 100; + } else if (className === 'Weld' || className === 'WeldConstraint' + || className === 'Motor6D' || className === 'Snap' + || className === 'HingeConstraint' || className === 'BallSocketConstraint' + || className === 'RopeConstraint' || className === 'SpringConstraint') { + inst = newInstance(className, className); + inst.Part0 = undefined; inst.Part1 = undefined; + inst.C0 = { Position: new RbxVector3(0, 0, 0) }; + inst.C1 = { Position: new RbxVector3(0, 0, 0) }; + inst.Enabled = true; + } else if (className === 'Sparkles' || className === 'ParticleEmitter' + || className === 'Smoke' || className === 'Fire' || className === 'Trail' + || className === 'Beam' || className === 'PointLight' + || className === 'SurfaceLight' || className === 'SpotLight') { + inst = newInstance(className, className); + inst.Enabled = true; + inst.Color = new RbxColor3(1, 1, 1); + inst.Rate = 20; + inst.Lifetime = { Min: 1, Max: 1 }; + inst.Brightness = 1; + inst.Range = 8; + inst.__particleKind = className.toLowerCase(); + // Шлём событие "создан particle-effect" — GameRuntime может его + // привязать к мешу на сцене (например, рукам игрока). + send('particleCreated', { + kind: inst.__particleKind, + color: [inst.Color.R, inst.Color.G, inst.Color.B], + }); + } else if (className === 'Mouse') { + inst = newInstance('Mouse', 'Mouse'); + inst.Button1Down = makeSignal(); + inst.Button1Up = makeSignal(); + inst.Button2Down = makeSignal(); + inst.Button2Up = makeSignal(); + inst.Move = makeSignal(); + inst.KeyDown = makeSignal(); + inst.KeyUp = makeSignal(); + inst.WheelForward = makeSignal(); + inst.WheelBackward = makeSignal(); + inst.Icon = ''; + inst.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0) }; + inst.Target = undefined; + inst.TargetSurface = 'Top'; + inst.X = 0; inst.Y = 0; + inst.ViewSizeX = 1920; inst.ViewSizeY = 1080; + } else { + inst = newInstance(className, className); + } + if (parent) { + inst.Parent = parent; + if (parent.Children) { + parent.Children.push(inst); + if (parent.ChildAdded) parent.ChildAdded.Fire(inst); + } + } + return inst; + }, + }); + + // === Leaderboard scan === + // Roblox-скрипт делает: Instance.new('IntValue').Name='leaderstats', + // stats.Parent = newPlayer, потом IntValue Reputation/Level внутри. + // Поскольку наш Lua не вызывает Children.push при Parent= (Lua делает rawset), + // мы периодически сканируем localPlayer на наличие leaderstats и шлём в плеер. + // === Helpers для скриптов === + const partById = new Map(); + global.set('__rbxl_get_part_by_id', (id) => partById.get(Number(id)) || undefined); + global.set('__rbxl_get_tool_by_name', (name) => allTools.find(t => t.Name === name) || undefined); + global.set('__rbxl_send_error', (id, errStr) => { + send('log', { level: 'error', text: `[Lua ${id}] ${errStr || 'unknown error'}` }); + }); + + // === Coroutines registry + task.wait через yield === + // Каждый скрипт стартует как coroutine. Когда юзер пишет task.wait(sec), + // мы делаем coroutine.yield(sec). Main-loop резюмирует когда время вышло. + const coroutines = new Map(); // id → coroutine + const waitingCoros = []; // [{coId, wakeAt}] + global.set('__rbxl_register_coroutine', (id, co) => { + coroutines.set(String(id), co); + }); + global.set('__rbxl_unregister_coroutine', (id) => { + coroutines.delete(String(id)); + }); + global.set('__rbxl_get_co', (id) => coroutines.get(String(id)) || undefined); + global.set('__rbxl_schedule_resume', (coId, delaySec) => { + waitingCoros.push({ + coId: String(coId), + wakeAt: SCHEDULER.now() + (Number(delaySec) || 0) * 1000, + }); + }); + + // Lua-prelude: task.wait через coroutine.yield + готовая resume-функция. + // Главное: __rbxl_resume_co определена в Lua и вызывается из JS через + // lua.global.get('__rbxl_resume_co') — это безопаснее чем doStringSync + // потому что не парсит код заново и не создаёт re-entrant проблем. + // Lua-side helper для логов (используется в task.wait/resume для отладки) + global.set('__log', (level, text) => { + send('log', { level: String(level || 'info'), text: String(text || '') }); + }); + + lua.doStringSync(` + local function rbx_wait(sec) + sec = sec or 0 + -- Минимум 1 кадр (≈0.0166с). wait() и wait(0) в Roblox ждут до + -- следующего Heartbeat — без этого while true do wait() end + -- стал бы tight loop без yield и упёрся в WASM stack overflow. + if sec < 0.016 then sec = 0.016 end + local ret = coroutine.yield(sec) + return ret or sec + end + if type(task) == 'table' then + task.wait = rbx_wait + else + task = { wait = rbx_wait } + end + wait = rbx_wait + + function __rbxl_resume_co(co) + if not co or coroutine.status(co) ~= 'suspended' then return nil end + local ok, ret = coroutine.resume(co) + if not ok then return false, tostring(ret) end + if coroutine.status(co) == 'dead' then return nil end + if type(ret) == 'number' then return ret end + return 0 + end + + -- Запуск Lua-handler'а из очереди в собственной coroutine. + -- Вызывается из JS tickScheduler — мы УЖЕ вышли из C-callback, + -- так что wait() внутри handler'а — yield в свою coroutine. + __rbxl_next_handler_id = 0 + function __rbxl_drain_handler(fn, a1, a2, a3, a4) + __rbxl_next_handler_id = __rbxl_next_handler_id + 1 + local handlerId = "handler_" .. __rbxl_next_handler_id + -- Оборачиваем call в pcall чтобы поглотить return value handler'а + -- (RayGun возвращает :connect(...) объект как последнее выражение, + -- что приводит к wasmoon promise-detection crash). pcall возвращает + -- (ok, ret1, ret2, ...) — мы их не используем. + local co = coroutine.create(function() + pcall(fn, a1, a2, a3, a4) + end) + __rbxl_register_coroutine(handlerId, co) + local ok, ret = coroutine.resume(co) + if not ok then + __rbxl_send_error(handlerId, tostring(ret)) + __rbxl_unregister_coroutine(handlerId) + elseif type(ret) == 'number' then + __rbxl_schedule_resume(handlerId, ret) + elseif coroutine.status(co) == 'dead' then + __rbxl_unregister_coroutine(handlerId) + end + -- Явно ничего не возвращаем чтобы wasmoon не оборачивал nil + end + `); + // Кешируем ссылку на Lua-функцию запуска handler'а + const luaDrainHandler = lua.global.get('__rbxl_drain_handler'); + // Добавим Lua-side helper для лога + global.set('__log', (level, text) => { + send('log', { level: String(level || 'info'), text: String(text || '') }); + }); + // Достаём ссылку на Lua-функцию один раз; вызовы безопасны (не doStringSync) + const luaResumeCo = lua.global.get('__rbxl_resume_co'); + + // === Setter Part-свойств (Position/Size/Color/...) === + // Юзер пишет: part.Position = Vector3.new(0, 10, 0) + // В Lua-side это просто rawset. Но мы хотим, чтобы JS-сторона узнала и применила. + // Решение: каждые N секунд (или по требованию) — сканируем partById и сравниваем + // _state. Это дорого. ПРОСТЕЕ: научим юзера использовать part:SetPosition(v). + // + // Для Этапа 3 сделаем proxy через явный метод. В Этапе 4 — введём + // metatable на Lua-стороне (более чистый путь). + + // Возвращаем api для main-loop + return { + onSceneSnapshot(snap) { + try { + const prims = snap?.primitives || []; + // Сохраняем Camera/Terrain + const kept = workspace.Children.filter(c => + c.ClassName === 'Camera' || c.ClassName === 'Terrain' + ); + workspace.Children.length = 0; + workspace.Children.push(...kept); + partById.clear(); + for (const p of prims) { + if (!p || p.id == null) continue; + const part = newPart(p, send); // setters внутри шлют через send + part.Parent = workspace; + workspace.Children.push(part); + partById.set(Number(p.id), part); + } + // eslint-disable-next-line no-console + console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts`); + } catch (e) { + send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` }); + } + }, + onGuiSnapshot() {}, + onDataSnapshot() {}, + + tickScheduler(_dt) { + // 0a. Lua-handlers из очереди (signal.Fire отложил их сюда). + // Запускаем каждый в своей coroutine — wait() внутри безопасен. + if (_pendingHandlerQueue.length > 0) { + const queue = _pendingHandlerQueue.splice(0, _pendingHandlerQueue.length); + for (const h of queue) { + try { + // Только реальное число аргументов. wasmoon не любит + // undefined/null — может попытаться обернуть как promise. + const a = h.args || []; + if (a.length === 0) luaDrainHandler(h.fn); + else if (a.length === 1) luaDrainHandler(h.fn, a[0]); + else if (a.length === 2) luaDrainHandler(h.fn, a[0], a[1]); + else if (a.length === 3) luaDrainHandler(h.fn, a[0], a[1], a[2]); + else luaDrainHandler(h.fn, a[0], a[1], a[2], a[3]); + } catch (e) { + console.error('[handler-drain]', e); + } + } + } + // 0b. Tweens + _stepTweens(_dt); + const now = SCHEDULER.now(); + // 1. task.delay / task.defer + if (SCHEDULER.sleeping.length > 0) { + 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 (_) {} + } + } + // 2. Резюм coroutine'ов которые task.wait() + const dueCoros = []; + for (let i = waitingCoros.length - 1; i >= 0; i--) { + if (waitingCoros[i].wakeAt <= now) { + dueCoros.push(waitingCoros[i]); + waitingCoros.splice(i, 1); + } + } + for (const entry of dueCoros) { + const co = coroutines.get(entry.coId); + if (!co) continue; + try { + const result = luaResumeCo(co); + if (result === null || result === undefined) { + coroutines.delete(entry.coId); + } else if (typeof result === 'number') { + waitingCoros.push({ + coId: entry.coId, + wakeAt: SCHEDULER.now() + result * 1000, + }); + } else if (result === false) { + coroutines.delete(entry.coId); + } + } catch (e) { + send('log', { level: 'error', text: `[coroutine ${entry.coId}] ${e?.message || e}` }); + coroutines.delete(entry.coId); + } + } + }, + fireHeartbeat(dt) { + try { HEARTBEAT_SIGNAL.Fire(dt); } catch (_) {} + try { STEPPED_SIGNAL.Fire(performance.now() / 1000, dt); } catch (_) {} + }, + fireTargetEvent(p) { + if (!p) return; + const id = p.primId ?? p.target; + const part = partById.get(Number(id)); + if (!part) return; + if (p.kind === 'touch' || p.kind === 'touched') { + part.Touched.Fire(hrp); + } else if (p.kind === 'untouch' || p.kind === 'untouched') { + part.TouchEnded.Fire(hrp); + } + }, + fireGlobalEvent(p) { + if (!p) return; + if (p.type === 'playerTouch' && p.target != null) { + let primId = null; + if (typeof p.target === 'number') primId = p.target; + else if (typeof p.target === 'string') { + const m = /^primitive:(\d+)$/.exec(p.target); + if (m) primId = +m[1]; + } else if (typeof p.target === 'object') { + primId = p.target.id ?? p.target.ref ?? null; + } + if (primId != null) { + const part = partById.get(Number(primId)); + if (part?.Touched) part.Touched.Fire(hrp); + if (humanoid.Touched) humanoid.Touched.Fire(part); + } + } + // GUI-клик: GameRuntime шлёт {type:'guiClick', localId, id} + if (p.type === 'guiClick') { + const ref = p.localId || p.id; + const guiEl = guiByLocalRef.get(ref); + if (guiEl?.MouseButton1Click) { + guiEl.MouseButton1Click.Fire(); + } + } + // Tool equip/unequip — клавиши 1-9 в плеере шлют + // {type:'equipTool', index:N}, {type:'unequipTool'} + if (p.type === 'equipTool') { + const idx = Number(p.index) - 1; + if (idx < 0 || idx >= allTools.length) return; + const tool = allTools[idx]; + if (equippedTool === tool) return; + // Снимаем предыдущий + if (equippedTool) { + try { equippedTool.Unequipped.Fire(); } catch (_) {} + } + equippedTool = tool; + // В Roblox Tool при equip перемещается в Character + tool.Parent = character; + try { tool.Equipped.Fire(playerMouse); } catch (_) {} + } + if (p.type === 'unequipTool') { + if (!equippedTool) return; + try { equippedTool.Unequipped.Fire(); } catch (_) {} + equippedTool.Parent = backpack; + equippedTool = null; + } + if (p.type === 'toolActivated') { + if (!equippedTool) return; + try { equippedTool.Activated.Fire(); } catch (_) {} + } + if (p.type === 'toolDeactivated') { + if (!equippedTool) return; + try { equippedTool.Deactivated.Fire(); } catch (_) {} + } + // Mouse-события из плеера: клики, движение, клавиши при equipped Tool + if (p.type === 'mouseButton1Down') { + if (p.hit) { + playerMouse.Hit.Position = new RbxVector3(p.hit.x, p.hit.y, p.hit.z); + playerMouse.Hit.p = playerMouse.Hit.Position; + } + try { playerMouse.Button1Down.Fire(); } catch (_) {} + } + if (p.type === 'mouseButton1Up') { + try { playerMouse.Button1Up.Fire(); } catch (_) {} + } + if (p.type === 'keyDown') { + try { playerMouse.KeyDown.Fire(String(p.key || '').toLowerCase()); } catch (_) {} + } + if (p.type === 'keyUp') { + try { playerMouse.KeyUp.Fire(String(p.key || '').toLowerCase()); } catch (_) {} + } + }, + // Tool registry (для GameRuntime: какой Tool сделать script.Parent) + getToolByName(name) { + return allTools.find(t => t.Name === name); + }, + getAllTools() { return allTools.slice(); }, + // Доступ к ключевым объектам (для тестов и отладки) + partById, localPlayer, humanoid, character, workspace, players, game, + }; +} diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index 7b6caa9..cbef7f7 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -1,13 +1,13 @@ /** - * rbxl-lua-integration.js — single-VM интеграция (v2). + * rbxl-lua-integration.js — вспомогательные функции для импорта .rbxl-карт. * - * Двухфазная инициализация: - * 1) init worker → pre-populate workspace + GUI tree (включая сигналы) - * 2) addScriptsBatch ВСЕ скрипты одним IPC сообщением - * 3) ready → kickoff → emit PlayerAdded, начать tick + * Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные + * Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua + * (см. GameRuntime.start()). Этот файл оставлен только для: + * - unpackRobloxLuaCode() — распаковка Lua из JS-комментария-обёртки; + * - handleLuaCommand() — обработка partSet/sceneCreate/sceneDelete/playerCmd + * команд от Lua-VM в BabylonScene. */ -import RobloxLuaSharedWorker from './RobloxLuaSharedWorker.js?worker'; -import { RobloxLuaSharedSandbox } from './RobloxLuaSharedSandbox.js'; /** Распаковка lua_source из packed-кода. */ export function unpackRobloxLuaCode(code) { @@ -20,6 +20,20 @@ export function unpackRobloxLuaCode(code) { return code.slice(start, closeIdx); } +/** Парсит JSON-метадату из 2-й строки packed-кода (`// {"roblox_class":..., "enabled": true}`). */ +export function parseRobloxLuaMeta(code) { + if (typeof code !== 'string') return null; + const lines = code.split('\n'); + if (lines.length < 2) return null; + const metaLine = lines[1]; + if (!metaLine.startsWith('// ')) return null; + try { + return JSON.parse(metaLine.slice(3)); + } catch (_) { + return null; + } +} + /** Сцена → snap для shim'а (workspace:GetChildren). */ export function buildLuaSceneSnap(primitives) { const out = { primitives: {} }; @@ -80,37 +94,6 @@ export function buildLuaGuiTree(guiElements) { return out; } -/** - * Старт shared-sandbox: init → addScripts → kickoff. - */ -export function startRobloxLuaShared(scripts, ctx) { - try { - const luaScripts = []; - for (const s of scripts) { - if (!s || typeof s.code !== 'string') continue; - if (!s.code.startsWith('// @roblox-lua')) continue; - const luaSource = unpackRobloxLuaCode(s.code); - if (!luaSource) continue; - luaScripts.push({ id: s.id, target: s.target, luaSource }); - } - if (luaScripts.length === 0) return { sandbox: null, count: 0 }; - - const worker = new RobloxLuaSharedWorker(); - const sceneSnap = buildLuaSceneSnap(ctx.primitives); - const guiTree = buildLuaGuiTree(ctx.guiElements || []); - const mgr = new RobloxLuaSharedSandbox(); - mgr.setOnCommand(ctx.onCommand); - mgr.start(sceneSnap, guiTree, worker); - mgr.addScriptsBatch(luaScripts); - mgr.kickoff(); - return { sandbox: mgr, count: luaScripts.length }; - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[rbxl-lua-shared v2] start failed:', e?.message || e); - return null; - } -} - /** * Обработка IPC команд от worker'а — мапим на действия в Babylon-сцене. */ @@ -122,28 +105,66 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) { return; } if (cmd === 'partSet') { + const pm = runtime.scene3d?.primitiveManager; + if (!pm) { + console.warn('[partSet] no primitiveManager. scene3d=', !!runtime.scene3d); + return; + } + const primId = payload?.primId; + const prop = payload?.prop; + const value = payload?.value; + const patch = {}; + if (prop === 'position' && value) { + patch.x = value.x; patch.y = value.y; patch.z = value.z; + } else if (prop === 'cframe' && value) { + patch.x = value.x; patch.y = value.y; patch.z = value.z; + patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz; + } else if (prop === 'size' && value) { + patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz; + } else if (prop === 'color') patch.color = value; + else if (prop === 'material') patch.material = value; + else if (prop === 'anchored') patch.anchored = value; + else if (prop === 'canCollide') patch.canCollide = value; + else if (prop === 'opacity') patch.opacity = value; + try { + if (typeof pm.updateInstance === 'function') pm.updateInstance(primId, patch); + else if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch); + else if (typeof pm.update === 'function') pm.update(primId, patch); + } catch (e) { + console.error('[partSet] updateInstance failed:', e); + } + return; + } + if (cmd === 'sceneCreate') { + // Lua: Instance.new("Part") + part.Parent = workspace → создание примитива. + // payload: { primId (предложенный id), type, x, y, z, sx, sy, sz, color, anchored } try { const pm = runtime.scene3d?.primitiveManager; - if (!pm) return; - const primId = payload?.primId; - const prop = payload?.prop; - const value = payload?.value; - const patch = {}; - if (prop === 'position' && value) { - patch.x = value.x; patch.y = value.y; patch.z = value.z; - } else if (prop === 'cframe' && value) { - patch.x = value.x; patch.y = value.y; patch.z = value.z; - patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz; - } else if (prop === 'size' && value) { - patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz; - } else if (prop === 'color') patch.color = value; - else if (prop === 'material') patch.material = value; - else if (prop === 'anchored') patch.anchored = value; - else if (prop === 'canCollide') patch.canCollide = value; - else if (prop === 'opacity') patch.opacity = value; - if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch); - else if (typeof pm.update === 'function') pm.update(primId, patch); - } catch (e) {} + if (!pm || typeof pm.addInstance !== 'function') return; + const opts = { + id: payload?.primId, + x: payload?.x || 0, y: payload?.y || 0, z: payload?.z || 0, + sx: payload?.sx || 1, sy: payload?.sy || 1, sz: payload?.sz || 1, + color: payload?.color, + anchored: payload?.anchored !== false, + canCollide: payload?.canCollide !== false, + }; + pm.addInstance(payload?.type || 'cube', opts); + } catch (e) { + console.error('[sceneCreate]', e); + } + return; + } + if (cmd === 'sceneDelete') { + // Lua: part:Destroy() → удаление примитива. + try { + const pm = runtime.scene3d?.primitiveManager; + if (!pm || typeof pm.removeInstance !== 'function') return; + const id = payload?.primId; + if (id != null) pm.removeInstance(Number(id)); + } catch (e) { + console.error('[sceneDelete]', e); + } return; } if (cmd === 'partVel') { diff --git a/src/editor/engine/roblox-physics.js b/src/editor/engine/roblox-physics.js deleted file mode 100644 index 0962898..0000000 --- a/src/editor/engine/roblox-physics.js +++ /dev/null @@ -1,216 +0,0 @@ -/** - * roblox-physics.js — эмуляция BodyMover / Constraint объектов Roblox. - * - * Roblox BodyMover'ы (старые, deprecated но массово используются): - * BodyVelocity — поддерживает заданную линейную velocity - * BodyAngularVelocity — поддерживает заданную угловую velocity - * BodyGyro — пытается удержать ориентацию (Lookat) - * BodyForce — постоянная сила - * BodyPosition — пытается удержать позицию - * BodyThrust — направленный импульс - * - * Constraint (новые): - * AlignPosition, AlignOrientation, LinearVelocity, AngularVelocity, Torque, - * VectorForce, Spring, RodConstraint, RopeConstraint, ... - * - * MVP: реализуем самые частые (BodyVelocity, BodyGyro, AlignPosition, VectorForce). - * Остальные — заглушки + warning. - * - * Архитектура: - * - Когда Lua делает `Instance.new("BodyVelocity", part)`, мы создаём RbxBodyVelocity, - * прикрепляем к Part через .Parent. - * - На каждом tick шедулера обходим активные movers и отсылаем physForce в main. - * - Main применяет к Babylon physics impostor. - */ - -import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js'; - -class RbxBodyMoverBase extends RbxInstance { - constructor(className) { - super(className, { Name: className }); - this._ctx = null; // { send, registerMover } - this.__parentPart = null; - } - /** Установить родителя и зарегистрироваться в physics-manager. */ - setMoverParent(part) { - this.Parent = part; - if (part && part.__primId != null) { - this.__parentPart = part; - this._ctx?.registerMover?.(this); - } - } -} - -export class RbxBodyVelocity extends RbxBodyMoverBase { - constructor() { - super('BodyVelocity'); - this.Velocity = new RbxVector3(0, 0, 0); - this.MaxForce = new RbxVector3(4000, 4000, 4000); - this.P = 1250; - } - _step(_dt) { - if (!this.__parentPart || !this._ctx) return; - // posVel — желаемая velocity. Применяем как setVelocity. - this._ctx.send('partVel', { - primId: this.__parentPart.__primId, - vx: this.Velocity.X, - vy: this.Velocity.Y, - vz: this.Velocity.Z, - }); - } -} - -export class RbxBodyGyro extends RbxBodyMoverBase { - constructor() { - super('BodyGyro'); - this.CFrame = null; // целевое вращение - this.MaxTorque = new RbxVector3(4000, 4000, 4000); - this.D = 500; - this.P = 3000; - } - _step(_dt) { - if (!this.__parentPart || !this._ctx || !this.CFrame) return; - const [rx, ry, rz] = this.CFrame.toEulerXYZ(); - this._ctx.send('partSet', { - primId: this.__parentPart.__primId, - prop: 'rotation', - value: { rx, ry, rz }, - }); - } -} - -export class RbxBodyPosition extends RbxBodyMoverBase { - constructor() { - super('BodyPosition'); - this.Position = new RbxVector3(0, 0, 0); - this.MaxForce = new RbxVector3(4000, 4000, 4000); - this.D = 1250; - this.P = 10000; - } - _step(_dt) { - if (!this.__parentPart || !this._ctx) return; - this._ctx.send('partSet', { - primId: this.__parentPart.__primId, - prop: 'position', - value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z }, - }); - } -} - -export class RbxBodyForce extends RbxBodyMoverBase { - constructor() { - super('BodyForce'); - this.Force = new RbxVector3(0, 0, 0); - } - _step(dt) { - if (!this.__parentPart || !this._ctx) return; - this._ctx.send('partForce', { - primId: this.__parentPart.__primId, - fx: this.Force.X * dt, fy: this.Force.Y * dt, fz: this.Force.Z * dt, - }); - } -} - -export class RbxBodyAngularVelocity extends RbxBodyMoverBase { - constructor() { - super('BodyAngularVelocity'); - this.AngularVelocity = new RbxVector3(0, 0, 0); - this.MaxTorque = new RbxVector3(4000, 4000, 4000); - } - _step(_dt) { - if (!this.__parentPart || !this._ctx) return; - this._ctx.send('partAngVel', { - primId: this.__parentPart.__primId, - wx: this.AngularVelocity.X, wy: this.AngularVelocity.Y, wz: this.AngularVelocity.Z, - }); - } -} - -/* ──────── New Constraints ──────── */ - -export class RbxAlignPosition extends RbxBodyMoverBase { - constructor() { - super('AlignPosition'); - this.Position = new RbxVector3(0, 0, 0); - this.Attachment0 = null; - this.Attachment1 = null; - this.MaxForce = 1e6; - this.Enabled = true; - } - _step(_dt) { - if (!this.Enabled || !this.__parentPart || !this._ctx) return; - this._ctx.send('partSet', { - primId: this.__parentPart.__primId, - prop: 'position', - value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z }, - }); - } -} - -export class RbxLinearVelocity extends RbxBodyMoverBase { - constructor() { - super('LinearVelocity'); - this.VectorVelocity = new RbxVector3(0, 0, 0); - this.MaxForce = 1e6; - this.Enabled = true; - } - _step(_dt) { - if (!this.Enabled || !this.__parentPart || !this._ctx) return; - this._ctx.send('partVel', { - primId: this.__parentPart.__primId, - vx: this.VectorVelocity.X, - vy: this.VectorVelocity.Y, - vz: this.VectorVelocity.Z, - }); - } -} - -/* ──────── Manager ──────── */ - -export class RobloxPhysicsManager { - constructor(send) { - this._send = send; - this._movers = new Set(); - } - - install(lua) { - const self = this; - const ctx = { - send: this._send, - registerMover: (m) => self._movers.add(m), - }; - - // Подменяем Instance.new для физических классов - const origInstance = lua.global.get('Instance'); - lua.global.set('Instance', { - new: (className, parent) => { - let inst = null; - switch (className) { - case 'BodyVelocity': inst = new RbxBodyVelocity(); break; - case 'BodyGyro': inst = new RbxBodyGyro(); break; - case 'BodyPosition': inst = new RbxBodyPosition(); break; - case 'BodyForce': inst = new RbxBodyForce(); break; - case 'BodyAngularVelocity':inst = new RbxBodyAngularVelocity(); break; - case 'AlignPosition': inst = new RbxAlignPosition(); break; - case 'LinearVelocity': inst = new RbxLinearVelocity(); break; - } - if (inst) { - inst._ctx = ctx; - if (parent) { - inst.setMoverParent(parent); - if (parent.Children) parent.Children.push(inst); - } - return inst; - } - return origInstance.new(className, parent); - }, - }); - } - - tick(dt) { - for (const m of [...this._movers]) { - if (m.__destroyed || !m.__parentPart) { this._movers.delete(m); continue; } - try { m._step(dt); } catch (e) {} - } - } -} diff --git a/src/editor/engine/roblox-scheduler.js b/src/editor/engine/roblox-scheduler.js deleted file mode 100644 index 936c181..0000000 --- a/src/editor/engine/roblox-scheduler.js +++ /dev/null @@ -1,209 +0,0 @@ -/** - * roblox-scheduler.js — шедулер корутин для Roblox-Lua wait/task. - * - * Архитектура: - * - Каждый верхне-уровневый Lua-код оборачивается в coroutine. - * - wait(sec) / task.wait(sec) делают coroutine.yield(sec) - * - Шедулер запоминает: { coro, resumeAt: tick + sec } - * - На каждом handleTick из main thread шедулер ресюмит готовые корутины - * - * RBXScriptSignal.Wait() = аналогично, но wait не на время, а на event'е: - * - { coro, waitingForSignal: signalName } - * - При Fire() сигнала шедулер ресюмит все ждущие - * - * Использование: - * const sched = new RobloxScheduler(luaEngine); - * sched.spawnMain(luaSource); - * // Каждый кадр: - * sched.tick(dtSec); - * // При событии: - * sched.fireSignal('Heartbeat', dt); - */ - -export class RobloxScheduler { - constructor(lua) { - this.lua = lua; - this.time = 0; - this.tasks = []; // [{ coro, resumeAt, waitForSignal?, signalArgsBuf? }] - this.signalWaiters = new Map(); // name → [task] - this._coroBox = null; - } - - /** - * Регистрирует глобалы wait/task.wait/task.spawn/task.delay в Lua-VM. - * Должно вызываться ПОСЛЕ registerRobloxApi (т.к. перебивает заглушки). - */ - install() { - const self = this; - // wait(sec) — yield в корутине на sec секунд - this.lua.global.set('wait', (sec) => { - // Этот wait вызовется из Lua. Если мы внутри корутины (а мы внутри - // т.к. spawnMain обернул всё) — yield. Иначе вернём дельту времени - // как обычное wait в Roblox. - const s = +sec || 0; - self._currentYield = { kind: 'sleep', sec: s }; - // Возврат тут — это значение которое получит await в Lua; - // wasmoon обработает yield извне. - return s; - }); - this.lua.global.set('task', { - wait: (sec) => { - self._currentYield = { kind: 'sleep', sec: +sec || 0 }; - return +sec || 0; - }, - spawn: (fn, ...args) => { - self.spawnCoroutine(fn, args); - }, - delay: (sec, fn, ...args) => { - self.tasks.push({ - resumeAt: self.time + (+sec || 0), - runFn: () => { try { fn(...args); } catch (e) {} }, - }); - }, - defer: (fn, ...args) => { - self.tasks.push({ - resumeAt: self.time, - runFn: () => { try { fn(...args); } catch (e) {} }, - }); - }, - }); - this.lua.global.set('spawn', (fn) => { self.spawnCoroutine(fn, []); }); - this.lua.global.set('delay', (sec, fn) => { - self.tasks.push({ - resumeAt: self.time + (+sec || 0), - runFn: () => { try { fn(); } catch (e) {} }, - }); - }); - } - - /** - * Запустить верхне-уровневый Lua-код как корутину. - * Возвращает Promise который резолвится когда код достиг ready (либо ушёл в первый yield). - */ - async spawnMain(luaSource) { - // Оборачиваем источник в coroutine.wrap(function() ... end) - // и сразу зовём — это даёт нам ручку на корутине через специальный - // приём: храним её в global _userCoro. - const wrapped = ` - _userCoro = coroutine.create(function() - ${luaSource} - end) - local ok, yieldVal = coroutine.resume(_userCoro) - if not ok then - error("user script error: " .. tostring(yieldVal)) - end - return yieldVal - `; - try { - await this.lua.doString(wrapped); - const coroStatus = await this.lua.doString('return coroutine.status(_userCoro)'); - if (coroStatus === 'suspended') { - // Ушла в yield — добавляем в шедулер - const yieldInfo = this._currentYield || { kind: 'sleep', sec: 0 }; - this._currentYield = null; - this.tasks.push({ - resumeAt: this.time + (yieldInfo.kind === 'sleep' ? yieldInfo.sec : 0), - waitForSignal: yieldInfo.kind === 'signal' ? yieldInfo.name : null, - coro: '_userCoro', - }); - } - } catch (e) { - console.warn('spawnMain error:', e); - } - } - - /** - * Запустить произвольную функцию как корутину (для task.spawn). - */ - spawnCoroutine(fn, args) { - // Создаём корутину на JS-стороне: просто вызываем fn() сразу, - // а если внутри неё дёрнут wait — yield не сработает (JS не делает - // sync yield в обычной функции). Поэтому task.spawn для JS-функций - // равен прямому вызову. - // В будущем (4.7.1) можно через Lua coroutine реализовать. - try { fn(...(args || [])); } catch (e) { /* swallow */ } - } - - /** - * Продвинуть время на dt и резюмить готовые корутины. - * Также автоматически fire'ит RunService.Heartbeat / Stepped / RenderStepped. - */ - async tick(dtSec) { - const dt = +dtSec || 0; - this.time += dt; - // Heartbeat / Stepped / RenderStepped для RunService - const game = this.lua.global.get('game'); - if (game && typeof game.GetService === 'function') { - const rs = game.GetService('RunService'); - if (rs) { - if (rs.Heartbeat && rs.Heartbeat.Fire) rs.Heartbeat.Fire(dt); - if (rs.Stepped && rs.Stepped.Fire) rs.Stepped.Fire(this.time, dt); - if (rs.RenderStepped && rs.RenderStepped.Fire) rs.RenderStepped.Fire(dt); - } - } - // Резюмим всё что готово - const ready = this.tasks.filter(t => !t.waitForSignal && t.resumeAt <= this.time); - this.tasks = this.tasks.filter(t => !(ready.includes(t))); - for (const t of ready) { - await this._resumeTask(t); - } - } - - /** - * Fire signal — разбудить все task'и ждущие этого сигнала. - */ - async fireSignal(name, ...args) { - const waiters = this.signalWaiters.get(name) || []; - this.signalWaiters.set(name, []); - for (const t of waiters) { - // Resume корутины передавая args как возврат :Wait() - await this._resumeTask(t, args); - } - } - - async _resumeTask(task, resumeArgs = []) { - if (task.runFn) { - try { - const ret = task.runFn(); - if (ret && typeof ret.then === 'function') await ret; - } catch (e) {} - return; - } - if (task.coro) { - try { - // resumeArgs идут как аргументы в coroutine.resume - const argsCode = resumeArgs.map((a, i) => { - if (typeof a === 'number') return String(a); - if (typeof a === 'string') return JSON.stringify(a); - return 'nil'; - }).join(', '); - const code = ` - local ok, val = coroutine.resume(${task.coro}${argsCode ? ', ' + argsCode : ''}) - if not ok then - error("coro error: " .. tostring(val)) - end - return val - `; - await this.lua.doString(code); - const status = await this.lua.doString(`return coroutine.status(${task.coro})`); - if (status === 'suspended') { - // Опять ушла в yield - const yi = this._currentYield || { kind: 'sleep', sec: 0 }; - this._currentYield = null; - if (yi.kind === 'sleep') { - this.tasks.push({ - resumeAt: this.time + yi.sec, - coro: task.coro, - }); - } else if (yi.kind === 'signal') { - const list = this.signalWaiters.get(yi.name) || []; - list.push({ coro: task.coro }); - this.signalWaiters.set(yi.name, list); - } - } - } catch (e) { - // Корутина завершилась с ошибкой — просто дропаем - } - } - } -} diff --git a/src/editor/engine/roblox-services.js b/src/editor/engine/roblox-services.js deleted file mode 100644 index 8ffbfba..0000000 --- a/src/editor/engine/roblox-services.js +++ /dev/null @@ -1,384 +0,0 @@ -/** - * roblox-services.js — расширения Roblox-API для сервисов: - * Players / Humanoid / UserInputService / RemoteEvent / RemoteFunction - * / DataStoreService / HttpService. - * - * Регистрируется ПОСЛЕ registerRobloxApi (см. roblox-shim.js). - * - * Поведение: - * - Players.LocalPlayer.Character.Humanoid.Health, WalkSpeed, JumpPower - * мапятся на game.player.* в Rublox через `playerCmd` IPC. - * - UserInputService.InputBegan/InputEnded — пробрасываются из main - * по событию через fireEvent. - * - RemoteEvent:FireServer/FireClient → broadcast. - * - DataStoreService:GetDataStore → game.save. - */ - -import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js'; - -/* ──────── Humanoid ──────── */ - -class RbxHumanoid extends RbxInstance { - constructor(ctx) { - super('Humanoid', { Name: 'Humanoid' }); - this._ctx = ctx; // { send, getPlayerState } - this._snap = { - Health: 100, - MaxHealth: 100, - WalkSpeed: 16, - JumpPower: 50, - JumpHeight: 7.2, - HipHeight: 0, - HumanoidStateType: 'GettingUp', - PlatformStand: false, - }; - this.Died = new RbxSignal('Died'); - this.HealthChanged = new RbxSignal('HealthChanged'); - this.Touched = new RbxSignal('Touched'); - this.Running = new RbxSignal('Running'); - this.Jumping = new RbxSignal('Jumping'); - this.StateChanged = new RbxSignal('StateChanged'); - } - - get Health() { return this._snap.Health; } - set Health(v) { - const old = this._snap.Health; - const nv = Math.max(0, +v || 0); - this._snap.Health = nv; - if (nv !== old) this.HealthChanged.Fire(nv); - if (nv <= 0 && old > 0) { - this.Died.Fire(); - this._ctx.send?.('playerCmd', { method: 'die', args: [] }); - } else { - this._ctx.send?.('playerCmd', { method: 'setHealth', args: [nv] }); - } - } - get MaxHealth() { return this._snap.MaxHealth; } - set MaxHealth(v) { - this._snap.MaxHealth = +v || 100; - this._ctx.send?.('playerCmd', { method: 'setMaxHealth', args: [this._snap.MaxHealth] }); - } - get WalkSpeed() { return this._snap.WalkSpeed; } - set WalkSpeed(v) { - this._snap.WalkSpeed = +v || 0; - this._ctx.send?.('playerCmd', { method: 'setWalkSpeed', args: [this._snap.WalkSpeed] }); - } - get JumpPower() { return this._snap.JumpPower; } - set JumpPower(v) { - this._snap.JumpPower = +v || 0; - this._ctx.send?.('playerCmd', { method: 'setJumpPower', args: [this._snap.JumpPower] }); - } - get JumpHeight() { return this._snap.JumpHeight; } - set JumpHeight(v) { - this._snap.JumpHeight = +v || 0; - this._ctx.send?.('playerCmd', { method: 'setJumpHeight', args: [this._snap.JumpHeight] }); - } - get PlatformStand() { return !!this._snap.PlatformStand; } - set PlatformStand(v) { - this._snap.PlatformStand = !!v; - this._ctx.send?.('playerCmd', { method: 'setPlatformStand', args: [!!v] }); - } - TakeDamage(amount) { - this.Health = Math.max(0, this.Health - (+amount || 0)); - } - Move(direction, relative) { - if (direction instanceof RbxVector3) { - this._ctx.send?.('playerCmd', { - method: 'move', - args: [{ x: direction.X, y: direction.Y, z: direction.Z }, !!relative], - }); - } - } - Jump() { - this._ctx.send?.('playerCmd', { method: 'jump', args: [] }); - } - LoadAnimation(animation) { - // Animation объект — content rbxassetid. Возвращаем animation-track stub. - const aid = animation?.AnimationId || ''; - return { - AnimationId: aid, - Length: 0, - IsPlaying: false, - Looped: false, - Play: () => this._ctx.send?.('playerCmd', { method: 'playAnim', args: [aid] }), - Stop: () => this._ctx.send?.('playerCmd', { method: 'stopAnim', args: [aid] }), - AdjustSpeed: (s) => this._ctx.send?.('playerCmd', { method: 'animSpeed', args: [aid, s] }), - GetTimeOfKeyframe: () => 0, - KeyframeReached: new RbxSignal('KeyframeReached'), - }; - } - ChangeState(state) { - this._snap.HumanoidStateType = state; - this.StateChanged.Fire(state); - } - SetStateEnabled(_state, _enabled) { /* noop */ } - GetState() { return this._snap.HumanoidStateType; } -} - -/* ──────── Character / Player ──────── */ - -class RbxCharacter extends RbxInstance { - constructor(ctx) { - super('Model', { Name: 'Character' }); - // HumanoidRootPart — это «Position персонажа» - this.HumanoidRootPart = new RbxInstance('Part', { Name: 'HumanoidRootPart', Parent: this }); - // mock Position через getter — берём текущую позицию из ctx - Object.defineProperty(this.HumanoidRootPart, 'Position', { - get: () => { - const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 }; - return new RbxVector3(p.x, p.y, p.z); - }, - set: (v) => { - if (v instanceof RbxVector3) { - ctx.send?.('playerCmd', { method: 'teleport', args: [v.X, v.Y, v.Z] }); - } - }, - }); - Object.defineProperty(this.HumanoidRootPart, 'CFrame', { - get: () => { - const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 }; - return { X: p.x, Y: p.y, Z: p.z, p: { X: p.x, Y: p.y, Z: p.z } }; - }, - set: (v) => { - if (v && typeof v === 'object') { - ctx.send?.('playerCmd', { method: 'teleport', args: [v.X || 0, v.Y || 0, v.Z || 0] }); - } - }, - }); - this.Children.push(this.HumanoidRootPart); - this.Humanoid = new RbxHumanoid(ctx); - this.Humanoid.Parent = this; - this.Children.push(this.Humanoid); - } -} - -class RbxPlayer extends RbxInstance { - constructor(ctx) { - super('Player', { Name: 'Player' }); - this.UserId = 1; - this.DisplayName = 'Player'; - this.Character = new RbxCharacter(ctx); - this.CharacterAdded = new RbxSignal('CharacterAdded'); - this.CharacterRemoving = new RbxSignal('CharacterRemoving'); - // На MVP — характер уже создан. - setTimeout(() => this.CharacterAdded.Fire(this.Character), 0); - this.leaderstats = new RbxInstance('Folder', { Name: 'leaderstats', Parent: this }); - this.Children.push(this.leaderstats); - } - GetMouse() { - return { Hit: { Position: new RbxVector3(0, 0, 0) }, Target: null, - Button1Down: new RbxSignal('Button1Down'), Move: new RbxSignal('Move') }; - } - Kick(reason) { - // в нашем плеере — просто log - return reason; - } -} - -/* ──────── UserInputService ──────── */ - -class RbxUserInputService extends RbxInstance { - constructor() { - super('UserInputService', { Name: 'UserInputService' }); - this.InputBegan = new RbxSignal('InputBegan'); - this.InputEnded = new RbxSignal('InputEnded'); - this.InputChanged = new RbxSignal('InputChanged'); - this.JumpRequest = new RbxSignal('JumpRequest'); - this.KeyboardEnabled = true; - this.MouseEnabled = true; - this.TouchEnabled = false; - } - GetMouseLocation() { return { X: 0, Y: 0 }; } - IsKeyDown(_keyCode) { return false; } // в MVP всегда false -} - -/* ──────── RemoteEvent / RemoteFunction ──────── */ - -class RbxRemoteEvent extends RbxInstance { - constructor(ctx) { - super('RemoteEvent', { Name: 'RemoteEvent' }); - this._ctx = ctx; - this.OnServerEvent = new RbxSignal('OnServerEvent'); - this.OnClientEvent = new RbxSignal('OnClientEvent'); - } - FireServer(...args) { - // singleplayer: server == client, просто отдаём в OnServerEvent - this.OnServerEvent.Fire(this._ctx.localPlayer, ...args); - this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args }); - } - FireClient(_player, ...args) { - this.OnClientEvent.Fire(...args); - this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args }); - } - FireAllClients(...args) { - this.OnClientEvent.Fire(...args); - this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args }); - } -} - -class RbxRemoteFunction extends RbxInstance { - constructor(ctx) { - super('RemoteFunction', { Name: 'RemoteFunction' }); - this._ctx = ctx; - this.OnServerInvoke = null; // function(player, ...args) → result - } - InvokeServer(...args) { - if (typeof this.OnServerInvoke === 'function') { - try { return this.OnServerInvoke(this._ctx.localPlayer, ...args); } catch (e) {} - } - return null; - } - InvokeClient(_player, ...args) { - if (typeof this.OnClientInvoke === 'function') { - try { return this.OnClientInvoke(...args); } catch (e) {} - } - return null; - } -} - -/* ──────── DataStoreService ──────── */ - -class RbxDataStore { - constructor(name, ctx) { - this.name = name; - this._ctx = ctx; - } - GetAsync(key) { - try { - const data = this._ctx.loadSave?.(this.name + ':' + key); - return data ?? null; - } catch (e) { return null; } - } - SetAsync(key, value) { - this._ctx.saveSave?.(this.name + ':' + key, value); - return value; - } - UpdateAsync(key, updaterFn) { - const cur = this.GetAsync(key); - const next = updaterFn(cur); - if (next !== undefined) this.SetAsync(key, next); - return next; - } - IncrementAsync(key, delta) { - const cur = +this.GetAsync(key) || 0; - const next = cur + (+delta || 1); - this.SetAsync(key, next); - return next; - } - RemoveAsync(key) { - this._ctx.removeSave?.(this.name + ':' + key); - } -} - -class RbxDataStoreService extends RbxInstance { - constructor(ctx) { - super('DataStoreService', { Name: 'DataStoreService' }); - this._ctx = ctx; - this._stores = new Map(); - } - GetDataStore(name) { - if (!this._stores.has(name)) this._stores.set(name, new RbxDataStore(name, this._ctx)); - return this._stores.get(name); - } - GetGlobalDataStore() { return this.GetDataStore('__global__'); } - GetOrderedDataStore(name) { return this.GetDataStore('ordered:' + name); } -} - -/* ──────── HttpService ──────── */ - -class RbxHttpService extends RbxInstance { - constructor(ctx) { - super('HttpService', { Name: 'HttpService' }); - this._ctx = ctx; - this.HttpEnabled = false; // в нашем плеере по дефолту выкл, безопаснее - } - GenerateGUID(wrap) { - const c = () => Math.random().toString(16).slice(2, 6); - const guid = `${c()}${c()}-${c()}-${c()}-${c()}-${c()}${c()}${c()}`.toUpperCase(); - return wrap === false ? guid : `{${guid}}`; - } - JSONEncode(value) { try { return JSON.stringify(value); } catch (e) { return ''; } } - JSONDecode(s) { try { return JSON.parse(s); } catch (e) { return null; } } - GetAsync(url) { - // CORS / sandbox: блокируем в MVP, возвращаем заглушку - this._ctx.send?.('log', { level: 'warn', text: `HttpService:GetAsync(${url}) blocked in MVP` }); - return ''; - } - PostAsync(url) { - this._ctx.send?.('log', { level: 'warn', text: `HttpService:PostAsync(${url}) blocked in MVP` }); - return ''; - } -} - -/* ──────── install ──────── */ - -export function installRobloxServices(lua, ctx) { - // ctx: { send, getPlayerState, getSnapPlayer, loadSave, saveSave, removeSave } - const game = lua.global.get('game'); - if (!game) return; - - // Создаём LocalPlayer - const player = new RbxPlayer({ - send: ctx.send, - getPlayerState: ctx.getPlayerState, - }); - - // Players service апгрейдим - const players = game.GetService('Players'); - if (players) { - players.LocalPlayer = player; - // GetPlayers / GetPlayerFromCharacter - players.GetPlayers = () => [player]; - players.GetPlayerFromCharacter = (c) => (c === player.Character ? player : null); - } - - // UserInputService - const uis = new RbxUserInputService(); - // RemoteEvent / DataStoreService / HttpService — выдаются через GetService - const dss = new RbxDataStoreService({ - loadSave: ctx.loadSave, - saveSave: ctx.saveSave, - removeSave: ctx.removeSave, - }); - const httpSvc = new RbxHttpService({ send: ctx.send }); - - // Подмена GetService — добавляем наши новые сервисы - const origGetService = game.GetService; - game.GetService = function(svc) { - if (svc === 'UserInputService') return uis; - if (svc === 'DataStoreService') return dss; - if (svc === 'HttpService') return httpSvc; - // ContextActionService — стаб - if (svc === 'ContextActionService') { - return { - ClassName: 'ContextActionService', Name: 'ContextActionService', - BindAction: (_name, fn, _gui, ...keys) => { /* в MVP — игнор */ }, - UnbindAction: () => {}, - }; - } - return origGetService.call(this, svc); - }; - - // Instance.new('RemoteEvent') / 'RemoteFunction' — переопределяем фабрику - const origInstance = lua.global.get('Instance'); - lua.global.set('Instance', { - new: (className, parent) => { - if (className === 'RemoteEvent') { - const r = new RbxRemoteEvent({ send: ctx.send, localPlayer: player }); - if (parent) { r.Parent = parent; parent.Children.push(r); } - return r; - } - if (className === 'RemoteFunction') { - const r = new RbxRemoteFunction({ send: ctx.send, localPlayer: player }); - if (parent) { r.Parent = parent; parent.Children.push(r); } - return r; - } - return origInstance.new(className, parent); - }, - }); - - return { player, uis, dss, httpSvc }; -} - -export { RbxHumanoid, RbxCharacter, RbxPlayer, RbxUserInputService, - RbxRemoteEvent, RbxRemoteFunction, RbxDataStoreService, RbxHttpService }; diff --git a/src/editor/engine/roblox-shim.js b/src/editor/engine/roblox-shim.js deleted file mode 100644 index 362b0bb..0000000 --- a/src/editor/engine/roblox-shim.js +++ /dev/null @@ -1,715 +0,0 @@ -/** - * roblox-shim.js — регистрация Roblox API внутри Lua-VM (wasmoon). - * - * Используется из RobloxLuaWorker.js. Регистрирует глобалы: - * - game, workspace, script ← Instance-прокси - * - Vector3, Color3, CFrame, UDim, UDim2 ← конструкторы математических классов - * - Instance.new(class) ← фабрика - * - wait, task, tick, os, print, warn ← стандартные глобалы - * - Enum ← enum-таблица - * - * Архитектура: - * - JS-классы (RbxVector3, RbxCFrame, ...) — обычные дата-объекты с - * перегруженными методами. - * - Instance — прокси-объект который хранит { className, properties, children, parent }. - * Геттеры/сеттеры эмулируются через __index/__newindex (mt в wasmoon). - * - RBXScriptSignal — JS-объект с Connect/Wait/Disconnect. - * - * Sandbox-side: при изменении Part.Position и т.п. отсылаем в main thread - * `partSet` → main применит к Babylon-сцене. - */ - -/* ──────── Math classes ──────── */ - -class RbxVector3 { - constructor(x, y, z) { - this.X = +x || 0; - this.Y = +y || 0; - this.Z = +z || 0; - } - get Magnitude() { - return Math.sqrt(this.X*this.X + this.Y*this.Y + this.Z*this.Z); - } - get Unit() { - const m = this.Magnitude || 1; - return new RbxVector3(this.X / m, this.Y / m, this.Z / m); - } - Dot(o) { return this.X*o.X + this.Y*o.Y + this.Z*o.Z; } - Cross(o) { - return new RbxVector3( - this.Y*o.Z - this.Z*o.Y, - this.Z*o.X - this.X*o.Z, - this.X*o.Y - this.Y*o.X, - ); - } - Lerp(o, alpha) { - return new RbxVector3( - this.X + (o.X - this.X) * alpha, - this.Y + (o.Y - this.Y) * alpha, - this.Z + (o.Z - this.Z) * alpha, - ); - } - add(o) { return new RbxVector3(this.X + o.X, this.Y + o.Y, this.Z + o.Z); } - sub(o) { return new RbxVector3(this.X - o.X, this.Y - o.Y, this.Z - o.Z); } - mul(scalar) { - if (typeof scalar === 'number') { - return new RbxVector3(this.X * scalar, this.Y * scalar, this.Z * scalar); - } - return new RbxVector3(this.X * scalar.X, this.Y * scalar.Y, this.Z * scalar.Z); - } - toString() { return `${this.X}, ${this.Y}, ${this.Z}`; } -} - -class RbxColor3 { - constructor(r, g, b) { - this.R = +r || 0; - this.G = +g || 0; - this.B = +b || 0; - } - static fromRGB(r, g, b) { - return new RbxColor3((r||0)/255, (g||0)/255, (b||0)/255); - } - static fromHex(hex) { - const h = String(hex || '#000000').replace('#',''); - return new RbxColor3( - parseInt(h.slice(0,2), 16)/255, - parseInt(h.slice(2,4), 16)/255, - parseInt(h.slice(4,6), 16)/255, - ); - } - Lerp(o, alpha) { - return new RbxColor3( - this.R + (o.R - this.R) * alpha, - this.G + (o.G - this.G) * alpha, - this.B + (o.B - this.B) * alpha, - ); - } - toHex() { - const h = (n) => Math.max(0, Math.min(255, Math.round(n * 255))).toString(16).padStart(2, '0'); - return `#${h(this.R)}${h(this.G)}${h(this.B)}`; - } - toString() { return `${this.R}, ${this.G}, ${this.B}`; } -} - -class RbxCFrame { - constructor(x, y, z, r00=1, r01=0, r02=0, r10=0, r11=1, r12=0, r20=0, r21=0, r22=1) { - this.X = +x || 0; this.Y = +y || 0; this.Z = +z || 0; - // Row-major 3x3 - this.r00 = r00; this.r01 = r01; this.r02 = r02; - this.r10 = r10; this.r11 = r11; this.r12 = r12; - this.r20 = r20; this.r21 = r21; this.r22 = r22; - } - static new(x, y, z) { - if (x instanceof RbxVector3) return new RbxCFrame(x.X, x.Y, x.Z); - return new RbxCFrame(x || 0, y || 0, z || 0); - } - static Angles(rx, ry, rz) { - // Euler XYZ → 3x3 (intrinsic) - const cx = Math.cos(rx), sx = Math.sin(rx); - const cy = Math.cos(ry), sy = Math.sin(ry); - const cz = Math.cos(rz), sz = Math.sin(rz); - // R = Rx * Ry * Rz - const r00 = cy*cz, r01 = -cy*sz, r02 = sy; - const r10 = sx*sy*cz + cx*sz, r11 = -sx*sy*sz + cx*cz, r12 = -sx*cy; - const r20 = -cx*sy*cz + sx*sz, r21 = cx*sy*sz + sx*cz, r22 = cx*cy; - return new RbxCFrame(0, 0, 0, r00, r01, r02, r10, r11, r12, r20, r21, r22); - } - static fromEulerAnglesXYZ(rx, ry, rz) { return RbxCFrame.Angles(rx, ry, rz); } - get Position() { return new RbxVector3(this.X, this.Y, this.Z); } - get LookVector() { return new RbxVector3(-this.r02, -this.r12, -this.r22); } - get RightVector() { return new RbxVector3(this.r00, this.r10, this.r20); } - get UpVector() { return new RbxVector3(this.r01, this.r11, this.r21); } - Lerp(o, a) { - // Линейная интерполяция (без правильного slerp на матрицах — для MVP сойдёт) - return new RbxCFrame( - this.X + (o.X - this.X) * a, - this.Y + (o.Y - this.Y) * a, - this.Z + (o.Z - this.Z) * a, - this.r00, this.r01, this.r02, - this.r10, this.r11, this.r12, - this.r20, this.r21, this.r22, - ); - } - Inverse() { - // Транспонируем 3x3 (для rotation matrix Inverse == Transpose) - return new RbxCFrame( - -this.X, -this.Y, -this.Z, - this.r00, this.r10, this.r20, - this.r01, this.r11, this.r21, - this.r02, this.r12, this.r22, - ); - } - toEulerXYZ() { - const rx = Math.atan2(this.r21, this.r22); - const ry = Math.atan2(-this.r20, Math.sqrt(this.r21*this.r21 + this.r22*this.r22)); - const rz = Math.atan2(this.r10, this.r00); - return [rx, ry, rz]; - } - toString() { return `${this.X}, ${this.Y}, ${this.Z}`; } -} - -class RbxUDim { - constructor(scale, offset) { this.Scale = +scale || 0; this.Offset = +offset | 0; } - toString() { return `${this.Scale}, ${this.Offset}`; } -} - -class RbxUDim2 { - constructor(xs, xo, ys, yo) { - this.X = new RbxUDim(xs, xo); - this.Y = new RbxUDim(ys, yo); - } - static new(xs, xo, ys, yo) { return new RbxUDim2(xs, xo, ys, yo); } - static fromScale(xs, ys) { return new RbxUDim2(xs, 0, ys, 0); } - static fromOffset(xo, yo) { return new RbxUDim2(0, xo, 0, yo); } - toString() { return `${this.X.Scale}, ${this.X.Offset}, ${this.Y.Scale}, ${this.Y.Offset}`; } -} - -/* ──────── RBXScriptSignal ──────── */ - -let _signalIdCounter = 1000; - -class RbxSignal { - constructor(name) { - this.name = name; - this.id = _signalIdCounter++; - this.connections = []; - } - Connect(callback) { - const conn = { callback, connected: true }; - this.connections.push(conn); - return { - Disconnect: () => { conn.connected = false; }, - disconnect: () => { conn.connected = false; }, - Connected: () => conn.connected, - }; - } - // Legacy Roblox API — lowercase alias - connect(callback) { return this.Connect(callback); } - Wait() { return null; } - wait() { return null; } - Fire(...args) { - for (const c of this.connections) { - if (!c.connected) continue; - try { c.callback(...args); } catch (e) { /* swallow */ } - } - } - fire(...args) { return this.Fire(...args); } -} - -/* ──────── Instance прокси ──────── */ - -let _instanceCounter = 1; - -// Null-stub: возвращается из FindFirstChild/WaitForChild когда объект не найден. -// Имеет все методы Instance как no-op, чтобы Lua-цепочки вроде -// game.Players.LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("X").Touched:Connect(fn) -// не падали с "attempt to call js_null", когда промежуточный объект не существует. -// Все методы возвращают сам _nullStub чтобы цепочка не разорвалась. -// nullSignal: callable proxy. Lua делает x:Connect(fn) = x.Connect(x, fn), -// но также pattern signal:Connect(fn) сначала достаёт signal.Connect (это функция), -// потом вызывает её. Мы возвращаем функцию которая безусловно возвращает {Disconnect}. -const _nullConn = { Disconnect: () => {}, disconnect: () => {}, Connected: false }; -const _nullSignalFn = () => _nullConn; -const _nullSignal = new Proxy(_nullSignalFn, { - get(_, k) { - if (k === 'Connect' || k === 'connect') return _nullSignalFn; - if (k === 'Wait' || k === 'wait') return () => null; - if (k === 'Fire' || k === 'fire') return () => {}; - return undefined; - }, -}); -// Известные имена сигналов (Touched, Changed, MouseButton1Click, ...) -const _SIGNAL_NAMES = new Set([ - 'Touched','TouchEnded','Changed','Activated', - 'MouseButton1Click','MouseButton1Down','MouseButton1Up', - 'MouseButton2Click','MouseButton2Down','MouseButton2Up', - 'MouseEnter','MouseLeave','InputBegan','InputEnded','InputChanged', - 'PlayerAdded','PlayerRemoving','CharacterAdded','CharacterRemoving', - 'Heartbeat','Stepped','RenderStepped','Died','HealthChanged', - 'FocusLost','Focused','ChildAdded','ChildRemoved', - 'AncestryChanged','DescendantAdded','DescendantRemoving', - // Tool сигналы - 'Equipped','Unequipped','Selected','Deselected', - // прочие популярные - 'OnInvoke','OnServerInvoke','OnClientInvoke', - 'OnServerEvent','OnClientEvent','Fired','Triggered', - 'ChatMakeSystemMessage','ChatMade', -]); -// _makeDeepStub — рекурсивный proxy которому всё равно сколько раз его -// индексируют. На любом уровне: -// - caps-имя из _SIGNAL_NAMES → возвращает _nullSignal -// - 'Parent' → возвращает _nullStub -// - любое другое имя → callable proxy + рекурсивная глубина -// Это позволяет цепочкам типа `tool.Selected:Connect(fn)` или -// `script.Parent.Parent.Frame.Visible` молча no-op'аться. -// Вместо JS Proxy (который wasmoon оборачивает в js_promise) — используем -// специальный маркер. Реальный stub живёт на Lua-стороне. -const NULL_STUB_MARKER = { __isNullStubMarker: true }; -function _makeDeepStub() { return NULL_STUB_MARKER; } -const _nullStubBase = { __isNullStub: true, ClassName: 'Nil', Name: 'Nil', Children: [], Value: 0, Text: '', Visible: false }; -// _nullStub оставлен как маркер, но не используется как реальный stub — -// debug.setmetatable(nil) в Lua перехватывает всё это. -const _nullStub = _nullStubBase; - -class RbxInstance { - constructor(className, init = {}) { - this.__id = _instanceCounter++; - this.ClassName = className; - this.Name = init.Name || className; - this.Parent = init.Parent || null; - this.Children = []; - this.__props = {}; // raw properties (для Position и т.п.) - // Signals доступны как прямые свойства, плюс дублируются в __signals для serv-кода - this.Touched = new RbxSignal('Touched'); - this.TouchEnded = new RbxSignal('TouchEnded'); - this.Changed = new RbxSignal('Changed'); - this.AncestryChanged = new RbxSignal('AncestryChanged'); - this.ChildAdded = new RbxSignal('ChildAdded'); - this.ChildRemoved = new RbxSignal('ChildRemoved'); - this.__signals = { - Touched: this.Touched, - TouchEnded: this.TouchEnded, - Changed: this.Changed, - AncestryChanged: this.AncestryChanged, - ChildAdded: this.ChildAdded, - ChildRemoved: this.ChildRemoved, - }; - this.__sceneState = null; - } - - GetChildren() { return [...this.Children]; } - GetDescendants() { - const out = []; - const walk = (n) => { - for (const c of n.Children) { out.push(c); walk(c); } - }; - walk(this); - return out; - } - FindFirstChild(name, recursive) { - for (const c of this.Children) { - if (c.Name === name) return c; - if (recursive) { - const found = c.FindFirstChild(name, true); - if (found) return found; - } - } - // Возвращаем undefined — wasmoon отдаст это как nil. - // Lua-side debug.setmetatable(nil) перехватит дальнейшую индексацию. - return undefined; - } - FindFirstChildOfClass(className) { - for (const c of this.Children) { - if (c.ClassName === className) return c; - } - return undefined; - } - FindFirstAncestor(name) { - let p = this.Parent; - while (p) { - if (p.Name === name) return p; - p = p.Parent; - } - return undefined; - } - WaitForChild(name, _timeout) { - // В MVP — синхронный поиск без ожидания. В 4.7 через корутины можно ждать. - return this.FindFirstChild(name); - } - IsA(className) { - if (this.ClassName === className) return true; - // Roblox class hierarchy: Part isA BasePart isA PVInstance isA Instance. - const hierarchy = { - 'Part': ['BasePart', 'PVInstance', 'Instance'], - 'WedgePart': ['BasePart', 'PVInstance', 'Instance'], - 'CornerWedgePart': ['BasePart', 'PVInstance', 'Instance'], - 'MeshPart': ['BasePart', 'PVInstance', 'Instance'], - 'UnionOperation': ['PartOperation', 'BasePart', 'PVInstance', 'Instance'], - 'TrussPart': ['BasePart', 'PVInstance', 'Instance'], - 'SpawnLocation': ['Part', 'BasePart', 'PVInstance', 'Instance'], - 'Script': ['BaseScript', 'LuaSourceContainer', 'Instance'], - 'LocalScript': ['BaseScript', 'LuaSourceContainer', 'Instance'], - 'ModuleScript': ['LuaSourceContainer', 'Instance'], - 'Folder': ['Instance'], - 'Model': ['PVInstance', 'Instance'], - 'Sound': ['Instance'], - 'PointLight': ['Light', 'Instance'], - 'SpotLight': ['Light', 'Instance'], - 'Humanoid': ['Instance'], - }; - const ancestors = hierarchy[this.ClassName] || []; - return ancestors.includes(className); - } - Destroy() { - if (this.Parent && this.Parent.Children) { - const idx = this.Parent.Children.indexOf(this); - if (idx >= 0) this.Parent.Children.splice(idx, 1); - } - this.Parent = null; - this.__destroyed = true; - } - Clone() { - const cl = new RbxInstance(this.ClassName); - cl.Name = this.Name; - cl.__props = JSON.parse(JSON.stringify(this.__props)); - for (const c of this.Children) { - const cc = c.Clone(); - cc.Parent = cl; - cl.Children.push(cc); - } - return cl; - } - - GetPropertyChangedSignal(propName) { - const sigName = `Changed:${propName}`; - if (!this.__signals[sigName]) this.__signals[sigName] = new RbxSignal(sigName); - return this.__signals[sigName]; - } -} - -/* ──────── Part — наследник Instance с реальными свойствами сцены ──────── */ - -class RbxPart extends RbxInstance { - constructor(primId, init = {}) { - super(init.ClassName || 'Part', init); - this.__primId = primId; // id примитива в Rublox-сцене - this.__sendFn = null; // setter из shim init - // Кешированные свойства (mirror'ятся через handleTick) - this._snap = init.snap || {}; - } - - get Position() { - return new RbxVector3(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0); - } - set Position(v) { - if (v instanceof RbxVector3) { - this._snap.x = v.X; this._snap.y = v.Y; this._snap.z = v.Z; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'position', value: { x: v.X, y: v.Y, z: v.Z } }); - } - } - get CFrame() { - return new RbxCFrame(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0); - } - set CFrame(cf) { - if (cf instanceof RbxCFrame) { - this._snap.x = cf.X; this._snap.y = cf.Y; this._snap.z = cf.Z; - const [rx, ry, rz] = cf.toEulerXYZ(); - this._snap.rx = rx; this._snap.ry = ry; this._snap.rz = rz; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'cframe', value: { x: cf.X, y: cf.Y, z: cf.Z, rx, ry, rz } }); - } - } - get Size() { - return new RbxVector3(this._snap.sx || 1, this._snap.sy || 1, this._snap.sz || 1); - } - set Size(v) { - if (v instanceof RbxVector3) { - this._snap.sx = v.X; this._snap.sy = v.Y; this._snap.sz = v.Z; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'size', value: { sx: v.X, sy: v.Y, sz: v.Z } }); - } - } - get Color() { return RbxColor3.fromHex(this._snap.color || '#cccccc'); } - set Color(c) { - if (c instanceof RbxColor3) { - const hex = c.toHex(); - this._snap.color = hex; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'color', value: hex }); - } - } - get BrickColor() { return { Color: this.Color, Name: 'Medium stone grey' }; } - set BrickColor(b) { if (b && b.Color) this.Color = b.Color; } - get Material() { return this._snap.material || 'glossy'; } - set Material(m) { - this._snap.material = m; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'material', value: m }); - } - get Anchored() { return !!this._snap.anchored; } - set Anchored(v) { - this._snap.anchored = !!v; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'anchored', value: !!v }); - } - get CanCollide() { return this._snap.canCollide !== false; } - set CanCollide(v) { - this._snap.canCollide = !!v; - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'canCollide', value: !!v }); - } - get Transparency() { return 1.0 - (this._snap.opacity ?? 1.0); } - set Transparency(v) { - this._snap.opacity = 1.0 - (+v || 0); - this.__sendFn?.('partSet', { primId: this.__primId, prop: 'opacity', value: this._snap.opacity }); - } - get Velocity() { return new RbxVector3(0, 0, 0); } - set Velocity(v) { - if (v instanceof RbxVector3) { - this.__sendFn?.('partVel', { primId: this.__primId, vx: v.X, vy: v.Y, vz: v.Z }); - } - } -} - -/* ──────── Главный entry-point: регистрация в Lua-VM ──────── */ - -export function registerRobloxApi(lua, ctx) { - const { getSceneSnap, targetPrimitiveId, send, getGuiTree, scheduler } = ctx; - - // 1. Math classes — как глобалы с .new factory - const wrap = (cls) => ({ - new: (...args) => new cls(...args), - }); - - lua.global.set('Vector3', { - new: (x, y, z) => new RbxVector3(x, y, z), - zero: new RbxVector3(0, 0, 0), - one: new RbxVector3(1, 1, 1), - xAxis: new RbxVector3(1, 0, 0), - yAxis: new RbxVector3(0, 1, 0), - zAxis: new RbxVector3(0, 0, 1), - }); - lua.global.set('Color3', { - new: (r, g, b) => new RbxColor3(r, g, b), - fromRGB: RbxColor3.fromRGB, - fromHex: RbxColor3.fromHex, - }); - lua.global.set('CFrame', { - new: RbxCFrame.new, - Angles: RbxCFrame.Angles, - fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ, - }); - lua.global.set('UDim', { new: (s, o) => new RbxUDim(s, o) }); - lua.global.set('UDim2', { - new: RbxUDim2.new, - fromScale: RbxUDim2.fromScale, - fromOffset: RbxUDim2.fromOffset, - }); - - // 2. Сцена — собираем JS-структуру из snap'а - // Workspace — корень. - const workspace = new RbxInstance('Workspace', { Name: 'Workspace' }); - const part_by_id = new Map(); - const snap = getSceneSnap(); - if (snap && snap.primitives) { - for (const [id, p] of Object.entries(snap.primitives)) { - const part = new RbxPart(+id, { - ClassName: p.type === 'wedge' ? 'WedgePart' : - p.type === 'cornerwedge' ? 'CornerWedgePart' : 'Part', - Name: p.name || 'Part', - snap: { ...p }, - }); - part.__sendFn = send; - // Сигналы Part: Touched/TouchEnded существуют на каждом по умолчанию - part.Touched = new RbxSignal('Touched'); - part.TouchEnded = new RbxSignal('TouchEnded'); - part.Parent = workspace; - workspace.Children.push(part); - part_by_id.set(+id, part); - } - } - - // 2b. GUI-tree: предсоздаём ScreenGui + Frame/Button/Label/etc по дереву - // конвертера. Каждый button получает MouseButton1Click/MouseButton1Down/Up - // сигналы которые fire'аются из main через sendGlobalEvent('guiClick'). - const gui_by_id = new Map(); - // PlayerGui контейнер внутри Players.LocalPlayer - const playerGui = new RbxInstance('PlayerGui', { Name: 'PlayerGui' }); - if (getGuiTree) { - const tree = getGuiTree() || []; - // первый проход — создаём instances - for (const el of tree) { - const cls = el.__roblox_class || 'Frame'; - const inst = new RbxInstance(cls, { Name: el.name || cls }); - inst.__guiId = el.id; - inst.Visible = el.visible !== false; - inst.Text = el.text || ''; - // Стандартные сигналы кнопок - if (cls === 'TextButton' || cls === 'ImageButton') { - inst.MouseButton1Click = new RbxSignal('MouseButton1Click'); - inst.MouseButton1Down = new RbxSignal('MouseButton1Down'); - inst.MouseButton1Up = new RbxSignal('MouseButton1Up'); - inst.Activated = new RbxSignal('Activated'); - inst.MouseEnter = new RbxSignal('MouseEnter'); - inst.MouseLeave = new RbxSignal('MouseLeave'); - } - // FocusLost для textboxes - if (cls === 'TextBox') { - inst.FocusLost = new RbxSignal('FocusLost'); - inst.Focused = new RbxSignal('Focused'); - } - // Changed-сигнал у каждого - inst.Changed = new RbxSignal('Changed'); - gui_by_id.set(el.id, inst); - } - // второй проход — parent-связи (parentId → Instance) - for (const el of tree) { - const inst = gui_by_id.get(el.id); - if (!inst) continue; - const parentInst = el.parentId ? gui_by_id.get(el.parentId) : playerGui; - if (parentInst) { - inst.Parent = parentInst; - parentInst.Children.push(inst); - } - } - } - - // 3. script — в shared-режиме не глобал, а локально создаётся при addScript. - // Здесь только заглушка чтобы простые non-shared скрипты не падали. - if (targetPrimitiveId != null && part_by_id.has(targetPrimitiveId)) { - const parentPart = part_by_id.get(targetPrimitiveId); - const scriptInst = new RbxInstance('LocalScript', { Name: 'Script' }); - scriptInst.Parent = parentPart; - parentPart.Children.push(scriptInst); - lua.global.set('script', scriptInst); - } - - // 4. game / game:GetService - const services = new Map(); - const game = new RbxInstance('DataModel', { Name: 'Game' }); - game.Children.push(workspace); - workspace.Parent = game; - - // Builtin services: - const lighting = new RbxInstance('Lighting', { Name: 'Lighting' }); - lighting.Parent = game; - game.Children.push(lighting); - services.set('Lighting', lighting); - - const replicatedStorage = new RbxInstance('ReplicatedStorage', { Name: 'ReplicatedStorage' }); - replicatedStorage.Parent = game; - game.Children.push(replicatedStorage); - services.set('ReplicatedStorage', replicatedStorage); - - const runService = new RbxInstance('RunService', { Name: 'RunService' }); - runService.Heartbeat = new RbxSignal('Heartbeat'); - runService.Stepped = new RbxSignal('Stepped'); - runService.RenderStepped = new RbxSignal('RenderStepped'); - services.set('RunService', runService); - - const playersService = new RbxInstance('Players', { Name: 'Players' }); - playersService.PlayerAdded = new RbxSignal('PlayerAdded'); - playersService.PlayerRemoving = new RbxSignal('PlayerRemoving'); - // LocalPlayer с PlayerGui + Character - const localPlayer = new RbxInstance('Player', { Name: 'Player1' }); - localPlayer.UserId = 1; - localPlayer.PlayerGui = playerGui; - playerGui.Parent = localPlayer; - localPlayer.Children.push(playerGui); - // Character заглушка с Humanoid и HumanoidRootPart - const character = new RbxInstance('Model', { Name: 'Character' }); - const humanoid = new RbxInstance('Humanoid', { Name: 'Humanoid' }); - humanoid.WalkSpeed = 16; - humanoid.JumpPower = 50; - humanoid.Health = 100; - humanoid.MaxHealth = 100; - humanoid.Died = new RbxSignal('Died'); - humanoid.HealthChanged = new RbxSignal('HealthChanged'); - humanoid.Touched = new RbxSignal('Touched'); - humanoid.Parent = character; - character.Children.push(humanoid); - character.Humanoid = humanoid; - const hrp = new RbxPart(-1, { ClassName: 'Part', Name: 'HumanoidRootPart' }); - hrp.Touched = new RbxSignal('Touched'); - hrp.Parent = character; - character.Children.push(hrp); - character.HumanoidRootPart = hrp; - localPlayer.Character = character; - localPlayer.CharacterAdded = new RbxSignal('CharacterAdded'); - localPlayer.CharacterRemoving = new RbxSignal('CharacterRemoving'); - playersService.LocalPlayer = localPlayer; - playersService.Children.push(localPlayer); - services.set('Players', playersService); - - game.GetService = function(svc) { - if (services.has(svc)) return services.get(svc); - if (svc === 'Workspace') return workspace; - if (svc === 'Workspace') return workspace; - // Неизвестный сервис — создаём заглушку, чтобы не падало - const stub = new RbxInstance(svc, { Name: svc }); - services.set(svc, stub); - return stub; - }; - game.Workspace = workspace; - game.Lighting = lighting; - game.Players = playersService; - game.ReplicatedStorage = replicatedStorage; - - lua.global.set('game', game); - lua.global.set('workspace', workspace); - lua.global.set('Workspace', workspace); - - // 5. Instance.new - lua.global.set('Instance', { - new: (className, parent) => { - const inst = new RbxInstance(className); - if (parent && parent instanceof RbxInstance) { - inst.Parent = parent; - parent.Children.push(inst); - } - return inst; - }, - }); - - // 6. wait/task.wait через scheduler. scheduler — main-side, поддерживает - // schedule(sec, fn) что fire'ит fn после задержки в следующих tick'ах. - // spawn/delay/defer запускают функцию через scheduler.spawn (отдельная корутина). - const sched = scheduler || { - schedule: (sec, fn) => { try { fn(); } catch (e) {} }, - spawn: (fn) => { try { fn(); } catch (e) {} }, - now: () => Date.now() / 1000, - }; - lua.global.set('wait', (sec) => { - // В корутине: yield на (sec || 0). Scheduler сам resume'ит. - // Тут мы синхронны (вызов из Lua) — реальный yield делается в lua-wrapper - // через coroutine.yield, который мы оборачиваем в addScript. - // Здесь просто возвращаем длительность для совместимости. - return [sec || 0, 0]; - }); - lua.global.set('task', { - wait: (sec) => sec || 0, - spawn: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; }, - delay: (sec, fn, ...args) => { sched.schedule(sec || 0, () => { try { fn(...args); } catch (e) {} }); return null; }, - defer: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; }, - }); - lua.global.set('spawn', (fn) => { sched.spawn(() => { try { fn(); } catch (e) {} }); }); - lua.global.set('delay', (sec, fn) => { sched.schedule(sec || 0, () => { try { fn(); } catch (e) {} }); }); - // require(ModuleScript) — возвращаем nil, debug.setmetatable перехватит. - lua.global.set('require', (_arg) => undefined); - lua.global.set('tick', () => Date.now() / 1000); - lua.global.set('time', () => Date.now() / 1000); - lua.global.set('elapsedTime', () => Date.now() / 1000); - - // 7. print / warn / error — пробрасываем в main как log - lua.global.set('print', (...args) => { - const text = args.map(a => luaToString(a)).join('\t'); - send('log', { level: 'info', text }); - }); - lua.global.set('warn', (...args) => { - const text = args.map(a => luaToString(a)).join('\t'); - send('log', { level: 'warn', text }); - }); - - // 8. Enum — упрощённая заглушка для самых популярных enums - const enumTable = { - Material: { Plastic: { Value: 256, Name: 'Plastic' }, Neon: { Value: 784, Name: 'Neon' }, - Metal: { Value: 512, Name: 'Metal' }, Glass: { Value: 1024, Name: 'Glass' }, - Wood: { Value: 272, Name: 'Wood' }, SmoothPlastic: { Value: 496, Name: 'SmoothPlastic' } }, - PartType: { Ball: { Value: 0, Name: 'Ball' }, Block: { Value: 1, Name: 'Block' }, - Cylinder: { Value: 2, Name: 'Cylinder' } }, - KeyCode: { Space: { Value: 32, Name: 'Space' }, W: { Value: 87, Name: 'W' }, - A: { Value: 65, Name: 'A' }, S: { Value: 83, Name: 'S' }, D: { Value: 68, Name: 'D' } }, - EasingStyle: { Linear: { Value: 0, Name: 'Linear' }, Quad: { Value: 1, Name: 'Quad' }, - Sine: { Value: 5, Name: 'Sine' } }, - EasingDirection: { In: { Value: 0, Name: 'In' }, Out: { Value: 1, Name: 'Out' }, - InOut: { Value: 2, Name: 'InOut' } }, - }; - lua.global.set('Enum', enumTable); - - return { workspace, game, part_by_id, services, gui_by_id, localPlayer, character, humanoid }; -} - -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 String(v); - if (v.toString) return v.toString(); - return ''; -} - -export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal }; diff --git a/src/editor/engine/roblox-tween.js b/src/editor/engine/roblox-tween.js deleted file mode 100644 index 4c55fd6..0000000 --- a/src/editor/engine/roblox-tween.js +++ /dev/null @@ -1,204 +0,0 @@ -/** - * roblox-tween.js — TweenService для Roblox Lua-shim. - * - * Использование в Lua: - * local TS = game:GetService("TweenService") - * local info = TweenInfo.new(2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out) - * local tween = TS:Create(part, info, {Position = Vector3.new(0, 10, 0)}) - * tween:Play() - * tween.Completed:Connect(function() print("done") end) - * - * Реализация: - * - Все активные tween'ы держатся в этом модуле. - * - На каждом tick() прогрессируется alpha = (now - startTime) / duration. - * - Применяется easing-кривая, и обновляется свойство объекта через __sendFn. - * - При alpha >= 1 — fire Completed signal и удаляем tween. - */ - -import { RbxSignal, RbxVector3, RbxColor3, RbxCFrame, RbxUDim2 } from './roblox-shim.js'; - -/* ──────── EasingStyle / Direction ──────── */ - -const EASING_FNS = { - 'Linear': (t) => t, - 'Quad': (t) => t * t, - 'Cubic': (t) => t * t * t, - 'Quart': (t) => t * t * t * t, - 'Quint': (t) => t * t * t * t * t, - 'Sine': (t) => 1 - Math.cos((t * Math.PI) / 2), - 'Bounce': (t) => { - const n1 = 7.5625, d1 = 2.75; - if (t < 1 / d1) return n1 * t * t; - if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; } - if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; } - t -= 2.625 / d1; return n1 * t * t + 0.984375; - }, - 'Elastic': (t) => { - if (t === 0) return 0; - if (t === 1) return 1; - return -(2 ** (10 * (t - 1))) * Math.sin((t - 1.1) * 5 * Math.PI); - }, - 'Back': (t) => t * t * (2.70158 * t - 1.70158), - 'Exponential': (t) => t === 0 ? 0 : 2 ** (10 * (t - 1)), -}; - -function applyDirection(t, direction) { - if (direction === 'In') return t; - if (direction === 'Out') return 1 - (1 - t); - if (direction === 'InOut') { - return t < 0.5 ? t * 2 : (1 - (1 - t) * 2); - } - return t; -} - -function easeValue(alpha, style, direction) { - const styleFn = EASING_FNS[style] || EASING_FNS.Linear; - if (direction === 'In') return styleFn(alpha); - if (direction === 'Out') return 1 - styleFn(1 - alpha); - // InOut - if (alpha < 0.5) return styleFn(alpha * 2) / 2; - return 1 - styleFn((1 - alpha) * 2) / 2; -} - -/* ──────── TweenInfo ──────── */ - -class RbxTweenInfo { - constructor(time = 1, easingStyle = 'Quad', easingDirection = 'Out', - repeatCount = 0, reverses = false, delayTime = 0) { - this.Time = +time || 0; - this.EasingStyle = typeof easingStyle === 'object' ? easingStyle.Name : easingStyle; - this.EasingDirection = typeof easingDirection === 'object' ? easingDirection.Name : easingDirection; - this.RepeatCount = repeatCount | 0; - this.Reverses = !!reverses; - this.DelayTime = +delayTime || 0; - } -} - -/* ──────── Tween ──────── */ - -class RbxTween { - constructor(instance, info, goalProps, manager) { - this.Instance = instance; - this.TweenInfo = info; - this.GoalProps = goalProps; - this._manager = manager; - this._startTime = null; - this._fromProps = null; - this._playing = false; - this._completed = false; - this.Completed = new RbxSignal('Completed'); - this.PlaybackState = 'Begin'; - } - - Play() { - if (this._playing) return; - // Снимок старых значений - this._fromProps = {}; - for (const k of Object.keys(this.GoalProps)) { - this._fromProps[k] = this.Instance[k]; // через getter Part'а - } - this._startTime = this._manager.time; - this._playing = true; - this.PlaybackState = 'Playing'; - this._manager._add(this); - } - - Pause() { this._playing = false; this.PlaybackState = 'Paused'; } - Cancel() { - this._playing = false; - this.PlaybackState = 'Cancelled'; - this._manager._remove(this); - } - - /** internal — вызывается из manager.tick */ - _step(now) { - if (!this._playing) return false; - const elapsed = now - this._startTime; - const dur = this.TweenInfo.Time || 0.001; - let alpha = Math.min(1, Math.max(0, elapsed / dur)); - const ea = easeValue(alpha, this.TweenInfo.EasingStyle, this.TweenInfo.EasingDirection); - for (const k of Object.keys(this.GoalProps)) { - const from = this._fromProps[k]; - const to = this.GoalProps[k]; - const interp = interpolate(from, to, ea); - // Set через setter в Part — он отправит partSet в main - try { this.Instance[k] = interp; } catch (e) {} - } - if (alpha >= 1) { - this._playing = false; - this._completed = true; - this.PlaybackState = 'Completed'; - this.Completed.Fire('Completed'); - return true; // удалить из активных - } - return false; - } -} - -function interpolate(from, to, a) { - if (from instanceof RbxVector3 && to instanceof RbxVector3) { - return from.Lerp(to, a); - } - if (from instanceof RbxColor3 && to instanceof RbxColor3) { - return from.Lerp(to, a); - } - if (from instanceof RbxCFrame && to instanceof RbxCFrame) { - return from.Lerp(to, a); - } - if (typeof from === 'number' && typeof to === 'number') { - return from + (to - from) * a; - } - // Иначе ничего не интерполируем - return a >= 1 ? to : from; -} - -/* ──────── Manager ──────── */ - -export class RobloxTweenManager { - constructor() { - this.active = new Set(); - this.time = 0; - } - install(lua) { - const self = this; - // TweenInfo конструктор - lua.global.set('TweenInfo', { - new: (time, style, direction, repeat_, reverses, delay_) => - new RbxTweenInfo(time, style, direction, repeat_, reverses, delay_), - }); - // Сервис: добавляем в services через game:GetService('TweenService') - // (services map передаётся в shim — но мы не имеем к нему доступа здесь; - // делаем по-другому: регистрируем сразу глобал TweenService который - // совместим с GetService('TweenService')) - const tweenService = { - ClassName: 'TweenService', - Name: 'TweenService', - Create(instance, info, goalProps) { - return new RbxTween(instance, info, goalProps, self); - }, - }; - lua.global.set('__tweenService', tweenService); - // и в game.GetService — мы делаем монки-патч если игра уже есть: - const game = lua.global.get('game'); - if (game && typeof game.GetService === 'function') { - const origGetService = game.GetService; - game.GetService = function(svc) { - if (svc === 'TweenService') return tweenService; - return origGetService.call(this, svc); - }; - } - } - - _add(tween) { this.active.add(tween); } - _remove(tween) { this.active.delete(tween); } - - tick(dtSec) { - this.time += +dtSec || 0; - for (const t of [...this.active]) { - const done = t._step(this.time); - if (done) this.active.delete(t); - } - } -} - -export { RbxTweenInfo, RbxTween }; diff --git a/src/editor/lua-monaco-setup.js b/src/editor/lua-monaco-setup.js new file mode 100644 index 0000000..ece634d --- /dev/null +++ b/src/editor/lua-monaco-setup.js @@ -0,0 +1,249 @@ +/** + * lua-monaco-setup — регистрация Lua-фич в Monaco: + * 1) Подсветка через встроенный 'lua' language (Monaco поставляется с basic-languages/lua) + * 2) Автодополнение Roblox-API (Vector3.new, Color3.fromRGB, script.Parent, game.Players, ...) + * 3) Hover-документация (наведя на Vector3 — описание + пример) + * 4) Подсветка ошибок через luaparse (на этапе 7, опционально) + * + * Регистрируется ОДИН раз глобально через флаг monaco.__rbxLuaRegistered. + */ + +const ROBLOX_LUA_API = [ + // === Глобальные функции === + { kind: 'function', name: 'print', insertText: 'print($0)', doc: 'Выводит сообщения в Output-панель.\n```lua\nprint("Привет", x, y)\n```' }, + { kind: 'function', name: 'warn', insertText: 'warn($0)', doc: 'Выводит предупреждение (жёлтым).\n```lua\nwarn("Что-то не так")\n```' }, + { kind: 'function', name: 'error', insertText: 'error(${1:"сообщение"})', doc: 'Бросает ошибку, останавливая текущий скрипт.\n```lua\nerror("Здоровье < 0")\n```' }, + { kind: 'function', name: 'wait', insertText: 'wait(${1:1})', doc: 'Приостанавливает скрипт на N секунд (заменяется на `task.wait` в новом коде).' }, + { kind: 'function', name: 'tick', insertText: 'tick()', doc: 'Возвращает количество секунд с эпохи (как `os.time()`, но дробное).' }, + { kind: 'function', name: 'pcall', insertText: 'pcall(${1:fn}, $0)', doc: 'Защищённый вызов. Возвращает `success, result|error`.\n```lua\nlocal ok, err = pcall(function() risky() end)\nif not ok then warn(err) end\n```' }, + { kind: 'function', name: 'xpcall', insertText: 'xpcall(${1:fn}, ${2:handler})', doc: 'Защищённый вызов с кастомным обработчиком ошибки.' }, + { kind: 'function', name: 'tostring', insertText: 'tostring($0)', doc: 'Преобразует значение в строку.' }, + { kind: 'function', name: 'tonumber', insertText: 'tonumber($0)', doc: 'Преобразует строку в число. Возвращает nil если не число.' }, + { kind: 'function', name: 'type', insertText: 'type($0)', doc: 'Возвращает строку с типом: "nil", "number", "string", "boolean", "table", "function", "userdata".' }, + { kind: 'function', name: 'typeof', insertText: 'typeof($0)', doc: 'Расширенная версия type — для Roblox-типов вернёт "Vector3", "CFrame", "Color3", "Instance".' }, + { kind: 'function', name: 'ipairs', insertText: 'ipairs(${1:t})', doc: 'Итератор по числовым ключам массива.\n```lua\nfor i, v in ipairs(arr) do ... end\n```' }, + { kind: 'function', name: 'pairs', insertText: 'pairs(${1:t})', doc: 'Итератор по всем ключам таблицы.\n```lua\nfor k, v in pairs(t) do ... end\n```' }, + { kind: 'function', name: 'next', insertText: 'next(${1:t}, $0)', doc: 'Возвращает следующую пару ключ-значение в таблице.' }, + { kind: 'function', name: 'select', insertText: 'select(${1:1}, $0)', doc: 'select("#", ...) — количество аргументов. select(n, ...) — n-й и далее аргументы.' }, + { kind: 'function', name: 'unpack', insertText: 'unpack(${1:t})', doc: 'Распаковывает массив в значения. (В Lua 5.4 — `table.unpack`)' }, + { kind: 'function', name: 'setmetatable', insertText: 'setmetatable(${1:t}, ${2:mt})', doc: 'Устанавливает metatable для таблицы.' }, + { kind: 'function', name: 'getmetatable', insertText: 'getmetatable($0)', doc: 'Возвращает metatable или nil.' }, + { kind: 'function', name: 'rawget', insertText: 'rawget(${1:t}, ${2:key})', doc: 'Чтение без вызова __index metatable.' }, + { kind: 'function', name: 'rawset', insertText: 'rawset(${1:t}, ${2:key}, ${3:value})', doc: 'Запись без вызова __newindex metatable.' }, + + // === task.* === + { kind: 'module', name: 'task', insertText: 'task', doc: 'Современный API планировщика Roblox-Lua.\nЗаменяет `wait`, `spawn`, `delay`, `defer` из старого API.' }, + { kind: 'function', name: 'task.wait', insertText: 'task.wait(${1:1})', doc: 'Приостанавливает на N секунд.\nВозвращает фактическое время ожидания.\n```lua\nlocal dt = task.wait(0.5)\n```' }, + { kind: 'function', name: 'task.spawn', insertText: 'task.spawn(${1:function() end})', doc: 'Немедленно запускает функцию как coroutine.\n```lua\ntask.spawn(function() heavy() end)\n```' }, + { kind: 'function', name: 'task.delay', insertText: 'task.delay(${1:1}, ${2:function() end})', doc: 'Отложенный запуск функции через N секунд.\n```lua\ntask.delay(3, function() print("через 3 сек") end)\n```' }, + { kind: 'function', name: 'task.defer', insertText: 'task.defer(${1:function() end})', doc: 'Запуск в следующем кадре (после Heartbeat).' }, + + // === Vector3 === + { kind: 'class', name: 'Vector3', insertText: 'Vector3', doc: '3D-вектор в Roblox.\nКонструктор: `Vector3.new(x, y, z)`.\nКонстанты: `Vector3.zero`, `Vector3.one`, `Vector3.xAxis`, `Vector3.yAxis`, `Vector3.zAxis`.' }, + { kind: 'function', name: 'Vector3.new', insertText: 'Vector3.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт `Vector3(x, y, z)`.\n```lua\nlocal v = Vector3.new(10, 5, 0)\nprint(v.X, v.Y, v.Z, v.Magnitude)\n```' }, + { kind: 'function', name: 'Vector3.zero', insertText: 'Vector3.zero', doc: '`Vector3(0, 0, 0)`.' }, + { kind: 'function', name: 'Vector3.one', insertText: 'Vector3.one', doc: '`Vector3(1, 1, 1)`.' }, + { kind: 'function', name: 'Vector3.xAxis', insertText: 'Vector3.xAxis', doc: '`Vector3(1, 0, 0)`.' }, + { kind: 'function', name: 'Vector3.yAxis', insertText: 'Vector3.yAxis', doc: '`Vector3(0, 1, 0)`.' }, + { kind: 'function', name: 'Vector3.zAxis', insertText: 'Vector3.zAxis', doc: '`Vector3(0, 0, 1)`.' }, + + // === Color3 === + { kind: 'class', name: 'Color3', insertText: 'Color3', doc: 'Цвет RGB в Roblox.\nКомпоненты `R`, `G`, `B` в диапазоне [0, 1].' }, + { kind: 'function', name: 'Color3.new', insertText: 'Color3.new(${1:1}, ${2:1}, ${3:1})', doc: 'Создаёт `Color3(r, g, b)`, где компоненты в [0, 1].' }, + { kind: 'function', name: 'Color3.fromRGB', insertText: 'Color3.fromRGB(${1:255}, ${2:255}, ${3:255})', doc: 'Создаёт `Color3` из 0-255 RGB.\n```lua\nlocal red = Color3.fromRGB(255, 0, 0)\n```' }, + { kind: 'function', name: 'Color3.fromHSV', insertText: 'Color3.fromHSV(${1:0}, ${2:1}, ${3:1})', doc: 'Создаёт цвет из HSV-компонентов в [0, 1].' }, + { kind: 'function', name: 'Color3.fromHex', insertText: 'Color3.fromHex(${1:"#FF0000"})', doc: 'Создаёт цвет из hex-строки.' }, + + // === CFrame === + { kind: 'class', name: 'CFrame', insertText: 'CFrame', doc: 'Coordinate Frame — позиция + поворот в 3D.\nИспользуется для трансформаций Part.CFrame.' }, + { kind: 'function', name: 'CFrame.new', insertText: 'CFrame.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт CFrame в указанной позиции.' }, + { kind: 'function', name: 'CFrame.lookAt', insertText: 'CFrame.lookAt(${1:eye}, ${2:target})', doc: 'CFrame, направленный из eye на target.' }, + { kind: 'function', name: 'CFrame.Angles', insertText: 'CFrame.Angles(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame только с поворотом (в радианах).' }, + { kind: 'function', name: 'CFrame.fromEulerAnglesXYZ', insertText: 'CFrame.fromEulerAnglesXYZ(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame с поворотом по эйлеровым углам.' }, + + // === UDim2 / Vector2 === + { kind: 'class', name: 'UDim2', insertText: 'UDim2', doc: 'Размер/позиция GUI: процент + пиксели по обеим осям.' }, + { kind: 'function', name: 'UDim2.new', insertText: 'UDim2.new(${1:0}, ${2:0}, ${3:0}, ${4:0})', doc: '`UDim2.new(scaleX, offsetX, scaleY, offsetY)`.\n```lua\nframe.Position = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана\n```' }, + { kind: 'function', name: 'UDim2.fromScale', insertText: 'UDim2.fromScale(${1:0.5}, ${2:0.5})', doc: 'Только процентные размеры.' }, + { kind: 'function', name: 'UDim2.fromOffset', insertText: 'UDim2.fromOffset(${1:100}, ${2:100})', doc: 'Только пиксельные размеры.' }, + { kind: 'class', name: 'Vector2', insertText: 'Vector2', doc: '2D-вектор.' }, + { kind: 'function', name: 'Vector2.new', insertText: 'Vector2.new(${1:0}, ${2:0})', doc: '`Vector2(x, y)`.' }, + { kind: 'class', name: 'UDim', insertText: 'UDim', doc: 'Одномерная UDim (scale + offset).' }, + { kind: 'function', name: 'UDim.new', insertText: 'UDim.new(${1:0}, ${2:0})', doc: '`UDim.new(scale, offset)`.' }, + + // === Instance === + { kind: 'class', name: 'Instance', insertText: 'Instance', doc: 'Базовый класс всех объектов Roblox.' }, + { kind: 'function', name: 'Instance.new', insertText: 'Instance.new("${1:Part}", ${2:workspace})', doc: 'Создаёт новый объект указанного класса.\n```lua\nlocal part = Instance.new("Part", workspace)\npart.Size = Vector3.new(4, 1, 4)\npart.Position = Vector3.new(0, 10, 0)\n```' }, + + // === game / services === + { kind: 'variable', name: 'game', insertText: 'game', doc: 'Корень DataModel. `game:GetService("Players")` — доступ к сервисам.' }, + { kind: 'variable', name: 'workspace', insertText: 'workspace', doc: 'Сокращение для `game.Workspace`. Содержит все Part-объекты сцены.' }, + { kind: 'variable', name: 'script', insertText: 'script', doc: 'Текущий скрипт. `script.Parent` — объект-носитель.\n```lua\nlocal part = script.Parent\npart.Touched:Connect(function(hit) ... end)\n```' }, + + // === Enum === + { kind: 'enum', name: 'Enum', insertText: 'Enum', doc: 'Перечисления Roblox: KeyCode, Material, UserInputType, EasingStyle, EasingDirection, HumanoidStateType.' }, + { kind: 'enum', name: 'Enum.KeyCode', insertText: 'Enum.KeyCode.${1:W}', doc: 'Клавиши клавиатуры: W, A, S, D, Space, LeftShift, Q, E, F, R, T, ..., One, Two, ..., Up, Down.' }, + { kind: 'enum', name: 'Enum.UserInputType', insertText: 'Enum.UserInputType.${1:MouseButton1}', doc: 'Типы ввода: MouseButton1/2/3, Keyboard, Touch, MouseMovement, MouseWheel.' }, + { kind: 'enum', name: 'Enum.Material', insertText: 'Enum.Material.${1:Plastic}', doc: 'Материалы: Plastic, Wood, Metal, Neon, Glass, Sand, Ice, Grass, Concrete.' }, + { kind: 'enum', name: 'Enum.HumanoidStateType', insertText: 'Enum.HumanoidStateType.${1:Running}', doc: 'Состояния Humanoid: Running, Jumping, Freefall, Landed, Dead, Climbing, Swimming, Seated.' }, +]; + +// === Сниппеты быстрого старта (готовые шаблоны) === +const ROBLOX_LUA_SNIPPETS = [ + { + label: 'killbrick', + documentation: 'KillBrick — убивает игрока при касании.', + insertText: [ + 'local part = script.Parent', + 'part.Touched:Connect(function(hit)', + '\tlocal humanoid = hit.Parent:FindFirstChildOfClass("Humanoid")', + '\tif humanoid then', + '\t\thumanoid.Health = 0', + '\tend', + 'end)', + ].join('\n'), + }, + { + label: 'teleportpad', + documentation: 'TeleportPad — телепортирует игрока в указанную точку.', + insertText: [ + 'local destination = Vector3.new(${1:0}, ${2:50}, ${3:0})', + 'local pad = script.Parent', + 'pad.Touched:Connect(function(hit)', + '\tlocal root = hit.Parent:FindFirstChild("HumanoidRootPart")', + '\tif root then', + '\t\troot.CFrame = CFrame.new(destination)', + '\tend', + 'end)', + ].join('\n'), + }, + { + label: 'coin', + documentation: 'Coin — даёт игроку монету при касании, потом исчезает.', + insertText: [ + 'local coin = script.Parent', + 'local collected = false', + 'coin.Touched:Connect(function(hit)', + '\tif collected then return end', + '\tif hit.Parent:FindFirstChildOfClass("Humanoid") then', + '\t\tcollected = true', + '\t\tprint("Монета собрана!")', + '\t\tcoin:Destroy()', + '\tend', + 'end)', + ].join('\n'), + }, + { + label: 'heartbeat', + documentation: 'RunService.Heartbeat — кадровый callback.', + insertText: [ + 'local RunService = game:GetService("RunService")', + 'RunService.Heartbeat:Connect(function(dt)', + '\t${0:-- код, выполняется каждый кадр}', + 'end)', + ].join('\n'), + }, + { + label: 'playeradded', + documentation: 'PlayerAdded — реакция на захождение игрока.', + insertText: [ + 'local Players = game:GetService("Players")', + 'Players.PlayerAdded:Connect(function(player)', + '\tprint("Игрок зашёл:", player.Name)', + '\t${0:}', + 'end)', + ].join('\n'), + }, + { + label: 'spinpart', + documentation: 'SpinPart — вращающаяся платформа.', + insertText: [ + 'local RunService = game:GetService("RunService")', + 'local part = script.Parent', + 'local speed = ${1:2} -- радиан/сек', + 'RunService.Heartbeat:Connect(function(dt)', + '\tpart.CFrame = part.CFrame * CFrame.Angles(0, speed * dt, 0)', + 'end)', + ].join('\n'), + }, +]; + +export function registerLuaInMonaco(monaco) { + if (monaco.__rbxLuaRegistered) return; + monaco.__rbxLuaRegistered = true; + + // 1. CompletionProvider — автодополнение + monaco.languages.registerCompletionItemProvider('lua', { + triggerCharacters: ['.', ':', '"', "'"], + provideCompletionItems: (model, position) => { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + const suggestions = []; + const kindMap = { + 'function': monaco.languages.CompletionItemKind.Function, + 'class': monaco.languages.CompletionItemKind.Class, + 'module': monaco.languages.CompletionItemKind.Module, + 'enum': monaco.languages.CompletionItemKind.Enum, + 'variable': monaco.languages.CompletionItemKind.Variable, + }; + for (const item of ROBLOX_LUA_API) { + suggestions.push({ + label: item.name, + kind: kindMap[item.kind] || monaco.languages.CompletionItemKind.Text, + insertText: item.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: { value: item.doc, isTrusted: true }, + range, + }); + } + for (const snip of ROBLOX_LUA_SNIPPETS) { + suggestions.push({ + label: snip.label, + kind: monaco.languages.CompletionItemKind.Snippet, + insertText: snip.insertText, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: { value: snip.documentation, isTrusted: true }, + detail: 'Сниппет Roblox-Lua', + range, + }); + } + return { suggestions }; + }, + }); + + // 2. HoverProvider — подсказки при наведении + const lookupTable = new Map(); + for (const item of ROBLOX_LUA_API) lookupTable.set(item.name, item); + monaco.languages.registerHoverProvider('lua', { + provideHover: (model, position) => { + const word = model.getWordAtPosition(position); + if (!word) return null; + // Пробуем найти точное совпадение или с префиксом (Vector3.new) + let found = lookupTable.get(word.word); + if (!found) { + // Возможно курсор на середине A.B — попробуем собрать всю цепочку + const line = model.getLineContent(position.lineNumber); + // Ищем имя.имя.имя на позиции + const left = line.slice(0, word.endColumn - 1); + const m = left.match(/[A-Za-z_][\w.]*$/); + if (m) found = lookupTable.get(m[0]); + } + if (!found) return null; + return { + range: new monaco.Range( + position.lineNumber, word.startColumn, + position.lineNumber, word.endColumn, + ), + contents: [ + { value: `**${found.name}**` }, + { value: found.doc }, + ], + }; + }, + }); +}