������� �������/�������� (�������) + ��������� + ������� ���������� #40

Merged
min merged 4 commits from restore/all-tasks into main 2026-06-09 22:49:22 +00:00
49 changed files with 15780 additions and 3934 deletions
Showing only changes of commit b03027e3d5 - Show all commits

1
.gitignore vendored
View File

@ -42,3 +42,4 @@ Thumbs.db
# Большие png-ассеты вики (73МБ). Будут перенесены на CDN отдельной задачей.
/public/wiki/
rbxl-importer/src/__pycache__/

124
RBXL_SOURCES.md Normal file
View 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
View 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).
Если что-то описанное здесь не работает — это баг, репортуй.

431
RUBLOX_LUA_API_CHANGELOG.md Normal file
View File

@ -0,0 +1,431 @@
# Lua API — журнал изменений
Файл фиксирует **что было добавлено в Lua-runtime** при работе с реальными
Roblox-играми. Цель — потом продублировать тот же API для **JS-движка**
(на будущее, сейчас работаем только с Lua).
Формат: дата + что и почему + куда добавлено + надо ли портировать в JS.
---
## 2026-06-08 — Итерация 4: Spawn-fix + философия импорта
**Контекст:** МИН подтвердил после ROBLOX Battle: 100% покрытие Lua-скриптов
из Roblox не получится (наш wasmoon не yield'ит из JS C-call boundary,
старый Roblox-pattern WaitForChild через ChildAdded:wait тривиально вешает
страницу). **Цель импорта сменилась**: показать геометрию и базовые
интеракции, а не полную скриптовую логику.
### Spawn fix (карта проекта 2853)
После переимпорта одной из карт игрок появлялся **внутри Anchored
геометрии** (стена/пол), не мог двигаться. Причина: SpawnLocation в старых
.rbxl ставится впритык к плите (Y+0.5), наш отступ +1.5 не спасал от
толстых Floor'ов 2-3 units high. Anchored=True (наш force-fix) не давал
выпрыгнуть.
Фиксы в `converter.py`:
1. **SpawnLocation +5** вместо +1.5. Если spawn внутри толстого пола —
гравитация уронит обратно за 1 кадр, не страшно. Если выше — отлично.
2. **Auto-fallback** если SpawnLocation в карте НЕ был (или дефолт остался
`(0, 2, 0)`):
```python
max_top = max(p['y'] + p['sy']/2 for p in primitives)
scene['spawnPoint'] = {x: 0, y: max_top + 5, z: 0}
```
Игрок появляется над самой высокой Part'ой → падает на крышу.
### Философия импорта (зафиксировано как принцип)
**Цель импорта .rbxl** = показать геометрию и сцену, а не воспроизвести
скриптовое поведение. Что работает (важно):
- ✅ Все примитивы (Part/Wedge/CornerWedge/Truss/Union/MeshPart)
- ✅ Цвета через BrickColor (расширенная палитра 120 цветов)
- ✅ Anchored=True для всех (карта не рассыпается)
- ✅ SpawnLocation с правильным Y (игрок не в стене)
- ✅ Корректный CFrame YXZ (мостики/wedge'и стоят правильно)
- ✅ Скайбокс, освещение, экспозиция/контраст через слайдеры
- ✅ Простые Touched-скрипты (Bouncer, BattleArmor, KillBrick)
- ✅ Tools.Equipped/Activated handlers (часть оружия)
Что НЕ воспроизводится (принимаем):
- ❌ Сложные RoundScript / GameClock / Spawner / KillFeed-логика
- ❌ WaitForChild через while+:wait() паттерны (regex-фильтр пропускает)
- ❌ Регенерация построек (Regenerate*) — не нужна т.к. Anchored
- ❌ LeaderboardV3 с DataStore (пропускается)
- ❌ Сетевые RemoteEvent/RemoteFunction (single-player только)
### Когда снова работать со скриптами
Если попадётся **новая карта (2015+)**`WaitForChild` встроен в API,
наш regex-фильтр не сработает. Скрипты пройдут больше и будут работать
лучше. Старые карты (2007-2010) принципиально ограничены.
### Что НЕ делать
- Не пытаться "ещё раз" решить yield-across-C-boundary через debug.sethook
или pcall-трюки. Проверено — не работает с wasmoon.
- Не переписывать wasmoon — это месяцы работы.
- Не сужать regex-фильтр в надежде запустить ещё пару скриптов — лучше
пусть пропустится лишний, чем висит страница.
### Что делать дальше
- Идти по .rbxl из Desktop/RBLX/ как пользователь.
- На каждой карте проверять: геометрия загрузилась? игрок ходит? видна?
- Если виснет — добавлять regex-паттерн в фильтр.
- Если игрок застрял — улучшать spawn-fallback.
- Если падают конкретные API — реализовывать в shim (как Mouse.Icon,
BodyVelocity-bouncer, leaderstats).
### В JS
Все фиксы spawn + философия общая для студии и плеера.
---
## 2026-06-08 — Итерация 3: ROBLOX Battle (arch1_ROBLOX_Battle_v2.rbxl, проект 2851)
**Контекст:** PvP-арена 2009 в XML, 1677 примитивов, 66 скриптов, 4 команды
(TeamBeacon), 5 оружий, 12 батутов, KillFeed, раунды.
### Реализовано 11 механик из 14
1. **Teams** — game.Teams сервис + Team-инстансы, эвристика TeamBeacon-Model
в converter.py → автоматически создаёт 4 команды по имени.
2. **Leaderstats UI** — IntValue.Value реактивно через Object.defineProperty,
при Parent=leaderstats шлёт leaderstatSet → существующий LeaderstatsManager.
3. **BindableFunction/RemoteFunction** + Message/Hint классы с реактивным Text.
4. **KillFeed UI** + creator-tag tracking в Humanoid.TakeDamage. DOM-overlay.
5. **SpawnLocation.TeamColor** → scene.team_spawns[].
6. **Tool/Model:Clone()** + :MakeJoints/:BreakJoints/:Remove no-op.
7. **Creator-tag**: ObjectValue.Name='creator' проверяется на Health=0.
8. **RegenerationScript** — no-op skip по имени (Anchored=True держит).
9. **BattleArmor** — реактивный Humanoid.MaxHealth/Health/WalkSpeed/JumpPower.
10. **WinGui/FireButton** через GuiManager.
11. **AdminConsole** — no-op.
12. **Bouncer** — BodyVelocity.Y > 10 + Parent=Torso → playerSet jumpVelocity.
14. **Mouse.Icon** → CSS cursor через canvas.style.cursor.
Также добавлены: **tick/time/delay/spawn/LoadLibrary** legacy globals,
**SpecialMesh/BlockMesh/CylinderMesh/FileMesh** Instance.new стабы.
### Новый модуль RbxlHudOverlay.js
DOM-оверлей поверх canvas с KillFeed (правый верх, fade 5с) + Message
(центр верх) + WinGui (центр). Lazy-создаётся.
### Tight-loop защита (КРИТИЧНО)
Roblox 2009 паттерн:
```lua
while not parent:FindFirstChild(name) do parent.ChildAdded:wait() end
```
Наш Signal:wait() возвращает синхронно — цикл бесконечный, страница виснет.
**Не можем yield** из JS-функции через wasmoon C-call boundary.
Перепробовали:
- debug.sethook(yield, 'i', N) — внутри C-call падает с `yield across C-call`.
- pcall(coroutine.yield) — ошибка ловится, счётчик не сбрасывается, вис.
**Финал**: regex-фильтр в GameRuntime.js пропускает скрипты с этими паттернами.
Из 66 скриптов 37 пропущены, 29 работают. Жертвы: RoundScript, GameClock,
Spawner, KillFeed, LeaderboardV3, оружие Launcher/Sword/Slingshot/Cannon.
### CFrame YXZ Euler
Переписал `to_euler_xyz` в `rbxl_types.py` под Babylon YXZ convention:
rx=asin(-r12), ry=atan2(r02,r22), rz=atan2(r10,r11) + gimbal-lock guard.
Раньше извлекал XYZ-Euler, Babylon применял как YXZ — мостики
поворачивались криво.
### Persistence настроек света
BabylonScene.serialize/loadFromState сохраняют scene.lighting:
sunIntensity, hemiIntensity, sceneAmbient, exposure, contrast, saturation.
### Известные баги
- `memory access out of bounds` (1 раз) — WASM-crash одного скрипта.
- `Cannot read properties of null ('then')` — wasmoon promise-detection,
скрипт init крашится но не блокирует.
- 0 teams при загрузке старого проекта — нужен переимпорт.
### В JS
✅ Всё: Teams формат общий, KillFeed/Message HUD общий для студии+плеера.
---
## 2026-06-08 — Итерация 2: Crossroads (arch1_Original_Crossroads.rbxl, проект 2827)
**Контекст:** Классическая Roblox-карта 2009 года для PvP, **XML-формат** .rbxl
(старее бинарного). 877 instances, 777 Part, 83 Model. Состоит из 4 зон:
крепость (Castle), дом (House Platform), деревья, дорожки крест-накрест.
2 скрипта: «Regenerate Playground» и «Regenerate Castle» — периодически
удаляют и восстанавливают постройки (для PvP).
### Главное: XML-парсер для .rbxl
`rbxl-importer/src/rbxl_xml_parser.py` (новый файл, ~330 строк):
- `is_xml_rbxl(blob)` — детект по `<roblox` без `!` (binary имеет magic `<roblox!`).
- `parse_xml(blob) → RobloxModel` — то же что `parse()` из binary parser'а,
совместимый формат, чтобы converter работал без изменений.
- Поддержанные property-теги: `string`, `bool`, `int`, `int64`, `float`,
`double`, `token`, `Vector3`, `Vector2`, `CoordinateFrame`, `Color3`,
`Color3uint8`, `BrickColor`, `Ref`, `BinaryString`, `UDim`, `UDim2`,
`Rect2D`, `OptionalCoordinateFrame`, `PhysicalProperties`, `NumberRange`,
`ProtectedString`, `Content`.
- Алиасы PascalCase: старые карты использовали `name/size/shape`
с маленькой буквы — добавлены как PascalCase для converter'а.
- `<int name="BrickColor">N</int>` — особый случай: в старом XML цвет
лежит как int с именем `BrickColor`, заворачиваем в `BrickColor(code=N)`.
В `app.py` добавлен автодетект формата:
```python
is_binary = blob.lstrip().startswith(b'<roblox!')
is_xml = blob.lstrip().startswith(b'<roblox') and not is_binary
if is_xml:
model = parse_xml(blob)
else:
model = parse(blob)
```
### Расширенная BrickColor палитра (converter.py)
Старая палитра: ~50 цветов. Новая: ~120 цветов. Главные добавления:
- **151 (Earth green)** — основная трава Crossroads (`#7c9b53`). Без неё
пол получал дефолтный `#cccccc` и выглядел белым на 344 примитива.
- 18, 26, 115-148, 168-301, 1021-1032 — заполнили дыры.
После: `#cccccc` дефолт упал с 344 → 68 на Crossroads (большинство цветов
теперь правильные).
### Anchored = True для всех импортированных Part
В `_convert_part`/`_convert_wedge`/`_convert_cornerwedge`/`_convert_truss`/
`_convert_meshpart`/`_convert_union` принудительно `'anchored': True`.
Причина: Roblox-карты держатся на **Welds** (склейки) или **BasePart-default
Anchored=true** (Crossroads). У нас Welds — заглушки, физика 700+ unanchored
Part'ов = карта рассыпается за 1 секунду (`Unanchored bodies: 767`).
После фикса: `Unanchored bodies: 0`, всё стоит на месте.
### Уважение поля `enabled` из метадаты
Уже было в Итерации 1, но напомню: скрипты с `Disabled=True` в Roblox
не запускаются. Парсер метадаты `parseRobloxLuaMeta()` смотрит вторую
строку packed-кода (JSON с `enabled`), если false — пропускаем.
### Визуальная настройка света
Главные находки:
1. **mat.ambientColor=(1,1,1)** обязательно — иначе `scene.ambientColor`
(«Заливка теней» слайдер) не влияет на материалы.
2. **mat.ambientColor=(цвет_от_diffuse)** даёт пересвет — не делать.
3. **scene.imageProcessingConfiguration** есть готовый в Babylon — даёт
exposure/contrast/saturation бесплатно.
В `BabylonScene.setLightingProps(patch)` добавлено:
- `patch.sceneAmbient` (0..1) → `scene.ambientColor` (заливка теней)
- `patch.exposure` (0.3..2) → `ipc.exposure`
- `patch.contrast` (0.5..2) → `ipc.contrast`
- `patch.saturation` (0..2) → `ipc.colorCurves.globalSaturation`
В `SelectionManager.selectLighting()` добавлены поля для чтения текущих значений.
В `InspectorPanel.jsx` добавлены 4 новых слайдера в «Свет и атмосфера»:
- Заливка теней (в блоке «Окружающий свет»)
- Экспозиция / Контраст / Насыщенность (новый блок «Цветокоррекция»)
### Persistence настроек света
`BabylonScene.serialize()` теперь включает `scene.lighting`:
```js
lighting: {
sunIntensity, hemiIntensity, sceneAmbient,
exposure, contrast, saturation,
}
```
`BabylonScene.loadFromState()` применяет эти параметры через `setLightingProps()`.
### Деплой rbxl-importer
**ВАЖНО:** rbxl-importer на VM 130 деплоится **напрямую через SSH**, не через CI/CD:
```bash
KEY="/c/Users/min/.ssh/id_ed25519"
scp -P 2280 -i "$KEY" rbxl-importer/src/FILE.py root@85.175.7.40:/tmp/
ssh -p 2280 -i "$KEY" root@85.175.7.40 \
"scp -P 22 -i /root/.ssh/id_ed25519 /tmp/FILE.py min@192.168.1.130:/tmp/ && \
ssh -p 22 -i /root/.ssh/id_ed25519 min@192.168.1.130 'sudo mv /tmp/FILE.py /opt/rbxl-importer/src/ && sudo systemctl restart rbxl-importer'"
```
S1 PVE root доступен по SSH-ключу `~/.ssh/id_ed25519` (без пароля).
См. [[vm130-direct-deploy]] в memory.
### Что НЕ доделано (известные баги Crossroads)
1. **2 скрипта падают `self2 is not a function`**:
- `Regenerate Playground` и `Regenerate Castle`.
- Используют `model:clone()` где `model = game.Workspace.Playground`
наш stub-Folder для Playground не имеет `:clone()` метода.
- Также `Instance.new("Message")` — класс не реализован.
- **Не критично**: Anchored=True держит постройки, регенерация не нужна.
2. **Цвета всё ещё чуть-чуть не такие как в Roblox**:
- Юзер крутит слайдеры sun/hemi/ambient/saturation и подбирает
baseline. Параметры сохраняются в проект через persistence.
### Надо ли в JS?
**Все правки** — да:
- BrickColor расширенная палитра — общая для обоих движков.
- Anchored=True для импорта — это про converter, не движок.
- Слайдеры света — UI студии, общий для обоих.
- persistence — общий формат `projectData.scene.lighting`.
---
## 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
View 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 вопросам перед стартом.

View File

@ -122,11 +122,22 @@ 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:
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
@ -199,6 +210,16 @@ def create():
data = request.get_json(silent=True) or {}
preview_hash = data.get('preview_hash')
title = (data.get('title') or '').strip() or 'Импортировано из Roblox'
# scripts_mode: 'disabled' (default) — оставить в проекте, но enabled=False
# 'enabled' — попытаться запустить, может вешать
# 'skip' — не импортировать совсем
scripts_mode = data.get('scripts_mode', 'disabled')
if scripts_mode not in ('disabled', 'enabled', 'skip'):
scripts_mode = 'disabled'
# gui_mode: 'all' / 'screen-only' (только ScreenGui-HUD) / 'skip' (без GUI)
gui_mode = data.get('gui_mode', 'all')
if gui_mode not in ('all', 'screen-only', 'skip'):
gui_mode = 'all'
if not preview_hash:
return jsonify({'error': 'preview_hash required'}), 400
@ -263,6 +284,14 @@ def create():
# Подставляем URLs в project_data
_resolve_asset_urls(project_data, asset_url_map)
# Применяем scripts_mode: меняем поле enabled в метадате каждого скрипта
# либо удаляем все скрипты полностью.
_apply_scripts_mode(project_data, scripts_mode)
# Применяем gui_mode: удаляем 3D-GUI (BillboardGui/SurfaceGui) или вообще
# всё, если выбрано 'skip'.
_apply_gui_mode(project_data, gui_mode)
# Создаём проект в kubikon3d_projects
# Используем bridge на user-service / storys API, ИЛИ напрямую в storys_db.
# Прямой INSERT — проще для MVP. id автогенерируется.
@ -324,5 +353,66 @@ def _resolve_asset_urls(project_data: dict, asset_map: dict) -> None:
snd['url'] = asset_map[rid]
def _apply_gui_mode(project_data: dict, mode: str) -> None:
"""Фильтрует scene.gui[] по режиму.
'all' оставить всё (default).
'screen-only' оставить только ScreenGui-HUD, удалить billboard/surface.
Карты с 200+ BillboardGui (Robloxity) перестают тормозить.
'skip' удалить gui[] совсем.
"""
scene = project_data.get('scene', {})
if mode == 'skip':
scene['gui'] = []
return
if mode == 'screen-only':
gui = scene.get('gui', [])
scene['gui'] = [g for g in gui
if g.get('gui_container_kind', 'screen') == 'screen']
return
# 'all' — без изменений
def _apply_scripts_mode(project_data: dict, mode: str) -> None:
"""Применяет режим scripts_mode к проекту.
mode='disabled' (default): для каждого скрипта меняем JSON-метадату
на 2-й строке packed-кода выставляем enabled=False. GameRuntime
уже умеет уважать этот флаг и не запускает.
mode='enabled': оставляем как было (как пришло из конвертера).
mode='skip': удаляем все scripts из scene.scripts полностью.
"""
scene = project_data.get('scene', {})
scripts = scene.get('scripts', [])
if not scripts:
return
if mode == 'skip':
scene['scripts'] = []
return
if mode == 'enabled':
return # ничего не делаем
# mode == 'disabled' — патчим метадату каждого скрипта.
# Формат packed-кода (см. converter._convert_script):
# "// @roblox-lua\n// {JSON}\n/* lua_source:\n...source...\n*/\n"
for s in scripts:
code = s.get('code', '')
lines = code.split('\n', 2)
if len(lines) < 2 or not lines[0].startswith('// @roblox-lua'):
continue
meta_line = lines[1]
if not meta_line.startswith('// '):
continue
try:
meta = json.loads(meta_line[3:])
meta['enabled'] = False
new_meta_line = '// ' + json.dumps(meta, ensure_ascii=False)
s['code'] = lines[0] + '\n' + new_meta_line + '\n' + (lines[2] if len(lines) > 2 else '')
except (json.JSONDecodeError, ValueError):
continue
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8690, debug=False)

View File

@ -103,19 +103,42 @@ SHAPE_TO_PRIMITIVE = {
# ────── BrickColor таблица (упрощённая) ──────
# Roblox использует old BrickColor enum (числа 1-1032). Только распространённые:
BRICKCOLOR_TO_HEX = {
1: '#f2f3f3', 5: '#d9e4f7', 9: '#9c9e9c', 11: '#e8eaea',
21: '#c4281c', 23: '#0d69ac', 24: '#f5cd30', 26: '#27313e',
28: '#293f1a', 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32',
101: '#dab8a3', 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a',
105: '#cf8b3e', 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50',
111: '#a7a6a6', 119: '#aac84a', 125: '#e8b486', 138: '#8a8a76',
141: '#26462b', 153: '#9b605a', 192: '#5a3019', 194: '#9c9b91',
199: '#3c3e3f', 208: '#dbdcdc', 224: '#f3e3a5', 226: '#fff8a8',
# Базовые тона
1: '#f2f3f3', 2: '#a1a5a2', 3: '#f9e999', 5: '#d9e4f7',
9: '#9c9e9c', 11: '#e8eaea', 18: '#cc8e69', 21: '#c4281c',
23: '#0d69ac', 24: '#f5cd30', 26: '#1b2a35', 28: '#293f1a',
29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32', 101: '#dab8a3',
102: '#82c1e9', 103: '#9b9696', 104: '#6b327a', 105: '#cf8b3e',
106: '#d97e29', 107: '#3a8fbf', 108: '#695b50', 111: '#a7a6a6',
115: '#c7d23c', 116: '#56fff0', 118: '#b4d2e4', 119: '#aac84a',
120: '#d4f0a6', 123: '#cf6b6f', 124: '#9c54a6', 125: '#e8b486',
126: '#a6c2e3', 127: '#deb87b', 128: '#a37e5b', 131: '#9ba19d',
133: '#cc7c39', 134: '#de8b5f', 135: '#74859c', 136: '#876a7a',
137: '#e6a262', 138: '#8a8a76', 140: '#234770', 141: '#26462b',
143: '#bdc3e3', 145: '#5c8aa1', 146: '#75718b', 147: '#9a8a64',
148: '#5a605a', 149: '#1b2a47', 150: '#9ea1a3',
# ВАЖНО: 151 — Earth green (тёмная трава Crossroads)
151: '#7c9b53',
153: '#9b605a', 154: '#7a2d2d', 157: '#f5e09c', 158: '#b58c9c',
168: '#3c3a37', 176: '#a39989', 178: '#aa724c', 180: '#cc9555',
190: '#f7b830', 191: '#e69138',
192: '#5a3019', 193: '#f59d24', 194: '#9c9b91', 195: '#447ba6',
196: '#283970', 198: '#7b4b85', 199: '#3c3e3f', 200: '#7a854b',
208: '#dbdcdc', 209: '#a4733f', 210: '#7d8a8e', 211: '#9da3b3',
212: '#a5cce0', 213: '#6584b5', 215: '#7c8aa4', 216: '#8a5040',
217: '#7a5443', 218: '#94748a', 219: '#5c5a8a', 220: '#a3a8c4',
221: '#cc4488', 222: '#e8a8e0', 223: '#dd7790', 224: '#f3e3a5',
225: '#e8b685', 226: '#fff8a8', 232: '#bce0f0', 268: '#3c2e74',
301: '#73584b',
# Бипалитра 1001-1032 — стандартные яркие цвета
1001: '#ffffff', 1002: '#cccccc', 1003: '#000000', 1004: '#ff0000',
1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00',
1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff',
1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0',
1017: '#ff8080', 1018: '#ffc080', 1019: '#ffff80', 1020: '#80ff80',
1021: '#80c0ff', 1022: '#80ffff', 1023: '#80ff00', 1024: '#00ff80',
1025: '#ff4040', 1026: '#8a0028', 1027: '#001f80', 1028: '#4d4d4d',
1029: '#9d9d9d', 1030: '#5e3923', 1031: '#7a4f30', 1032: '#cca5a5',
}
@ -241,12 +264,49 @@ class Converter:
'sounds': [],
'glbModels': [],
'scripts': [],
# Команды PvP (Roblox Battle): {id, name, color_hex, auto_assignable}
'teams': [],
# Spawn-точки команд (для SpawnLocation.TeamColor)
'team_spawns': [], # {team_color_hex, x, y, z}
}
# Эвристика для Roblox Battle: Model с именем "TeamBeacon X" →
# команда X. PvP-карты часто используют этот паттерн вместо Team-инстансов.
TEAM_BEACON_COLORS = {
'Black': '#1f1f1f', 'Blue': '#0d69ac', 'Red': '#c4281c',
'Green': '#4b9740', 'White': '#f2f3f3', 'Yellow': '#f5cd30',
'Orange': '#d97e29', 'Purple': '#6b327a',
}
for inst in self.model.instances:
name = inst.properties.get('Name', '')
if (inst.class_name == 'Model' and isinstance(name, str)
and name.startswith('TeamBeacon ')):
team_name = name.replace('TeamBeacon ', '').strip()
color = TEAM_BEACON_COLORS.get(team_name, '#cccccc')
scene['teams'].append({
'id': f'team_{len(scene["teams"]) + 1}',
'name': team_name,
'color_hex': color,
'auto_assignable': True,
})
# Обходим все instances и конвертим
for inst in self.model.instances:
self._convert_one(inst, scene)
# Spawn fallback: если SpawnLocation в карте НЕ был (или дефолт 0,2,0
# остался) — поднимаем выше самой высокой Part'ы. Иначе игрок
# появляется внутри Anchored=True геометрии и не может двигаться.
sp = scene.get('spawnPoint', {'x': 0, 'y': 2, 'z': 0})
if sp.get('x') == 0 and sp.get('y') == 2 and sp.get('z') == 0:
prims = scene.get('primitives', [])
if prims:
max_top = max(
(p['y'] + p.get('sy', 1) / 2) for p in prims
if isinstance(p.get('y'), (int, float))
)
scene['spawnPoint'] = {'x': 0, 'y': max_top + 5, 'z': 0}
# Финальный отчёт о скипнутых классах
for cls, n in sorted(self.stats.skipped_classes.items(), key=lambda x: -x[1])[:30]:
self.stats.warnings.append(f"skipped {n}× {cls}")
@ -308,8 +368,12 @@ class Converter:
elif cls == 'Workspace':
# Workspace = root, его свойства мапим на scene.worldSize и т.п.
pass
elif cls == 'Team':
# PvP-команда: имя + цвет в scene.teams[].
self._convert_team(inst, scene)
elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
'StarterPack', 'StarterCharacterScripts', 'Players',
'Teams',
'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
'SoundService', 'TweenService', 'RunService',
'UserInputService', 'HttpService', 'DataStoreService',
@ -374,7 +438,9 @@ class Converter:
'canCollide': bool(props.get('CanCollide', True)),
'visible': props.get('Transparency', 0) < 1.0 if isinstance(props.get('Transparency'), (int, float)) else True,
'opacity': max(0.0, 1.0 - (props.get('Transparency', 0) or 0)),
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
}
@ -405,7 +471,9 @@ class Converter:
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
})
@ -434,7 +502,9 @@ class Converter:
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
})
@ -506,7 +576,9 @@ class Converter:
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'note': f'MeshPart (no GLB) rbxid={rbx_id}',
@ -527,7 +599,9 @@ class Converter:
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'color': get_part_color(props),
'canCollide': bool(props.get('CanCollide', True)),
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'origin': 'roblox-meshpart',
'rbxAssetId': rbx_id,
})
@ -567,7 +641,9 @@ class Converter:
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'note': f'Union (no CSG GLB) rbxid={rbx_id}',
@ -586,7 +662,9 @@ class Converter:
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'color': get_part_color(props),
'canCollide': bool(props.get('CanCollide', True)),
'anchored': bool(props.get('Anchored', False)),
# FORCE-ANCHORED — Welds импортируем как заглушки, без них
# физика 700+ unanchored Part'ов = карта рассыпается.
'anchored': True,
'origin': 'roblox-union',
'rbxAssetId': rbx_id,
})
@ -594,15 +672,43 @@ class Converter:
# ─── Spawn ───
def _convert_team(self, inst: Instance, scene: Dict) -> None:
"""Roblox Team → scene.teams[]."""
props = inst.properties
name = str(props.get('Name', 'Team'))
# TeamColor — BrickColor код, мапим в hex через существующую таблицу
team_color = props.get('TeamColor')
color_hex = '#ffffff'
if isinstance(team_color, BrickColor):
color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc')
scene['teams'].append({
'id': f'team_{len(scene["teams"]) + 1}',
'name': name,
'color_hex': color_hex,
'auto_assignable': bool(props.get('AutoAssignable', True)),
})
def _convert_spawn(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
pos, _ = cframe_to_pos_rot(cf, self.scale)
# Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита,
# юзер появляется на её верхней грани.
# TeamColor (если есть) → spawn для команды.
team_color = props.get('TeamColor')
if isinstance(team_color, BrickColor):
color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc')
scene['team_spawns'].append({
'team_color_hex': color_hex,
'x': pos['x'], 'y': pos['y'] + 1.5, 'z': pos['z'],
'neutral': not bool(props.get('Neutral', True)) and team_color.code != 0,
})
# Spawn должен быть значительно выше — старые Roblox-карты часто имеют
# толстый Floor выше плиты, юзер появляется внутри стены/пола если
# не дать запас. +5 единиц достаточно — гравитация уронит на пол.
scene['spawnPoint'] = {
'x': pos['x'],
'y': pos['y'] + 1.5, # отступ вверх чтобы не залипнуть в плите
'y': pos['y'] + 5,
'z': pos['z'],
}
@ -719,9 +825,13 @@ class Converter:
if not hasattr(self, '_screen_gui_refs'):
self._screen_gui_refs = set()
self._screen_gui_enabled = {}
self._screen_gui_kind = {} # ref → 'screen' | 'billboard' | 'surface'
self._screen_gui_refs.add(inst.referent)
enabled = inst.properties.get('Enabled', True)
self._screen_gui_enabled[inst.referent] = bool(enabled) if enabled is not None else True
# Сохраняем тип контейнера — потом отфильтруем 3D-GUI если выбрано screen-only
kind = {'ScreenGui': 'screen', 'BillboardGui': 'billboard', 'SurfaceGui': 'surface'}.get(inst.class_name, 'screen')
self._screen_gui_kind[inst.referent] = kind
def _gui_parent_id(self, parent_ref) -> Optional[str]:
if parent_ref is None:
@ -815,12 +925,14 @@ class Converter:
# элемент тоже невидим.
parent_ref = inst.parent_referent
screen_enabled = True
container_kind = 'screen' # default
if hasattr(self, '_screen_gui_refs'):
cur = parent_ref
depth = 0
while cur is not None and depth < 50:
if cur in self._screen_gui_refs:
screen_enabled = self._screen_gui_enabled.get(cur, True)
container_kind = self._screen_gui_kind.get(cur, 'screen')
break
# Поиск родителя cur в instances (если есть)
cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None
@ -873,6 +985,10 @@ class Converter:
'imageAsset': None,
'zIndex': int(props.get('ZIndex', 1) or 1),
'origin': 'roblox-' + cls.lower(),
# 'screen' — обычный HUD; 'billboard' — 3D-табличка над частью;
# 'surface' — на грани Part. Last 2 рендерятся в 3D-сцене и
# сильно тормозят если их сотни.
'gui_container_kind': container_kind,
}
scene['gui'].append(element)

View File

@ -113,18 +113,28 @@ class CFrame:
matrix: tuple # (r00, r01, r02, r10, r11, r12, r20, r21, r22)
def to_euler_xyz(self) -> tuple:
"""Конверт 3x3 rotation matrix в Euler XYZ (radians).
"""Конверт 3x3 rotation matrix в Euler YXZ (Babylon convention).
Использует стандартную intrinsic XYZ rotation extraction:
Rx = atan2(r21, r22)
Ry = atan2(-r20, sqrt(r21² + r22²))
Rz = atan2(r10, r00)
Babylon mesh.rotation = Vector3(rx, ry, rz) применяется в порядке YXZ
(rotate Y first, then X, then Z). Чтобы извлечь Euler из матрицы под
этот convention, используем формулу YXZ-extraction:
Rx = asin(-r12)
Ry = atan2(r02, r22)
Rz = atan2(r10, r11)
(имя метода to_euler_xyz сохраняем для совместимости вызовов.)
"""
import math
r00, r01, r02, r10, r11, r12, r20, r21, r22 = self.matrix
rx = math.atan2(r21, r22)
ry = math.atan2(-r20, math.sqrt(r21*r21 + r22*r22))
rz = math.atan2(r10, r00)
# Edge case: r12 близко к ±1 (gimbal lock на X = ±90°)
clamped = max(-1.0, min(1.0, -r12))
rx = math.asin(clamped)
if abs(clamped) > 0.99999:
# Gimbal lock — z = 0, y = atan2(-r20, r00)
ry = math.atan2(-r20, r00)
rz = 0.0
else:
ry = math.atan2(r02, r22)
rz = math.atan2(r10, r11)
return (rx, ry, rz)
@ -551,19 +561,30 @@ def _cframe_orientation_to_matrix(orientation_id: int) -> tuple:
Источник: https://dom.rojo.space/binary#cframe-orientation-ids
Это полная таблица 24-х валидных orientation id для cube symmetries.
Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22).
Формула из rbx-dom:
orientation_id = (rx_axis * 6) + ry_axis + 1
где rx_axis, ry_axis {0..5} = (R0, R1, R2, R3, R4, R5):
R0 = +X, R1 = +Y, R2 = +Z, R3 = -X, R4 = -Y, R5 = -Z
rx это направление куда смотрит локальная +X ось куба (правая грань),
ry направление куда смотрит локальная +Y ось (верхняя грань).
Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22) row-major.
Матрица собирается так: rx, ry, rz это столбцы.
"""
# Таблица из rbx-dom. Каждое значение — пара (rx_axis, ry_axis) где
# значения в {0,1,2,3,4,5} = +X, -X, +Y, -Y, +Z, -Z
# Правильный порядок axes (rbx-dom):
# 0=+X, 1=+Y, 2=+Z, 3=-X, 4=-Y, 5=-Z
AXES = [
(1, 0, 0), (-1, 0, 0),
(0, 1, 0), (0, -1, 0),
(0, 0, 1), (0, 0, -1),
(1, 0, 0), # +X
(0, 1, 0), # +Y
(0, 0, 1), # +Z
(-1, 0, 0), # -X
(0, -1, 0), # -Y
(0, 0, -1), # -Z
]
# orientation_id = 1..24 (1-based)
if not (1 <= orientation_id <= 24):
# Неверный id — возвращаем identity
# orientation_id = 1..36 (некоторые комбинации rx==ry невалидны, в файлах
# не встречаются — но id может доходить до 6*6 = 36, не 24).
if not (1 <= orientation_id <= 36):
return (1, 0, 0, 0, 1, 0, 0, 0, 1)
idx = orientation_id - 1
@ -571,16 +592,14 @@ def _cframe_orientation_to_matrix(orientation_id: int) -> tuple:
ry_idx = idx % 6
rx = AXES[rx_idx]
ry = AXES[ry_idx]
# rz = rx × ry (cross product)
# rz = rx × ry (cross product) — третий столбец
rz = (
rx[1] * ry[2] - rx[2] * ry[1],
rx[2] * ry[0] - rx[0] * ry[2],
rx[0] * ry[1] - rx[1] * ry[0],
)
# Матрица: первые 3 — first row (R_xx, R_yx, R_zx)
# Сложновато; берём из rbx-dom convention: первые три — основа R*XAxis,
# затем R*YAxis, затем R*ZAxis. Расширяем в row-major form.
# На практике: orientation вектора (rx, ry, rz) — это **столбцы** матрицы.
# rx, ry, rz — это СТОЛБЦЫ матрицы.
# row-major: [r00=rx[0], r01=ry[0], r02=rz[0], r10=rx[1], r11=ry[1], r12=rz[1], ...]
r00, r10, r20 = rx
r01, r11, r21 = ry
r02, r12, r22 = rz

View 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=[],
)

View File

@ -10,7 +10,7 @@ export const USER_addres = BASE + '/api-user';
export const ACHIVES_addres = BASE + '/api-achievs';
export const COMMENTS_addres = BASE + '/api-comments';
export const STORYS_addres = BASE + '/api-storys';
// rbxl-importer: только для МИНа (тест-фича импорта .rbxl карт Roblox)
// rbxl-importer: импорт .rbxl карт Roblox (см. вики «Импорт из Roblox»)
export const RBXL_addres = BASE + '/api-rbxl';
export const NOTICES_addres = BASE + '/api-notices';
export const HELP_addres = BASE + '/api-help';

View File

@ -52,11 +52,20 @@ export async function analyzeRbxl(file) {
/**
* Создаёт проект из preview_hash. Возвращает { project_id, redirect, assets_downloaded, assets_failed }.
*/
export async function createRbxlProject(previewHash, title) {
export async function createRbxlProject(previewHash, title, opts = {}) {
const resp = await fetch(`${RBXL_addres}/import/rbxl/create`, {
method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ preview_hash: previewHash, title: title || '' }),
body: JSON.stringify({
preview_hash: previewHash,
title: title || '',
// 'disabled' (default) — импортнуть выключенными, читать можно
// 'enabled' — попытаться запустить (может вешать карту)
// 'skip' — не импортировать совсем
scripts_mode: opts.scriptsMode || 'disabled',
// 'all' (default) / 'screen-only' (только HUD) / 'skip' (без GUI)
gui_mode: opts.guiMode || 'all',
}),
});
if (!resp.ok) {
const text = await resp.text();

View File

@ -13,6 +13,8 @@ import { GAMES, GAME_GROUPS } from './docsGames';
import { LESSONS, hasLesson } from './docsLessons';
import { buildGameProject } from './docsGamesBuilders';
import DocIcon from './docsIcons';
import { DocsLangProvider, DocsLangPicker, DOCS_LANG_STYLES, useDocsLang } from './docsLang';
import { LUA_OVERRIDES } from './docsGamesBuildersLua';
/**
* KubikonDocs вика редактора Рублокс.
@ -76,6 +78,7 @@ const KubikonDocs = () => {
return (
<div className={cl.studio}>
<style>{INLINE_STYLES}</style>
<style>{DOCS_LANG_STYLES}</style>
{/* === Левая боковая панель === */}
<aside className={cl.sidebar}>
@ -383,12 +386,15 @@ const ChapterPage = ({ chapter, mainRef }) => {
{/* Контент раздела */}
<div className="docsContent">
<DocsLangProvider>
<DocsLangPicker />
{chapter.sections.map((s) => (
<article key={s.id} id={`sec-${s.id}`} className="docsChapter">
<h3 className="docsSectionTitle">{s.title}</h3>
<div className="docsSectionBody">{s.body}</div>
</article>
))}
</DocsLangProvider>
</div>
</section>
);
@ -399,17 +405,20 @@ const ChapterPage = ({ chapter, mainRef }) => {
//
const LessonPage = ({ game, navigate }) => {
const lesson = LESSONS[game.id];
// 'idle' | 'creating' | 'error'
// 'idle' | 'choosing' | 'creating' | 'error'
const [state, setState] = useState('idle');
// Создаёт НОВУЮ копию игры-урока на текущем пользователе и
// открывает её в редакторе. Оригинал при этом ВСЕГДА цел.
const openInEditor = async () => {
// Шаг 1: юзер нажал «Открыть копию» показываем модалку выбора языка.
const openInEditor = () => {
const userId = getCurrentUserId();
if (!userId) {
setState('error');
return;
}
if (!userId) { setState('error'); return; }
setState('choosing');
};
// Шаг 2: язык выбран создаём копию с нужными скриптами и открываем.
const createCopyWithLang = async (lang) => {
const userId = getCurrentUserId();
if (!userId) { setState('error'); return; }
setState('creating');
try {
// project_data копии берём двумя способами:
@ -422,9 +431,11 @@ const LessonPage = ({ game, navigate }) => {
const pd = orig && orig.data && orig.data.project_data;
if (!pd) { setState('error'); return; }
// project_data может прийти строкой или объектом нормализуем в строку.
projectDataStr = typeof pd === 'string' ? pd : JSON.stringify(pd);
let pdObj = typeof pd === 'string' ? JSON.parse(pd) : pd;
if (lang === 'lua') pdObj = convertProjectScriptsToLua(pdObj);
projectDataStr = JSON.stringify(pdObj);
} else {
const project = buildGameProject(game.id);
const project = buildGameProject(game.id, { lang });
if (!project) { setState('error'); return; }
projectDataStr = JSON.stringify(project);
}
@ -477,6 +488,12 @@ const LessonPage = ({ game, navigate }) => {
: <><Icon name="play" size={15} /> Открыть мою копию в редакторе</>}
</button>
</div>
{state === 'choosing' && (
<LangChoiceModal
onPick={(lang) => createCopyWithLang(lang)}
onCancel={() => setState('idle')}
/>
)}
{state === 'error' && (
<div className="lessonErr">
Не получилось открыть игру. Проверь, что ты вошёл в аккаунт,
@ -484,14 +501,134 @@ const LessonPage = ({ game, navigate }) => {
</div>
)}
{/* Тело урока */}
{/* Тело урока с переключателем JS/Lua */}
<article className="docsChapter lessonBody">
<DocsLangProvider>
<DocsLangPicker />
<LuaLessonBanner gameId={game.id} />
<div className="docsSectionBody">{lesson.body}</div>
</DocsLangProvider>
</article>
</section>
);
};
// При выбранном Lua показывает плашку с готовыми Lua-скриптами для урока
// (если они есть в LUA_OVERRIDES). Скрипты ниже в основном теле остаются
// на JS как референс Lua-версия здесь сверху для копирования.
const LuaLessonBanner = ({ gameId }) => {
const { lang } = useDocsLang();
if (lang !== 'lua') return null;
const overrides = LUA_OVERRIDES[gameId];
if (!overrides) {
return (
<div className="luaLessonBanner luaLessonBanner--missing">
<b>Lua-версия в работе.</b>
<p>
Для этого урока пока готова только JS-версия (показана ниже).
Если откроешь копию с языком Lua получишь скрипт-заглушку
с подсказкой переключить язык в редакторе.
</p>
</div>
);
}
const entries = Object.entries(overrides);
return (
<div className="luaLessonBanner">
<div className="luaLessonBanner__head">
<b>Готовые Lua-скрипты для этой игры</b>
<span className="luaLessonBanner__hint">
Эти скрипты автоматически попадут в твою копию, если откроешь её на Lua.
</span>
</div>
{entries.map(([id, codeOrFn]) => {
const code = typeof codeOrFn === 'function' ? codeOrFn({ id }) : codeOrFn;
return (
<details key={id} className="luaLessonBanner__script">
<summary>{id}</summary>
<pre className="docCode" data-lang="lua"><code>{code}</code></pre>
</details>
);
})}
</div>
);
};
//
// Модалка выбора языка скриптов при «Открыть копию»
//
const LangChoiceModal = ({ onPick, onCancel }) => {
return (
<div className="langChoiceOverlay" onClick={onCancel}>
<div className="langChoiceDialog" onClick={(e) => e.stopPropagation()}>
<h3 className="langChoiceTitle">На каком языке открыть копию?</h3>
<p className="langChoiceSub">
Скрипты в твоей копии будут написаны на выбранном языке.
Логика игры одинаковая отличается только запись кода.
</p>
<div className="langChoiceBtns">
<button className="langChoiceBtn langChoiceBtn--js"
onClick={() => onPick('js')}>
<div className="langChoiceBtn__name">JavaScript</div>
<div className="langChoiceBtn__hint">
Если ты новичок этот выбор проще.
</div>
</button>
<button className="langChoiceBtn langChoiceBtn--lua"
onClick={() => onPick('lua')}>
<div className="langChoiceBtn__name">Lua</div>
<div className="langChoiceBtn__hint">
Если играл в Roblox узнаешь команды.
</div>
</button>
</div>
<button className="langChoiceCancel" onClick={onCancel}>Отмена</button>
</div>
</div>
);
};
/**
* Конвертирует все JS-скрипты в project_data в Lua-эквивалент.
* Сейчас простая стратегия: если в скрипте есть code_lua слот, делает его
* активным. Иначе ставит флаг language='lua' и пустой Lua-шаблон с TODO.
* Полноценная транспиляция JSLua невозможна без AST-анализа.
*/
function convertProjectScriptsToLua(projectData) {
const scene = projectData?.scene;
if (!scene || !Array.isArray(scene.scripts)) return projectData;
scene.scripts = scene.scripts.map(s => {
if (s.language === 'lua') return s;
// Если уже есть готовый Lua-слот используем его
if (s.code_lua && s.code_lua.trim()) {
return {
...s,
language: 'lua',
code: s.code_lua,
code_js: s.code_js || s.code,
code_lua: s.code_lua,
};
}
// Иначе ставим заглушку с подсказкой
const luaStub = `-- TODO: версия этого скрипта на Lua пока не готова.
-- Оригинальный JS-код сохранён ниже (переключи язык назад на JS в редакторе).
-- Доступные API: game:GetService("Players"), game.Workspace, script.Parent
--
-- Например, простой пример:
local Players = game:GetService("Players")
print("Привет от Lua-скрипта")
`;
return {
...s,
language: 'lua',
code: luaStub,
code_js: s.code_js || s.code,
code_lua: luaStub,
};
});
return projectData;
}
//
// Инлайн-стили
//
@ -732,13 +869,14 @@ const INLINE_STYLES = `
.docsSectionBody b { color: #0f172a; font-weight: 800; }
.docsSectionBody h4 { font-family: inherit; }
.docsSectionBody code {
background: #e0e8ff;
color: #3357ff;
background: #fff5e0;
color: #b14400;
padding: 2px 7px;
border-radius: 6px;
font-family: Consolas, Menlo, "Courier New", monospace;
font-size: 13px;
font-weight: 700;
border: 1px solid #f5d8a8;
}
/* kbd */
@ -770,6 +908,7 @@ const INLINE_STYLES = `
.docCode code {
background: none; color: inherit; padding: 0;
font-weight: 500; font-size: 13px; white-space: pre;
border: none;
}
/* Скриншот интерфейса с подписью.

View File

@ -390,18 +390,16 @@ const KubikonStudio = () => {
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
<span>ВИКИ</span>
</button>
{/* Импорт Roblox .rbxl — только для МИНа (user_id=1) */}
{getCurrentUserId() === 1 && (
{/* Импорт Roblox .rbxl — доступно всем */}
<button
className={cl.navItem}
onClick={() => setRbxlImportOpen(true)}
title="Импортировать игру из Roblox (.rbxl файл) — тест-фича"
title="Импортировать игру из Roblox (.rbxl файл)"
style={{ marginTop: 8, borderTop: '1px solid #333', paddingTop: 12 }}
>
<span className={cl.navIcon}>📦</span>
<span>Импорт Roblox</span>
</button>
)}
</nav>
<RbxlImportModal

File diff suppressed because it is too large Load Diff

View File

@ -748,7 +748,9 @@ function game6ColorTiles() {
id,
type: 'cube',
name: 'Плитка_' + id,
x: -4 + c * 2, y: 1.15, z: -4 + r * 2,
// Платформа grass: x от -6 до 5 (blocks=1unit, центры [-5.5..5.5]).
// Сетка 6×6 плиток (центры через 2) центрируем на [-5..5].
x: -5 + c * 2, y: 1.15, z: -5 + r * 2,
sx: 1.8, sy: 0.3, sz: 1.8,
color: '#9aa0aa', // серый — не раскрашена
material: 'matte',
@ -6158,8 +6160,143 @@ export function hasGameBuilder(id) {
return typeof GAME_BUILDERS[id] === 'function';
}
/** Построить project_data для игры-урока. Возвращает объект или null. */
export function buildGameProject(id) {
const fn = GAME_BUILDERS[id];
return fn ? fn() : null;
// ══════════════════════════════════════════════════════════════════
// LUA_OVERRIDES — реестр Lua-версий скриптов для уроков.
// Структура: { gameId: { scriptId: 'lua code' | (script) => 'lua code' } }
// Если скрипт описан здесь — при buildGameProject(id, {lang:'lua'}) его
// code будет заменён на Lua-версию.
// См. docsGamesBuildersLua.js для содержимого.
// ══════════════════════════════════════════════════════════════════
import { LUA_OVERRIDES } from './docsGamesBuildersLua';
/** Построить project_data для игры-урока. Возвращает объект или null.
* opts.lang: 'js' (default) | 'lua' на каком языке скрипты в копии.
*/
/**
* Генерирует минимальный рабочий Lua-каркас для скрипта когда явной
* Lua-реализации в LUA_OVERRIDES нет. Анализирует target и name чтобы
* сделать что-то осмысленное:
* - target=null (главный скрипт): показывает подсказку, слушает событие
* FinishReached и при срабатывании конфетти + Победа
* - target=primitive с именем содержащим "Финиш"/"Final": Touched
* шлёт FinishReached
* - target=primitive с любым другим именем: Touched красит примитив
* в случайный цвет (визуальный feedback что скрипт работает)
*/
function generateFallbackLua(s, gameTitle) {
const target = s.target;
const name = s.name || s.id || '';
const title = gameTitle || 'игра';
// Главный скрипт (target=null)
if (!target || target === null) {
return `-- === ${name} (Lua, авто-каркас) ===
-- Полная Lua-версия этой игры пока в разработке.
-- Этот каркас обеспечивает базовое поведение: подсказка + победа на финише.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local function getEvent(eventName)
local ev = ReplicatedStorage:FindFirstChild(eventName)
if not ev then
ev = Instance.new("BindableEvent")
ev.Name = eventName
ev.Parent = ReplicatedStorage
end
return ev
end
__rbxl_show_text("${title.replace(/"/g, '\\"')}", 3)
local winSound = Instance.new("Sound", workspace)
winSound.SoundId = "win"; winSound.Volume = 1
local won = false
local winEvent = getEvent("FinishReached")
winEvent.Event:Connect(function()
if won then return end
won = true
winSound:Play()
__rbxl_show_text("Победа!", 4)
local px = __rbxl_player_x()
local py = __rbxl_player_y()
local pz = __rbxl_player_z()
__rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3)
end)`;
}
// Скрипт на примитиве с именем "Финиш" / "ФинишЗона" / "Final"
const isFinish = /финиш|финал|final/i.test(name);
if (isFinish) {
return `-- === ${name} (Lua, авто-каркас) ===
-- При касании игроком шлём событие победы.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local part = script.Parent
local fired = false
part.Touched:Connect(function(hit)
if fired then return end
local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
if not h then return end
fired = true
local ev = ReplicatedStorage:FindFirstChild("FinishReached")
if not ev then
ev = Instance.new("BindableEvent")
ev.Name = "FinishReached"
ev.Parent = ReplicatedStorage
end
ev:Fire()
end)`;
}
// Общий каркас для любого target-примитива — Touched красит в случайный цвет
return `-- === ${name} (Lua, авто-каркас) ===
-- Полная Lua-версия этого скрипта пока в разработке.
-- Базовое поведение: при касании предмет реагирует визуально.
local part = script.Parent
local touched = false
part.Touched:Connect(function(hit)
if touched then return end
local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
if not h then return end
touched = true
-- Меняем цвет на яркий зелёный простой feedback
part.Color = Color3.fromRGB(60, 230, 80)
end)`;
}
export function buildGameProject(id, opts = {}) {
const fn = GAME_BUILDERS[id];
if (!fn) return null;
const project = fn();
if (opts.lang === 'lua' && project) {
const scene = project.scene || {};
if (Array.isArray(scene.scripts)) {
const overrides = LUA_OVERRIDES[id] || {};
// Извлекаем название игры из любого скрипта (для подсказки в fallback)
let gameTitle = '';
const mainScript = scene.scripts.find(s => !s.target);
if (mainScript) {
const m = /===\s*ИГРА\s*[«"](.+?)[»"]/i.exec(mainScript.code || '');
if (m) gameTitle = m[1];
}
scene.scripts = scene.scripts.map(s => {
if (s.language === 'lua') return s;
// Приоритет: явный code_lua → override из реестра → авто-fallback.
let luaCode = s.code_lua;
if (!luaCode) {
const ov = overrides[s.id];
if (typeof ov === 'function') luaCode = ov(s);
else if (typeof ov === 'string') luaCode = ov;
}
if (!luaCode || !luaCode.trim()) {
luaCode = generateFallbackLua(s, gameTitle);
}
return {
...s,
language: 'lua',
code: luaCode,
code_js: s.code_js || s.code,
code_lua: luaCode,
};
});
}
}
return project;
}

File diff suppressed because it is too large Load Diff

463
src/community/docsLang.jsx Normal file
View File

@ -0,0 +1,463 @@
/**
* docsLang.jsx поддержка вкладок JS/Lua в статьях вики.
*
* Компоненты:
* <DocsLangProvider> оборачивает страницу статьи, хранит выбранный язык
* в localStorage 'rublox.docs.lang' ('js' | 'lua').
* <DocsLangPicker /> большой переключатель JS/Lua над статьёй.
* <LangTabs js lua /> вкладка-переключатель внутри статьи. Показывает
* либо js, либо lua, согласно текущему языку.
* useDocsLang() хук: возвращает {lang, setLang}.
*
* Если в статье нет ни одного <LangTabs> она одинаково выглядит на обоих
* языках (общая теория, не зависящая от языка скриптов).
*/
import React, { createContext, useContext, useEffect, useState } from 'react';
//
// Простая подсветка синтаксиса для JS и Lua
//
const JS_KEYWORDS = new Set([
'let', 'const', 'var', 'function', 'return', 'if', 'else', 'for', 'while',
'do', 'switch', 'case', 'break', 'continue', 'new', 'this', 'class',
'extends', 'super', 'true', 'false', 'null', 'undefined', 'try', 'catch',
'finally', 'throw', 'typeof', 'instanceof', 'in', 'of', 'async', 'await',
'import', 'export', 'from', 'default', 'delete', 'void',
]);
const JS_BUILTINS = new Set([
'game', 'Math', 'Object', 'Array', 'String', 'Number', 'Boolean', 'JSON',
'console', 'setTimeout', 'setInterval', 'Promise', 'document', 'window',
]);
const LUA_KEYWORDS = new Set([
'local', 'function', 'end', 'if', 'then', 'else', 'elseif', 'for', 'while',
'do', 'repeat', 'until', 'return', 'break', 'and', 'or', 'not', 'true',
'false', 'nil', 'in', 'goto',
]);
const LUA_BUILTINS = new Set([
'game', 'workspace', 'script', 'Instance', 'Vector3', 'Vector2', 'Color3',
'CFrame', 'UDim2', 'UDim', 'BrickColor', 'Enum', 'math', 'string', 'table',
'task', 'print', 'warn', 'pairs', 'ipairs', 'pcall', 'tostring', 'tonumber',
'TweenInfo', 'wait', 'tick', 'type', 'require', 'next', 'setmetatable',
'getmetatable', 'rawget', 'rawset',
]);
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Возвращает HTML-строку с раскрашенным кодом. lang: 'js' | 'lua'. */
export function highlightCode(text, lang) {
if (typeof text !== 'string') return escapeHtml(String(text || ''));
const isLua = lang === 'lua';
const keywords = isLua ? LUA_KEYWORDS : JS_KEYWORDS;
const builtins = isLua ? LUA_BUILTINS : JS_BUILTINS;
// Регулярки для токенов порядок важен: сначала комменты и строки,
// потом числа, потом identifier'ы.
// JS: //... и /*...*/. Lua: --... и --[[...]].
const commentRe = isLua
? /--\[\[[\s\S]*?\]\]|--[^\n]*/g
: /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
// Строки: одинарные, двойные, в JS ещё бэктики.
const stringRe = isLua
? /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|\[\[[\s\S]*?\]\]/g
: /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`/g;
const numRe = /\b\d+(?:\.\d+)?\b/g;
const idRe = /[A-Za-zА-Яа-я_$][A-Za-zА-Яа-я0-9_$]*/g;
// Берём весь текст, делим на токены через одну общую регулярку.
const tokens = [];
const combined = new RegExp(
commentRe.source + '|' + stringRe.source + '|' + numRe.source + '|' + idRe.source,
'g'
);
let lastIndex = 0;
let match;
while ((match = combined.exec(text)) !== null) {
const start = match.index;
const tok = match[0];
if (start > lastIndex) {
tokens.push({ type: 'raw', text: text.slice(lastIndex, start) });
}
// Классифицируем
if (tok.startsWith('--') || tok.startsWith('//') || tok.startsWith('/*')) {
tokens.push({ type: 'comment', text: tok });
} else if (/^["'`\[]/.test(tok)) {
tokens.push({ type: 'string', text: tok });
} else if (/^\d/.test(tok)) {
tokens.push({ type: 'number', text: tok });
} else if (keywords.has(tok)) {
tokens.push({ type: 'keyword', text: tok });
} else if (builtins.has(tok)) {
tokens.push({ type: 'builtin', text: tok });
} else {
// Идентификатор проверим, идёт ли за ним ( функция
const rest = text.slice(start + tok.length);
if (/^\s*\(/.test(rest)) {
tokens.push({ type: 'fn', text: tok });
} else {
tokens.push({ type: 'ident', text: tok });
}
}
lastIndex = start + tok.length;
}
if (lastIndex < text.length) {
tokens.push({ type: 'raw', text: text.slice(lastIndex) });
}
return tokens.map(t => {
const safe = escapeHtml(t.text);
if (t.type === 'raw' || t.type === 'ident') return safe;
return `<span class="hl-${t.type}">${safe}</span>`;
}).join('');
}
// v2 раньше при первом включении lua-режима сохранялся в LS и юзер
// потом всегда видел Lua-таб по умолчанию. Бамп ключа = сброс на JS
// у всех уже-открытых вкладок.
const LS_KEY = 'rublox.docs.lang.v2';
const LS_KEY_OLD = 'rublox.docs.lang';
const DEFAULT_LANG = 'js';
const DocsLangContext = createContext({
lang: DEFAULT_LANG,
setLang: () => {},
});
export function DocsLangProvider({ children }) {
const [lang, setLangState] = useState(() => {
try {
// Очищаем старый ключ у части юзеров там залип 'lua'
localStorage.removeItem(LS_KEY_OLD);
const v = localStorage.getItem(LS_KEY);
return v === 'lua' ? 'lua' : 'js';
} catch (_) {
return DEFAULT_LANG;
}
});
const setLang = (next) => {
const v = next === 'lua' ? 'lua' : 'js';
setLangState(v);
try { localStorage.setItem(LS_KEY, v); } catch (_) {}
};
useEffect(() => {
// Слушаем смену из других вкладок
const onStorage = (e) => {
if (e.key === LS_KEY && (e.newValue === 'js' || e.newValue === 'lua')) {
setLangState(e.newValue);
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
return (
<DocsLangContext.Provider value={{ lang, setLang }}>
{children}
</DocsLangContext.Provider>
);
}
export function useDocsLang() {
return useContext(DocsLangContext);
}
/** Большой переключатель над статьёй: «На каком языке смотреть код?» */
export function DocsLangPicker() {
const { lang, setLang } = useDocsLang();
return (
<div className="docsLangPicker">
<div className="docsLangPicker__label">
Язык скриптов в этой статье:
</div>
<div className="docsLangPicker__tabs">
<button
type="button"
className={
'docsLangPicker__tab docsLangPicker__tab--js' +
(lang === 'js' ? ' is-active' : '')
}
onClick={() => setLang('js')}
>
JavaScript
</button>
<button
type="button"
className={
'docsLangPicker__tab docsLangPicker__tab--lua' +
(lang === 'lua' ? ' is-active' : '')
}
onClick={() => setLang('lua')}
>
Lua
</button>
</div>
<div className="docsLangPicker__hint">
Не знаешь что выбрать? Смотри статью <b>D0. Скриптинг: JS или Lua?</b>
</div>
</div>
);
}
/**
* Локальный переключатель вкладок внутри статьи. Если js/lua
* прямой контент (children), если на странице нет <DocsLangProvider>
* показываем оба заголовками.
*
* Использование:
* <LangTabs
* js={<Code>game.log('Привет')</Code>}
* lua={<Code>print('Привет')</Code>}
* />
*/
export function LangTabs({ js, lua }) {
const { lang, setLang } = useDocsLang();
const hasJs = js !== undefined && js !== null;
const hasLua = lua !== undefined && lua !== null;
if (!hasJs && !hasLua) return null;
// Если есть только один язык показываем без переключателя
if (hasJs && !hasLua) return <>{js}</>;
if (!hasJs && hasLua) return <>{lua}</>;
return (
<div className="docsLangTabs">
<div className="docsLangTabs__head">
<button
type="button"
className={'docsLangTabs__tab' + (lang === 'js' ? ' is-active' : '')}
onClick={() => setLang('js')}
>
JS
</button>
<button
type="button"
className={'docsLangTabs__tab' + (lang === 'lua' ? ' is-active' : '')}
onClick={() => setLang('lua')}
>
Lua
</button>
</div>
<div className="docsLangTabs__body">
{lang === 'lua' ? lua : js}
</div>
</div>
);
}
export const DOCS_LANG_STYLES = `
.docsLangPicker {
background: linear-gradient(135deg, #1a1d2e 0%, #14172b 100%);
border: 1px solid #2a3050;
border-radius: 10px;
padding: 14px 18px;
margin: 16px 0 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.docsLangPicker__label {
font-size: 13px;
font-weight: 600;
color: #c8cce0;
}
.docsLangPicker__tabs {
display: flex;
gap: 8px;
}
.docsLangPicker__tab {
flex: 1;
padding: 10px 16px;
border-radius: 6px;
border: 1px solid transparent;
background: #232842;
color: #aab0c8;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
}
.docsLangPicker__tab:hover { background: #2a304f; color: #fff; }
.docsLangPicker__tab--js.is-active {
background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%);
color: #1a1a1c;
border-color: #d4b500;
}
.docsLangPicker__tab--lua.is-active {
background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%);
color: #fff;
border-color: #1565c0;
}
.docsLangPicker__hint {
font-size: 12px;
color: #8a90a8;
font-style: italic;
}
.docsLangTabs {
margin: 12px 0;
border-radius: 10px;
overflow: hidden;
border: 1px solid #e0e6f0;
background: #fff;
}
.docsLangTabs__head {
display: flex;
background: #f4f6fb;
border-bottom: 1px solid #e0e6f0;
}
.docsLangTabs__tab {
padding: 9px 18px;
border: none;
background: transparent;
color: #64748b;
font-size: 12px;
font-weight: 700;
cursor: pointer;
letter-spacing: 0.5px;
border-bottom: 2px solid transparent;
}
.docsLangTabs__tab:hover { color: #1e293b; }
.docsLangTabs__tab.is-active {
color: #1e3a8a;
border-bottom-color: #3357ff;
background: #fff;
}
.docsLangTabs__body {
padding: 0;
background: #fff;
}
.docsLangTabs__body > pre,
.docsLangTabs__body > .docCode { margin: 0; border-radius: 0; }
/* Заголовки колонок таблицы (th) в основных стилях вики не определены.
Делаем светлыми чтобы не сливались с фоном таблицы. */
.docTable th {
padding: 9px 14px;
background: #eef2ff;
color: #1e3a8a;
font-size: 13px;
font-weight: 700;
text-align: left;
border-bottom: 1px solid #d4dcef;
border-right: 1px solid #eef2f7;
}
.docTable th:last-child { border-right: none; }
.docTable thead tr:first-child th:first-child { border-top-left-radius: 12px; }
.docTable thead tr:first-child th:last-child { border-top-right-radius: 12px; }
.langChoiceOverlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.75);
display: flex; align-items: center; justify-content: center;
z-index: 10000;
}
.langChoiceDialog {
background: #1a1d2e;
border: 1px solid #2a3050;
border-radius: 14px;
padding: 28px;
width: 100%;
max-width: 520px;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
}
.langChoiceTitle {
font-size: 20px;
margin: 0 0 8px;
color: #fff;
}
.langChoiceSub {
margin: 0 0 20px;
font-size: 13px;
color: #aab0c8;
line-height: 1.5;
}
.langChoiceBtns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 14px;
}
.langChoiceBtn {
padding: 18px 16px;
border-radius: 10px;
border: 2px solid transparent;
text-align: left;
cursor: pointer;
transition: all 0.15s;
color: #fff;
}
.langChoiceBtn--js {
background: linear-gradient(135deg, #f7df1e 0%, #d4b500 100%);
color: #1a1a1c;
}
.langChoiceBtn--js:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(247,223,30,0.3); }
.langChoiceBtn--lua {
background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%);
}
.langChoiceBtn--lua:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(33,150,243,0.4); }
.langChoiceBtn__name {
font-size: 18px;
font-weight: 800;
margin-bottom: 4px;
}
.langChoiceBtn__hint {
font-size: 12px;
font-weight: 400;
opacity: 0.85;
}
.langChoiceCancel {
width: 100%;
padding: 10px;
background: transparent;
border: 1px solid #2a3050;
color: #aab0c8;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
}
.langChoiceCancel:hover { background: #232842; color: #fff; }
/*
Подсветка синтаксиса в код-блоках
*/
.docCode .hl-keyword { color: #ff79c6; font-weight: 600; } /* let/const/local/function */
.docCode .hl-builtin { color: #8be9fd; } /* game / workspace / Math */
.docCode .hl-string { color: #f1fa8c; } /* 'строки' "строки" */
.docCode .hl-number { color: #bd93f9; } /* 42, 3.14 */
.docCode .hl-comment { color: #6272a4; font-style: italic; } /* // или -- */
.docCode .hl-fn { color: #50fa7b; } /* myFunc() */
/*
Баннер «Lua-скрипты для урока»
*/
.luaLessonBanner {
background: #eef4ff;
border: 1px solid #c7d8f5;
border-radius: 10px;
padding: 14px 18px;
margin: 14px 0 22px;
}
.luaLessonBanner--missing {
background: #fff7e0;
border-color: #f0d599;
color: #5a4500;
}
.luaLessonBanner--missing p { margin: 4px 0 0; font-size: 13px; }
.luaLessonBanner__head { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.luaLessonBanner__head b { font-size: 14px; color: #1e3a8a; }
.luaLessonBanner__hint { font-size: 12px; color: #475569; font-style: italic; }
.luaLessonBanner__script { margin: 6px 0; }
.luaLessonBanner__script summary {
cursor: pointer;
padding: 8px 12px;
background: #fff;
border-radius: 6px;
border: 1px solid #d0dcf0;
font-family: Consolas, monospace;
font-size: 13px;
color: #1e3a8a;
font-weight: 600;
}
.luaLessonBanner__script summary:hover { background: #f4f8ff; }
.luaLessonBanner__script[open] summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
.luaLessonBanner__script pre { margin: 0; border-top-left-radius: 0; border-top-right-radius: 0; }
`;

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
/**
* RbxlImportModal модалка импорта .rbxl Roblox-карт в Rublox.
*
* Доступна ТОЛЬКО МИНу (user_id === 1) это тест-фича.
* Доступна всем пользователям (см. вики «Импорт из Roblox» о нюансах).
*
* Поток:
* 1. Юзер дропает или выбирает .rbxl файл.
@ -13,8 +13,6 @@
import React, { useState, useRef } from 'react';
import { analyzeRbxl, createRbxlProject } from '../api/rbxlImporterApi.js';
const ALLOWED_USER_ID = 1; // МИН
const MAX_SIZE = 50 * 1024 * 1024; // 50 MB
export default function RbxlImportModal({ open, onClose, currentUserId, onCreated }) {
@ -26,25 +24,22 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
const [previewHash, setPreviewHash] = useState(null);
const [title, setTitle] = useState('');
const [error, setError] = useState(null);
// Режим скриптов: 'disabled' (импортнуть выключенными для чтения),
// 'enabled' (попытаться запустить может вешать карту), 'skip' (удалить).
const [scriptsMode, setScriptsMode] = useState('disabled');
// Режим GUI: 'all' все, 'screen-only' только ScreenGui (HUD),
// 'skip' не импортировать. Старые карты часто имеют 200+ BillboardGui
// (вывески города), что вешает рендер.
const [guiMode, setGuiMode] = useState('all');
const fileInputRef = useRef(null);
if (!open) return null;
if (currentUserId !== ALLOWED_USER_ID) {
return (
<div style={overlayStyle} onClick={onClose}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<h2 style={{ marginTop: 0 }}>Импорт из Roblox</h2>
<p>Эта тест-функция доступна только администратору.</p>
<button style={btnStyle} onClick={onClose}>Закрыть</button>
</div>
</div>
);
}
const reset = () => {
setFile(null); setReport(null); setPreviewHash(null);
setTitle(''); setError(null); setAnalyzing(false); setCreating(false);
setScriptsMode('disabled');
setGuiMode('all');
};
const handleClose = () => { reset(); onClose?.(); };
@ -88,7 +83,7 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
setCreating(true);
setError(null);
try {
const result = await createRbxlProject(previewHash, title);
const result = await createRbxlProject(previewHash, title, { scriptsMode, guiMode });
onCreated?.(result);
handleClose();
// редирект на редактор
@ -175,6 +170,29 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
</tbody>
</table>
{report.primitives_created > 5000 && (
<div style={{
marginTop: 12, padding: 12,
background: report.primitives_created > 15000 ? '#5a1a1a' : '#4a3a1a',
borderRadius: 6,
border: '1px solid ' + (report.primitives_created > 15000 ? '#a55' : '#a85'),
}}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
{report.primitives_created > 15000
? '🛑 Очень большая карта'
: '⚠️ Большая карта'}
</div>
<div style={{ fontSize: 12, color: '#ddd' }}>
{report.primitives_created} Part'ов это много. Студия может
{report.primitives_created > 15000
? ' зависнуть или работать с FPS &lt; 1.'
: ' тормозить (FPS 10-30).'}
{' '}Рекомендуем выбрать ниже «Не импортировать скрипты»
чтобы хоть посмотреть геометрию.
</div>
</div>
)}
{report.top_classes?.length > 0 && (
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer' }}>Что внутри (топ-25 классов)</summary>
@ -206,6 +224,98 @@ export default function RbxlImportModal({ open, onClose, currentUserId, onCreate
</div>
</div>
{report.scripts_total > 0 && (
<div style={{ marginTop: 16, padding: 12, background: '#1f1f1f', borderRadius: 6, border: '1px solid #333' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
Что делать со скриптами ({report.scripts_total} шт.)?
</div>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 0', cursor: 'pointer' }}>
<input
type="radio" name="scriptsMode" value="disabled"
checked={scriptsMode === 'disabled'}
onChange={() => setScriptsMode('disabled')}
style={{ marginTop: 3 }}
/>
<div>
<div style={{ fontSize: 13 }}>Импортировать <b>выключенными</b> (рекомендуется)</div>
<div style={{ fontSize: 11, color: '#888' }}>
Скрипты видны в иерархии и редакторе, можно читать как референс,
но не исполняются. Карта не подвиснет.
</div>
</div>
</label>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 0', cursor: 'pointer' }}>
<input
type="radio" name="scriptsMode" value="enabled"
checked={scriptsMode === 'enabled'}
onChange={() => setScriptsMode('enabled')}
style={{ marginTop: 3 }}
/>
<div>
<div style={{ fontSize: 13 }}>Импортировать <b>активными</b></div>
<div style={{ fontSize: 11, color: '#888' }}>
Попытаться запустить. Старые Roblox-скрипты могут подвешивать игру
тогда вернись и переимпортируй с другим режимом.
</div>
</div>
</label>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 0', cursor: 'pointer' }}>
<input
type="radio" name="scriptsMode" value="skip"
checked={scriptsMode === 'skip'}
onChange={() => setScriptsMode('skip')}
style={{ marginTop: 3 }}
/>
<div>
<div style={{ fontSize: 13 }}>Не импортировать совсем</div>
<div style={{ fontSize: 11, color: '#888' }}>
Только геометрия. Скрипты не попадут в проект чистое начало.
</div>
</div>
</label>
</div>
)}
{(() => {
const guiCount = (report.top_classes || [])
.filter(c => /Gui|Frame|Label|Button|Image|Text/.test(c.class))
.reduce((s, c) => s + c.count, 0);
if (guiCount < 50) return null;
return (
<div style={{ marginTop: 16, padding: 12, background: '#1f1f1f', borderRadius: 6, border: '1px solid #333' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
Что делать с GUI ({guiCount}+ элементов)?
</div>
<div style={{ fontSize: 11, color: '#888', marginBottom: 8 }}>
В этой карте много GUI-элементов (BillboardGui вывески, табло).
Они сильно тормозят рендер если их сотни.
</div>
{['all', 'screen-only', 'skip'].map((m) => (
<label key={m} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '6px 0', cursor: 'pointer' }}>
<input
type="radio" name="guiMode" value={m}
checked={guiMode === m}
onChange={() => setGuiMode(m)}
style={{ marginTop: 3 }}
/>
<div>
<div style={{ fontSize: 13 }}>
{m === 'all' && 'Все GUI'}
{m === 'screen-only' && (<>Только <b>ScreenGui</b> (рекомендуется)</>)}
{m === 'skip' && 'Без GUI вообще'}
</div>
<div style={{ fontSize: 11, color: '#888' }}>
{m === 'all' && 'Может тормозить.'}
{m === 'screen-only' && 'HUD остаётся, BillboardGui/SurfaceGui (3D-вывески) удаляются.'}
{m === 'skip' && 'Самый быстрый рендер. Только геометрия мира.'}
</div>
</div>
</label>
))}
</div>
);
})()}
<div style={{ marginTop: 16 }}>
<label style={{ display: 'block', marginBottom: 6 }}>Название игры:</label>
<input

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

@ -0,0 +1,197 @@
/**
* 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,
onCancel, // если задан вызывается при клике на «cancel» вместо тихого закрытия
onClose,
}) {
const handleCancel = () => {
try { onCancel?.(); } finally { 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={handleCancel}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid #3a3a40',
background: 'transparent',
color: '#c8c8cc',
fontSize: 13,
fontWeight: 700,
fontFamily: 'inherit',
cursor: 'pointer',
transition: 'all 120ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2e2e34';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#c8c8cc';
}}
>
{cancelLabel}
</button>
<button
ref={confirmBtnRef}
onClick={handleConfirm}
style={{
padding: '8px 18px',
borderRadius: 8,
border: 'none',
background: confirmTone === 'danger'
? 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)'
: 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)',
color: '#fff',
fontSize: 13,
fontWeight: 800,
fontFamily: 'inherit',
cursor: 'pointer',
letterSpacing: 0.2,
boxShadow: confirmTone === 'danger'
? '0 6px 16px rgba(192, 48, 63, 0.4)'
: '0 6px 16px rgba(79, 116, 255, 0.4)',
transition: 'all 120ms',
outline: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.filter = 'brightness(1.12)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.filter = 'brightness(1)';
e.currentTarget.style.transform = 'translateY(0)';
}}
onFocus={(e) => {
e.currentTarget.style.boxShadow = confirmTone === 'danger'
? '0 6px 16px rgba(192, 48, 63, 0.5), 0 0 0 3px rgba(192, 48, 63, 0.35)'
: '0 6px 16px rgba(79, 116, 255, 0.5), 0 0 0 3px rgba(79, 116, 255, 0.35)';
}}
onBlur={(e) => {
e.currentTarget.style.boxShadow = confirmTone === 'danger'
? '0 6px 16px rgba(192, 48, 63, 0.4)'
: '0 6px 16px rgba(79, 116, 255, 0.4)';
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@ -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: '✏️',

View File

@ -526,11 +526,73 @@ const InspectorPanel = ({
style={{ width: '100%' }}
/>
</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Заливка теней</span>
<span style={{ opacity: 0.6 }}>{(selection.sceneAmbient ?? 0.3).toFixed(2)}</span>
</div>
<input
type="range" min="0" max="1" step="0.05"
value={selection.sceneAmbient ?? 0.3}
onChange={(e) => props.onSetLightingProps?.({ sceneAmbient: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 2 }}>
Подсветка теней цвет в затенённых гранях. 0 = чёрные тени, 1 = плоско.
</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 4 }}>
<Icon name="sparkle" size={11} /> Цвет окружающего света подбирается автоматически по времени суток.
</div>
</div>
{/* Цветокоррекция */}
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="sparkle" size={12} /> Цветокоррекция</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Экспозиция</span>
<span style={{ opacity: 0.6 }}>{(selection.exposure ?? 1.0).toFixed(2)}</span>
</div>
<input
type="range" min="0.3" max="2" step="0.05"
value={selection.exposure ?? 1.0}
onChange={(e) => props.onSetLightingProps?.({ exposure: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 2 }}>
Общая яркость. &lt;1 = темнее, &gt;1 = светлее.
</div>
</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Контраст</span>
<span style={{ opacity: 0.6 }}>{(selection.contrast ?? 1.0).toFixed(2)}</span>
</div>
<input
type="range" min="0.5" max="2" step="0.05"
value={selection.contrast ?? 1.0}
onChange={(e) => props.onSetLightingProps?.({ contrast: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Насыщенность</span>
<span style={{ opacity: 0.6 }}>{(selection.saturation ?? 1.0).toFixed(2)}</span>
</div>
<input
type="range" min="0" max="2" step="0.05"
value={selection.saturation ?? 1.0}
onChange={(e) => props.onSetLightingProps?.({ saturation: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 2 }}>
0 = чёрно-белое, 1 = норма, 2 = очень сочно.
</div>
</div>
</div>
{/* Туман */}
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="fog" size={12} /> Туман</div>

View File

@ -31,7 +31,7 @@ import BillboardEditorModal from './BillboardEditorModal';
import TerrainGenPanel from './TerrainGenPanel';
import ScriptConsole from './ScriptConsole';
import SceneTabs from './SceneTabs';
import ScriptEditor from './ScriptEditor';
import ScriptEditor, { LUA_TEMPLATE_PART, LUA_TEMPLATE_GLOBAL, JS_TEMPLATE_GLOBAL } from './ScriptEditor';
import GameHud from './GameHud';
import MinimapOverlay from './MinimapOverlay';
import GuiOverlay from './GuiOverlay';
@ -44,6 +44,7 @@ import KubikonDesktopOnlyStub from '../community/KubikonDesktopOnlyStub';
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
import cl from './KubikonEditor.module.css';
import Icon from './Icon';
import ConfirmModal from './ConfirmModal';
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины авто-сохранение
@ -513,6 +514,8 @@ const KubikonEditor = () => {
// BillboardEditorModal открывается из инспектора при клике
// «Редактировать табличку». Содержит primitiveData выделенного билборда.
const [billboardEditorData, setBillboardEditorData] = useState(null);
// ConfirmModal кастомная модалка вместо window.confirm.
const [confirmState, setConfirmState] = useState(null);
// Bumper для обновления списков в Toolbox после edit/settings/delete.
const [userModelsRefreshKey, setUserModelsRefreshKey] = useState(0);
// Bump-счётчик: инкрементируется при создании/очистке гладкого
@ -2071,14 +2074,20 @@ const KubikonEditor = () => {
// Флаш ScriptEditor без этого 600мс свежих правок не успеют
// попасть в _scripts[]/dirtyRef и confirm-диалог не покажется.
try { scriptEditorFlushRef.current?.(); } catch (_) {}
// Несохранённые изменения спрашиваем
// Несохранённые изменения кастомная модалка с 3 кнопками:
// Сохранить (по умолчанию), Не сохранять, Отмена.
if (dirtyRef.current) {
const ok = window.confirm('Есть несохранённые изменения. Сохранить перед выходом?');
if (ok) {
doSave().finally(() => navigate('/'));
setConfirmState({
title: 'Несохранённые изменения',
message: 'Сохранить проект перед выходом? Если выйти без сохранения — последние правки пропадут.',
confirmLabel: 'Сохранить и выйти',
cancelLabel: 'Выйти без сохранения',
confirmTone: 'primary',
onConfirm: () => doSave().finally(() => navigate('/')),
onCancel: () => navigate('/'), // выйти без сохранения
});
return;
}
}
navigate('/');
};
@ -3355,10 +3364,43 @@ const KubikonEditor = () => {
scriptId={sc.id}
value={sc.code}
target={sc.target}
language={sc.language || 'js'}
flushRef={scriptEditorFlushRef}
isSoloRunning={soloScriptId === sc.id}
onLanguageChange={(lang, currentEditorCode) => {
// Два слота: code_js и code_lua живут в самом скрипте.
// При переключении: сохраняем текущий код в слот ТЕКУЩЕГО
// языка, достаём слот ЦЕЛЕВОГО языка (или шаблон если пусто).
const fromLang = sc.language === 'lua' ? 'lua' : 'js';
if (fromLang === lang) return;
const fromSlotKey = fromLang === 'lua' ? 'code_lua' : 'code_js';
const toSlotKey = lang === 'lua' ? 'code_lua' : 'code_js';
// Сохраняем текущий редактируемый код в слот текущего языка
const savedSlots = {
...(sc.code_js !== undefined ? { code_js: sc.code_js } : {}),
...(sc.code_lua !== undefined ? { code_lua: sc.code_lua } : {}),
[fromSlotKey]: currentEditorCode || '',
};
// Достаём слот целевого языка или подставляем шаблон
let nextCode = savedSlots[toSlotKey];
if (nextCode === undefined || nextCode === '') {
nextCode = lang === 'lua'
? (sc.target ? LUA_TEMPLATE_PART : LUA_TEMPLATE_GLOBAL)
: JS_TEMPLATE_GLOBAL;
}
sceneRef.current?.upsertScript(
sc.id, nextCode, undefined, undefined, lang, savedSlots
);
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty();
}}
onSave={(code) => {
sceneRef.current?.upsertScript(sc.id, code, sc.target);
// Зеркалим в слот активного языка чтобы при swap не потерять.
const slotKey = (sc.language === 'lua') ? 'code_lua' : 'code_js';
sceneRef.current?.upsertScript(
sc.id, code, sc.target, undefined, undefined,
{ [slotKey]: code }
);
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty();
}}
@ -4226,6 +4268,13 @@ const KubikonEditor = () => {
setBillboardEditorData(null);
}}
/>
{/* Кастомная модалка подтверждения вместо window.confirm. */}
{confirmState && (
<ConfirmModal
{...confirmState}
onClose={() => setConfirmState(null)}
/>
)}
</div>
);
};

View File

@ -7,6 +7,8 @@ import Icon from './Icon';
// при правке одного файла не перетряхивать все остальные.
import { GAME_TYPE_LIBS } from './engine/types/bundle';
import { registerSnippets } from './engine/snippets';
import { registerLuaInMonaco } from './lua-monaco-setup';
import ConfirmModal from './ConfirmModal';
/**
* ScriptEditor Monaco-редактор кода скрипта в табе.
@ -34,7 +36,50 @@ 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 или глобальный).
// Используется при смене языка JSLua когда текущий код выглядит «пустым».
export const LUA_TEMPLATE_PART = `-- Скрипт привязан к Part. script.Parent = эта часть.
local part = script.Parent
print("Скрипт детали", part.Name, "запущен")
part.Touched:Connect(function(hit)
print("Касание:", hit.Name)
end)
`;
export const LUA_TEMPLATE_GLOBAL = `-- Глобальный Lua-скрипт. Доступ к game.* API через Roblox-обёртку.
local Players = game:GetService("Players")
print("Привет, Рублокс! Lua-скрипты работают.")
-- Здороваемся со всеми кто уже в игре + кто заходит позже
for _, player in ipairs(Players:GetPlayers()) do
print("Игрок в игре:", player.Name)
end
Players.PlayerAdded:Connect(function(player)
print("Зашёл игрок:", player.Name)
end)
`;
export 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 || '');
@ -76,6 +121,15 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
// При смене языка принудительно синхронизируем код со слотом нового языка.
// (родитель swap'нул code_js code_lua и прислал свежий value.)
useEffect(() => {
if (value !== undefined && value !== localCodeRef.current) {
setLocalCode(value || '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [language]);
// Дебаунс-сохранение
const scheduleSave = useCallback((code) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
@ -162,6 +216,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 +339,54 @@ 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;
// Логика двух слотов (code_js / code_lua) живёт в родителе.
// Здесь только сигналим: «переключи на lang».
// Текущий код отдаём чтобы родитель сохранил в слот.
onLanguageChange(lang, 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 +499,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 +540,12 @@ function ScriptEditor({ value, onSave, onRunSolo, isSoloRunning, scriptId, targe
}}
/>
</div>
{confirmState && (
<ConfirmModal
{...confirmState}
onClose={() => setConfirmState(null)}
/>
)}
</div>
);
}

View File

@ -37,6 +37,7 @@ import {
Ray,
PointerEventTypes,
Tools as BabylonTools,
ColorCurves,
} from '@babylonjs/core';
import { PlacementManager } from './PlacementManager';
import { ShopInventoryUi } from './ShopInventoryUi';
@ -1927,9 +1928,41 @@ export class BabylonScene {
}
if (typeof patch.sunIntensity === 'number' && this._sunLight) {
this._sunLight.intensity = Math.max(0, patch.sunIntensity);
this._sunIntensity = patch.sunIntensity;
}
if (typeof patch.hemiIntensity === 'number' && this._hemiLight) {
this._hemiLight.intensity = Math.max(0, patch.hemiIntensity);
this._hemiIntensity = patch.hemiIntensity;
}
// Окружающий свет (scene.ambientColor) — отдельный множитель.
// Применяется ко всем материалам через ambient*ambient.
if (typeof patch.sceneAmbient === 'number') {
const v = Math.max(0, Math.min(1, patch.sceneAmbient));
this.scene.ambientColor = new Color3(v, v, v);
this._sceneAmbient = v;
}
// Цветокоррекция — экспозиция, контраст, насыщенность через
// imageProcessingConfiguration (включает HDR pipeline).
if (typeof patch.exposure === 'number' || typeof patch.contrast === 'number'
|| typeof patch.saturation === 'number') {
const ipc = this.scene.imageProcessingConfiguration;
ipc.isEnabled = true;
if (typeof patch.exposure === 'number') {
ipc.exposure = Math.max(0.1, Math.min(3, patch.exposure));
this._exposure = ipc.exposure;
}
if (typeof patch.contrast === 'number') {
ipc.contrast = Math.max(0.5, Math.min(2.5, patch.contrast));
this._contrast = ipc.contrast;
}
if (typeof patch.saturation === 'number') {
// colorCurves для saturation (стандартный Babylon приём)
if (!ipc.colorCurves) ipc.colorCurves = new ColorCurves();
const s = Math.max(-100, Math.min(100, (patch.saturation - 1) * 100));
ipc.colorCurves.globalSaturation = s;
ipc.colorCurvesEnabled = true;
this._saturation = patch.saturation;
}
}
if (this.environment && typeof this.environment.setFog === 'function') {
// Текущие значения берём из Environment, поверх накладываем patch
@ -3044,6 +3077,7 @@ export class BabylonScene {
if (md.isBlock) {
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
}
if (md.npcId != null) return { kind: 'npc', id: md.npcId };
if (md.isModel) return { kind: 'model', id: md.instanceId };
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
return null;
@ -3078,8 +3112,10 @@ export class BabylonScene {
const EPS = 0.25;
// 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId)
let _firedThisFrame = 0;
for (const s of scripts) {
if (!s.target) continue;
try {
const key = 's:' + s.id;
seen.add(key);
const aabb = this._targetAABB(s.target);
@ -3093,10 +3129,20 @@ export class BabylonScene {
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);
}
}
}
// 2) Касания примитивов-триггеров (type === 'trigger') БЕЗ скрипта —
@ -3203,6 +3249,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 {
@ -3257,7 +3314,30 @@ export class BabylonScene {
}
if (!this.gameRuntime) return;
const pick = this._pickFromCenter();
// В pointer-lock (1-е лицо) курсор скрыт в центре — пикаем центром.
// В 3-м лице (свободный курсор) — пикаем по координатам клика.
const locked = (document.pointerLockElement === this.canvas);
let pick;
if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) {
const pi = this.scene.pick(clickX, clickY, (mesh) => {
if (!mesh.isPickable) return false;
if (mesh === this._ghostMesh) return false;
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
return true;
});
if (pi?.hit) {
let m = pi.pickedMesh;
if (m?.metadata?._isBlockProto && this.blockManager) {
const proxy = this.blockManager.findProxyByPickInfo(pi);
if (proxy) m = proxy;
}
pick = { mesh: m, point: pi.pickedPoint, pickInfo: pi };
} else {
pick = null;
}
} else {
pick = this._pickFromCenter();
}
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
// 1) Self-onClick — только если target есть
@ -5406,6 +5486,7 @@ export class BabylonScene {
code: s.code,
name: s.name || null,
target: newTarget,
language: s.language || 'js',
});
}
if (srcScripts.length > 0) {
@ -5548,7 +5629,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 — приватный режим / переполнение */ }
@ -5563,7 +5644,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();
@ -6719,7 +6800,7 @@ export class BabylonScene {
}
/** Установить код одного скрипта по id. Если id нет — создать новый. */
upsertScript(id, code, target = undefined, name = undefined) {
upsertScript(id, code, target = undefined, name = undefined, language = undefined, slots = undefined) {
const i = this._scripts.findIndex(s => s.id === id);
if (i >= 0) {
this._scripts[i] = {
@ -6727,6 +6808,11 @@ export class BabylonScene {
code,
...(target !== undefined ? { target } : {}),
...(name !== undefined ? { name } : {}),
...(language !== undefined ? { language } : {}),
// Слоты code_js и code_lua — сохраняемый код для каждого языка.
// Передаются при переключении языка, чтобы код другого языка
// не пропадал.
...(slots && typeof slots === 'object' ? slots : {}),
};
} else {
this._scripts.push({
@ -6734,6 +6820,8 @@ export class BabylonScene {
code,
target: target !== undefined ? target : null,
name: name || null,
language: language || 'js',
...(slots && typeof slots === 'object' ? slots : {}),
});
}
// Скрипты — часть сцены: фиксируем в истории, иначе undo откатит
@ -7760,6 +7848,15 @@ export class BabylonScene {
shadowQuality: this._shadowQuality || 'soft',
environment: this.environment ? this.environment.serialize() : null,
graphics: this.getGraphicsState(),
// Кастомные настройки света — слайдеры из «Свет и атмосфера»
lighting: {
sunIntensity: this._sunIntensity ?? this._sunLight?.intensity ?? 0.8,
hemiIntensity: this._hemiIntensity ?? this._hemiLight?.intensity ?? 0.65,
sceneAmbient: this._sceneAmbient ?? 0.3,
exposure: this._exposure ?? 1.0,
contrast: this._contrast ?? 1.0,
saturation: this._saturation ?? 1.0,
},
skybox: this.skybox ? this.skybox.serialize() : null,
leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
achievements: this.achievements ? this.achievements.serialize() : null,
@ -7779,6 +7876,7 @@ export class BabylonScene {
code: s.code,
target: s.target || null,
name: s.name || null,
language: s.language === 'lua' ? 'lua' : 'js',
})),
},
editorCamera: this.camera ? {
@ -8236,6 +8334,7 @@ export class BabylonScene {
code: s.code,
target: s.target || null,
name: s.name || null,
language: s.language === 'lua' ? 'lua' : 'js',
}));
}
// Окружение (время суток, скайбокс, туман)
@ -8248,6 +8347,12 @@ export class BabylonScene {
&& state.scene.graphics.preset !== 'off') {
try { this.setGraphics(state.scene.graphics); } catch (e) { /* ignore */ }
}
// Кастомные настройки света/цветокоррекции — применяем через
// setLightingProps (он сам подхватит default-ы если значения нет).
if (state.scene.lighting) {
try { this.setLightingProps(state.scene.lighting); }
catch (e) { console.warn('[BabylonScene] lighting load failed:', e); }
}
// Кастомное небо (задача 16)
if (state.scene.skybox && this.skybox) {
this.skybox.load(state.scene.skybox);

View File

@ -19,7 +19,9 @@ 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';
import { RbxlHudOverlay } from './RbxlHudOverlay.js';
export class GameRuntime {
constructor(scene3d) {
@ -115,11 +117,70 @@ 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; }
// Пропускаем Regeneration-скрипты: у нас Anchored=True для
// импорта, постройки не разрушаются, регенерация не нужна.
// Их работа (model:remove + Clone) даст визуальные глитчи.
const sname = String(s.name || '').toLowerCase();
if (sname.startsWith('regenerate') || sname === 'regenerationscript') {
rbxlSkipped++; continue;
}
const luaSource = unpackRobloxLuaCode(s.code);
// SAFETY: пропускаем скрипты с tight-loop'ами через ChildAdded:wait()
// или WaitForChild через пользовательский while-not-FindFirstChild.
// Они подвешивают страницу (wait() возвращает синхронно, скрипт
// никогда не yield'ит из C-call). Распространённый Roblox 2009
// паттерн который мы не можем безопасно эмулировать.
if (luaSource && (
/while\s+not\s+\w+[:.]FindFirstChild/.test(luaSource) ||
/ChildAdded:[Ww]ait\(\)/.test(luaSource) ||
/:[Gg]etChildren\(\)\s*\[\d/.test(luaSource)
)) {
rbxlSkipped++;
console.warn(`[GameRuntime] skipped ${s.name}: содержит небезопасный tight-loop (WaitForChild/ChildAdded:wait)`);
continue;
}
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 +212,157 @@ 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),
// Импортированные .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 if (cmd === 'mouseIconChanged') {
// Roblox Mouse.Icon → CSS cursor на canvas
try {
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
if (canvas) canvas.style.cursor = payload.cssCursor || 'default';
} catch (_) {}
} else if (cmd === 'hudMessage') {
// Roblox Message/Hint в верхней трети экрана
try {
this._ensureRbxlHud();
if (payload.visible && payload.text) {
this._rbxlHud.showMessage(payload.text);
} else {
this._rbxlHud.hideMessage();
}
} catch (_) {}
} else if (cmd === 'killFeed') {
// Кастомное событие от нашего creator-tag tracker'а
try {
this._ensureRbxlHud();
this._rbxlHud.addKillFeed(payload.killer, payload.victim, payload.weapon);
} catch (_) {}
} else if (cmd === 'winShow') {
try {
this._ensureRbxlHud();
this._rbxlHud.showWin(payload.text || 'WIN!');
} catch (_) {}
} else if (cmd === 'ui.showText') {
// Lua-helper __rbxl_show_text: красивый центрированный
// текст без рамки (паритет с JS game.ui.showText).
try {
this._ensureRbxlHud();
this._rbxlHud.showMessage(payload.text || '');
const dur = Number(payload.duration) || 2;
const t = payload.text || '';
setTimeout(() => {
try {
if (this._rbxlHud._lastMessage === t) {
this._rbxlHud.hideMessage();
}
} catch (_) {}
}, dur * 1000);
try { this._rbxlHud._lastMessage = t; } catch (_) {}
} catch (_) {}
} else if (cmd === 'leaderstatSet') {
// Roblox leaderstats: IntValue.Value меняется → HUD.
try {
const lm = this.scene3d?.leaderstats;
if (lm) {
const statName = String(payload.statName || 'Stat');
if (!lm._defs.some(d => d.name === statName)) {
lm.define(statName, { initial: 0 });
}
lm.set(lm._meId || 'me', statName, Number(payload.value) || 0);
}
} catch (_) {}
} else {
this._handleCommand(null, cmd, payload);
}
});
if (result && result.sandbox) {
this.sandboxes.push(result.sandbox);
this._rbxlSharedSandbox = result.sandbox;
rbxlCount = result.count;
// Передаём 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);
// Чёткий маркер языка в логах — чтобы было видно что запущено
const lang = (luaWritten > 0 || rbxlImported > 0)
? (jsOnly > 0 ? 'СМЕШАННЫЙ (JS+Lua)' : 'LUA')
: 'JS';
// eslint-disable-next-line no-console
console.warn(`[GameRuntime] === ЯЗЫК СКРИПТОВ: ${lang} === (JS=${jsOnly}, Lua=${luaWritten}, rbxl=${rbxlImported})`);
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 +660,146 @@ export class GameRuntime {
return null;
}
/** Создаёт DOM-overlay для импортированных Roblox-карт (KillFeed,
* Message, WinGui). Лениво только при первом hudMessage/killFeed. */
_ensureRbxlHud() {
if (this._rbxlHud) return;
const canvas = this.scene3d?.engine?.getRenderingCanvas?.();
const parent = canvas?.parentElement || document.body;
this._rbxlHud = new RbxlHudOverlay(parent);
}
/** Регистрирует 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 +807,14 @@ 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;
// Очищаем Roblox HUD overlay (KillFeed/Message/WinGui)
try { this._rbxlHud?.dispose(); } catch (_) {}
this._rbxlHud = null;
// Удаляем все объекты, которые скрипты наспавнили через
// game.scene.spawn/clone — иначе после Stop они остаются на сцене
// и накапливаются при повторных запусках.
@ -621,7 +962,61 @@ export class GameRuntime {
this._syncPhysicsToScene();
}
const state = this._collectState();
// Реальная позиция игрока для Lua __rbxl_player_pos()
// PlayerController хранит позицию в player._pos (Vector3).
const player = this.scene3d?.player;
let realPos = null;
if (player?._pos) {
const halfH = player.HALF_H ?? 0.9;
realPos = { x: player._pos.x, y: player._pos.y - halfH, z: player._pos.z };
} else if (state?.player) {
realPos = { x: state.player.x, y: state.player.y, z: state.player.z };
}
// Собираем актуальные позиции спавненных динамических примитивов
// (id >= 800000) — нужно для AABB-touched-check в Lua-shim, чтобы
// ловить попадание игрока в падающий куб.
let spawnedPositions = null;
try {
const pm = this.scene3d?.primitiveManager;
if (pm && pm.instances) {
for (const [id, data] of pm.instances.entries()) {
if (id < 800000 || data.anchored !== false) continue;
if (!spawnedPositions) spawnedPositions = [];
spawnedPositions.push([id, data.x, data.y, data.z]);
}
}
} catch (_) {}
// Собираем позиции NPC для Lua-shim
const npcPositions = [];
try {
const nm = this.scene3d?.npcManager;
if (nm && nm.npcs && this._localToReal) {
// localRef ('npc_lua_N') → реальный 'npc:<id>' → npc
for (const [localRef, realRef] of this._localToReal.entries()) {
if (typeof realRef !== 'string' || !realRef.startsWith('npc:')) continue;
const npcId = Number(realRef.slice(4));
const npc = nm.npcs.get(npcId);
if (npc) npcPositions.push([localRef, npc.x, npc.y, npc.z]);
}
}
} catch (_) {}
for (const sb of this.sandboxes) {
// Обновляем реальную позицию игрока для Lua-shim
if (realPos && sb.api?.updatePlayerPos) {
try { sb.api.updatePlayerPos(realPos.x, realPos.y, realPos.z); } catch (_) {}
}
// Синк спавненных динамических примитивов
if (spawnedPositions && sb.api?.updateSpawnedPos) {
for (const [id, x, y, z] of spawnedPositions) {
try { sb.api.updateSpawnedPos(id, x, y, z); } catch (_) {}
}
}
// Синк позиций NPC
if (npcPositions.length > 0 && sb.api?.updateNpcPos) {
for (const [ref, x, y, z] of npcPositions) {
try { sb.api.updateNpcPos(ref, x, y, z); } catch (_) {}
}
}
// Для скриптов с target — добавляем актуальную позицию self
const stateForSb = sb.target
? { ...state, selfPosition: this._collectSelfPosition(sb.target) }
@ -1118,7 +1513,8 @@ export class GameRuntime {
const nid = this._resolveNpcId(ref);
if (nid != null) { fn(nid); return; }
// ещё не резолвится — откладываем (только для локальных ref NPC)
if (typeof ref === 'string' && ref.indexOf('npc:_local_') === 0) {
if (typeof ref === 'string'
&& (ref.indexOf('npc:_local_') === 0 || ref.startsWith('npc_lua_'))) {
if (!this._pendingNpcCmds) this._pendingNpcCmds = new Map();
if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []);
this._pendingNpcCmds.get(ref).push(fn);
@ -1183,6 +1579,32 @@ export class GameRuntime {
const d = tryGet(this.scene3d?.modelManager);
if (d) return { kind: 'model', data: d };
}
// NPC — для setLabel/clearLabel над NPC.
if (kind === 'npc' || kind == null) {
const nm = this.scene3d?.npcManager;
if (nm && nm.npcs) {
let npc = nm.npcs.get(rawId);
if (!npc) {
const n = Number(rawId);
if (Number.isFinite(n)) npc = nm.npcs.get(n);
}
if (npc) {
// У NPC реальный mesh лежит в npc.data.rootMesh (модель).
const mesh = npc.data?.rootMesh || npc.data?.rootNode
|| npc.rootMesh || npc.rootNode || null;
return {
kind: 'npc',
data: {
mesh,
rootMesh: mesh,
x: npc.x ?? 0,
y: npc.y ?? 0,
z: npc.z ?? 0,
},
};
}
}
}
const um = tryGet(this.scene3d?.userModelManager);
if (um) return { kind: 'userModel', data: um };
return null;
@ -1288,6 +1710,17 @@ export class GameRuntime {
routeEvent(target, eventType, extra = {}) {
if (!target || !eventType) return;
for (const sb of this.sandboxes) {
// LuaSharedSandbox = один sandbox на все Lua-скрипты, target=null.
// Шлём ему ВСЕ события — shim сам найдёт соответствующий Part
// через partById и сфейерит Touched на нужной части.
if (sb.constructor?.name === 'LuaSharedSandbox' || sb._luaShared) {
const kind = eventType === 'touch' ? 'touched'
: eventType === 'untouch' ? 'untouched'
: eventType;
const primId = target.id ?? target.ref ?? null;
sb.sendEvent({ kind, primId, target, ...extra });
continue;
}
if (!sb.target) continue;
if (!this._targetMatches(sb.target, target)) continue;
sb.sendEvent({ type: eventType, ...extra });
@ -1739,6 +2172,13 @@ export class GameRuntime {
// после spawnNpc (follow/moveTo/say) — они ждали
// резолва ref в очереди.
this._flushPendingNpcCmds(payload.ref, npcId);
// Также сообщаем Lua-sandbox-ам маппинг, чтобы
// npc.onDeath по локальному ref находил npcId.
for (const sb of this.sandboxes) {
if (sb.api?.setNpcLocalRef) {
try { sb.api.setNpcLocalRef(payload.ref, 'npc:' + npcId); } catch (_) {}
}
}
}
// Сообщаем воркеру маппинг localRef → npcId, чтобы
// npc.onDeath по локальному ref находил правильного NPC.
@ -3202,17 +3642,29 @@ export class GameRuntime {
const ref = payload?.ref;
const text = payload?.text;
if (typeof ref !== 'string') return;
// ленивое создание менеджера меток
if (!this.scene3d._labelManager) {
this.scene3d._labelManager = new LabelManager(this.scene3d.scene);
}
const lm = this.scene3d._labelManager;
// резолвим меш объекта (примитив или модель)
const applyLabel = () => {
const tgt = this._resolveTweenTarget(ref);
const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode);
if (mesh) {
lm.setLabel(ref, mesh, text, payload?.opts || {});
}
};
// Если NPC ещё не зарезолвлен — откладываем через _npcCmd
// (или просто несколько попыток с retry).
const tgt = this._resolveTweenTarget(ref);
if (tgt) {
applyLabel();
} else if (typeof ref === 'string' && ref.startsWith('npc_lua_')) {
// NPC ещё спавнится — откладываем
this._npcCmd(ref, () => applyLabel());
} else {
// Retry через 0.3с (для primitive после sceneCreate)
setTimeout(applyLabel, 300);
}
} catch (e) {
console.warn('[GameRuntime] scene.setLabel failed', e);
}
@ -3939,6 +4391,73 @@ 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;
}
} else if (payload.prop === 'jumpVelocity') {
// Bouncer (батут): Lua-скрипт даёт игроку Y-velocity = N
try {
if (player._vy !== undefined) player._vy = Number(payload.value) || 0;
else if (player.velocity) player.velocity.y = Number(payload.value) || 0;
} catch (_) {}
} else if (payload.prop === 'walkSpeed') {
try { player.walkSpeed = Number(payload.value) || player.walkSpeed; } catch (_) {}
} else if (payload.prop === 'jumpPower') {
try { player.jumpPower = Number(payload.value) || player.jumpPower; } catch (_) {}
} else if (payload.prop === 'maxHealth') {
try {
const max = Math.max(1, Number(payload.value) || 100);
player.maxHp = max;
if (player.hp > max) player.hp = max;
} catch (_) {}
} else if (payload.prop === 'position') {
// Lua-вызов hrp.Position = ... — телепорт игрока
try {
const v = payload.value || {};
const halfH = player.HALF_H ?? 0.9;
if (player._pos) {
player._pos.set(v.x || 0, (v.y || 0) + halfH, v.z || 0);
if (player._vy != null) player._vy = 0;
} else if (player.body?.position?.set) {
player.body.position.set(v.x || 0, v.y || 0, v.z || 0);
}
} catch (_) {}
} else if (payload.prop === 'respawn') {
// Lua-вызов player:LoadCharacter() — телепорт к spawn и сброс HP
try {
if (typeof player.respawn === 'function') {
player.respawn();
} else {
const sp = this.scene3d?.projectData?.scene?.spawnPoint
|| this.projectData?.scene?.spawnPoint
|| { x: 0, y: 5, z: 0 };
// PlayerController хранит позицию в player._pos.
const halfH = player.HALF_H ?? 0.9;
if (player._pos) {
player._pos.set(sp.x, sp.y + halfH, sp.z);
if (player._vy != null) player._vy = 0;
} else if (player.body?.position?.set) {
player.body.position.set(sp.x, sp.y, sp.z);
}
player.hp = player.maxHp || 100;
}
} catch (_) {}
}
return;
}
// eslint-disable-next-line no-console
console.warn('[GameRuntime] unknown cmd', cmd);
}
@ -4217,6 +4736,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,
@ -4226,11 +4746,18 @@ 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,
});
}
}
return { blocks, models, primitives };
// Teams и team_spawns из projectData (импортированные из .rbxl)
const teams = this.projectData?.scene?.teams || [];
const teamSpawns = this.projectData?.scene?.team_spawns || [];
return { blocks, models, primitives, teams, teamSpawns };
}
// Разобрать ref-строку ('primitive:N' / 'model:N' / 'block:x,y,z') в target
@ -4363,6 +4890,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 */ }
}

View File

@ -470,7 +470,7 @@ export class NpcManager {
const show = npc.hp < npc.maxHp;
hb.anchor.setEnabled(show);
if (show) {
hb.anchor.position.set(npc.x, npc.y + 2.4, npc.z);
hb.anchor.position.set(npc.x, npc.y + 1.9, npc.z);
const pct = Math.max(0, Math.min(1, npc.hp / npc.maxHp));
hb.fill.scaling.x = pct;
hb.fill.position.x = -(1 - pct) * hb.barWidth / 2;

View File

@ -507,6 +507,11 @@ export class PrimitiveManager {
const matName = `${mesh.name}_mat`;
const mat = new StandardMaterial(matName, this.scene);
mat.diffuseColor = Color3.FromHexString(color || '#888888');
// ambient = (1,1,1) — пассивный, реагирует на scene.ambientColor.
// Юзер крутит «Заливку теней» (sceneAmbient) → тени светлеют.
// На прямом свете diffuse доминирует — пересвета нет если
// sceneAmbient в разумных пределах (0..0.5).
mat.ambientColor = new Color3(1, 1, 1);
// Если задан textureUrl — подгружаем PNG как diffuseTexture. Это
// используется для GD-скинов куба (например /gd/skins/cube_smile.png).
@ -602,9 +607,18 @@ export class PrimitiveManager {
break;
}
case 'matte':
default:
mat.specularColor = new Color3(0, 0, 0);
break;
case 'glossy':
default: {
// Roblox Plastic — слабый specular, без emissive.
// diffuse=#cccccc должно выглядеть СЕРЫМ (как в Roblox).
// ambient (от scene 0.3 × mat.ambient 0.4) даёт цвет в тенях,
// но не убивает контраст.
mat.specularColor = new Color3(0.05, 0.05, 0.05);
mat.specularPower = 64;
break;
}
}
// Триггеры — всегда полупрозрачные жёлтые в редакторе
@ -724,7 +738,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;

View File

@ -0,0 +1,177 @@
/**
* RbxlHudOverlay DOM-оверлей с HUD-элементами для импортированных
* Roblox-карт: KillFeed (правый верх), Message/Hint (центр), WinGui.
*
* Один контейнер `.rbxl-hud-overlay` на canvas.parentElement, в нём дочерние
* блоки по типу. Стили inline, ничего не зависит от CSS приложения.
*
* API:
* const hud = new RbxlHudOverlay(canvasParent);
* hud.addKillFeed(killer, victim, weapon)
* hud.showMessage(text, opts)
* hud.hideMessage()
* hud.showWin(text)
* hud.dispose()
*/
export class RbxlHudOverlay {
constructor(parent) {
this._parent = parent || document.body;
this._root = null;
this._killFeed = null;
this._message = null;
this._winBox = null;
this._killEntries = []; // [{el, expireAt}]
this._mount();
}
_mount() {
if (this._root) return;
const root = document.createElement('div');
root.className = 'rbxl-hud-overlay';
Object.assign(root.style, {
position: 'absolute',
inset: '0',
pointerEvents: 'none',
zIndex: '999',
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
});
this._parent.appendChild(root);
this._root = root;
// KillFeed — правый верхний угол
const kf = document.createElement('div');
Object.assign(kf.style, {
position: 'absolute',
top: '60px',
right: '12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
maxWidth: '320px',
pointerEvents: 'none',
});
root.appendChild(kf);
this._killFeed = kf;
// Message — центр сверху (Roblox Message по центру экрана,
// но в верхней трети чтобы не мешать игре)
const msg = document.createElement('div');
Object.assign(msg.style, {
position: 'absolute',
top: '15%',
left: '50%',
transform: 'translateX(-50%)',
padding: '10px 24px',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
fontSize: '22px',
fontWeight: '600',
borderRadius: '6px',
textShadow: '0 2px 4px rgba(0,0,0,0.8)',
display: 'none',
pointerEvents: 'none',
});
root.appendChild(msg);
this._message = msg;
// WinGui — большая надпись по центру
const win = document.createElement('div');
Object.assign(win.style, {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '24px 48px',
background: 'rgba(0,0,0,0.75)',
color: '#ffd86b',
fontSize: '48px',
fontWeight: '800',
borderRadius: '12px',
textShadow: '0 4px 8px rgba(0,0,0,0.8)',
display: 'none',
pointerEvents: 'none',
});
root.appendChild(win);
this._winBox = win;
// Тик для авто-исчезновения KillFeed entries (через 5с)
this._tickInterval = setInterval(() => this._cleanupKills(), 500);
}
addKillFeed(killer, victim, weapon) {
if (!this._killFeed) return;
const entry = document.createElement('div');
Object.assign(entry.style, {
background: 'rgba(0,0,0,0.55)',
color: '#fff',
padding: '6px 10px',
borderRadius: '4px',
fontSize: '13px',
display: 'flex',
gap: '6px',
alignItems: 'center',
animation: 'rbxlHudFadeIn 0.3s',
});
const killerEl = document.createElement('span');
killerEl.textContent = String(killer || '?');
killerEl.style.color = '#5bd1e8';
const arrow = document.createElement('span');
arrow.textContent = weapon ? `→ [${weapon}] →` : '→';
arrow.style.color = '#ff9a52';
const victimEl = document.createElement('span');
victimEl.textContent = String(victim || '?');
victimEl.style.color = '#f87a7a';
entry.appendChild(killerEl);
entry.appendChild(arrow);
entry.appendChild(victimEl);
this._killFeed.appendChild(entry);
this._killEntries.push({ el: entry, expireAt: performance.now() + 5000 });
// Keep only last 8
while (this._killEntries.length > 8) {
const old = this._killEntries.shift();
try { old.el.remove(); } catch (_) {}
}
}
_cleanupKills() {
const now = performance.now();
const keep = [];
for (const e of this._killEntries) {
if (e.expireAt < now) { try { e.el.remove(); } catch (_) {} }
else keep.push(e);
}
this._killEntries = keep;
}
showMessage(text, opts = {}) {
if (!this._message) return;
this._message.textContent = String(text || '');
this._message.style.display = text ? 'block' : 'none';
if (opts.duration) {
clearTimeout(this._msgTimer);
this._msgTimer = setTimeout(() => this.hideMessage(), opts.duration);
}
}
hideMessage() {
if (this._message) this._message.style.display = 'none';
}
showWin(text) {
if (!this._winBox) return;
this._winBox.textContent = String(text || '');
this._winBox.style.display = 'block';
// Auto-hide через 6с
clearTimeout(this._winTimer);
this._winTimer = setTimeout(() => { if (this._winBox) this._winBox.style.display = 'none'; }, 6000);
}
dispose() {
try { this._root?.remove(); } catch (_) {}
clearInterval(this._tickInterval);
clearTimeout(this._msgTimer);
clearTimeout(this._winTimer);
this._root = null;
}
}

View File

@ -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) {}
}
}
}

View File

@ -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) {}
}

View File

@ -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;

View File

@ -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;

View File

@ -282,6 +282,11 @@ export class SelectionManager {
fogColor: env ? `#${[env.fogColor?.[0] ?? 0.7, env.fogColor?.[1] ?? 0.8, env.fogColor?.[2] ?? 0.9].map(c => Math.round(Math.max(0, Math.min(1, c)) * 255).toString(16).padStart(2, '0')).join('')}` : '#b0c8e6',
shadowQuality: this._scene3d.getShadowQuality?.() || 'soft',
ssaoEnabled: this._scene3d.getSsaoEnabled?.() || false,
// Новые: глобальный ambient + image processing
sceneAmbient: this._scene3d._sceneAmbient ?? 0.3,
exposure: this._scene3d._exposure ?? 1.0,
contrast: this._scene3d._contrast ?? 1.0,
saturation: this._scene3d._saturation ?? 1.0,
};
this._notifyChange();
}

View File

@ -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',
});
}
}
@ -523,7 +524,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':

View File

@ -0,0 +1,337 @@
/**
* 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;
// Маркер для GameRuntime.routeEvent — этот sandbox принимает все
// события и сам маршрутизирует через shim.fireTargetEvent.
this._luaShared = true;
}
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 батчами по 5 с задержкой 20мс между ними, чтобы UI отзывался.
const BATCH_SIZE = 5;
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, 20);
} else {
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`);
// После того как все скрипты подключили хендлеры — фейрим
// events для уже существующих сущностей. Roblox-конвенция:
// если игрок уже на сервере когда скрипт подключается,
// Players.PlayerAdded не сработает повторно. Юзеру нужно
// делать ручной обход GetPlayers() — но это редко кто помнит.
// Мы дублируем событие через короткую задержку.
setTimeout(() => {
try {
if (this.api?.fireExistingPlayers) {
this.api.fireExistingPlayers();
}
} catch (e) {
console.warn('[LuaSharedSandbox] fireExistingPlayers failed:', e);
}
}, 100);
}
};
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
-- Если parentExpr вернул primitive у него уже есть :FindFirstChild и пр.
-- Если ничего не вернёт workspace (всегда валидный).
-- script.Parent.Parent (Tool.Parent = StarterPack / Backpack / workspace).
local _scriptParent = ${parentExpr}
if _scriptParent == nil then _scriptParent = workspace end
if _scriptParent.Parent == nil then _scriptParent.Parent = workspace end
local script = setmetatable({
Name = ${JSON.stringify(scriptName)},
Parent = _scriptParent,
ClassName = "Script",
Disabled = false,
Source = nil,
}, {
-- Любой доступ к несуществующему полю workspace
-- (на случай script.Foo:Bar() в старом коде)
__index = function(t, k)
if k == "FindFirstChild" or k == "WaitForChild" or k == "GetChildren" then
return function() return nil end
end
return workspace[k]
end,
})
local co = coroutine.create(function()
-- WATCHDOG: каждые 100000 инструкций yield 1 кадр.
-- НЕ оборачиваем в pcall внутри C-call boundary yield
-- упадёт ошибкой, что прервёт скрипт. Это лучше чем виснуть.
debug.sethook(function()
coroutine.yield(0.016)
end, "", 20000)
-- 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;

File diff suppressed because it is too large Load Diff

View File

@ -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,9 +105,11 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
return;
}
if (cmd === 'partSet') {
try {
const pm = runtime.scene3d?.primitiveManager;
if (!pm) return;
if (!pm) {
console.warn('[partSet] no primitiveManager. scene3d=', !!runtime.scene3d);
return;
}
const primId = payload?.primId;
const prop = payload?.prop;
const value = payload?.value;
@ -141,9 +126,57 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
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);
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) {}
} 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 || 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);
// Если unanchored — регистрируем в физике на лету, иначе он не падает.
if (opts.anchored === false) {
try {
const dm = runtime.scene3d?.dynamics;
const data = pm.instances?.get?.(opts.id);
if (dm && data && typeof dm.registerPrimitive === 'function') {
dm.registerPrimitive(data);
}
} catch (e) {
console.warn('[sceneCreate] registerPrimitive failed', e);
}
}
} 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') {

View File

@ -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) {}
}
}
}

View File

@ -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) {
// Корутина завершилась с ошибкой — просто дропаем
}
}
}
}

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -0,0 +1,249 @@
/**
* lua-monaco-setup регистрация Lua-фич в Monaco:
* 1) Подсветка через встроенный 'lua' language (Monaco поставляется с basic-languages/lua)
* 2) Автодополнение Roblox-API (Vector3.new, Color3.fromRGB, script.Parent, game.Players, ...)
* 3) Hover-документация (наведя на Vector3 описание + пример)
* 4) Подсветка ошибок через luaparse (на этапе 7, опционально)
*
* Регистрируется ОДИН раз глобально через флаг monaco.__rbxLuaRegistered.
*/
const ROBLOX_LUA_API = [
// === Глобальные функции ===
{ kind: 'function', name: 'print', insertText: 'print($0)', doc: 'Выводит сообщения в Output-панель.\n```lua\nprint("Привет", x, y)\n```' },
{ kind: 'function', name: 'warn', insertText: 'warn($0)', doc: 'Выводит предупреждение (жёлтым).\n```lua\nwarn("Что-то не так")\n```' },
{ kind: 'function', name: 'error', insertText: 'error(${1:"сообщение"})', doc: 'Бросает ошибку, останавливая текущий скрипт.\n```lua\nerror("Здоровье < 0")\n```' },
{ kind: 'function', name: 'wait', insertText: 'wait(${1:1})', doc: 'Приостанавливает скрипт на N секунд (заменяется на `task.wait` в новом коде).' },
{ kind: 'function', name: 'tick', insertText: 'tick()', doc: 'Возвращает количество секунд с эпохи (как `os.time()`, но дробное).' },
{ kind: 'function', name: 'pcall', insertText: 'pcall(${1:fn}, $0)', doc: 'Защищённый вызов. Возвращает `success, result|error`.\n```lua\nlocal ok, err = pcall(function() risky() end)\nif not ok then warn(err) end\n```' },
{ kind: 'function', name: 'xpcall', insertText: 'xpcall(${1:fn}, ${2:handler})', doc: 'Защищённый вызов с кастомным обработчиком ошибки.' },
{ kind: 'function', name: 'tostring', insertText: 'tostring($0)', doc: 'Преобразует значение в строку.' },
{ kind: 'function', name: 'tonumber', insertText: 'tonumber($0)', doc: 'Преобразует строку в число. Возвращает nil если не число.' },
{ kind: 'function', name: 'type', insertText: 'type($0)', doc: 'Возвращает строку с типом: "nil", "number", "string", "boolean", "table", "function", "userdata".' },
{ kind: 'function', name: 'typeof', insertText: 'typeof($0)', doc: 'Расширенная версия type — для Roblox-типов вернёт "Vector3", "CFrame", "Color3", "Instance".' },
{ kind: 'function', name: 'ipairs', insertText: 'ipairs(${1:t})', doc: 'Итератор по числовым ключам массива.\n```lua\nfor i, v in ipairs(arr) do ... end\n```' },
{ kind: 'function', name: 'pairs', insertText: 'pairs(${1:t})', doc: 'Итератор по всем ключам таблицы.\n```lua\nfor k, v in pairs(t) do ... end\n```' },
{ kind: 'function', name: 'next', insertText: 'next(${1:t}, $0)', doc: 'Возвращает следующую пару ключ-значение в таблице.' },
{ kind: 'function', name: 'select', insertText: 'select(${1:1}, $0)', doc: 'select("#", ...) — количество аргументов. select(n, ...) — n-й и далее аргументы.' },
{ kind: 'function', name: 'unpack', insertText: 'unpack(${1:t})', doc: 'Распаковывает массив в значения. (В Lua 5.4 — `table.unpack`)' },
{ kind: 'function', name: 'setmetatable', insertText: 'setmetatable(${1:t}, ${2:mt})', doc: 'Устанавливает metatable для таблицы.' },
{ kind: 'function', name: 'getmetatable', insertText: 'getmetatable($0)', doc: 'Возвращает metatable или nil.' },
{ kind: 'function', name: 'rawget', insertText: 'rawget(${1:t}, ${2:key})', doc: 'Чтение без вызова __index metatable.' },
{ kind: 'function', name: 'rawset', insertText: 'rawset(${1:t}, ${2:key}, ${3:value})', doc: 'Запись без вызова __newindex metatable.' },
// === task.* ===
{ kind: 'module', name: 'task', insertText: 'task', doc: 'Современный API планировщика Roblox-Lua.\nЗаменяет `wait`, `spawn`, `delay`, `defer` из старого API.' },
{ kind: 'function', name: 'task.wait', insertText: 'task.wait(${1:1})', doc: 'Приостанавливает на N секунд.\nВозвращает фактическое время ожидания.\n```lua\nlocal dt = task.wait(0.5)\n```' },
{ kind: 'function', name: 'task.spawn', insertText: 'task.spawn(${1:function() end})', doc: 'Немедленно запускает функцию как coroutine.\n```lua\ntask.spawn(function() heavy() end)\n```' },
{ kind: 'function', name: 'task.delay', insertText: 'task.delay(${1:1}, ${2:function() end})', doc: 'Отложенный запуск функции через N секунд.\n```lua\ntask.delay(3, function() print("через 3 сек") end)\n```' },
{ kind: 'function', name: 'task.defer', insertText: 'task.defer(${1:function() end})', doc: 'Запуск в следующем кадре (после Heartbeat).' },
// === Vector3 ===
{ kind: 'class', name: 'Vector3', insertText: 'Vector3', doc: '3D-вектор в Roblox.\nКонструктор: `Vector3.new(x, y, z)`.\nКонстанты: `Vector3.zero`, `Vector3.one`, `Vector3.xAxis`, `Vector3.yAxis`, `Vector3.zAxis`.' },
{ kind: 'function', name: 'Vector3.new', insertText: 'Vector3.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт `Vector3(x, y, z)`.\n```lua\nlocal v = Vector3.new(10, 5, 0)\nprint(v.X, v.Y, v.Z, v.Magnitude)\n```' },
{ kind: 'function', name: 'Vector3.zero', insertText: 'Vector3.zero', doc: '`Vector3(0, 0, 0)`.' },
{ kind: 'function', name: 'Vector3.one', insertText: 'Vector3.one', doc: '`Vector3(1, 1, 1)`.' },
{ kind: 'function', name: 'Vector3.xAxis', insertText: 'Vector3.xAxis', doc: '`Vector3(1, 0, 0)`.' },
{ kind: 'function', name: 'Vector3.yAxis', insertText: 'Vector3.yAxis', doc: '`Vector3(0, 1, 0)`.' },
{ kind: 'function', name: 'Vector3.zAxis', insertText: 'Vector3.zAxis', doc: '`Vector3(0, 0, 1)`.' },
// === Color3 ===
{ kind: 'class', name: 'Color3', insertText: 'Color3', doc: 'Цвет RGB в Roblox.\nКомпоненты `R`, `G`, `B` в диапазоне [0, 1].' },
{ kind: 'function', name: 'Color3.new', insertText: 'Color3.new(${1:1}, ${2:1}, ${3:1})', doc: 'Создаёт `Color3(r, g, b)`, где компоненты в [0, 1].' },
{ kind: 'function', name: 'Color3.fromRGB', insertText: 'Color3.fromRGB(${1:255}, ${2:255}, ${3:255})', doc: 'Создаёт `Color3` из 0-255 RGB.\n```lua\nlocal red = Color3.fromRGB(255, 0, 0)\n```' },
{ kind: 'function', name: 'Color3.fromHSV', insertText: 'Color3.fromHSV(${1:0}, ${2:1}, ${3:1})', doc: 'Создаёт цвет из HSV-компонентов в [0, 1].' },
{ kind: 'function', name: 'Color3.fromHex', insertText: 'Color3.fromHex(${1:"#FF0000"})', doc: 'Создаёт цвет из hex-строки.' },
// === CFrame ===
{ kind: 'class', name: 'CFrame', insertText: 'CFrame', doc: 'Coordinate Frame — позиция + поворот в 3D.\nИспользуется для трансформаций Part.CFrame.' },
{ kind: 'function', name: 'CFrame.new', insertText: 'CFrame.new(${1:0}, ${2:0}, ${3:0})', doc: 'Создаёт CFrame в указанной позиции.' },
{ kind: 'function', name: 'CFrame.lookAt', insertText: 'CFrame.lookAt(${1:eye}, ${2:target})', doc: 'CFrame, направленный из eye на target.' },
{ kind: 'function', name: 'CFrame.Angles', insertText: 'CFrame.Angles(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame только с поворотом (в радианах).' },
{ kind: 'function', name: 'CFrame.fromEulerAnglesXYZ', insertText: 'CFrame.fromEulerAnglesXYZ(${1:0}, ${2:0}, ${3:0})', doc: 'CFrame с поворотом по эйлеровым углам.' },
// === UDim2 / Vector2 ===
{ kind: 'class', name: 'UDim2', insertText: 'UDim2', doc: 'Размер/позиция GUI: процент + пиксели по обеим осям.' },
{ kind: 'function', name: 'UDim2.new', insertText: 'UDim2.new(${1:0}, ${2:0}, ${3:0}, ${4:0})', doc: '`UDim2.new(scaleX, offsetX, scaleY, offsetY)`.\n```lua\nframe.Position = UDim2.new(0.5, 0, 0.5, 0) -- центр экрана\n```' },
{ kind: 'function', name: 'UDim2.fromScale', insertText: 'UDim2.fromScale(${1:0.5}, ${2:0.5})', doc: 'Только процентные размеры.' },
{ kind: 'function', name: 'UDim2.fromOffset', insertText: 'UDim2.fromOffset(${1:100}, ${2:100})', doc: 'Только пиксельные размеры.' },
{ kind: 'class', name: 'Vector2', insertText: 'Vector2', doc: '2D-вектор.' },
{ kind: 'function', name: 'Vector2.new', insertText: 'Vector2.new(${1:0}, ${2:0})', doc: '`Vector2(x, y)`.' },
{ kind: 'class', name: 'UDim', insertText: 'UDim', doc: 'Одномерная UDim (scale + offset).' },
{ kind: 'function', name: 'UDim.new', insertText: 'UDim.new(${1:0}, ${2:0})', doc: '`UDim.new(scale, offset)`.' },
// === Instance ===
{ kind: 'class', name: 'Instance', insertText: 'Instance', doc: 'Базовый класс всех объектов Roblox.' },
{ kind: 'function', name: 'Instance.new', insertText: 'Instance.new("${1:Part}", ${2:workspace})', doc: 'Создаёт новый объект указанного класса.\n```lua\nlocal part = Instance.new("Part", workspace)\npart.Size = Vector3.new(4, 1, 4)\npart.Position = Vector3.new(0, 10, 0)\n```' },
// === game / services ===
{ kind: 'variable', name: 'game', insertText: 'game', doc: 'Корень DataModel. `game:GetService("Players")` — доступ к сервисам.' },
{ kind: 'variable', name: 'workspace', insertText: 'workspace', doc: 'Сокращение для `game.Workspace`. Содержит все Part-объекты сцены.' },
{ kind: 'variable', name: 'script', insertText: 'script', doc: 'Текущий скрипт. `script.Parent` — объект-носитель.\n```lua\nlocal part = script.Parent\npart.Touched:Connect(function(hit) ... end)\n```' },
// === Enum ===
{ kind: 'enum', name: 'Enum', insertText: 'Enum', doc: 'Перечисления Roblox: KeyCode, Material, UserInputType, EasingStyle, EasingDirection, HumanoidStateType.' },
{ kind: 'enum', name: 'Enum.KeyCode', insertText: 'Enum.KeyCode.${1:W}', doc: 'Клавиши клавиатуры: W, A, S, D, Space, LeftShift, Q, E, F, R, T, ..., One, Two, ..., Up, Down.' },
{ kind: 'enum', name: 'Enum.UserInputType', insertText: 'Enum.UserInputType.${1:MouseButton1}', doc: 'Типы ввода: MouseButton1/2/3, Keyboard, Touch, MouseMovement, MouseWheel.' },
{ kind: 'enum', name: 'Enum.Material', insertText: 'Enum.Material.${1:Plastic}', doc: 'Материалы: Plastic, Wood, Metal, Neon, Glass, Sand, Ice, Grass, Concrete.' },
{ kind: 'enum', name: 'Enum.HumanoidStateType', insertText: 'Enum.HumanoidStateType.${1:Running}', doc: 'Состояния Humanoid: Running, Jumping, Freefall, Landed, Dead, Climbing, Swimming, Seated.' },
];
// === Сниппеты быстрого старта (готовые шаблоны) ===
const ROBLOX_LUA_SNIPPETS = [
{
label: 'killbrick',
documentation: 'KillBrick — убивает игрока при касании.',
insertText: [
'local part = script.Parent',
'part.Touched:Connect(function(hit)',
'\tlocal humanoid = hit.Parent:FindFirstChildOfClass("Humanoid")',
'\tif humanoid then',
'\t\thumanoid.Health = 0',
'\tend',
'end)',
].join('\n'),
},
{
label: 'teleportpad',
documentation: 'TeleportPad — телепортирует игрока в указанную точку.',
insertText: [
'local destination = Vector3.new(${1:0}, ${2:50}, ${3:0})',
'local pad = script.Parent',
'pad.Touched:Connect(function(hit)',
'\tlocal root = hit.Parent:FindFirstChild("HumanoidRootPart")',
'\tif root then',
'\t\troot.CFrame = CFrame.new(destination)',
'\tend',
'end)',
].join('\n'),
},
{
label: 'coin',
documentation: 'Coin — даёт игроку монету при касании, потом исчезает.',
insertText: [
'local coin = script.Parent',
'local collected = false',
'coin.Touched:Connect(function(hit)',
'\tif collected then return end',
'\tif hit.Parent:FindFirstChildOfClass("Humanoid") then',
'\t\tcollected = true',
'\t\tprint("Монета собрана!")',
'\t\tcoin:Destroy()',
'\tend',
'end)',
].join('\n'),
},
{
label: 'heartbeat',
documentation: 'RunService.Heartbeat — кадровый callback.',
insertText: [
'local RunService = game:GetService("RunService")',
'RunService.Heartbeat:Connect(function(dt)',
'\t${0:-- код, выполняется каждый кадр}',
'end)',
].join('\n'),
},
{
label: 'playeradded',
documentation: 'PlayerAdded — реакция на захождение игрока.',
insertText: [
'local Players = game:GetService("Players")',
'Players.PlayerAdded:Connect(function(player)',
'\tprint("Игрок зашёл:", player.Name)',
'\t${0:}',
'end)',
].join('\n'),
},
{
label: 'spinpart',
documentation: 'SpinPart — вращающаяся платформа.',
insertText: [
'local RunService = game:GetService("RunService")',
'local part = script.Parent',
'local speed = ${1:2} -- радиан/сек',
'RunService.Heartbeat:Connect(function(dt)',
'\tpart.CFrame = part.CFrame * CFrame.Angles(0, speed * dt, 0)',
'end)',
].join('\n'),
},
];
export function registerLuaInMonaco(monaco) {
if (monaco.__rbxLuaRegistered) return;
monaco.__rbxLuaRegistered = true;
// 1. CompletionProvider — автодополнение
monaco.languages.registerCompletionItemProvider('lua', {
triggerCharacters: ['.', ':', '"', "'"],
provideCompletionItems: (model, position) => {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const suggestions = [];
const kindMap = {
'function': monaco.languages.CompletionItemKind.Function,
'class': monaco.languages.CompletionItemKind.Class,
'module': monaco.languages.CompletionItemKind.Module,
'enum': monaco.languages.CompletionItemKind.Enum,
'variable': monaco.languages.CompletionItemKind.Variable,
};
for (const item of ROBLOX_LUA_API) {
suggestions.push({
label: item.name,
kind: kindMap[item.kind] || monaco.languages.CompletionItemKind.Text,
insertText: item.insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: { value: item.doc, isTrusted: true },
range,
});
}
for (const snip of ROBLOX_LUA_SNIPPETS) {
suggestions.push({
label: snip.label,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: snip.insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: { value: snip.documentation, isTrusted: true },
detail: 'Сниппет Roblox-Lua',
range,
});
}
return { suggestions };
},
});
// 2. HoverProvider — подсказки при наведении
const lookupTable = new Map();
for (const item of ROBLOX_LUA_API) lookupTable.set(item.name, item);
monaco.languages.registerHoverProvider('lua', {
provideHover: (model, position) => {
const word = model.getWordAtPosition(position);
if (!word) return null;
// Пробуем найти точное совпадение или с префиксом (Vector3.new)
let found = lookupTable.get(word.word);
if (!found) {
// Возможно курсор на середине A.B — попробуем собрать всю цепочку
const line = model.getLineContent(position.lineNumber);
// Ищем имя.имя.имя на позиции
const left = line.slice(0, word.endColumn - 1);
const m = left.match(/[A-Za-z_][\w.]*$/);
if (m) found = lookupTable.get(m[0]);
}
if (!found) return null;
return {
range: new monaco.Range(
position.lineNumber, word.startColumn,
position.lineNumber, word.endColumn,
),
contents: [
{ value: `**${found.name}**` },
{ value: found.doc },
],
};
},
});
}