feat(rbxl): XML-������ ������ .rbxl + Day/Night + Tool/Mouse/Backpack flow #38
124
RBXL_SOURCES.md
Normal file
124
RBXL_SOURCES.md
Normal file
@ -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.
|
||||
504
RUBLOX_LUA_API.md
Normal file
504
RUBLOX_LUA_API.md
Normal file
@ -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).
|
||||
Если что-то описанное здесь не работает — это баг, репортуй.
|
||||
148
RUBLOX_LUA_API_CHANGELOG.md
Normal file
148
RUBLOX_LUA_API_CHANGELOG.md
Normal file
@ -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. **Логировать сюда после каждой итерации** — что было добавлено и из какой игры.
|
||||
434
RUBLOX_LUA_SUPPORT_PLAN.md
Normal file
434
RUBLOX_LUA_SUPPORT_PLAN.md
Normal file
@ -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 вопросам перед стартом.
|
||||
BIN
rbxl-importer/src/__pycache__/converter.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/converter.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_binreader.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_parser.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_types.cpython-314.pyc
Normal file
Binary file not shown.
BIN
rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc
Normal file
BIN
rbxl-importer/src/__pycache__/rbxl_xml_parser.cpython-314.pyc
Normal file
Binary file not shown.
@ -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'<roblox!'):
|
||||
return jsonify({'error': 'not a .rbxl binary file (missing <roblox! magic)'}), 400
|
||||
# Авто-детект XML vs Binary формата.
|
||||
# Бинарный: <roblox!\x89\xff\r\n\x1a\n (magic bytes).
|
||||
# XML (старые карты до 2010): <roblox version="4">...
|
||||
stripped = blob.lstrip()
|
||||
is_binary = stripped.startswith(b'<roblox!')
|
||||
is_xml = stripped.startswith(b'<roblox') and not is_binary
|
||||
|
||||
if not is_binary and not is_xml:
|
||||
return jsonify({'error': 'not a .rbxl file (no <roblox magic)'}), 400
|
||||
|
||||
# Парсим
|
||||
try:
|
||||
model = parse(blob)
|
||||
if is_xml:
|
||||
from rbxl_xml_parser import parse_xml
|
||||
model = parse_xml(blob)
|
||||
else:
|
||||
model = parse(blob)
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'parse failed: {e}'}), 422
|
||||
|
||||
|
||||
342
rbxl-importer/src/rbxl_xml_parser.py
Normal file
342
rbxl-importer/src/rbxl_xml_parser.py
Normal file
@ -0,0 +1,342 @@
|
||||
"""
|
||||
rbxl_xml_parser.py — парсер XML-формата .rbxl (старые карты до 2010 года).
|
||||
|
||||
Roblox-XML формат — текстовый предок бинарного .rbxl. Файл начинается с
|
||||
<roblox version="4"> и содержит дерево <Item class="...">...</Item>.
|
||||
|
||||
Возвращает тот же `RobloxModel` что и rbxl_parser.parse — чтобы converter.py
|
||||
работал без изменений.
|
||||
|
||||
Пример входного файла:
|
||||
<roblox version="4">
|
||||
<Item class="Workspace">
|
||||
<Properties>
|
||||
<string name="Name">Workspace</string>
|
||||
</Properties>
|
||||
<Item class="Part">
|
||||
<Properties>
|
||||
<CoordinateFrame name="CFrame">
|
||||
<X>0</X><Y>10</Y><Z>0</Z>
|
||||
<R00>1</R00>...<R22>1</R22>
|
||||
</CoordinateFrame>
|
||||
<Vector3 name="size"><X>4</X><Y>1</Y><Z>2</Z></Vector3>
|
||||
<Color3uint8 name="Color3uint8">4286611584</Color3uint8>
|
||||
<token name="BrickColor">21</token>
|
||||
</Properties>
|
||||
</Item>
|
||||
</Item>
|
||||
</roblox>
|
||||
|
||||
Поддерживает все типичные 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'<roblox'
|
||||
|
||||
|
||||
def is_xml_rbxl(blob: bytes) -> bool:
|
||||
"""Проверяет XML это или нет. Бинарный начинается с <roblox!..."""
|
||||
stripped = blob.lstrip()
|
||||
if stripped.startswith(b'<roblox') and not stripped.startswith(b'<roblox!'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _text(el: ET.Element, default: str = '') -> 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:
|
||||
"""<Color3 name="..."><R>...</R><G>...</G><B>...</B></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:
|
||||
"""<Color3uint8>4286611584</Color3uint8> — 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 цвет хранится как <int name="BrickColor">21</int>,
|
||||
# а 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:
|
||||
"""Рекурсивный обход <Item class="..."> элементов."""
|
||||
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 <Item class="..."> идут под <roblox>
|
||||
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)
|
||||
|
||||
# Версия из атрибута <roblox version="4">
|
||||
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=[],
|
||||
)
|
||||
193
src/editor/ConfirmModal.jsx
Normal file
193
src/editor/ConfirmModal.jsx
Normal file
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* ConfirmModal — кастомная модалка подтверждения вместо window.confirm.
|
||||
*
|
||||
* Использование:
|
||||
* const [confirmState, setConfirmState] = useState(null);
|
||||
* ...
|
||||
* setConfirmState({
|
||||
* title: 'Сменить язык?',
|
||||
* message: '...',
|
||||
* confirmLabel: 'Сменить',
|
||||
* cancelLabel: 'Отмена',
|
||||
* onConfirm: () => doSomething(),
|
||||
* });
|
||||
* ...
|
||||
* {confirmState && <ConfirmModal {...confirmState} onClose={() => setConfirmState(null)} />}
|
||||
*
|
||||
* Стиль — тёмная тема Рублокс-студии, кнопка confirm заметная.
|
||||
*/
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export default function ConfirmModal({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'OK',
|
||||
cancelLabel = 'Отмена',
|
||||
confirmTone = 'primary', // 'primary' | 'danger'
|
||||
onConfirm,
|
||||
onClose,
|
||||
}) {
|
||||
const confirmBtnRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Автофокус на кнопке подтверждения
|
||||
const t = setTimeout(() => confirmBtnRef.current?.focus(), 50);
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); onClose?.(); }
|
||||
else if (e.key === 'Enter') {
|
||||
// Enter — confirm только если кнопка в фокусе или ничего не в фокусе
|
||||
if (document.activeElement === confirmBtnRef.current || document.activeElement?.tagName === 'BODY') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => { clearTimeout(t); window.removeEventListener('keydown', onKey); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
try { onConfirm?.(); } finally { onClose?.(); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.55)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||||
animation: 'rbxConfirmFadeIn 140ms ease-out',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes rbxConfirmFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes rbxConfirmPopIn {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #2a2a2e 0%, #1f1f22 100%)',
|
||||
border: '1px solid #3a3a40',
|
||||
borderRadius: 14,
|
||||
padding: '22px 26px 18px',
|
||||
minWidth: 380,
|
||||
maxWidth: 480,
|
||||
color: '#e8e8ea',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.04)',
|
||||
animation: 'rbxConfirmPopIn 160ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<div style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
letterSpacing: -0.2,
|
||||
marginBottom: 10,
|
||||
color: '#fff',
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div style={{
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.55,
|
||||
color: '#c8c8cc',
|
||||
marginBottom: 20,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #3a3a40',
|
||||
background: 'transparent',
|
||||
color: '#c8c8cc',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 120ms',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2e2e34';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = '#c8c8cc';
|
||||
}}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmBtnRef}
|
||||
onClick={handleConfirm}
|
||||
style={{
|
||||
padding: '8px 18px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: confirmTone === 'danger'
|
||||
? 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)'
|
||||
: 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: 0.2,
|
||||
boxShadow: confirmTone === 'danger'
|
||||
? '0 6px 16px rgba(192, 48, 63, 0.4)'
|
||||
: '0 6px 16px rgba(79, 116, 255, 0.4)',
|
||||
transition: 'all 120ms',
|
||||
outline: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.filter = 'brightness(1.12)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.filter = 'brightness(1)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.boxShadow = confirmTone === 'danger'
|
||||
? '0 6px 16px rgba(192, 48, 63, 0.5), 0 0 0 3px rgba(192, 48, 63, 0.35)'
|
||||
: '0 6px 16px rgba(79, 116, 255, 0.5), 0 0 0 3px rgba(79, 116, 255, 0.35)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.boxShadow = confirmTone === 'danger'
|
||||
? '0 6px 16px rgba(192, 48, 63, 0.4)'
|
||||
: '0 6px 16px rgba(79, 116, 255, 0.4)';
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 = ({
|
||||
>
|
||||
<span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{renderRowIcon(icon)}</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>{label}</span>
|
||||
{badge && (
|
||||
<span style={{ flexShrink: 0, display: 'inline-flex' }}>{badge}</span>
|
||||
)}
|
||||
{plusItems && plusItems.length > 0 && (
|
||||
<HoverPlusMenu visible={hovered} items={plusItems} />
|
||||
)}
|
||||
@ -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 = (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 800,
|
||||
padding: '1px 5px',
|
||||
borderRadius: 4,
|
||||
lineHeight: 1.4,
|
||||
letterSpacing: 0.4,
|
||||
marginRight: 4,
|
||||
background: isLua ? '#2196f3' : '#f7df1e',
|
||||
color: isLua ? '#fff' : '#1a1a1c',
|
||||
}}
|
||||
title={isLua ? 'Lua (Roblox API)' : 'JavaScript (game.* API)'}
|
||||
>
|
||||
{isLua ? 'LUA' : 'JS'}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<ItemRow
|
||||
icon="📜"
|
||||
label={displayName}
|
||||
title={`${displayName} (id: ${script.id})`}
|
||||
title={`${displayName} (id: ${script.id}, язык: ${isLua ? 'Lua' : 'JavaScript'})`}
|
||||
depth={depth}
|
||||
selected={selected}
|
||||
onClick={onSelect}
|
||||
onContextMenu={onContextMenu}
|
||||
badge={badge}
|
||||
plusItems={[
|
||||
{
|
||||
id: 'rename', label: 'Переименовать', icon: '✏️',
|
||||
|
||||
@ -3324,8 +3324,14 @@ const KubikonEditor = () => {
|
||||
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?.() || []);
|
||||
|
||||
@ -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}</span>
|
||||
)}
|
||||
{/* Переключатель языка JS / Lua */}
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
background: '#1a1a1c',
|
||||
border: '1px solid #3a3a3a',
|
||||
borderRadius: 8,
|
||||
padding: 2,
|
||||
}}>
|
||||
{['js', 'lua'].map((lang) => {
|
||||
const active = currentLanguage === lang;
|
||||
return (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => {
|
||||
if (active) return;
|
||||
if (!onLanguageChange) return;
|
||||
if (isCodeLikelyEmptyTemplate(localCode)) {
|
||||
const nextCode = lang === 'lua'
|
||||
? (target ? LUA_TEMPLATE_PART : LUA_TEMPLATE_GLOBAL)
|
||||
: JS_TEMPLATE_GLOBAL;
|
||||
setLocalCode(nextCode);
|
||||
onLanguageChange(lang, nextCode);
|
||||
return;
|
||||
}
|
||||
// Код не пустой — показываем кастомную модалку.
|
||||
// ВАЖНО: lang захвачен через map'a, но localCode и onLanguageChange
|
||||
// надо взять из ref'ов на момент confirm, иначе stale closure.
|
||||
const targetLang = lang;
|
||||
setConfirmState({
|
||||
title: `Сменить язык на ${targetLang === 'lua' ? 'Lua' : 'JavaScript'}?`,
|
||||
message: `Код останется как есть — синтаксис прежнего языка перестанет подсвечиваться, но текст не исчезнет. Можно переключиться обратно в любой момент.`,
|
||||
confirmLabel: `Сменить на ${targetLang === 'lua' ? 'Lua' : 'JS'}`,
|
||||
cancelLabel: 'Отмена',
|
||||
onConfirm: () => {
|
||||
// Берём актуальное значение из ref (не stale closure)
|
||||
onLanguageChange(targetLang, localCodeRef.current);
|
||||
},
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
fontFamily: 'inherit',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: active ? 'default' : 'pointer',
|
||||
background: active
|
||||
? (lang === 'lua'
|
||||
? 'linear-gradient(135deg, #2196f3 0%, #1565c0 100%)'
|
||||
: 'linear-gradient(135deg, #f7df1e 0%, #d4b500 100%)')
|
||||
: 'transparent',
|
||||
color: active
|
||||
? (lang === 'lua' ? '#fff' : '#1a1a1c')
|
||||
: '#9a9a9e',
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
title={lang === 'lua'
|
||||
? 'Lua с Roblox-совместимым API (Vector3, CFrame, Instance)'
|
||||
: 'JavaScript с game.* API Рублокса'}
|
||||
>
|
||||
{lang === 'lua' ? 'Lua' : 'JS'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
{/* Фаза 6.1.4: кнопка «Проверить» — включает семантический анализ TS
|
||||
на 4 секунды, подсвечивает все ошибки (опечатки в game.X.Y и пр.).
|
||||
@ -394,10 +502,11 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="javascript"
|
||||
defaultLanguage={currentLanguage === 'lua' ? 'lua' : 'javascript'}
|
||||
language={currentLanguage === 'lua' ? 'lua' : 'javascript'}
|
||||
theme="vs-dark"
|
||||
value={localCode}
|
||||
path={`script_${scriptId}.js`}
|
||||
path={`script_${scriptId}.${currentLanguage === 'lua' ? 'lua' : 'js'}`}
|
||||
onChange={handleChange}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleEditorMount}
|
||||
@ -434,6 +543,12 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{confirmState && (
|
||||
<ConfirmModal
|
||||
{...confirmState}
|
||||
onClose={() => setConfirmState(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
}));
|
||||
}
|
||||
// Окружение (время суток, скайбокс, туман)
|
||||
|
||||
@ -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 */ }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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':
|
||||
|
||||
298
src/editor/engine/lua/LuaSharedSandbox.js
Normal file
298
src/editor/engine/lua/LuaSharedSandbox.js
Normal file
@ -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;
|
||||
1637
src/editor/engine/lua/RobloxShim.js
Normal file
1637
src/editor/engine/lua/RobloxShim.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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') {
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
// Корутина завершилась с ошибкой — просто дропаем
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
@ -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 '<object>';
|
||||
}
|
||||
|
||||
export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal };
|
||||
@ -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 };
|
||||
249
src/editor/lua-monaco-setup.js
Normal file
249
src/editor/lua-monaco-setup.js
Normal file
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* lua-monaco-setup — регистрация Lua-фич в Monaco:
|
||||
* 1) Подсветка через встроенный 'lua' language (Monaco поставляется с basic-languages/lua)
|
||||
* 2) Автодополнение Roblox-API (Vector3.new, Color3.fromRGB, script.Parent, game.Players, ...)
|
||||
* 3) Hover-документация (наведя на Vector3 — описание + пример)
|
||||
* 4) Подсветка ошибок через luaparse (на этапе 7, опционально)
|
||||
*
|
||||
* Регистрируется ОДИН раз глобально через флаг monaco.__rbxLuaRegistered.
|
||||
*/
|
||||
|
||||
const ROBLOX_LUA_API = [
|
||||
// === Глобальные функции ===
|
||||
{ kind: 'function', name: 'print', insertText: 'print($0)', doc: 'Выводит сообщения в Output-панель.\n```lua\nprint("Привет", x, y)\n```' },
|
||||
{ kind: 'function', name: 'warn', insertText: 'warn($0)', doc: 'Выводит предупреждение (жёлтым).\n```lua\nwarn("Что-то не так")\n```' },
|
||||
{ kind: 'function', name: 'error', insertText: 'error(${1:"сообщение"})', doc: 'Бросает ошибку, останавливая текущий скрипт.\n```lua\nerror("Здоровье < 0")\n```' },
|
||||
{ kind: 'function', name: 'wait', insertText: 'wait(${1:1})', doc: 'Приостанавливает скрипт на N секунд (заменяется на `task.wait` в новом коде).' },
|
||||
{ kind: 'function', name: 'tick', insertText: 'tick()', doc: 'Возвращает количество секунд с эпохи (как `os.time()`, но дробное).' },
|
||||
{ kind: 'function', name: 'pcall', insertText: 'pcall(${1:fn}, $0)', doc: 'Защищённый вызов. Возвращает `success, result|error`.\n```lua\nlocal ok, err = pcall(function() risky() end)\nif not ok then warn(err) end\n```' },
|
||||
{ kind: 'function', name: 'xpcall', insertText: 'xpcall(${1:fn}, ${2:handler})', doc: 'Защищённый вызов с кастомным обработчиком ошибки.' },
|
||||
{ kind: 'function', name: 'tostring', insertText: 'tostring($0)', doc: 'Преобразует значение в строку.' },
|
||||
{ kind: 'function', name: 'tonumber', insertText: 'tonumber($0)', doc: 'Преобразует строку в число. Возвращает nil если не число.' },
|
||||
{ kind: 'function', name: 'type', insertText: 'type($0)', doc: 'Возвращает строку с типом: "nil", "number", "string", "boolean", "table", "function", "userdata".' },
|
||||
{ kind: 'function', name: 'typeof', insertText: 'typeof($0)', doc: 'Расширенная версия type — для Roblox-типов вернёт "Vector3", "CFrame", "Color3", "Instance".' },
|
||||
{ kind: 'function', name: 'ipairs', insertText: 'ipairs(${1:t})', doc: 'Итератор по числовым ключам массива.\n```lua\nfor i, v in ipairs(arr) do ... end\n```' },
|
||||
{ kind: 'function', name: 'pairs', insertText: 'pairs(${1:t})', doc: 'Итератор по всем ключам таблицы.\n```lua\nfor k, v in pairs(t) do ... end\n```' },
|
||||
{ kind: 'function', name: 'next', insertText: 'next(${1:t}, $0)', doc: 'Возвращает следующую пару ключ-значение в таблице.' },
|
||||
{ kind: 'function', name: 'select', insertText: 'select(${1:1}, $0)', doc: 'select("#", ...) — количество аргументов. select(n, ...) — n-й и далее аргументы.' },
|
||||
{ kind: 'function', name: 'unpack', insertText: 'unpack(${1:t})', doc: 'Распаковывает массив в значения. (В Lua 5.4 — `table.unpack`)' },
|
||||
{ kind: 'function', name: 'setmetatable', insertText: 'setmetatable(${1:t}, ${2:mt})', doc: 'Устанавливает metatable для таблицы.' },
|
||||
{ kind: 'function', name: 'getmetatable', insertText: 'getmetatable($0)', doc: 'Возвращает metatable или nil.' },
|
||||
{ kind: 'function', name: 'rawget', insertText: 'rawget(${1:t}, ${2:key})', doc: 'Чтение без вызова __index metatable.' },
|
||||
{ kind: 'function', name: 'rawset', insertText: 'rawset(${1:t}, ${2:key}, ${3:value})', doc: 'Запись без вызова __newindex metatable.' },
|
||||
|
||||
// === task.* ===
|
||||
{ kind: 'module', name: 'task', insertText: 'task', doc: 'Современный API планировщика Roblox-Lua.\nЗаменяет `wait`, `spawn`, `delay`, `defer` из старого API.' },
|
||||
{ kind: 'function', name: 'task.wait', insertText: 'task.wait(${1:1})', doc: 'Приостанавливает на N секунд.\nВозвращает фактическое время ожидания.\n```lua\nlocal dt = task.wait(0.5)\n```' },
|
||||
{ kind: 'function', name: 'task.spawn', insertText: 'task.spawn(${1:function() end})', doc: 'Немедленно запускает функцию как coroutine.\n```lua\ntask.spawn(function() heavy() end)\n```' },
|
||||
{ kind: 'function', name: 'task.delay', insertText: 'task.delay(${1:1}, ${2:function() end})', doc: 'Отложенный запуск функции через N секунд.\n```lua\ntask.delay(3, function() print("через 3 сек") end)\n```' },
|
||||
{ kind: 'function', name: 'task.defer', insertText: 'task.defer(${1:function() end})', doc: 'Запуск в следующем кадре (после Heartbeat).' },
|
||||
|
||||
// === Vector3 ===
|
||||
{ kind: 'class', name: 'Vector3', insertText: 'Vector3', doc: '3D-вектор в Roblox.\nКонструктор: `Vector3.new(x, y, z)`.\nКонстанты: `Vector3.zero`, `Vector3.one`, `Vector3.xAxis`, `Vector3.yAxis`, `Vector3.zAxis`.' },
|
||||
{ kind: 'function', name: 'Vector3.new', insertText: 'Vector3.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт `Vector3(x, y, z)`.\n```lua\nlocal v = Vector3.new(10, 5, 0)\nprint(v.X, v.Y, v.Z, v.Magnitude)\n```' },
|
||||
{ kind: 'function', name: 'Vector3.zero', insertText: 'Vector3.zero', doc: '`Vector3(0, 0, 0)`.' },
|
||||
{ kind: 'function', name: 'Vector3.one', insertText: 'Vector3.one', doc: '`Vector3(1, 1, 1)`.' },
|
||||
{ kind: 'function', name: 'Vector3.xAxis', insertText: 'Vector3.xAxis', doc: '`Vector3(1, 0, 0)`.' },
|
||||
{ kind: 'function', name: 'Vector3.yAxis', insertText: 'Vector3.yAxis', doc: '`Vector3(0, 1, 0)`.' },
|
||||
{ kind: 'function', name: 'Vector3.zAxis', insertText: 'Vector3.zAxis', doc: '`Vector3(0, 0, 1)`.' },
|
||||
|
||||
// === Color3 ===
|
||||
{ kind: 'class', name: 'Color3', insertText: 'Color3', doc: 'Цвет RGB в Roblox.\nКомпоненты `R`, `G`, `B` в диапазоне [0, 1].' },
|
||||
{ kind: 'function', name: 'Color3.new', insertText: 'Color3.new(${1:1}, ${2:1}, ${3:1})', doc: 'Создаёт `Color3(r, g, b)`, где компоненты в [0, 1].' },
|
||||
{ kind: 'function', name: 'Color3.fromRGB', insertText: 'Color3.fromRGB(${1:255}, ${2:255}, ${3:255})', doc: 'Создаёт `Color3` из 0-255 RGB.\n```lua\nlocal red = Color3.fromRGB(255, 0, 0)\n```' },
|
||||
{ kind: 'function', name: 'Color3.fromHSV', insertText: 'Color3.fromHSV(${1:0}, ${2:1}, ${3:1})', doc: 'Создаёт цвет из HSV-компонентов в [0, 1].' },
|
||||
{ kind: 'function', name: 'Color3.fromHex', insertText: 'Color3.fromHex(${1:"#FF0000"})', doc: 'Создаёт цвет из hex-строки.' },
|
||||
|
||||
// === CFrame ===
|
||||
{ kind: 'class', name: 'CFrame', insertText: 'CFrame', doc: 'Coordinate Frame — позиция + поворот в 3D.\nИспользуется для трансформаций Part.CFrame.' },
|
||||
{ kind: 'function', name: 'CFrame.new', insertText: 'CFrame.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт CFrame в указанной позиции.' },
|
||||
{ kind: 'function', name: 'CFrame.lookAt', insertText: 'CFrame.lookAt(${1:eye}, ${2:target})', doc: 'CFrame, направленный из eye на target.' },
|
||||
{ kind: 'function', name: 'CFrame.Angles', insertText: 'CFrame.Angles(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame только с поворотом (в радианах).' },
|
||||
{ kind: 'function', name: 'CFrame.fromEulerAnglesXYZ', insertText: 'CFrame.fromEulerAnglesXYZ(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame с поворотом по эйлеровым углам.' },
|
||||
|
||||
// === UDim2 / Vector2 ===
|
||||
{ kind: 'class', name: 'UDim2', insertText: 'UDim2', doc: 'Размер/позиция GUI: процент + пиксели по обеим осям.' },
|
||||
{ kind: 'function', name: 'UDim2.new', insertText: 'UDim2.new(${1:0}, ${2:0}, ${3:0}, ${4:0})', doc: '`UDim2.new(scaleX, offsetX, scaleY, offsetY)`.\n```lua\nframe.Position = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана\n```' },
|
||||
{ kind: 'function', name: 'UDim2.fromScale', insertText: 'UDim2.fromScale(${1:0.5}, ${2:0.5})', doc: 'Только процентные размеры.' },
|
||||
{ kind: 'function', name: 'UDim2.fromOffset', insertText: 'UDim2.fromOffset(${1:100}, ${2:100})', doc: 'Только пиксельные размеры.' },
|
||||
{ kind: 'class', name: 'Vector2', insertText: 'Vector2', doc: '2D-вектор.' },
|
||||
{ kind: 'function', name: 'Vector2.new', insertText: 'Vector2.new(${1:0}, ${2:0})', doc: '`Vector2(x, y)`.' },
|
||||
{ kind: 'class', name: 'UDim', insertText: 'UDim', doc: 'Одномерная UDim (scale + offset).' },
|
||||
{ kind: 'function', name: 'UDim.new', insertText: 'UDim.new(${1:0}, ${2:0})', doc: '`UDim.new(scale, offset)`.' },
|
||||
|
||||
// === Instance ===
|
||||
{ kind: 'class', name: 'Instance', insertText: 'Instance', doc: 'Базовый класс всех объектов Roblox.' },
|
||||
{ kind: 'function', name: 'Instance.new', insertText: 'Instance.new("${1:Part}", ${2:workspace})', doc: 'Создаёт новый объект указанного класса.\n```lua\nlocal part = Instance.new("Part", workspace)\npart.Size = Vector3.new(4, 1, 4)\npart.Position = Vector3.new(0, 10, 0)\n```' },
|
||||
|
||||
// === game / services ===
|
||||
{ kind: 'variable', name: 'game', insertText: 'game', doc: 'Корень DataModel. `game:GetService("Players")` — доступ к сервисам.' },
|
||||
{ kind: 'variable', name: 'workspace', insertText: 'workspace', doc: 'Сокращение для `game.Workspace`. Содержит все Part-объекты сцены.' },
|
||||
{ kind: 'variable', name: 'script', insertText: 'script', doc: 'Текущий скрипт. `script.Parent` — объект-носитель.\n```lua\nlocal part = script.Parent\npart.Touched:Connect(function(hit) ... end)\n```' },
|
||||
|
||||
// === Enum ===
|
||||
{ kind: 'enum', name: 'Enum', insertText: 'Enum', doc: 'Перечисления Roblox: KeyCode, Material, UserInputType, EasingStyle, EasingDirection, HumanoidStateType.' },
|
||||
{ kind: 'enum', name: 'Enum.KeyCode', insertText: 'Enum.KeyCode.${1:W}', doc: 'Клавиши клавиатуры: W, A, S, D, Space, LeftShift, Q, E, F, R, T, ..., One, Two, ..., Up, Down.' },
|
||||
{ kind: 'enum', name: 'Enum.UserInputType', insertText: 'Enum.UserInputType.${1:MouseButton1}', doc: 'Типы ввода: MouseButton1/2/3, Keyboard, Touch, MouseMovement, MouseWheel.' },
|
||||
{ kind: 'enum', name: 'Enum.Material', insertText: 'Enum.Material.${1:Plastic}', doc: 'Материалы: Plastic, Wood, Metal, Neon, Glass, Sand, Ice, Grass, Concrete.' },
|
||||
{ kind: 'enum', name: 'Enum.HumanoidStateType', insertText: 'Enum.HumanoidStateType.${1:Running}', doc: 'Состояния Humanoid: Running, Jumping, Freefall, Landed, Dead, Climbing, Swimming, Seated.' },
|
||||
];
|
||||
|
||||
// === Сниппеты быстрого старта (готовые шаблоны) ===
|
||||
const ROBLOX_LUA_SNIPPETS = [
|
||||
{
|
||||
label: 'killbrick',
|
||||
documentation: 'KillBrick — убивает игрока при касании.',
|
||||
insertText: [
|
||||
'local part = script.Parent',
|
||||
'part.Touched:Connect(function(hit)',
|
||||
'\tlocal humanoid = hit.Parent:FindFirstChildOfClass("Humanoid")',
|
||||
'\tif humanoid then',
|
||||
'\t\thumanoid.Health = 0',
|
||||
'\tend',
|
||||
'end)',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
label: 'teleportpad',
|
||||
documentation: 'TeleportPad — телепортирует игрока в указанную точку.',
|
||||
insertText: [
|
||||
'local destination = Vector3.new(${1:0}, ${2:50}, ${3:0})',
|
||||
'local pad = script.Parent',
|
||||
'pad.Touched:Connect(function(hit)',
|
||||
'\tlocal root = hit.Parent:FindFirstChild("HumanoidRootPart")',
|
||||
'\tif root then',
|
||||
'\t\troot.CFrame = CFrame.new(destination)',
|
||||
'\tend',
|
||||
'end)',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
label: 'coin',
|
||||
documentation: 'Coin — даёт игроку монету при касании, потом исчезает.',
|
||||
insertText: [
|
||||
'local coin = script.Parent',
|
||||
'local collected = false',
|
||||
'coin.Touched:Connect(function(hit)',
|
||||
'\tif collected then return end',
|
||||
'\tif hit.Parent:FindFirstChildOfClass("Humanoid") then',
|
||||
'\t\tcollected = true',
|
||||
'\t\tprint("Монета собрана!")',
|
||||
'\t\tcoin:Destroy()',
|
||||
'\tend',
|
||||
'end)',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
label: 'heartbeat',
|
||||
documentation: 'RunService.Heartbeat — кадровый callback.',
|
||||
insertText: [
|
||||
'local RunService = game:GetService("RunService")',
|
||||
'RunService.Heartbeat:Connect(function(dt)',
|
||||
'\t${0:-- код, выполняется каждый кадр}',
|
||||
'end)',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
label: 'playeradded',
|
||||
documentation: 'PlayerAdded — реакция на захождение игрока.',
|
||||
insertText: [
|
||||
'local Players = game:GetService("Players")',
|
||||
'Players.PlayerAdded:Connect(function(player)',
|
||||
'\tprint("Игрок зашёл:", player.Name)',
|
||||
'\t${0:}',
|
||||
'end)',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
label: 'spinpart',
|
||||
documentation: 'SpinPart — вращающаяся платформа.',
|
||||
insertText: [
|
||||
'local RunService = game:GetService("RunService")',
|
||||
'local part = script.Parent',
|
||||
'local speed = ${1:2} -- радиан/сек',
|
||||
'RunService.Heartbeat:Connect(function(dt)',
|
||||
'\tpart.CFrame = part.CFrame * CFrame.Angles(0, speed * dt, 0)',
|
||||
'end)',
|
||||
].join('\n'),
|
||||
},
|
||||
];
|
||||
|
||||
export function registerLuaInMonaco(monaco) {
|
||||
if (monaco.__rbxLuaRegistered) return;
|
||||
monaco.__rbxLuaRegistered = true;
|
||||
|
||||
// 1. CompletionProvider — автодополнение
|
||||
monaco.languages.registerCompletionItemProvider('lua', {
|
||||
triggerCharacters: ['.', ':', '"', "'"],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
const suggestions = [];
|
||||
const kindMap = {
|
||||
'function': monaco.languages.CompletionItemKind.Function,
|
||||
'class': monaco.languages.CompletionItemKind.Class,
|
||||
'module': monaco.languages.CompletionItemKind.Module,
|
||||
'enum': monaco.languages.CompletionItemKind.Enum,
|
||||
'variable': monaco.languages.CompletionItemKind.Variable,
|
||||
};
|
||||
for (const item of ROBLOX_LUA_API) {
|
||||
suggestions.push({
|
||||
label: item.name,
|
||||
kind: kindMap[item.kind] || monaco.languages.CompletionItemKind.Text,
|
||||
insertText: item.insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: { value: item.doc, isTrusted: true },
|
||||
range,
|
||||
});
|
||||
}
|
||||
for (const snip of ROBLOX_LUA_SNIPPETS) {
|
||||
suggestions.push({
|
||||
label: snip.label,
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText: snip.insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: { value: snip.documentation, isTrusted: true },
|
||||
detail: 'Сниппет Roblox-Lua',
|
||||
range,
|
||||
});
|
||||
}
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
|
||||
// 2. HoverProvider — подсказки при наведении
|
||||
const lookupTable = new Map();
|
||||
for (const item of ROBLOX_LUA_API) lookupTable.set(item.name, item);
|
||||
monaco.languages.registerHoverProvider('lua', {
|
||||
provideHover: (model, position) => {
|
||||
const word = model.getWordAtPosition(position);
|
||||
if (!word) return null;
|
||||
// Пробуем найти точное совпадение или с префиксом (Vector3.new)
|
||||
let found = lookupTable.get(word.word);
|
||||
if (!found) {
|
||||
// Возможно курсор на середине A.B — попробуем собрать всю цепочку
|
||||
const line = model.getLineContent(position.lineNumber);
|
||||
// Ищем имя.имя.имя на позиции
|
||||
const left = line.slice(0, word.endColumn - 1);
|
||||
const m = left.match(/[A-Za-z_][\w.]*$/);
|
||||
if (m) found = lookupTable.get(m[0]);
|
||||
}
|
||||
if (!found) return null;
|
||||
return {
|
||||
range: new monaco.Range(
|
||||
position.lineNumber, word.startColumn,
|
||||
position.lineNumber, word.endColumn,
|
||||
),
|
||||
contents: [
|
||||
{ value: `**${found.name}**` },
|
||||
{ value: found.doc },
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user